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 a59ce1dd5c..846adb2159 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 @@ -76,26 +81,85 @@ 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) | -| 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 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. - -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. +| 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 +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 +`%TEMP%` handed to package-manager subprocesses when UniGetUI runs elevated. Portable mode moves +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 +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 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. + +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. +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. + +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 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 | | --- | --- | 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 new file mode 100644 index 0000000000..7fbc02ce4b --- /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 = Path.TrimEndingDirectorySeparator(installationDirectory); + return $"{CommonWindowsArguments} /TASKS=\"portableinstall\" /DIR=\"{directory}\""; + } +} 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/AvaloniaAppHost.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs index d48405ee53..23c5ad62ec 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs @@ -60,6 +60,10 @@ 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)) { WindowsConsoleHost.PrepareCliIO(); @@ -96,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/Infrastructure/AvaloniaAutoUpdater.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs index 698ee9137e..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; @@ -106,8 +107,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" ); @@ -669,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/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 eeec41faf4..3b8d36788e 100644 --- a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj +++ b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj @@ -198,7 +198,7 @@ - + @@ -218,6 +218,7 @@ + diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index 810e121371..29f0cb42c9 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,11 @@ public MainWindowViewModel() TelemetryWarner.IsOpen = true; } + if (PortableDataImport.FindImportableSource() is { } importableSource) + { + ShowPortableImportBanner(importableSource); + } + if (WasUpdatedSinceLastRun() && !Settings.Get(Settings.K.DisableReleaseNotesOnUpdate)) { NavigateTo(PageType.ReleaseNotes); @@ -548,6 +556,47 @@ public MainWindowViewModel() } } + private void ShowPortableImportBanner(string importableSource) + { + void RunImport() + { + try + { + int copied = PortableDataImport.Import(importableSource); + AppPaths.ClearFirstPortableRun(); + 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); + PortableImportBanner.ActionButtonText = CoreTools.Translate("Restart"); + PortableImportBanner.ActionButtonCommand = new CommunityToolkit.Mvvm.Input.RelayCommand( + AppRestartHelper.Restart); + } + catch (Exception ex) + { + 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; + 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 = AppPaths.ClearFirstPortableRun; + 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..0831570f24 --- /dev/null +++ b/src/UniGetUI.Core.Data.Tests/PortableDataImportTests.cs @@ -0,0 +1,143 @@ +using UniGetUI.Core.Logging; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] + +namespace UniGetUI.Core.Data.Tests +{ + 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; + CoreData.TEST_PerUserDataDirectoryOverride = 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 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 ASourceIsOnlyOfferedOnTheFirstRunOfAPortableFolder() + { + string source = CreateSource(); + CoreData.TEST_PerUserDataDirectoryOverride = source; + UsePortableDestination(); + + Assert.Equal( + source, + PortableDataImport.FindImportableSource(isFirstPortableRun: true)); + Assert.Null(PortableDataImport.FindImportableSource(isFirstPortableRun: false)); + } + + [Fact] + public void NoSourceIsOfferedWhenThePerUserDirectoryHasNoSettings() + { + string empty = Path.Combine(_testRoot, "Empty"); + Directory.CreateDirectory(empty); + CoreData.TEST_PerUserDataDirectoryOverride = empty; + UsePortableDestination(); + + Assert.Null(PortableDataImport.FindImportableSource(isFirstPortableRun: true)); + } + + [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 7409e607c7..f1fae676b0 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,16 @@ 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; + + /// + /// Where the per-user data directory lives, regardless of whether portable mode is + /// active. Unlike this creates and migrates nothing. + /// + 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; } @@ -130,41 +131,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( @@ -321,6 +290,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"); @@ -349,52 +326,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 +596,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 +608,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.Data/PortableDataImport.cs b/src/UniGetUI.Core.Data/PortableDataImport.cs new file mode 100644 index 0000000000..a04b74e9c7 --- /dev/null +++ b/src/UniGetUI.Core.Data/PortableDataImport.cs @@ -0,0 +1,117 @@ +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. + /// 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.IsFirstPortableRun); + + public static string? FindImportableSource(bool isFirstPortableRun) + { + if (!AppPaths.IsPortable || !isFirstPortableRun) + 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) into the portable folder"); + 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.Logger/AppPaths.cs b/src/UniGetUI.Core.Logger/AppPaths.cs new file mode 100644 index 0000000000..a448aba9c8 --- /dev/null +++ b/src/UniGetUI.Core.Logger/AppPaths.cs @@ -0,0 +1,195 @@ +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 FirstRunMarkerFileName = "FirstRun.pending"; + 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 volatile 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; + } + } + + /// + /// 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 IsFirstPortableRun => + PortableDataDirectory is { } directory + && File.Exists(Path.Join(directory, FirstRunMarkerFileName)); + + /// + /// Records that the portable folder has completed its first run. + /// + public static void ClearFirstPortableRun() + { + try + { + if (PortableDataDirectory is { } directory) + File.Delete(Path.Join(directory, FirstRunMarkerFileName)); + } + catch (Exception ex) + { + Logger.Warn("Could not clear the portable first-run marker"); + Logger.Warn(ex); + } + } + + /// + /// 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 + { + bool created = !Directory.Exists(path); + if (created) + Directory.CreateDirectory(path); + + 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) + { + 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.TrimEndingDirectorySeparator(Path.GetFullPath(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/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..4e2a27bd8a --- /dev/null +++ b/src/UniGetUI.Core.Logging.Tests/AppPathsTests.cs @@ -0,0 +1,139 @@ +[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 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( + directory, + AppPaths.ResolveInstallationDirectory( + directory + Path.DirectorySeparatorChar, + static _ => false, + 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() + { + 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/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 @@ - + 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; } diff --git a/src/UniGetUI.Tests/AutoUpdaterTests.cs b/src/UniGetUI.Tests/AutoUpdaterTests.cs index c6e75c1cd5..5771064cb3 100644 --- a/src/UniGetUI.Tests/AutoUpdaterTests.cs +++ b/src/UniGetUI.Tests/AutoUpdaterTests.cs @@ -6,6 +6,50 @@ 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_KeepTheSeparatorForAVolumeRoot() + { + string arguments = AutoUpdaterInstallerArguments.ForWindows(true, @"E:\"); + + Assert.Contains(@"/DIR=""E:\""", arguments); + Assert.DoesNotContain(@"/DIR=""E:""", 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 @@ +