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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
102 changes: 83 additions & 19 deletions docs/PORTABLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -76,26 +81,85 @@ whenever the bundle is replaced by an update. Re-create it after upgrading.
| Cached language files | `<data dir>\CachedLanguageFiles` | `<install dir>\Settings\CachedLanguageFiles` |
| Stored secrets, macOS and Linux | `<data dir>/SecureStorage` | `<install dir>/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 | `<install dir>\Settings\Temp` |
Comment thread
GabrielDuf marked this conversation as resolved.
| Default package-backup folder | `Documents\UniGetUI` | `<install dir>\Settings\Backups` |
| Bundled Pinget store, Windows | `%LOCALAPPDATA%\Devolutions\Pinget` | `<install dir>\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 `<install dir>\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 |
| --- | --- |
Expand Down
7 changes: 6 additions & 1 deletion src/Languages/lang_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
24 changes: 24 additions & 0 deletions src/Shared/AutoUpdater.InstallerArguments.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace UniGetUI.Shared;

internal static class AutoUpdaterInstallerArguments
{
private const string CommonWindowsArguments =
"/SILENT /SUPPRESSMSGBOXES /NORESTART /SP- /NoVCRedist /NoEdgeWebView /NoWinGet /NoRedirectionGuard /NoDesktopShortcut";

/// <summary>
/// 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.
/// </summary>
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}\"";
}
}
2 changes: 1 addition & 1 deletion src/UniGetUI.Avalonia/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
3 changes: 2 additions & 1 deletion src/UniGetUI.Avalonia/CrashHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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}");
Expand Down
15 changes: 12 additions & 3 deletions src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using UniGetUI.Core.Logging;
using UniGetUI.Core.SettingsEngine;
using UniGetUI.Core.Tools;
using UniGetUI.Shared;

namespace UniGetUI.Avalonia.Infrastructure;

Expand Down Expand Up @@ -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"
);

Expand Down Expand Up @@ -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,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics;
using UniGetUI.Core.Data;
using UniGetUI.Core.Logging;
using UniGetUI.Core.SettingsEngine;
using UniGetUI.Core.Tools;
Expand All @@ -21,6 +22,42 @@ public static void PrepareForCurrentPlatform()
ApplyProxySettingsToProcess();
}

/// <summary>
/// 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.
/// </summary>
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}");
Comment thread
GabrielDuf marked this conversation as resolved.
}

public static void ApplyProxySettingsToProcess()
{
try
Expand Down
3 changes: 2 additions & 1 deletion src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@
<PackageReference Include="Avalonia.Controls.WebView" Version="12.0.0" />
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
<PackageReference Include="Devolutions.UniGetUI.Elevator" Version="2.6.1.3" GeneratePathProperty="true" ExcludeAssets="build;buildTransitive;native" Condition="$([MSBuild]::IsOSPlatform('Windows'))" />
<PackageReference Include="Devolutions.Pinget.Cli.Rust" Version="0.10.0" GeneratePathProperty="true" ExcludeAssets="build;buildTransitive;native" />
<PackageReference Include="Devolutions.Pinget.Cli.Rust" Version="0.11.0" GeneratePathProperty="true" ExcludeAssets="build;buildTransitive;native" />
</ItemGroup>

<ItemGroup>
Expand All @@ -218,6 +218,7 @@
<Compile Include="..\SharedAssemblyInfo.cs" Link="SharedAssemblyInfo.cs" />
<Compile Include="..\Shared\SharedPreUiCommandDispatcher.cs" Link="Shared\SharedPreUiCommandDispatcher.cs" />
<Compile Include="..\Shared\StartupBundleArguments.cs" Link="Shared\StartupBundleArguments.cs" />
<Compile Include="..\Shared\AutoUpdater.InstallerArguments.cs" Link="Shared\AutoUpdater.InstallerArguments.cs" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading
Loading