From bade7638dd12759a42b136d3c67cac65c2568aed Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 1 Sep 2026 10:31:23 -0400 Subject: [PATCH 01/10] Keep the portable footprint inside the portable folder --- docs/PORTABLE.md | 7 + src/UniGetUI.Avalonia/App.axaml.cs | 2 +- src/UniGetUI.Avalonia/CrashHandler.cs | 3 +- .../Infrastructure/AvaloniaAutoUpdater.cs | 3 +- src/UniGetUI.Core.Data/CoreData.cs | 110 +----------- src/UniGetUI.Core.Logger/AppPaths.cs | 166 ++++++++++++++++++ src/UniGetUI.Core.Logger/Logger.cs | 11 +- .../AppPathsTests.cs | 85 +++++++++ .../LoggerTests.cs | 4 +- .../TelemetryHandlerTests.cs | 14 +- .../TelemetryHandler.cs | 2 +- .../ClientHelpers/PingetCliHelper.cs | 2 +- .../PingetPackageDetailsProvider.cs | 2 +- .../ClientHelpers/WinGetCliHelper.cs | 10 +- .../WinGet.cs | 4 +- .../AbstractProcessOperation.cs | 2 +- 16 files changed, 289 insertions(+), 138 deletions(-) create mode 100644 src/UniGetUI.Core.Logger/AppPaths.cs create mode 100644 src/UniGetUI.Core.Logging.Tests/AppPathsTests.cs diff --git a/docs/PORTABLE.md b/docs/PORTABLE.md index a59ce1dd5c..4abae3ba94 100644 --- a/docs/PORTABLE.md +++ b/docs/PORTABLE.md @@ -76,12 +76,19 @@ whenever the bundle is replaced by an update. Re-create it after upgrading. | Cached language files | `\CachedLanguageFiles` | `\Settings\CachedLanguageFiles` | | Stored secrets, macOS and Linux | `/SecureStorage` | `/Settings/SecureStorage` | | Stored secrets, Windows | Credential Manager | Credential Manager (**not** relocated) | +| Session log, WebView2 profile, update logs | `%TEMP%\UniGetUI` on Windows; `$TMPDIR/UniGetUI` elsewhere | `\Settings\Temp` | | Default package-backup folder | `Documents\UniGetUI` | `Documents\UniGetUI` (**not** relocated) | Package backups are one exception: their default location stays in the user's Documents folder, and portable mode does not move it. Point it somewhere inside the portable folder from the Backup settings page if you want backups to travel with the app. +The scratch directory holds files that are rebuilt on demand: the session log, the crash report +left behind for the next launch, the per-attempt auto-updater log, the WebView2 profile, and the +`%TEMP%` handed to package-manager subprocesses when UniGetUI runs elevated. Portable mode moves +it inside the portable folder, so a portable copy leaves nothing behind in the system temporary +directory. It is safe to delete while UniGetUI is not running. + The GitHub backup token is the other exception, and where it lives depends on the platform. On macOS and Linux it is written to `SecureStorage` inside the data directory, so it travels with a portable folder, as a plain file on disk. On Windows it is held in Credential Manager instead, diff --git a/src/UniGetUI.Avalonia/App.axaml.cs b/src/UniGetUI.Avalonia/App.axaml.cs index 593850905c..39f32902c5 100644 --- a/src/UniGetUI.Avalonia/App.axaml.cs +++ b/src/UniGetUI.Avalonia/App.axaml.cs @@ -290,7 +290,7 @@ public static void ApplyTheme(string value) } public static string WebViewUserDataFolder { get; } = - Path.Join(Path.GetTempPath(), "UniGetUI", "WebView"); + Path.Join(AppPaths.ScratchDirectory, "WebView"); private static void SetUpWebViewUserDataFolder() { diff --git a/src/UniGetUI.Avalonia/CrashHandler.cs b/src/UniGetUI.Avalonia/CrashHandler.cs index 2e67164c7a..dc1a8f26b5 100644 --- a/src/UniGetUI.Avalonia/CrashHandler.cs +++ b/src/UniGetUI.Avalonia/CrashHandler.cs @@ -11,7 +11,7 @@ namespace UniGetUI.Avalonia; public static class CrashHandler { public static readonly string PendingCrashFile = - Path.Combine(Path.GetTempPath(), "UniGetUI_pending_crash.txt"); + Path.Combine(AppPaths.ScratchDirectory, "pending_crash.txt"); private const string NO_CORRUPT_DIALOG = "--no-corrupt-dialog"; @@ -223,6 +223,7 @@ Inner exception details (depth level: {{i}}) // Persist crash data so the next normal app launch can show the report. try { + Directory.CreateDirectory(Path.GetDirectoryName(PendingCrashFile)!); File.WriteAllText(PendingCrashFile, Error_String, Encoding.UTF8); } catch diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs index 698ee9137e..f0b0895e14 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs @@ -106,8 +106,7 @@ private static void RaiseStatus( private static readonly Lock _updateLogLock = new(); private static StringBuilder? _updateLogBuilder; private static readonly string _updateLogPath = Path.Combine( - Path.GetTempPath(), - "UniGetUI", + AppPaths.ScratchDirectory, "last-update-attempt.log" ); diff --git a/src/UniGetUI.Core.Data/CoreData.cs b/src/UniGetUI.Core.Data/CoreData.cs index 7409e607c7..7b50171104 100644 --- a/src/UniGetUI.Core.Data/CoreData.cs +++ b/src/UniGetUI.Core.Data/CoreData.cs @@ -8,9 +8,6 @@ public static class CoreData { private const string GitHubReleasePageBaseUrl = "https://github.com/Devolutions/UniGetUI/releases/tag/"; private const string GitHubReleaseApiBaseUrl = "https://api.github.com/repos/Devolutions/UniGetUI/releases/tags/"; - private const string BundledModernAppDirectoryName = "Avalonia"; - private const string WindowsExecutableName = "UniGetUI.exe"; - private const string BundledPingetExecutableName = "pinget.exe"; public const string ReleaseNotesUrl = "https://devolutions.net/unigetui/release-notes/"; private static int? __code_page; @@ -109,12 +106,7 @@ private static bool UsesPrefixedCalendarReleaseTags(string versionName) return int.TryParse(year, out int parsedYear) && parsedYear >= 2000; } - private static bool? IS_PORTABLE; - private static string? PORTABLE_PATH; - public static bool IsPortable - { - get => IS_PORTABLE ?? false; - } + public static bool IsPortable => AppPaths.IsPortable; public static string? TEST_DataDirectoryOverride { private get; set; } @@ -130,41 +122,9 @@ public static string UniGetUIDataDirectory return TEST_DataDirectoryOverride; } - if (IS_PORTABLE is null) - { - IS_PORTABLE = File.Exists( - Path.Join(UniGetUIExecutableDirectory, "ForceUniGetUIPortable") - ); - - if (IS_PORTABLE is true) - { - string path = Path.Join(UniGetUIExecutableDirectory, "Settings"); - try - { - if (!Directory.Exists(path)) - Directory.CreateDirectory(path); - var testfilepath = Path.Join(path, "PermissionTestFile"); - File.WriteAllText( - testfilepath, - "https://www.youtube.com/watch?v=dQw4w9WgXcQ" - ); - PORTABLE_PATH = path; - return path; - } - catch (Exception ex) - { - IS_PORTABLE = false; - Logger.Error( - $"Could not acces/write path {path}. UniGetUI will NOT be run in portable mode, and User settings will be used instead" - ); - Logger.Error(ex); - } - } - } - else if (IS_PORTABLE is true) + if (AppPaths.PortableDataDirectory is { } portableDirectory) { - return PORTABLE_PATH - ?? throw new InvalidOperationException("This shouldn't be possible"); + return portableDirectory; } string old_path = Path.Join( @@ -349,52 +309,13 @@ public static string UniGetUI_DefaultBackupDirectory /// /// A path pointing to the location where the app is installed /// - public static string UniGetUIExecutableDirectory - { - get - { - string dir = NormalizeDirectoryPath(AppContext.BaseDirectory); - if (!string.IsNullOrEmpty(dir)) - { - return ResolveInstallationDirectory(dir); - } - - Logger.Error("AppContext.BaseDirectory returned an empty path"); - - return ResolveInstallationDirectory(NormalizeDirectoryPath(AppContext.BaseDirectory)); - } - } + public static string UniGetUIExecutableDirectory => AppPaths.InstallationDirectory; public static string ResolveInstallationDirectory( string executableDirectory, Func? fileExists = null, Func? directoryExists = null - ) - { - fileExists ??= File.Exists; - directoryExists ??= Directory.Exists; - - string normalizedDirectory = NormalizeDirectoryPath(executableDirectory); - if (!string.Equals( - Path.GetFileName(normalizedDirectory), - BundledModernAppDirectoryName, - StringComparison.OrdinalIgnoreCase - )) - { - return normalizedDirectory; - } - - string? parentDirectory = Path.GetDirectoryName(normalizedDirectory); - if (string.IsNullOrEmpty(parentDirectory)) - { - return normalizedDirectory; - } - - parentDirectory = NormalizeDirectoryPath(parentDirectory); - return IsInstallRoot(parentDirectory, fileExists, directoryExists) - ? parentDirectory - : normalizedDirectory; - } + ) => AppPaths.ResolveInstallationDirectory(executableDirectory, fileExists, directoryExists); /// /// A path pointing to the executable file of the app @@ -658,14 +579,6 @@ private static string GetUserHomeDirectory() return Environment.GetEnvironmentVariable("HOME") ?? AppContext.BaseDirectory; } - private static string NormalizeDirectoryPath(string path) - { - return Path.GetFullPath(path).TrimEnd( - Path.DirectorySeparatorChar, - Path.AltDirectorySeparatorChar - ); - } - private static string NormalizeExecutablePath(string path) { if ( @@ -678,18 +591,5 @@ private static string NormalizeExecutablePath(string path) return path; } - - private static bool IsInstallRoot( - string directory, - Func fileExists, - Func directoryExists - ) - { - return fileExists(Path.Join(directory, WindowsExecutableName)) - || fileExists(Path.Join(directory, BundledPingetExecutableName)) - || fileExists(Path.Join(directory, "IntegrityTree.json")) - || directoryExists(Path.Join(directory, "Assets", "Utilities")) - || directoryExists(Path.Join(directory, "Assets", "Data")); - } } } diff --git a/src/UniGetUI.Core.Logger/AppPaths.cs b/src/UniGetUI.Core.Logger/AppPaths.cs new file mode 100644 index 0000000000..7bfc064612 --- /dev/null +++ b/src/UniGetUI.Core.Logger/AppPaths.cs @@ -0,0 +1,166 @@ +namespace UniGetUI.Core.Logging +{ + public static class AppPaths + { + private const string PortableMarkerFileName = "ForceUniGetUIPortable"; + private const string PortableDataDirectoryName = "Settings"; + private const string PortablePermissionTestFileName = "PermissionTestFile"; + private const string PortableScratchDirectoryName = "Temp"; + private const string ScratchDirectoryName = "UniGetUI"; + private const string BundledModernAppDirectoryName = "Avalonia"; + private const string WindowsExecutableName = "UniGetUI.exe"; + private const string BundledPingetExecutableName = "pinget.exe"; + + private static readonly Lock PortableModeLock = new(); + private static string? __installation_directory; + private static bool __portable_mode_resolved; + private static string? __portable_data_directory; + + [ThreadStatic] + private static bool __resolving_portable_mode; + + /// + /// A path pointing to the location where the app is installed + /// + public static string InstallationDirectory => + __installation_directory ??= ResolveInstallationDirectory( + NormalizeDirectoryPath(AppContext.BaseDirectory) + ); + + public static string? TEST_PortableDataDirectoryOverride { private get; set; } + + /// + /// Whether UniGetUI stores its data next to the executable. False when the marker file is + /// absent, and also when it is present but the installation directory is not writable. + /// + public static bool IsPortable => PortableDataDirectory is not null; + + /// + /// The portable data directory, or null when not running in portable mode. + /// + public static string? PortableDataDirectory => + TEST_PortableDataDirectoryOverride ?? ResolvedPortableDataDirectory; + + private static string? ResolvedPortableDataDirectory + { + get + { + if (__portable_mode_resolved) + return __portable_data_directory; + + if (__resolving_portable_mode) + return null; + + lock (PortableModeLock) + { + if (__portable_mode_resolved) + return __portable_data_directory; + + __resolving_portable_mode = true; + try + { + __portable_data_directory = ResolvePortableDataDirectory(InstallationDirectory); + } + finally + { + __resolving_portable_mode = false; + __portable_mode_resolved = true; + } + } + + return __portable_data_directory; + } + } + + /// + /// The directory for files that must not outlive an uninstall: the session log, the + /// WebView2 profile, per-attempt update logs, and the %TEMP% handed to elevated + /// subprocesses. Not created automatically; callers that write must ensure it exists. + /// + public static string ScratchDirectory => + PortableDataDirectory is { } portableDirectory + ? Path.Join(portableDirectory, PortableScratchDirectoryName) + : Path.Join(Path.GetTempPath(), ScratchDirectoryName); + + public static string ResolveInstallationDirectory( + string executableDirectory, + Func? fileExists = null, + Func? directoryExists = null + ) + { + fileExists ??= File.Exists; + directoryExists ??= Directory.Exists; + + string normalizedDirectory = NormalizeDirectoryPath(executableDirectory); + if (!string.Equals( + Path.GetFileName(normalizedDirectory), + BundledModernAppDirectoryName, + StringComparison.OrdinalIgnoreCase + )) + { + return normalizedDirectory; + } + + string? parentDirectory = Path.GetDirectoryName(normalizedDirectory); + if (string.IsNullOrEmpty(parentDirectory)) + { + return normalizedDirectory; + } + + parentDirectory = NormalizeDirectoryPath(parentDirectory); + return IsInstallRoot(parentDirectory, fileExists, directoryExists) + ? parentDirectory + : normalizedDirectory; + } + + public static string? ResolvePortableDataDirectory(string installationDirectory) + { + if (!File.Exists(Path.Join(installationDirectory, PortableMarkerFileName))) + { + return null; + } + + string path = Path.Join(installationDirectory, PortableDataDirectoryName); + try + { + if (!Directory.Exists(path)) + Directory.CreateDirectory(path); + + File.WriteAllText( + Path.Join(path, PortablePermissionTestFileName), + "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + ); + return path; + } + catch (Exception ex) + { + Logger.Error( + $"Could not acces/write path {path}. UniGetUI will NOT be run in portable mode, and User settings will be used instead" + ); + Logger.Error(ex); + return null; + } + } + + private static string NormalizeDirectoryPath(string path) + { + return Path.GetFullPath(path).TrimEnd( + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar + ); + } + + private static bool IsInstallRoot( + string directory, + Func fileExists, + Func directoryExists + ) + { + return fileExists(Path.Join(directory, WindowsExecutableName)) + || fileExists(Path.Join(directory, BundledPingetExecutableName)) + || fileExists(Path.Join(directory, "IntegrityTree.json")) + || directoryExists(Path.Join(directory, "Assets", "Utilities")) + || directoryExists(Path.Join(directory, "Assets", "Data")); + } + } +} diff --git a/src/UniGetUI.Core.Logger/Logger.cs b/src/UniGetUI.Core.Logger/Logger.cs index 3d2e744cc2..65d13c35ce 100644 --- a/src/UniGetUI.Core.Logger/Logger.cs +++ b/src/UniGetUI.Core.Logger/Logger.cs @@ -6,11 +6,7 @@ public static class Logger { private static readonly List LogContents = []; private static readonly Lock LogWriteLock = new(); - private static readonly string SessionLogPath = Path.Combine( - Path.GetTempPath(), - "UniGetUI", - "session.log" - ); + private static string SessionLogPath => Path.Combine(AppPaths.ScratchDirectory, "session.log"); private static readonly string UserName = Environment.UserName; @@ -45,11 +41,12 @@ private static void AppendToSessionLog(string text) { try { - Directory.CreateDirectory(Path.GetDirectoryName(SessionLogPath)!); + string sessionLogPath = SessionLogPath; + Directory.CreateDirectory(Path.GetDirectoryName(sessionLogPath)!); lock (LogWriteLock) { File.AppendAllText( - SessionLogPath, + sessionLogPath, $"[{DateTime.Now:yyyy-MM-dd h:mm:ss tt}] {text}{Environment.NewLine}" ); } diff --git a/src/UniGetUI.Core.Logging.Tests/AppPathsTests.cs b/src/UniGetUI.Core.Logging.Tests/AppPathsTests.cs new file mode 100644 index 0000000000..3ccebe7e43 --- /dev/null +++ b/src/UniGetUI.Core.Logging.Tests/AppPathsTests.cs @@ -0,0 +1,85 @@ +[assembly: CollectionBehavior(DisableTestParallelization = true)] + +namespace UniGetUI.Core.Logging.Tests +{ + public sealed class AppPathsTests : IDisposable + { + private readonly string _testRoot; + + public AppPathsTests() + { + _testRoot = Path.Combine( + Path.GetTempPath(), + $"UniGetUI-AppPathsTests-{Guid.NewGuid():N}" + ); + Directory.CreateDirectory(_testRoot); + } + + public void Dispose() + { + AppPaths.TEST_PortableDataDirectoryOverride = null; + + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, true); + } + } + + [Fact] + public void ResolvePortableDataDirectoryReturnsNullWithoutTheMarkerFile() + { + Assert.Null(AppPaths.ResolvePortableDataDirectory(_testRoot)); + Assert.False(Directory.Exists(Path.Combine(_testRoot, "Settings"))); + } + + [Fact] + public void ResolvePortableDataDirectoryReturnsSettingsFolderWhenMarked() + { + File.WriteAllText(Path.Combine(_testRoot, "ForceUniGetUIPortable"), string.Empty); + + string? resolved = AppPaths.ResolvePortableDataDirectory(_testRoot); + + Assert.Equal(Path.Join(_testRoot, "Settings"), resolved); + Assert.True(File.Exists(Path.Combine(_testRoot, "Settings", "PermissionTestFile"))); + } + + [Fact] + public void ResolvePortableDataDirectoryFallsBackWhenTheFolderIsNotWritable() + { + File.WriteAllText(Path.Combine(_testRoot, "ForceUniGetUIPortable"), string.Empty); + File.WriteAllText(Path.Combine(_testRoot, "Settings"), string.Empty); + + Assert.Null(AppPaths.ResolvePortableDataDirectory(_testRoot)); + } + + [Fact] + public void ScratchDirectoryStaysOutsideTheInstallFolderWhenNotPortable() + { + AppPaths.TEST_PortableDataDirectoryOverride = null; + + Assert.False(AppPaths.IsPortable); + Assert.Equal(Path.Join(Path.GetTempPath(), "UniGetUI"), AppPaths.ScratchDirectory); + } + + [Fact] + public void ScratchDirectoryMovesIntoThePortableFolderWhenPortable() + { + string portableDirectory = Path.Combine(_testRoot, "Settings"); + AppPaths.TEST_PortableDataDirectoryOverride = portableDirectory; + + Assert.True(AppPaths.IsPortable); + Assert.Equal(Path.Join(portableDirectory, "Temp"), AppPaths.ScratchDirectory); + } + + [Fact] + public void SessionLogIsWrittenInsideThePortableFolderWhenPortable() + { + string portableDirectory = Path.Combine(_testRoot, "Settings"); + AppPaths.TEST_PortableDataDirectoryOverride = portableDirectory; + + Logger.Info($"Portable session log probe {Guid.NewGuid():N}"); + + Assert.True(File.Exists(Path.Join(portableDirectory, "Temp", "session.log"))); + } + } +} diff --git a/src/UniGetUI.Core.Logging.Tests/LoggerTests.cs b/src/UniGetUI.Core.Logging.Tests/LoggerTests.cs index 250a559ac7..5773fd615e 100644 --- a/src/UniGetUI.Core.Logging.Tests/LoggerTests.cs +++ b/src/UniGetUI.Core.Logging.Tests/LoggerTests.cs @@ -6,6 +6,8 @@ public class LoggerTests [Fact] public void TestLogger() { + int baseIndex = Logger.GetLogs().Length; + DateTime startTime = DateTime.Now; Logger.Info("Hello World"); Logger.Debug("Hello World 2"); @@ -14,7 +16,7 @@ public void TestLogger() DateTime endTime = DateTime.Now; - LogEntry[] logs = Logger.GetLogs(); + LogEntry[] logs = Logger.GetLogs()[baseIndex..]; Assert.Equal("Hello World", logs[0].Content); Assert.Equal("Hello World 2", logs[1].Content); diff --git a/src/UniGetUI.Interface.Telemetry.Tests/TelemetryHandlerTests.cs b/src/UniGetUI.Interface.Telemetry.Tests/TelemetryHandlerTests.cs index 0d041c25b8..50475a1447 100644 --- a/src/UniGetUI.Interface.Telemetry.Tests/TelemetryHandlerTests.cs +++ b/src/UniGetUI.Interface.Telemetry.Tests/TelemetryHandlerTests.cs @@ -19,7 +19,6 @@ public sealed class TelemetryHandlerTests : IDisposable "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; private readonly string _testRoot; - private readonly string _portableMarkerPath; private readonly bool _originalWasDaemon; public TelemetryHandlerTests() @@ -29,7 +28,6 @@ public TelemetryHandlerTests() nameof(TelemetryHandlerTests), Guid.NewGuid().ToString("N") ); - _portableMarkerPath = Path.Combine(Environment.CurrentDirectory, "ForceUniGetUIPortable"); _originalWasDaemon = CoreData.WasDaemon; CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data"); @@ -42,7 +40,7 @@ public TelemetryHandlerTests() Settings.SetValue(Settings.K.TelemetryClientToken, KnownInstallId); TelemetryHandler.ResetTestState(); - File.Delete(_portableMarkerPath); + AppPaths.TEST_PortableDataDirectoryOverride = null; CoreData.WasDaemon = false; } @@ -52,11 +50,7 @@ public void Dispose() ClearSettingsCaches(); CoreData.TEST_DataDirectoryOverride = null; CoreData.WasDaemon = _originalWasDaemon; - - if (File.Exists(_portableMarkerPath)) - { - File.Delete(_portableMarkerPath); - } + AppPaths.TEST_PortableDataDirectoryOverride = null; if (Directory.Exists(_testRoot)) { @@ -129,7 +123,7 @@ public void ComputeActiveSettingsBitmask_IncludesDeterministicSettingsAndSpecial Settings.Set(Settings.K.EnablePackageBackup_LOCAL, false); Settings.Set(Settings.K.DoCacheAdminRights, false); Settings.Set(Settings.K.DoCacheAdminRightsForBatches, false); - File.WriteAllText(_portableMarkerPath, string.Empty); + AppPaths.TEST_PortableDataDirectoryOverride = Path.Combine(_testRoot, "Portable"); CoreData.WasDaemon = true; int activeSettings = TelemetryHandler.ComputeActiveSettingsBitmask(); @@ -146,7 +140,7 @@ public async Task InitializeAsync_SendsActivityPayloadWithRequiredFields() Settings.Set(Settings.K.DisableNotifications, true); Settings.Set(Settings.K.DisableAutoCheckforUpdates, false); Settings.Set(Settings.K.AutomaticallyUpdatePackages, true); - File.WriteAllText(_portableMarkerPath, string.Empty); + AppPaths.TEST_PortableDataDirectoryOverride = Path.Combine(_testRoot, "Portable"); CoreData.WasDaemon = true; TelemetryHandler.Configure("telemetry-user", "telemetry-pass"); diff --git a/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs b/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs index d2b87d7ac3..bdfca096a4 100644 --- a/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs +++ b/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs @@ -163,7 +163,7 @@ internal static int ComputeActiveSettingsBitmask() { bool enabled = sp switch { - "SP1" => File.Exists("ForceUniGetUIPortable"), + "SP1" => CoreData.IsPortable, "SP2" => CoreData.WasDaemon, _ => throw new NotImplementedException(), }; diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetCliHelper.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetCliHelper.cs index 3dd9aee8cf..dcea3da6d1 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetCliHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetCliHelper.cs @@ -208,7 +208,7 @@ private T RunJson(LoggableTaskType taskType, string arguments) if (CoreTools.IsAdministrator()) { - string winGetTemp = Path.Join(Path.GetTempPath(), "UniGetUI", "ElevatedWinGetTemp"); + string winGetTemp = Path.Join(AppPaths.ScratchDirectory, "ElevatedWinGetTemp"); logger.AddToStdErr( $"[WARN] Redirecting %TEMP% folder to {winGetTemp}, since UniGetUI was run as admin" ); diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs index bfb732eba8..ed4f0354cf 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs @@ -169,7 +169,7 @@ private string RunPinget(IReadOnlyList arguments, INativeTaskLogger logg if (CoreTools.IsAdministrator()) { - string winGetTemp = Path.Join(Path.GetTempPath(), "UniGetUI", "ElevatedWinGetTemp"); + string winGetTemp = Path.Join(AppPaths.ScratchDirectory, "ElevatedWinGetTemp"); logger.Log( $"[WARN] Redirecting %TEMP% folder to {winGetTemp}, since UniGetUI was run as admin" ); diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs index 8e13c797f5..2a3eeaaee8 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs @@ -66,7 +66,7 @@ public IReadOnlyList GetAvailableUpdates_UnSafe() if (CoreTools.IsAdministrator()) { - string WinGetTemp = Path.Join(Path.GetTempPath(), "UniGetUI", "ElevatedWinGetTemp"); + string WinGetTemp = Path.Join(AppPaths.ScratchDirectory, "ElevatedWinGetTemp"); logger.AddToStdErr( $"[WARN] Redirecting %TEMP% folder to {WinGetTemp}, since UniGetUI was run as admin" ); @@ -211,7 +211,7 @@ public IReadOnlyList GetInstalledPackages_UnSafe() if (CoreTools.IsAdministrator()) { - string WinGetTemp = Path.Join(Path.GetTempPath(), "UniGetUI", "ElevatedWinGetTemp"); + string WinGetTemp = Path.Join(AppPaths.ScratchDirectory, "ElevatedWinGetTemp"); logger.AddToStdErr( $"[WARN] Redirecting %TEMP% folder to {WinGetTemp}, since UniGetUI was run as admin" ); @@ -346,7 +346,7 @@ public IReadOnlyList FindPackages_UnSafe(string query) if (CoreTools.IsAdministrator()) { - string WinGetTemp = Path.Join(Path.GetTempPath(), "UniGetUI", "ElevatedWinGetTemp"); + string WinGetTemp = Path.Join(AppPaths.ScratchDirectory, "ElevatedWinGetTemp"); logger.AddToStdErr( $"[WARN] Redirecting %TEMP% folder to {WinGetTemp}, since UniGetUI was run as admin" ); @@ -478,7 +478,7 @@ public IReadOnlyList GetInstallableVersions_Unsafe(IPackage package) ); if (CoreTools.IsAdministrator()) { - string WinGetTemp = Path.Join(Path.GetTempPath(), "UniGetUI", "ElevatedWinGetTemp"); + string WinGetTemp = Path.Join(AppPaths.ScratchDirectory, "ElevatedWinGetTemp"); Logger.Warn( $"[WARN] Redirecting %TEMP% folder to {WinGetTemp}, since UniGetUI was run as admin" ); @@ -536,7 +536,7 @@ public IReadOnlyList GetSources_UnSafe() IProcessTaskLogger logger = Manager.TaskLogger.CreateNew(LoggableTaskType.FindPackages, p); if (CoreTools.IsAdministrator()) { - string WinGetTemp = Path.Join(Path.GetTempPath(), "UniGetUI", "ElevatedWinGetTemp"); + string WinGetTemp = Path.Join(AppPaths.ScratchDirectory, "ElevatedWinGetTemp"); Logger.Warn( $"[WARN] Redirecting %TEMP% folder to {WinGetTemp}, since UniGetUI was run as admin" ); diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs index 496e352939..7b8c4d3abe 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs @@ -628,7 +628,7 @@ protected override void _loadManagerVersion(out string version) if (CoreTools.IsAdministrator()) { - string WinGetTemp = Path.Join(Path.GetTempPath(), "UniGetUI", "ElevatedWinGetTemp"); + string WinGetTemp = Path.Join(AppPaths.ScratchDirectory, "ElevatedWinGetTemp"); process.StartInfo.Environment["TEMP"] = WinGetTemp; process.StartInfo.Environment["TMP"] = WinGetTemp; } @@ -796,7 +796,7 @@ public override void RefreshPackageIndexes() if (CoreTools.IsAdministrator()) { - string WinGetTemp = Path.Join(Path.GetTempPath(), "UniGetUI", "ElevatedWinGetTemp"); + string WinGetTemp = Path.Join(AppPaths.ScratchDirectory, "ElevatedWinGetTemp"); logger.AddToStdErr( $"[WARN] Redirecting %TEMP% folder to {WinGetTemp}, since UniGetUI was run as admin" ); diff --git a/src/UniGetUI.PackageEngine.Operations/AbstractProcessOperation.cs b/src/UniGetUI.PackageEngine.Operations/AbstractProcessOperation.cs index a912c038d6..4e266e186c 100644 --- a/src/UniGetUI.PackageEngine.Operations/AbstractProcessOperation.cs +++ b/src/UniGetUI.PackageEngine.Operations/AbstractProcessOperation.cs @@ -68,7 +68,7 @@ protected void RequestCachingOfUACPrompt() protected void RedirectWinGetTempFolder() { - string WinGetTemp = Path.Join(Path.GetTempPath(), "UniGetUI", "ElevatedWinGetTemp"); + string WinGetTemp = Path.Join(AppPaths.ScratchDirectory, "ElevatedWinGetTemp"); process.StartInfo.Environment["TEMP"] = WinGetTemp; process.StartInfo.Environment["TMP"] = WinGetTemp; } From 540ba35c568ecc20e223f9f9f10a3f35b5ff50df Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 1 Sep 2026 14:02:21 -0400 Subject: [PATCH 02/10] Ship the portable marker in the Windows zip --- .github/workflows/build-release.yml | 1 + README.md | 2 + docs/PORTABLE.md | 27 ++++- .../ViewModels/MainWindowViewModel.cs | 45 +++++++ .../PortableDataImportTests.cs | 101 ++++++++++++++++ src/UniGetUI.Core.Data/CoreData.cs | 6 + src/UniGetUI.Core.Data/PortableDataImport.cs | 113 ++++++++++++++++++ .../SettingsEngine_Names.cs | 2 + 8 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs create mode 100644 src/UniGetUI.Core.Data/PortableDataImport.cs diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index dbd34c9380..4aec54ae62 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -295,6 +295,7 @@ jobs: # Zip Compress-Archive -Path "unigetui_bin/*" -DestinationPath "output/UniGetUI.$Platform.zip" -CompressionLevel Optimal + Compress-Archive -Path "InstallerExtras/ForceUniGetUIPortable" -DestinationPath "output/UniGetUI.$Platform.zip" -Update # Installer is created in output during the previous step diff --git a/README.md b/README.md index 963a46961f..e38db8afd1 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,8 @@ UniGetUI is primarily built for Windows. The Microsoft Store is the recommended ![GitHub Release](https://img.shields.io/github/v/release/Devolutions/UniGetUI?style=for-the-badge) Use the installer for the best Windows experience. `UniGetUI.Installer.exe` is the legacy/default x64 installer alias; use the explicit architecture downloads if needed. +The `.zip` is portable: it keeps settings and caches next to the executable rather than in your user profile. See [docs/PORTABLE.md](docs/PORTABLE.md). + | Architecture | Installer | Portable `.zip` | |---|---|---| | x64 | [UniGetUI.Installer.x64.exe](https://github.com/Devolutions/UniGetUI/releases/latest/download/UniGetUI.Installer.x64.exe) ([default x64 alias](https://github.com/Devolutions/UniGetUI/releases/latest/download/UniGetUI.Installer.exe)) | [UniGetUI.x64.zip](https://github.com/Devolutions/UniGetUI/releases/latest/download/UniGetUI.x64.zip) | diff --git a/docs/PORTABLE.md b/docs/PORTABLE.md index 4abae3ba94..2267dbbd85 100644 --- a/docs/PORTABLE.md +++ b/docs/PORTABLE.md @@ -10,6 +10,9 @@ outside the installation folder. Portable mode moves all of that next to the exe whole application, settings included, can live on a removable drive or be copied between machines. A few things deliberately stay outside that folder; see [What changes](#what-changes). +The Windows `.zip` release ships in portable mode. The installer, and the macOS and Linux +archives, do not. + ## Enabling portable mode Portable mode is controlled by a single marker file named `ForceUniGetUIPortable`, placed in @@ -50,9 +53,11 @@ New-Item -ItemType File -Path "C:\Path\To\UniGetUI\ForceUniGetUIPortable" touch /path/to/unigetui/ForceUniGetUIPortable ``` -This is how you make the portable `.zip` and `.tar.gz` release archives actually portable. -They ship **without** the marker, so out of the box they still write to the per-user data -directory like a regular install. +The Windows `.zip` already ships with the marker, so it is portable out of the box; creating +the file by hand is only needed for the macOS and Linux `.tar.gz` archives, which do not carry +it. Deleting the marker is the supported way to turn portable mode back off in the `.zip`, and +it is deliberately excluded from `IntegrityTree.json` so removing it cannot fail the integrity +check. ### Where the marker goes @@ -99,6 +104,22 @@ Portable mode also does not relocate anything owned by the package managers them Scoop, Chocolatey, npm and the rest keep their own state in their usual per-user or system locations, and the packages they install are installed normally. +## Importing settings from a per-user installation + +A portable folder starts empty, so an existing installation's settings are not picked up +automatically — they stay in the per-user data directory, untouched. + +The first time UniGetUI runs portable and finds settings there, it offers a one-time +**Import** action in a notification. Accepting copies `Configuration` and `InstallationOptions` +into the portable folder; caches are skipped because they are rebuilt on demand and are far +larger than the settings themselves. Nothing is overwritten and nothing is removed from the +source, so a per-user installation on the same machine keeps working. Restart UniGetUI +afterwards for the imported settings to take effect. + +Dismissing the notification, or importing once, stops it from appearing again. This matters for +a portable copy carried between machines: it will never silently absorb the settings of a +machine it happens to be plugged into. + ## What a portable install does not register The Windows installer registers these only for a regular installation, so a portable install diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index 810e121371..f12cc69ce9 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -16,6 +16,7 @@ using UniGetUI.Avalonia.Views.Pages.LogPages; using UniGetUI.Avalonia.Views.Pages.SettingsPages; using UniGetUI.Core.Data; +using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.Tools; using UniGetUI.Interface.Enums; @@ -288,6 +289,7 @@ private void OnPageViewModelPropertyChanged(object? sender, System.ComponentMode public InfoBarViewModel UpdatesBanner { get; } = new() { Severity = InfoBarSeverity.Success }; public InfoBarViewModel WinGetWarningBanner { get; } = new() { Severity = InfoBarSeverity.Warning }; public InfoBarViewModel TelemetryWarner { get; } = new() { Severity = InfoBarSeverity.Informational }; + public InfoBarViewModel PortableImportBanner { get; } = new() { Severity = InfoBarSeverity.Informational }; // Oldest first (rendered bottom-up so the newest sits nearest the corner). public ObservableCollection Toasts { get; } = new(); @@ -415,6 +417,7 @@ public MainWindowViewModel() RegisterBannerToast(UpdatesBanner); RegisterBannerToast(WinGetWarningBanner); RegisterBannerToast(TelemetryWarner); + RegisterBannerToast(PortableImportBanner); DiscoverPage = new DiscoverSoftwarePage(); UpdatesPage = new SoftwareUpdatesPage(); @@ -538,6 +541,12 @@ public MainWindowViewModel() TelemetryWarner.IsOpen = true; } + if (!Settings.Get(Settings.K.ShownPortableImportBanner) + && PortableDataImport.FindImportableSource() is { } importableSource) + { + ShowPortableImportBanner(importableSource); + } + if (WasUpdatedSinceLastRun() && !Settings.Get(Settings.K.DisableReleaseNotesOnUpdate)) { NavigateTo(PageType.ReleaseNotes); @@ -548,6 +557,42 @@ public MainWindowViewModel() } } + private void ShowPortableImportBanner(string importableSource) + { + PortableImportBanner.Title = CoreTools.Translate("Import your previous settings?"); + PortableImportBanner.Message = CoreTools.Translate( + "UniGetUI is running in portable mode and started with empty settings. Settings from a previous installation were found at {0}.", + importableSource + ); + PortableImportBanner.IsClosable = true; + PortableImportBanner.ActionButtonText = CoreTools.Translate("Import"); + PortableImportBanner.ActionButtonCommand = new CommunityToolkit.Mvvm.Input.RelayCommand(() => + { + PortableImportBanner.ActionButtonText = ""; + PortableImportBanner.ActionButtonCommand = null; + Settings.Set(Settings.K.ShownPortableImportBanner, true); + + try + { + int copied = PortableDataImport.Import(importableSource); + PortableImportBanner.Severity = InfoBarSeverity.Success; + PortableImportBanner.Title = CoreTools.Translate("Settings imported"); + PortableImportBanner.Message = CoreTools.Translate( + "{0} file(s) were copied. Restart UniGetUI to apply them.", copied); + } + catch (Exception ex) + { + Logger.Error("Could not import settings into the portable folder"); + Logger.Error(ex); + PortableImportBanner.Severity = InfoBarSeverity.Error; + PortableImportBanner.Title = CoreTools.Translate("Could not import settings"); + PortableImportBanner.Message = ex.Message; + } + }); + PortableImportBanner.OnClosed = () => Settings.Set(Settings.K.ShownPortableImportBanner, true); + PortableImportBanner.IsOpen = true; + } + // Returns true the first time the app runs after being updated to a newer build private static bool WasUpdatedSinceLastRun() { diff --git a/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs b/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs new file mode 100644 index 0000000000..5d03c3ec1c --- /dev/null +++ b/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs @@ -0,0 +1,101 @@ +using UniGetUI.Core.Logging; + +namespace UniGetUI.Core.Data.Tests +{ + [Collection("PortableDataImport")] + public sealed class PortableDataImportTests : IDisposable + { + private readonly string _testRoot; + + public PortableDataImportTests() + { + _testRoot = Path.Combine( + Path.GetTempPath(), + $"UniGetUI-PortableImportTests-{Guid.NewGuid():N}" + ); + Directory.CreateDirectory(_testRoot); + } + + public void Dispose() + { + CoreData.TEST_DataDirectoryOverride = null; + AppPaths.TEST_PortableDataDirectoryOverride = null; + + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, true); + } + } + + private string CreateSource() + { + string source = Path.Combine(_testRoot, "PerUser"); + Directory.CreateDirectory(Path.Combine(source, "Configuration")); + Directory.CreateDirectory(Path.Combine(source, "InstallationOptions")); + Directory.CreateDirectory(Path.Combine(source, "CachedMedia")); + File.WriteAllText(Path.Combine(source, "Configuration", "EnableScoop"), ""); + File.WriteAllText(Path.Combine(source, "Configuration", "Settings.json"), "{}"); + File.WriteAllText(Path.Combine(source, "InstallationOptions", "winget.pkg.json"), "{}"); + File.WriteAllText(Path.Combine(source, "CachedMedia", "icon.png"), "not-a-real-icon"); + return source; + } + + private string UsePortableDestination() + { + string destination = Path.Combine(_testRoot, "Portable"); + Directory.CreateDirectory(destination); + AppPaths.TEST_PortableDataDirectoryOverride = destination; + CoreData.TEST_DataDirectoryOverride = destination; + return destination; + } + + [Fact] + public void ImportCopiesUserDataButNotCaches() + { + string source = CreateSource(); + string destination = UsePortableDestination(); + + int copied = PortableDataImport.Import(source); + + Assert.Equal(3, copied); + Assert.True(File.Exists(Path.Combine(destination, "Configuration", "EnableScoop"))); + Assert.True(File.Exists(Path.Combine(destination, "Configuration", "Settings.json"))); + Assert.True(File.Exists(Path.Combine(destination, "InstallationOptions", "winget.pkg.json"))); + Assert.False(Directory.Exists(Path.Combine(destination, "CachedMedia"))); + } + + [Fact] + public void ImportLeavesTheSourceUntouched() + { + string source = CreateSource(); + UsePortableDestination(); + + PortableDataImport.Import(source); + + Assert.True(File.Exists(Path.Combine(source, "Configuration", "EnableScoop"))); + Assert.True(File.Exists(Path.Combine(source, "InstallationOptions", "winget.pkg.json"))); + } + + [Fact] + public void ImportNeverOverwritesAnExistingFile() + { + string source = CreateSource(); + string destination = UsePortableDestination(); + Directory.CreateDirectory(Path.Combine(destination, "Configuration")); + File.WriteAllText(Path.Combine(destination, "Configuration", "Settings.json"), "portable"); + + int copied = PortableDataImport.Import(source); + + Assert.Equal(2, copied); + Assert.Equal("portable", File.ReadAllText(Path.Combine(destination, "Configuration", "Settings.json"))); + } + + [Fact] + public void NoSourceIsOfferedWhenNotPortable() + { + AppPaths.TEST_PortableDataDirectoryOverride = null; + + Assert.Null(PortableDataImport.FindImportableSource()); + } + } +} diff --git a/src/UniGetUI.Core.Data/CoreData.cs b/src/UniGetUI.Core.Data/CoreData.cs index 7b50171104..f485503f15 100644 --- a/src/UniGetUI.Core.Data/CoreData.cs +++ b/src/UniGetUI.Core.Data/CoreData.cs @@ -108,6 +108,12 @@ private static bool UsesPrefixedCalendarReleaseTags(string versionName) public static bool IsPortable => AppPaths.IsPortable; + /// + /// Where the per-user data directory lives, regardless of whether portable mode is + /// active. Unlike this creates and migrates nothing. + /// + public static string PerUserDataDirectoryPath => Path.Join(GetLocalDataRoot(), "UniGetUI"); + public static string? TEST_DataDirectoryOverride { private get; set; } /// diff --git a/src/UniGetUI.Core.Data/PortableDataImport.cs b/src/UniGetUI.Core.Data/PortableDataImport.cs new file mode 100644 index 0000000000..d19f9020df --- /dev/null +++ b/src/UniGetUI.Core.Data/PortableDataImport.cs @@ -0,0 +1,113 @@ +using UniGetUI.Core.Logging; + +namespace UniGetUI.Core.Data +{ + /// + /// Brings a per-user installation's settings into a freshly created portable folder. + /// Only the directories a user would miss are considered; caches are left behind because + /// they are rebuilt on demand and dwarf the rest. + /// + public static class PortableDataImport + { + private static readonly string[] ImportableDirectoryNames = + [ + "Configuration", + "InstallationOptions", + ]; + + /// + /// The per-user directory worth importing from, or null when there is nothing to offer. + /// Showing this at most once is the caller's business: startup writes settings files + /// before anything could ask, so the portable folder's own contents say nothing about + /// whether the user has seen the offer. + /// + public static string? FindImportableSource() + { + if (!AppPaths.IsPortable) + return null; + + try + { + string source = CoreData.PerUserDataDirectoryPath; + if (PathsAreEqual(source, CoreData.UniGetUIDataDirectory) || !HasImportableContent(source)) + return null; + + return source; + } + catch (Exception ex) + { + Logger.Warn("Could not determine whether portable settings can be imported"); + Logger.Warn(ex); + return null; + } + } + + /// + /// Copies the importable directories into the portable data directory and returns the + /// number of files copied. Existing files are never overwritten, and the source is left + /// untouched so a co-installed per-user instance keeps working. + /// + public static int Import(string sourceDirectory) + { + string destination = CoreData.UniGetUIDataDirectory; + int copied = 0; + + foreach (string directoryName in ImportableDirectoryNames) + { + string source = Path.Join(sourceDirectory, directoryName); + if (!Directory.Exists(source)) + continue; + + copied += CopyDirectory(source, Path.Join(destination, directoryName)); + } + + Logger.ImportantInfo($"Imported {copied} settings file(s) from {sourceDirectory}"); + return copied; + } + + private static int CopyDirectory(string source, string destination) + { + Directory.CreateDirectory(destination); + int copied = 0; + + foreach (string file in Directory.GetFiles(source, "*", SearchOption.AllDirectories)) + { + string relativePath = Path.GetRelativePath(source, file); + string target = Path.Join(destination, relativePath); + + if (File.Exists(target)) + continue; + + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target); + copied++; + } + + return copied; + } + + private static bool HasImportableContent(string directory) + { + foreach (string directoryName in ImportableDirectoryNames) + { + string candidate = Path.Join(directory, directoryName); + if (Directory.Exists(candidate) + && Directory.EnumerateFiles(candidate, "*", SearchOption.AllDirectories).Any()) + { + return true; + } + } + + return false; + } + + private static bool PathsAreEqual(string left, string right) + { + return string.Equals( + Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal + ); + } + } +} diff --git a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs index d97cfeb0e7..e8dd551c0e 100644 --- a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs +++ b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs @@ -40,6 +40,7 @@ public enum K AlreadyWarnedAboutAdmin, AlreadyWarnedAboutChocolateyMigration, ShownTelemetryBanner, + ShownPortableImportBanner, CollapseNavMenuOnWideScreen, NavMenuMode, EnablePackageBackup_LOCAL, @@ -175,6 +176,7 @@ public static string ResolveKey(K key) K.AlreadyWarnedAboutAdmin => "AlreadyWarnedAboutAdmin", K.AlreadyWarnedAboutChocolateyMigration => "AlreadyWarnedAboutChocolateyMigration", K.ShownTelemetryBanner => "ShownTelemetryBanner", + K.ShownPortableImportBanner => "ShownPortableImportBanner", K.CollapseNavMenuOnWideScreen => "CollapseNavMenuOnWideScreen", K.NavMenuMode => "NavMenuMode", K.EnablePackageBackup_LOCAL => "EnablePackageBackup", From 90c6d5446d75902c925b14ede76e9ab407277b39 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 1 Sep 2026 14:20:33 -0400 Subject: [PATCH 03/10] Keep the auto-updater from converting a portable install --- src/Shared/AutoUpdater.InstallerArguments.cs | 24 +++++++++++++ .../Infrastructure/AvaloniaAutoUpdater.cs | 12 ++++++- .../UniGetUI.Avalonia.csproj | 1 + src/UniGetUI.Tests/AutoUpdaterTests.cs | 35 +++++++++++++++++++ src/UniGetUI.Tests/UniGetUI.Tests.csproj | 1 + 5 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 src/Shared/AutoUpdater.InstallerArguments.cs diff --git a/src/Shared/AutoUpdater.InstallerArguments.cs b/src/Shared/AutoUpdater.InstallerArguments.cs new file mode 100644 index 0000000000..8c19d521ad --- /dev/null +++ b/src/Shared/AutoUpdater.InstallerArguments.cs @@ -0,0 +1,24 @@ +namespace UniGetUI.Shared; + +internal static class AutoUpdaterInstallerArguments +{ + private const string CommonWindowsArguments = + "/SILENT /SUPPRESSMSGBOXES /NORESTART /SP- /NoVCRedist /NoEdgeWebView /NoWinGet /NoRedirectionGuard /NoDesktopShortcut"; + + /// + /// Arguments for the Windows installer when it is run to update an existing copy. + /// A portable copy is pinned to its own directory and re-selects the portable task, + /// because the installer would otherwise fall back to its default directory and + /// install a second, regular copy elsewhere. + /// + internal static string ForWindows(bool isPortable, string installationDirectory) + { + if (!isPortable || string.IsNullOrWhiteSpace(installationDirectory)) + { + return CommonWindowsArguments; + } + + string directory = installationDirectory.TrimEnd('\\', '/'); + return $"{CommonWindowsArguments} /TASKS=\"portableinstall\" /DIR=\"{directory}\""; + } +} diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs index f0b0895e14..a4ae056ea3 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs @@ -14,6 +14,7 @@ using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.Tools; +using UniGetUI.Shared; namespace UniGetUI.Avalonia.Infrastructure; @@ -668,13 +669,22 @@ private static async Task LaunchInstallerAsync(string installerLocation) return; } + string installerArguments = AutoUpdaterInstallerArguments.ForWindows( + CoreData.IsPortable, + CoreData.UniGetUIExecutableDirectory); + + if (CoreData.IsPortable) + { + LogUpdateInfo($"Portable install: updating in place at {CoreData.UniGetUIExecutableDirectory}"); + } + LogUpdateInfo($"Launching installer: {installerLocation}"); using Process p = new() { StartInfo = new ProcessStartInfo { FileName = installerLocation, - Arguments = "/SILENT /SUPPRESSMSGBOXES /NORESTART /SP- /NoVCRedist /NoEdgeWebView /NoWinGet /NoRedirectionGuard /NoDesktopShortcut", + Arguments = installerArguments, UseShellExecute = true, CreateNoWindow = true, }, diff --git a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj index eeec41faf4..a64dcd8f6a 100644 --- a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj +++ b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj @@ -218,6 +218,7 @@ + diff --git a/src/UniGetUI.Tests/AutoUpdaterTests.cs b/src/UniGetUI.Tests/AutoUpdaterTests.cs index c6e75c1cd5..c5d456ad0d 100644 --- a/src/UniGetUI.Tests/AutoUpdaterTests.cs +++ b/src/UniGetUI.Tests/AutoUpdaterTests.cs @@ -6,6 +6,41 @@ namespace UniGetUI.Tests; public sealed class AutoUpdaterTests { + [Fact] + public void InstallerArguments_LeaveARegularInstallToTheInstallersOwnDirectoryLogic() + { + string arguments = AutoUpdaterInstallerArguments.ForWindows(false, @"C:\Program Files\UniGetUI"); + + Assert.DoesNotContain("/DIR=", arguments); + Assert.DoesNotContain("/TASKS=", arguments); + Assert.Contains("/SILENT", arguments); + } + + [Fact] + public void InstallerArguments_PinAPortableInstallToItsOwnDirectory() + { + string arguments = AutoUpdaterInstallerArguments.ForWindows(true, @"E:\Portable Apps\UniGetUI"); + + Assert.Contains(@"/DIR=""E:\Portable Apps\UniGetUI""", arguments); + Assert.Contains(@"/TASKS=""portableinstall""", arguments); + Assert.Contains("/SILENT", arguments); + } + + [Fact] + public void InstallerArguments_DoNotLeaveATrailingSeparatorBeforeTheClosingQuote() + { + string arguments = AutoUpdaterInstallerArguments.ForWindows(true, @"E:\UniGetUI\"); + + Assert.Contains(@"/DIR=""E:\UniGetUI""", arguments); + Assert.DoesNotContain(@"\""", arguments); + } + + [Fact] + public void InstallerArguments_FallBackToDefaultsWhenTheDirectoryIsUnknown() + { + Assert.DoesNotContain("/DIR=", AutoUpdaterInstallerArguments.ForWindows(true, "")); + } + [Theory] [InlineData("https://devolutions.net/productinfo.json", false, true)] [InlineData("https://updates.devolutions.net/productinfo.json", false, true)] diff --git a/src/UniGetUI.Tests/UniGetUI.Tests.csproj b/src/UniGetUI.Tests/UniGetUI.Tests.csproj index f61fb16a71..c23da156c1 100644 --- a/src/UniGetUI.Tests/UniGetUI.Tests.csproj +++ b/src/UniGetUI.Tests/UniGetUI.Tests.csproj @@ -47,5 +47,6 @@ + From 07b058b9c28cb6bd1c59ef9e65b1f1bb862cd87c Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 1 Sep 2026 15:21:37 -0400 Subject: [PATCH 04/10] Keep Pinget's storage inside the portable folder --- .../Infrastructure/AvaloniaAppHost.cs | 2 + .../ProcessEnvironmentConfigurator.cs | 37 +++++++++++++++++++ .../UniGetUI.Avalonia.csproj | 2 +- ...GetUI.PackageEngine.Managers.WinGet.csproj | 2 +- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs index d48405ee53..f9fab40876 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs @@ -60,6 +60,8 @@ public static void Run(string[] args) AppDomain.CurrentDomain.UnhandledException += (_, e) => CrashHandler.ReportFatalException((Exception)e.ExceptionObject); + ProcessEnvironmentConfigurator.ConfigurePingetStorage(); + if (ShouldPrepareCliConsole(args)) { WindowsConsoleHost.PrepareCliIO(); diff --git a/src/UniGetUI.Avalonia/Infrastructure/ProcessEnvironmentConfigurator.cs b/src/UniGetUI.Avalonia/Infrastructure/ProcessEnvironmentConfigurator.cs index 7abe3ce280..f06386209e 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/ProcessEnvironmentConfigurator.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/ProcessEnvironmentConfigurator.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using UniGetUI.Core.Data; using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.Tools; @@ -21,6 +22,42 @@ public static void PrepareForCurrentPlatform() ApplyProxySettingsToProcess(); } + /// + /// Points Pinget at the portable folder so a portable copy leaves nothing in + /// %LOCALAPPDATA%\Devolutions\Pinget. The source mode has to be set alongside it: + /// an app-root override alone makes Pinget fall back to its own private source list + /// instead of the machine's real WinGet sources. An externally supplied value wins, + /// so an administrator can still place the store elsewhere. + /// + public static void ConfigurePingetStorage() + { + try + { + if (!CoreData.IsPortable) + return; + + SetIfUnset("PINGET_APPROOT", Path.Join(CoreData.UniGetUIDataDirectory, "Pinget")); + SetIfUnset("PINGET_SOURCE_MODE", "auto"); + } + catch (Exception ex) + { + Logger.Error("Could not point Pinget at the portable folder:"); + Logger.Error(ex); + } + } + + private static void SetIfUnset(string name, string value) + { + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(name))) + { + Logger.Info($"{name} is already set; leaving it untouched"); + return; + } + + Environment.SetEnvironmentVariable(name, value, EnvironmentVariableTarget.Process); + Logger.Info($"{name} set to {value}"); + } + public static void ApplyProxySettingsToProcess() { try diff --git a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj index a64dcd8f6a..3b8d36788e 100644 --- a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj +++ b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj @@ -198,7 +198,7 @@ - + diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/UniGetUI.PackageEngine.Managers.WinGet.csproj b/src/UniGetUI.PackageEngine.Managers.WinGet/UniGetUI.PackageEngine.Managers.WinGet.csproj index f90f9bebb8..ffa3ede4b8 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/UniGetUI.PackageEngine.Managers.WinGet.csproj +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/UniGetUI.PackageEngine.Managers.WinGet.csproj @@ -24,7 +24,7 @@ - + From 85510f19fc7baac9ae8ba69b5f8d86a3c2e6cb0f Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 1 Sep 2026 15:36:28 -0400 Subject: [PATCH 05/10] Keep package backups and document the two exceptions --- docs/PORTABLE.md | 27 ++++++++++++------- .../PortableDataImportTests.cs | 16 +++++++++++ src/UniGetUI.Core.Data/CoreData.cs | 8 ++++++ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/docs/PORTABLE.md b/docs/PORTABLE.md index 2267dbbd85..04bd296ef2 100644 --- a/docs/PORTABLE.md +++ b/docs/PORTABLE.md @@ -82,11 +82,17 @@ whenever the bundle is replaced by an update. Re-create it after upgrading. | Stored secrets, macOS and Linux | `/SecureStorage` | `/Settings/SecureStorage` | | Stored secrets, Windows | Credential Manager | Credential Manager (**not** relocated) | | Session log, WebView2 profile, update logs | `%TEMP%\UniGetUI` on Windows; `$TMPDIR/UniGetUI` elsewhere | `\Settings\Temp` | -| Default package-backup folder | `Documents\UniGetUI` | `Documents\UniGetUI` (**not** relocated) | +| Default package-backup folder | `Documents\UniGetUI` | `\Settings\Backups` | +| Elevated secure settings, Windows | `%ProgramFiles%\UniGetUI\SecureSettings` | `%ProgramFiles%\UniGetUI\SecureSettings` (**not** relocated) | -Package backups are one exception: their default location stays in the user's Documents -folder, and portable mode does not move it. Point it somewhere inside the portable folder from -the Backup settings page if you want backups to travel with the app. +Package backups follow the portable folder, so they travel with the app. A path chosen on the +Backup settings page always wins over that default. + +Two things deliberately stay put. Elevated secure settings — the toggles that permit CLI +arguments, custom manager paths and pre/post-operation commands — live under `%ProgramFiles%` +precisely because writing there needs administrator rights. Moving them into a user-writable +portable folder would let any process running as the user grant UniGetUI the right to execute +arbitrary commands, so they stay where they are. The scratch directory holds files that are rebuilt on demand: the session log, the crash report left behind for the next launch, the per-attempt auto-updater log, the WebView2 profile, and the @@ -94,11 +100,14 @@ left behind for the next launch, the per-attempt auto-updater log, the WebView2 it inside the portable folder, so a portable copy leaves nothing behind in the system temporary directory. It is safe to delete while UniGetUI is not running. -The GitHub backup token is the other exception, and where it lives depends on the platform. On -macOS and Linux it is written to `SecureStorage` inside the data directory, so it travels with a -portable folder, as a plain file on disk. On Windows it is held in Credential Manager instead, -so a portable copy does not carry the login, and every portable copy on one machine shares -the same stored token unless `UNIGETUI_GITHUB_TOKEN_NAMESPACE` is set to separate them. +The GitHub backup token is the second, and where it lives depends on the platform. On Windows it +is held in Credential Manager, which encrypts it per user and does not travel with the folder, so +a portable copy asks you to sign in on each machine. On macOS and Linux it is written to +`SecureStorage` inside the data directory **as a plain file**, so it does travel — treat a +portable folder carrying one as you would the token itself. Relocating the Windows token into the +portable folder would mean that same plaintext trade-off, on removable media, so it stays in +Credential Manager. Every portable copy on one machine shares the same stored token unless +`UNIGETUI_GITHUB_TOKEN_NAMESPACE` is set to separate them. Portable mode also does not relocate anything owned by the package managers themselves. WinGet, Scoop, Chocolatey, npm and the rest keep their own state in their usual per-user or system diff --git a/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs b/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs index 5d03c3ec1c..335a5dd639 100644 --- a/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs +++ b/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs @@ -90,6 +90,22 @@ public void ImportNeverOverwritesAnExistingFile() Assert.Equal("portable", File.ReadAllText(Path.Combine(destination, "Configuration", "Settings.json"))); } + [Fact] + public void TheDefaultBackupFolderMovesIntoThePortableFolder() + { + string destination = UsePortableDestination(); + + Assert.Equal(Path.Join(destination, "Backups"), CoreData.UniGetUI_DefaultBackupDirectory); + } + + [Fact] + public void TheDefaultBackupFolderStaysInDocumentsWhenNotPortable() + { + AppPaths.TEST_PortableDataDirectoryOverride = null; + + Assert.DoesNotContain("Backups", CoreData.UniGetUI_DefaultBackupDirectory); + } + [Fact] public void NoSourceIsOfferedWhenNotPortable() { diff --git a/src/UniGetUI.Core.Data/CoreData.cs b/src/UniGetUI.Core.Data/CoreData.cs index f485503f15..d91ab2c084 100644 --- a/src/UniGetUI.Core.Data/CoreData.cs +++ b/src/UniGetUI.Core.Data/CoreData.cs @@ -287,6 +287,14 @@ public static string UniGetUI_DefaultBackupDirectory { get { + if (AppPaths.PortableDataDirectory is { } portableDirectory) + { + string portableBackups = Path.Join(portableDirectory, "Backups"); + if (!Directory.Exists(portableBackups)) + Directory.CreateDirectory(portableBackups); + return portableBackups; + } + string documentsDirectory = GetDocumentsRoot(); string old_dir = Path.Join(documentsDirectory, "WingetUI"); string new_dir = Path.Join(documentsDirectory, "UniGetUI"); From 84cd2a7a1533e6edf0c9500a3fe9ea5f0fc42a35 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 1 Sep 2026 15:52:31 -0400 Subject: [PATCH 06/10] Review fixes: sticky success banner, memory barrier, test isolation --- src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs | 8 ++++++-- src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs | 3 ++- src/UniGetUI.Core.Logger/AppPaths.cs | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index f12cc69ce9..150afc859b 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -568,8 +568,6 @@ private void ShowPortableImportBanner(string importableSource) PortableImportBanner.ActionButtonText = CoreTools.Translate("Import"); PortableImportBanner.ActionButtonCommand = new CommunityToolkit.Mvvm.Input.RelayCommand(() => { - PortableImportBanner.ActionButtonText = ""; - PortableImportBanner.ActionButtonCommand = null; Settings.Set(Settings.K.ShownPortableImportBanner, true); try @@ -579,6 +577,9 @@ private void ShowPortableImportBanner(string importableSource) PortableImportBanner.Title = CoreTools.Translate("Settings imported"); PortableImportBanner.Message = CoreTools.Translate( "{0} file(s) were copied. Restart UniGetUI to apply them.", copied); + PortableImportBanner.ActionButtonText = CoreTools.Translate("Restart"); + PortableImportBanner.ActionButtonCommand = new CommunityToolkit.Mvvm.Input.RelayCommand( + AppRestartHelper.Restart); } catch (Exception ex) { @@ -587,6 +588,9 @@ private void ShowPortableImportBanner(string importableSource) PortableImportBanner.Severity = InfoBarSeverity.Error; PortableImportBanner.Title = CoreTools.Translate("Could not import settings"); PortableImportBanner.Message = ex.Message; + PortableImportBanner.ActionButtonText = CoreTools.Translate("View log"); + PortableImportBanner.ActionButtonCommand = new CommunityToolkit.Mvvm.Input.RelayCommand( + () => NavigateTo(PageType.OwnLog)); } }); PortableImportBanner.OnClosed = () => Settings.Set(Settings.K.ShownPortableImportBanner, true); diff --git a/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs b/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs index 335a5dd639..f11cc337c1 100644 --- a/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs +++ b/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs @@ -1,8 +1,9 @@ using UniGetUI.Core.Logging; +[assembly: CollectionBehavior(DisableTestParallelization = true)] + namespace UniGetUI.Core.Data.Tests { - [Collection("PortableDataImport")] public sealed class PortableDataImportTests : IDisposable { private readonly string _testRoot; diff --git a/src/UniGetUI.Core.Logger/AppPaths.cs b/src/UniGetUI.Core.Logger/AppPaths.cs index 7bfc064612..047ecc1948 100644 --- a/src/UniGetUI.Core.Logger/AppPaths.cs +++ b/src/UniGetUI.Core.Logger/AppPaths.cs @@ -13,7 +13,7 @@ public static class AppPaths private static readonly Lock PortableModeLock = new(); private static string? __installation_directory; - private static bool __portable_mode_resolved; + private static volatile bool __portable_mode_resolved; private static string? __portable_data_directory; [ThreadStatic] From 79c5d81e4d7e10127daa743d5fded734d92ff3ee Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 1 Sep 2026 16:34:06 -0400 Subject: [PATCH 07/10] Address review: volume roots, import retry, translations, docs --- docs/PORTABLE.md | 8 ++++++-- src/Languages/lang_en.json | 7 ++++++- src/Shared/AutoUpdater.InstallerArguments.cs | 2 +- .../ViewModels/MainWindowViewModel.cs | 4 ++-- src/UniGetUI.Core.Logger/AppPaths.cs | 5 +---- src/UniGetUI.Core.Logging.Tests/AppPathsTests.cs | 13 +++++++++++++ src/UniGetUI.Tests/AutoUpdaterTests.cs | 9 +++++++++ 7 files changed, 38 insertions(+), 10 deletions(-) diff --git a/docs/PORTABLE.md b/docs/PORTABLE.md index 04bd296ef2..ec9f3f7309 100644 --- a/docs/PORTABLE.md +++ b/docs/PORTABLE.md @@ -97,8 +97,12 @@ arbitrary commands, so they stay where they are. The scratch directory holds files that are rebuilt on demand: the session log, the crash report left behind for the next launch, the per-attempt auto-updater log, the WebView2 profile, and the `%TEMP%` handed to package-manager subprocesses when UniGetUI runs elevated. Portable mode moves -it inside the portable folder, so a portable copy leaves nothing behind in the system temporary -directory. It is safe to delete while UniGetUI is not running. +all of those inside the portable folder. It is safe to delete while UniGetUI is not running. + +Two macOS-only artifacts still land in the system temporary directory and are not covered by +this: the single-instance lock file, which the OS releases on exit but does not delete, and the +scratch files written when launching a manual install in Terminal. Both are macOS code paths; +on Windows the single-instance guard is a named mutex and writes nothing. The GitHub backup token is the second, and where it lives depends on the platform. On Windows it is held in Credential Manager, which encrypts it per user and does not travel with the folder, so diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index cfb1d3c97d..cb2e775000 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -1064,5 +1064,10 @@ "Stop tracking this shortcut": "Stop tracking this shortcut", "Shortcut management": "Shortcut management", "Manage Start Menu shortcuts": "Manage Start Menu shortcuts", - "That folder name cannot be used. Type a plain name, without a drive letter or a .. segment.": "That folder name cannot be used. Type a plain name, without a drive letter or a .. segment." + "That folder name cannot be used. Type a plain name, without a drive letter or a .. segment.": "That folder name cannot be used. Type a plain name, without a drive letter or a .. segment.", + "Import your previous settings?": "Import your previous settings?", + "UniGetUI is running in portable mode and started with empty settings. Settings from a previous installation were found at {0}.": "UniGetUI is running in portable mode and started with empty settings. Settings from a previous installation were found at {0}.", + "Settings imported": "Settings imported", + "{0} file(s) were copied. Restart UniGetUI to apply them.": "{0} file(s) were copied. Restart UniGetUI to apply them.", + "Could not import settings": "Could not import settings" } diff --git a/src/Shared/AutoUpdater.InstallerArguments.cs b/src/Shared/AutoUpdater.InstallerArguments.cs index 8c19d521ad..7fbc02ce4b 100644 --- a/src/Shared/AutoUpdater.InstallerArguments.cs +++ b/src/Shared/AutoUpdater.InstallerArguments.cs @@ -18,7 +18,7 @@ internal static string ForWindows(bool isPortable, string installationDirectory) return CommonWindowsArguments; } - string directory = installationDirectory.TrimEnd('\\', '/'); + string directory = Path.TrimEndingDirectorySeparator(installationDirectory); return $"{CommonWindowsArguments} /TASKS=\"portableinstall\" /DIR=\"{directory}\""; } } diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index 150afc859b..1f18100b14 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -568,11 +568,10 @@ private void ShowPortableImportBanner(string importableSource) PortableImportBanner.ActionButtonText = CoreTools.Translate("Import"); PortableImportBanner.ActionButtonCommand = new CommunityToolkit.Mvvm.Input.RelayCommand(() => { - Settings.Set(Settings.K.ShownPortableImportBanner, true); - try { int copied = PortableDataImport.Import(importableSource); + Settings.Set(Settings.K.ShownPortableImportBanner, true); PortableImportBanner.Severity = InfoBarSeverity.Success; PortableImportBanner.Title = CoreTools.Translate("Settings imported"); PortableImportBanner.Message = CoreTools.Translate( @@ -585,6 +584,7 @@ private void ShowPortableImportBanner(string importableSource) { Logger.Error("Could not import settings into the portable folder"); Logger.Error(ex); + PortableImportBanner.OnClosed = null; PortableImportBanner.Severity = InfoBarSeverity.Error; PortableImportBanner.Title = CoreTools.Translate("Could not import settings"); PortableImportBanner.Message = ex.Message; diff --git a/src/UniGetUI.Core.Logger/AppPaths.cs b/src/UniGetUI.Core.Logger/AppPaths.cs index 047ecc1948..ceecedc746 100644 --- a/src/UniGetUI.Core.Logger/AppPaths.cs +++ b/src/UniGetUI.Core.Logger/AppPaths.cs @@ -144,10 +144,7 @@ public static string ResolveInstallationDirectory( private static string NormalizeDirectoryPath(string path) { - return Path.GetFullPath(path).TrimEnd( - Path.DirectorySeparatorChar, - Path.AltDirectorySeparatorChar - ); + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); } private static bool IsInstallRoot( diff --git a/src/UniGetUI.Core.Logging.Tests/AppPathsTests.cs b/src/UniGetUI.Core.Logging.Tests/AppPathsTests.cs index 3ccebe7e43..f4364d7b20 100644 --- a/src/UniGetUI.Core.Logging.Tests/AppPathsTests.cs +++ b/src/UniGetUI.Core.Logging.Tests/AppPathsTests.cs @@ -52,6 +52,19 @@ public void ResolvePortableDataDirectoryFallsBackWhenTheFolderIsNotWritable() Assert.Null(AppPaths.ResolvePortableDataDirectory(_testRoot)); } + [Theory] + [InlineData(@"E:\", @"E:\")] + [InlineData(@"E:\UniGetUI\", @"E:\UniGetUI")] + [InlineData(@"E:\UniGetUI", @"E:\UniGetUI")] + public void ResolveInstallationDirectoryTrimsTrailingSeparatorsButKeepsVolumeRoots( + string directory, + string expected) + { + Assert.Equal( + expected, + AppPaths.ResolveInstallationDirectory(directory, static _ => false, static _ => false)); + } + [Fact] public void ScratchDirectoryStaysOutsideTheInstallFolderWhenNotPortable() { diff --git a/src/UniGetUI.Tests/AutoUpdaterTests.cs b/src/UniGetUI.Tests/AutoUpdaterTests.cs index c5d456ad0d..5771064cb3 100644 --- a/src/UniGetUI.Tests/AutoUpdaterTests.cs +++ b/src/UniGetUI.Tests/AutoUpdaterTests.cs @@ -35,6 +35,15 @@ public void InstallerArguments_DoNotLeaveATrailingSeparatorBeforeTheClosingQuote Assert.DoesNotContain(@"\""", arguments); } + [Fact] + public void InstallerArguments_KeepTheSeparatorForAVolumeRoot() + { + string arguments = AutoUpdaterInstallerArguments.ForWindows(true, @"E:\"); + + Assert.Contains(@"/DIR=""E:\""", arguments); + Assert.DoesNotContain(@"/DIR=""E:""", arguments); + } + [Fact] public void InstallerArguments_FallBackToDefaultsWhenTheDirectoryIsUnknown() { From 7b25a4c69800f4ca9552756aa6b09f0ab74c6043 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Wed, 2 Sep 2026 08:45:14 -0400 Subject: [PATCH 08/10] Address review: offer freshness, log redaction, test portability --- .../Infrastructure/AvaloniaAppHost.cs | 4 +-- .../ViewModels/MainWindowViewModel.cs | 25 +++++++++-------- .../PortableDataImportTests.cs | 25 +++++++++++++++++ src/UniGetUI.Core.Data/CoreData.cs | 5 +++- src/UniGetUI.Core.Data/PortableDataImport.cs | 16 +++++++---- src/UniGetUI.Core.Logger/AppPaths.cs | 17 +++++++++++ .../AppPathsTests.cs | 28 +++++++++++++------ 7 files changed, 90 insertions(+), 30 deletions(-) diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs index f9fab40876..23c5ad62ec 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs @@ -60,6 +60,8 @@ public static void Run(string[] args) AppDomain.CurrentDomain.UnhandledException += (_, e) => CrashHandler.ReportFatalException((Exception)e.ExceptionObject); + Logger.RedactUsername = Core.SettingsEngine.Settings.Get(Core.SettingsEngine.Settings.K.RedactUsernameInLog); + ProcessEnvironmentConfigurator.ConfigurePingetStorage(); if (ShouldPrepareCliConsole(args)) @@ -98,8 +100,6 @@ __ __ _ ______ __ __ ______ Welcome to UniGetUI Version {CoreData.VersionName} """; - Logger.RedactUsername = Core.SettingsEngine.Settings.Get(Core.SettingsEngine.Settings.K.RedactUsernameInLog); - Logger.ImportantInfo(textart); Logger.ImportantInfo(" "); Logger.ImportantInfo($"Build {CoreData.BuildNumber}"); diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index 1f18100b14..6dfc7b8a9d 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -559,14 +559,7 @@ public MainWindowViewModel() private void ShowPortableImportBanner(string importableSource) { - PortableImportBanner.Title = CoreTools.Translate("Import your previous settings?"); - PortableImportBanner.Message = CoreTools.Translate( - "UniGetUI is running in portable mode and started with empty settings. Settings from a previous installation were found at {0}.", - importableSource - ); - PortableImportBanner.IsClosable = true; - PortableImportBanner.ActionButtonText = CoreTools.Translate("Import"); - PortableImportBanner.ActionButtonCommand = new CommunityToolkit.Mvvm.Input.RelayCommand(() => + void RunImport() { try { @@ -588,11 +581,19 @@ private void ShowPortableImportBanner(string importableSource) PortableImportBanner.Severity = InfoBarSeverity.Error; PortableImportBanner.Title = CoreTools.Translate("Could not import settings"); PortableImportBanner.Message = ex.Message; - PortableImportBanner.ActionButtonText = CoreTools.Translate("View log"); - PortableImportBanner.ActionButtonCommand = new CommunityToolkit.Mvvm.Input.RelayCommand( - () => NavigateTo(PageType.OwnLog)); + PortableImportBanner.ActionButtonText = CoreTools.Translate("Retry"); + PortableImportBanner.ActionButtonCommand = new CommunityToolkit.Mvvm.Input.RelayCommand(RunImport); } - }); + } + + PortableImportBanner.Title = CoreTools.Translate("Import your previous settings?"); + PortableImportBanner.Message = CoreTools.Translate( + "UniGetUI is running in portable mode and started with empty settings. Settings from a previous installation were found at {0}.", + importableSource + ); + PortableImportBanner.IsClosable = true; + PortableImportBanner.ActionButtonText = CoreTools.Translate("Import"); + PortableImportBanner.ActionButtonCommand = new CommunityToolkit.Mvvm.Input.RelayCommand(RunImport); PortableImportBanner.OnClosed = () => Settings.Set(Settings.K.ShownPortableImportBanner, true); PortableImportBanner.IsOpen = true; } diff --git a/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs b/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs index f11cc337c1..ef9eae2c63 100644 --- a/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs +++ b/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs @@ -20,6 +20,7 @@ public PortableDataImportTests() public void Dispose() { CoreData.TEST_DataDirectoryOverride = null; + CoreData.TEST_PerUserDataDirectoryOverride = null; AppPaths.TEST_PortableDataDirectoryOverride = null; if (Directory.Exists(_testRoot)) @@ -107,6 +108,30 @@ public void TheDefaultBackupFolderStaysInDocumentsWhenNotPortable() Assert.DoesNotContain("Backups", CoreData.UniGetUI_DefaultBackupDirectory); } + [Fact] + public void ASourceIsOnlyOfferedToAFreshlyCreatedPortableFolder() + { + string source = CreateSource(); + CoreData.TEST_PerUserDataDirectoryOverride = source; + UsePortableDestination(); + + Assert.Equal( + source, + PortableDataImport.FindImportableSource(portableDirectoryWasCreated: true)); + Assert.Null(PortableDataImport.FindImportableSource(portableDirectoryWasCreated: false)); + } + + [Fact] + public void NoSourceIsOfferedWhenThePerUserDirectoryHasNoSettings() + { + string empty = Path.Combine(_testRoot, "Empty"); + Directory.CreateDirectory(empty); + CoreData.TEST_PerUserDataDirectoryOverride = empty; + UsePortableDestination(); + + Assert.Null(PortableDataImport.FindImportableSource(portableDirectoryWasCreated: true)); + } + [Fact] public void NoSourceIsOfferedWhenNotPortable() { diff --git a/src/UniGetUI.Core.Data/CoreData.cs b/src/UniGetUI.Core.Data/CoreData.cs index d91ab2c084..f1fae676b0 100644 --- a/src/UniGetUI.Core.Data/CoreData.cs +++ b/src/UniGetUI.Core.Data/CoreData.cs @@ -112,7 +112,10 @@ private static bool UsesPrefixedCalendarReleaseTags(string versionName) /// Where the per-user data directory lives, regardless of whether portable mode is /// active. Unlike this creates and migrates nothing. /// - public static string PerUserDataDirectoryPath => Path.Join(GetLocalDataRoot(), "UniGetUI"); + public static string? TEST_PerUserDataDirectoryOverride { private get; set; } + + public static string PerUserDataDirectoryPath => + TEST_PerUserDataDirectoryOverride ?? Path.Join(GetLocalDataRoot(), "UniGetUI"); public static string? TEST_DataDirectoryOverride { private get; set; } diff --git a/src/UniGetUI.Core.Data/PortableDataImport.cs b/src/UniGetUI.Core.Data/PortableDataImport.cs index d19f9020df..01ff905226 100644 --- a/src/UniGetUI.Core.Data/PortableDataImport.cs +++ b/src/UniGetUI.Core.Data/PortableDataImport.cs @@ -17,13 +17,17 @@ public static class PortableDataImport /// /// The per-user directory worth importing from, or null when there is nothing to offer. - /// Showing this at most once is the caller's business: startup writes settings files - /// before anything could ask, so the portable folder's own contents say nothing about - /// whether the user has seen the offer. + /// Requires that this run created the portable data directory: an established portable + /// copy has settings of its own, and merging a per-user installation's settings into it + /// is not what the offer promises. The folder's own contents cannot answer that, because + /// startup writes settings files before anything could ask. /// - public static string? FindImportableSource() + public static string? FindImportableSource() => + FindImportableSource(AppPaths.PortableDataDirectoryWasCreated); + + public static string? FindImportableSource(bool portableDirectoryWasCreated) { - if (!AppPaths.IsPortable) + if (!AppPaths.IsPortable || !portableDirectoryWasCreated) return null; try @@ -61,7 +65,7 @@ public static int Import(string sourceDirectory) copied += CopyDirectory(source, Path.Join(destination, directoryName)); } - Logger.ImportantInfo($"Imported {copied} settings file(s) from {sourceDirectory}"); + Logger.ImportantInfo($"Imported {copied} settings file(s) into the portable folder"); return copied; } diff --git a/src/UniGetUI.Core.Logger/AppPaths.cs b/src/UniGetUI.Core.Logger/AppPaths.cs index ceecedc746..69934b7f62 100644 --- a/src/UniGetUI.Core.Logger/AppPaths.cs +++ b/src/UniGetUI.Core.Logger/AppPaths.cs @@ -15,6 +15,7 @@ public static class AppPaths private static string? __installation_directory; private static volatile bool __portable_mode_resolved; private static string? __portable_data_directory; + private static bool __portable_data_directory_created; [ThreadStatic] private static bool __resolving_portable_mode; @@ -72,6 +73,19 @@ private static string? ResolvedPortableDataDirectory } } + /// + /// Whether this process created the portable data directory, meaning this is the first + /// run since the folder became portable. Reading it resolves portable mode first. + /// + public static bool PortableDataDirectoryWasCreated + { + get + { + _ = PortableDataDirectory; + return __portable_data_directory_created; + } + } + /// /// The directory for files that must not outlive an uninstall: the session log, the /// WebView2 profile, per-attempt update logs, and the %TEMP% handed to elevated @@ -124,7 +138,10 @@ public static string ResolveInstallationDirectory( try { if (!Directory.Exists(path)) + { Directory.CreateDirectory(path); + __portable_data_directory_created = true; + } File.WriteAllText( Path.Join(path, PortablePermissionTestFileName), diff --git a/src/UniGetUI.Core.Logging.Tests/AppPathsTests.cs b/src/UniGetUI.Core.Logging.Tests/AppPathsTests.cs index f4364d7b20..9ea0c7095e 100644 --- a/src/UniGetUI.Core.Logging.Tests/AppPathsTests.cs +++ b/src/UniGetUI.Core.Logging.Tests/AppPathsTests.cs @@ -52,17 +52,27 @@ public void ResolvePortableDataDirectoryFallsBackWhenTheFolderIsNotWritable() Assert.Null(AppPaths.ResolvePortableDataDirectory(_testRoot)); } - [Theory] - [InlineData(@"E:\", @"E:\")] - [InlineData(@"E:\UniGetUI\", @"E:\UniGetUI")] - [InlineData(@"E:\UniGetUI", @"E:\UniGetUI")] - public void ResolveInstallationDirectoryTrimsTrailingSeparatorsButKeepsVolumeRoots( - string directory, - string expected) + [Fact] + public void ResolveInstallationDirectoryKeepsAVolumeRootIntact() + { + string root = Path.GetPathRoot(Path.GetFullPath("."))!; + + Assert.Equal( + root, + AppPaths.ResolveInstallationDirectory(root, static _ => false, static _ => false)); + } + + [Fact] + public void ResolveInstallationDirectoryTrimsATrailingSeparator() { + string directory = Path.Join(Path.GetFullPath("."), "UniGetUI"); + Assert.Equal( - expected, - AppPaths.ResolveInstallationDirectory(directory, static _ => false, static _ => false)); + directory, + AppPaths.ResolveInstallationDirectory( + directory + Path.DirectorySeparatorChar, + static _ => false, + static _ => false)); } [Fact] From 70c633f67fc65c629ad93934f4890d0ca3495e87 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Wed, 2 Sep 2026 09:00:37 -0400 Subject: [PATCH 09/10] Document the relocated Pinget store --- docs/PORTABLE.md | 46 +++++++++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/docs/PORTABLE.md b/docs/PORTABLE.md index ec9f3f7309..473a432de5 100644 --- a/docs/PORTABLE.md +++ b/docs/PORTABLE.md @@ -83,6 +83,7 @@ whenever the bundle is replaced by an update. Re-create it after upgrading. | Stored secrets, Windows | Credential Manager | Credential Manager (**not** relocated) | | Session log, WebView2 profile, update logs | `%TEMP%\UniGetUI` on Windows; `$TMPDIR/UniGetUI` elsewhere | `\Settings\Temp` | | Default package-backup folder | `Documents\UniGetUI` | `\Settings\Backups` | +| Bundled Pinget store, Windows | `%LOCALAPPDATA%\Devolutions\Pinget` | `\Settings\Pinget` | | Elevated secure settings, Windows | `%ProgramFiles%\UniGetUI\SecureSettings` | `%ProgramFiles%\UniGetUI\SecureSettings` (**not** relocated) | Package backups follow the portable folder, so they travel with the app. A path chosen on the @@ -113,30 +114,49 @@ portable folder would mean that same plaintext trade-off, on removable media, so Credential Manager. Every portable copy on one machine shares the same stored token unless `UNIGETUI_GITHUB_TOKEN_NAMESPACE` is set to separate them. -Portable mode also does not relocate anything owned by the package managers themselves. WinGet, -Scoop, Chocolatey, npm and the rest keep their own state in their usual per-user or system -locations, and the packages they install are installed normally. +Portable mode does not relocate anything owned by a package manager you installed yourself. +WinGet, Scoop, Chocolatey, npm and the rest keep their own state in their usual per-user or +system locations, and the packages they install are installed normally. + +Pinget is the exception, because UniGetUI ships it rather than finding it on the machine. It +backs the WinGet integration, runs on every WinGet configuration rather than only when selected +as the CLI, and by default keeps its source cache and downloaded manifests in +`%LOCALAPPDATA%\Devolutions\Pinget`. A portable copy points it at `\Settings\Pinget` +instead, via the `PINGET_APPROOT` environment variable, so that cache travels with the folder +rather than accumulating in the user profile. Setting `PINGET_APPROOT` yourself takes precedence. + +What does *not* change is which sources it resolves against: UniGetUI also sets +`PINGET_SOURCE_MODE=auto`, so a portable copy still mirrors the machine's configured WinGet +sources rather than falling back to a private list. Without that, sources you added to WinGet +would silently be missing. The cache starts empty in a new portable folder, so the first search +re-downloads the source index. ## Importing settings from a per-user installation A portable folder starts empty, so an existing installation's settings are not picked up automatically — they stay in the per-user data directory, untouched. -The first time UniGetUI runs portable and finds settings there, it offers a one-time -**Import** action in a notification. Accepting copies `Configuration` and `InstallationOptions` -into the portable folder; caches are skipped because they are rebuilt on demand and are far -larger than the settings themselves. Nothing is overwritten and nothing is removed from the -source, so a per-user installation on the same machine keeps working. Restart UniGetUI -afterwards for the imported settings to take effect. +On the run that creates the portable folder — and only that run — UniGetUI checks the per-user +directory and, if it holds settings, offers a one-time **Import** action in a notification. An +established portable copy is never offered the import, because merging another installation's +settings into a folder already in use is not what the offer is for. + +Accepting copies `Configuration` and `InstallationOptions` into the portable folder; caches are +skipped because they are rebuilt on demand and are far larger than the settings themselves. +Nothing is overwritten and nothing is removed from the source, so a per-user installation on the +same machine keeps working. Restart UniGetUI afterwards for the imported settings to take +effect. -Dismissing the notification, or importing once, stops it from appearing again. This matters for -a portable copy carried between machines: it will never silently absorb the settings of a -machine it happens to be plugged into. +This matters for a portable copy carried between machines: its folder is created once, on the +first machine, so it is never offered — and never silently absorbs — the settings of a machine +it is later plugged into. ## What a portable install does not register The Windows installer registers these only for a regular installation, so a portable install -gets none of them: +gets none of them. An auto-update keeps it that way: the updater re-selects the portable +installation type and pins the installer to the existing folder, so updating does not quietly +turn a portable copy into a regular one. | Feature | Consequence when portable | | --- | --- | From 1dd69948e7c9f6bf345eb2f8757220caed76660b Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Wed, 2 Sep 2026 09:20:37 -0400 Subject: [PATCH 10/10] Persist the portable first-run state in the folder --- docs/PORTABLE.md | 17 +++++---- .../ViewModels/MainWindowViewModel.cs | 7 ++-- .../PortableDataImportTests.cs | 8 ++-- src/UniGetUI.Core.Data/PortableDataImport.cs | 14 +++---- src/UniGetUI.Core.Logger/AppPaths.cs | 37 +++++++++++++------ .../AppPathsTests.cs | 31 ++++++++++++++++ .../SettingsEngine_Names.cs | 2 - 7 files changed, 81 insertions(+), 35 deletions(-) diff --git a/docs/PORTABLE.md b/docs/PORTABLE.md index 473a432de5..846adb2159 100644 --- a/docs/PORTABLE.md +++ b/docs/PORTABLE.md @@ -136,10 +136,12 @@ re-downloads the source index. A portable folder starts empty, so an existing installation's settings are not picked up automatically — they stay in the per-user data directory, untouched. -On the run that creates the portable folder — and only that run — UniGetUI checks the per-user -directory and, if it holds settings, offers a one-time **Import** action in a notification. An -established portable copy is never offered the import, because merging another installation's -settings into a folder already in use is not what the offer is for. +A new portable folder is marked as awaiting its first run. On the first launch that reaches the +interface, UniGetUI checks the per-user directory and, if it holds settings, offers a one-time +**Import** action in a notification. The mark is recorded in the folder, so a first launch that +never reaches the interface — a headless run, a command-line invocation, a crash — does not +consume the offer. An established portable copy is never offered the import, because merging +another installation's settings into a folder already in use is not what the offer is for. Accepting copies `Configuration` and `InstallationOptions` into the portable folder; caches are skipped because they are rebuilt on demand and are far larger than the settings themselves. @@ -147,9 +149,10 @@ Nothing is overwritten and nothing is removed from the source, so a per-user ins same machine keeps working. Restart UniGetUI afterwards for the imported settings to take effect. -This matters for a portable copy carried between machines: its folder is created once, on the -first machine, so it is never offered — and never silently absorbs — the settings of a machine -it is later plugged into. +Importing, or dismissing the notification, clears the mark. A failed import does not, so it can +be retried on the next launch. This matters for a portable copy carried between machines: the +mark is cleared on the first machine, so the copy is never offered — and never silently absorbs +— the settings of a machine it is later plugged into. ## What a portable install does not register diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index 6dfc7b8a9d..29f0cb42c9 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -541,8 +541,7 @@ public MainWindowViewModel() TelemetryWarner.IsOpen = true; } - if (!Settings.Get(Settings.K.ShownPortableImportBanner) - && PortableDataImport.FindImportableSource() is { } importableSource) + if (PortableDataImport.FindImportableSource() is { } importableSource) { ShowPortableImportBanner(importableSource); } @@ -564,7 +563,7 @@ void RunImport() try { int copied = PortableDataImport.Import(importableSource); - Settings.Set(Settings.K.ShownPortableImportBanner, true); + AppPaths.ClearFirstPortableRun(); PortableImportBanner.Severity = InfoBarSeverity.Success; PortableImportBanner.Title = CoreTools.Translate("Settings imported"); PortableImportBanner.Message = CoreTools.Translate( @@ -594,7 +593,7 @@ void RunImport() PortableImportBanner.IsClosable = true; PortableImportBanner.ActionButtonText = CoreTools.Translate("Import"); PortableImportBanner.ActionButtonCommand = new CommunityToolkit.Mvvm.Input.RelayCommand(RunImport); - PortableImportBanner.OnClosed = () => Settings.Set(Settings.K.ShownPortableImportBanner, true); + PortableImportBanner.OnClosed = AppPaths.ClearFirstPortableRun; PortableImportBanner.IsOpen = true; } diff --git a/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs b/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs index ef9eae2c63..0831570f24 100644 --- a/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs +++ b/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs @@ -109,7 +109,7 @@ public void TheDefaultBackupFolderStaysInDocumentsWhenNotPortable() } [Fact] - public void ASourceIsOnlyOfferedToAFreshlyCreatedPortableFolder() + public void ASourceIsOnlyOfferedOnTheFirstRunOfAPortableFolder() { string source = CreateSource(); CoreData.TEST_PerUserDataDirectoryOverride = source; @@ -117,8 +117,8 @@ public void ASourceIsOnlyOfferedToAFreshlyCreatedPortableFolder() Assert.Equal( source, - PortableDataImport.FindImportableSource(portableDirectoryWasCreated: true)); - Assert.Null(PortableDataImport.FindImportableSource(portableDirectoryWasCreated: false)); + PortableDataImport.FindImportableSource(isFirstPortableRun: true)); + Assert.Null(PortableDataImport.FindImportableSource(isFirstPortableRun: false)); } [Fact] @@ -129,7 +129,7 @@ public void NoSourceIsOfferedWhenThePerUserDirectoryHasNoSettings() CoreData.TEST_PerUserDataDirectoryOverride = empty; UsePortableDestination(); - Assert.Null(PortableDataImport.FindImportableSource(portableDirectoryWasCreated: true)); + Assert.Null(PortableDataImport.FindImportableSource(isFirstPortableRun: true)); } [Fact] diff --git a/src/UniGetUI.Core.Data/PortableDataImport.cs b/src/UniGetUI.Core.Data/PortableDataImport.cs index 01ff905226..a04b74e9c7 100644 --- a/src/UniGetUI.Core.Data/PortableDataImport.cs +++ b/src/UniGetUI.Core.Data/PortableDataImport.cs @@ -17,17 +17,17 @@ public static class PortableDataImport /// /// The per-user directory worth importing from, or null when there is nothing to offer. - /// Requires that this run created the portable data directory: an established portable - /// copy has settings of its own, and merging a per-user installation's settings into it - /// is not what the offer promises. The folder's own contents cannot answer that, because - /// startup writes settings files before anything could ask. + /// Requires that the portable folder has not completed a first run: an established + /// portable copy has settings of its own, and merging a per-user installation's settings + /// into it is not what the offer promises. The folder's own contents cannot answer that, + /// because startup writes settings files before anything could ask. /// public static string? FindImportableSource() => - FindImportableSource(AppPaths.PortableDataDirectoryWasCreated); + FindImportableSource(AppPaths.IsFirstPortableRun); - public static string? FindImportableSource(bool portableDirectoryWasCreated) + public static string? FindImportableSource(bool isFirstPortableRun) { - if (!AppPaths.IsPortable || !portableDirectoryWasCreated) + if (!AppPaths.IsPortable || !isFirstPortableRun) return null; try diff --git a/src/UniGetUI.Core.Logger/AppPaths.cs b/src/UniGetUI.Core.Logger/AppPaths.cs index 69934b7f62..a448aba9c8 100644 --- a/src/UniGetUI.Core.Logger/AppPaths.cs +++ b/src/UniGetUI.Core.Logger/AppPaths.cs @@ -6,6 +6,7 @@ public static class AppPaths private const string PortableDataDirectoryName = "Settings"; private const string PortablePermissionTestFileName = "PermissionTestFile"; private const string PortableScratchDirectoryName = "Temp"; + private const string FirstRunMarkerFileName = "FirstRun.pending"; private const string ScratchDirectoryName = "UniGetUI"; private const string BundledModernAppDirectoryName = "Avalonia"; private const string WindowsExecutableName = "UniGetUI.exe"; @@ -15,7 +16,6 @@ public static class AppPaths private static string? __installation_directory; private static volatile bool __portable_mode_resolved; private static string? __portable_data_directory; - private static bool __portable_data_directory_created; [ThreadStatic] private static bool __resolving_portable_mode; @@ -74,15 +74,28 @@ private static string? ResolvedPortableDataDirectory } /// - /// Whether this process created the portable data directory, meaning this is the first - /// run since the folder became portable. Reading it resolves portable mode first. + /// Whether the portable folder has yet to complete a first run. Recorded in the folder + /// itself rather than in memory, so it survives a first launch that never reaches the UI + /// - a headless run, a pre-UI CLI command, or a crash - and travels with the folder. /// - public static bool PortableDataDirectoryWasCreated + public static bool IsFirstPortableRun => + PortableDataDirectory is { } directory + && File.Exists(Path.Join(directory, FirstRunMarkerFileName)); + + /// + /// Records that the portable folder has completed its first run. + /// + public static void ClearFirstPortableRun() { - get + try + { + if (PortableDataDirectory is { } directory) + File.Delete(Path.Join(directory, FirstRunMarkerFileName)); + } + catch (Exception ex) { - _ = PortableDataDirectory; - return __portable_data_directory_created; + Logger.Warn("Could not clear the portable first-run marker"); + Logger.Warn(ex); } } @@ -137,16 +150,18 @@ public static string ResolveInstallationDirectory( string path = Path.Join(installationDirectory, PortableDataDirectoryName); try { - if (!Directory.Exists(path)) - { + bool created = !Directory.Exists(path); + if (created) Directory.CreateDirectory(path); - __portable_data_directory_created = true; - } File.WriteAllText( Path.Join(path, PortablePermissionTestFileName), "https://www.youtube.com/watch?v=dQw4w9WgXcQ" ); + + if (created) + File.WriteAllText(Path.Join(path, FirstRunMarkerFileName), ""); + return path; } catch (Exception ex) diff --git a/src/UniGetUI.Core.Logging.Tests/AppPathsTests.cs b/src/UniGetUI.Core.Logging.Tests/AppPathsTests.cs index 9ea0c7095e..4e2a27bd8a 100644 --- a/src/UniGetUI.Core.Logging.Tests/AppPathsTests.cs +++ b/src/UniGetUI.Core.Logging.Tests/AppPathsTests.cs @@ -75,6 +75,37 @@ public void ResolveInstallationDirectoryTrimsATrailingSeparator() static _ => false)); } + [Fact] + public void ResolvePortableDataDirectoryMarksAFirstRunOnlyWhenItCreatesTheFolder() + { + File.WriteAllText(Path.Combine(_testRoot, "ForceUniGetUIPortable"), string.Empty); + + string? first = AppPaths.ResolvePortableDataDirectory(_testRoot); + Assert.NotNull(first); + Assert.True(File.Exists(Path.Combine(first!, "FirstRun.pending"))); + + File.Delete(Path.Combine(first!, "FirstRun.pending")); + AppPaths.ResolvePortableDataDirectory(_testRoot); + Assert.False( + File.Exists(Path.Combine(first!, "FirstRun.pending")), + "an established portable folder must not be marked as a first run again"); + } + + [Fact] + public void ClearFirstPortableRunRemovesTheMarker() + { + string portableDirectory = Path.Combine(_testRoot, "Settings"); + Directory.CreateDirectory(portableDirectory); + File.WriteAllText(Path.Combine(portableDirectory, "FirstRun.pending"), string.Empty); + AppPaths.TEST_PortableDataDirectoryOverride = portableDirectory; + + Assert.True(AppPaths.IsFirstPortableRun); + + AppPaths.ClearFirstPortableRun(); + + Assert.False(AppPaths.IsFirstPortableRun); + } + [Fact] public void ScratchDirectoryStaysOutsideTheInstallFolderWhenNotPortable() { diff --git a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs index e8dd551c0e..d97cfeb0e7 100644 --- a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs +++ b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs @@ -40,7 +40,6 @@ public enum K AlreadyWarnedAboutAdmin, AlreadyWarnedAboutChocolateyMigration, ShownTelemetryBanner, - ShownPortableImportBanner, CollapseNavMenuOnWideScreen, NavMenuMode, EnablePackageBackup_LOCAL, @@ -176,7 +175,6 @@ public static string ResolveKey(K key) K.AlreadyWarnedAboutAdmin => "AlreadyWarnedAboutAdmin", K.AlreadyWarnedAboutChocolateyMigration => "AlreadyWarnedAboutChocolateyMigration", K.ShownTelemetryBanner => "ShownTelemetryBanner", - K.ShownPortableImportBanner => "ShownPortableImportBanner", K.CollapseNavMenuOnWideScreen => "CollapseNavMenuOnWideScreen", K.NavMenuMode => "NavMenuMode", K.EnablePackageBackup_LOCAL => "EnablePackageBackup",