diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json
index 232ab1df40..d59006d72b 100644
--- a/src/Languages/lang_en.json
+++ b/src/Languages/lang_en.json
@@ -854,5 +854,8 @@
"Operations completed": "Operations completed",
"Operations finished with errors": "Operations finished with errors",
"{0} of {1} succeeded, {2} failed": "{0} of {1} succeeded, {2} failed",
- "Show an in-app summary toast when a batch of operations finishes": "Show an in-app summary toast when a batch of operations finishes"
+ "Show an in-app summary toast when a batch of operations finishes": "Show an in-app summary toast when a batch of operations finishes",
+ "Manual install": "Manual install",
+ "Manual update": "Manual update",
+ "Manual uninstall": "Manual uninstall"
}
diff --git a/src/UniGetUI.Avalonia/Infrastructure/ManualInstallHelper.cs b/src/UniGetUI.Avalonia/Infrastructure/ManualInstallHelper.cs
new file mode 100644
index 0000000000..28f8b6a5dc
--- /dev/null
+++ b/src/UniGetUI.Avalonia/Infrastructure/ManualInstallHelper.cs
@@ -0,0 +1,227 @@
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.Versioning;
+using System.Text;
+using Avalonia.Input.Platform;
+using UniGetUI.Avalonia.Views;
+using UniGetUI.Core.Data;
+using UniGetUI.Core.Logging;
+using UniGetUI.PackageEngine.Enums;
+using UniGetUI.PackageEngine.Interfaces;
+using UniGetUI.PackageEngine.PackageClasses;
+
+namespace UniGetUI.Avalonia.Infrastructure;
+
+///
+/// "Manual install/update/uninstall": generate the CLI command for a package operation and open
+/// a terminal with the command pre-typed at the prompt, ready for the user to review and run by
+/// hand. The command is also copied to the clipboard as a fallback. Shared by the package list
+/// menus and the install-options dialog.
+///
+internal static class ManualInstallHelper
+{
+ /// Builds the CLI command for a package (identical to the install-options preview),
+ /// using its currently applicable options; null for virtual/local packages.
+ private static async Task BuildCommandForPackageAsync(IPackage? package, OperationType operation)
+ {
+ if (package is null || package.Source.IsVirtualManager) return null;
+ var options = await InstallOptionsFactory.LoadApplicableAsync(package);
+ var args = await Task.Run(() => package.Manager.OperationHelper.GetParameters(package, options, operation));
+ return package.Manager.Properties.ExecutableFriendlyName + " " + string.Join(' ', args);
+ }
+
+ /// Entry point for the "Manual install/update/uninstall" menu and toolbar actions.
+ public static async Task LaunchManualAsync(IPackage? package, OperationType operation)
+ {
+ var command = await BuildCommandForPackageAsync(package, operation);
+ if (!string.IsNullOrWhiteSpace(command))
+ await LaunchManualAsync(command);
+ }
+
+ /// Copies the command to the clipboard and opens a terminal with it pre-typed at the prompt.
+ public static async Task LaunchManualAsync(string command)
+ {
+ // Clipboard acts as a fallback in case the terminal cannot be pre-filled (e.g. non-Windows).
+ await CopyToClipboardAsync(command);
+ OpenPrefilledTerminal(command);
+ }
+
+ private static async Task CopyToClipboardAsync(string text)
+ {
+ if (MainWindow.Instance?.Clipboard is { } clipboard)
+ await clipboard.SetTextAsync(text);
+ }
+
+ ///
+ /// Opens an interactive terminal with already typed at the prompt
+ /// (but not executed), so the user can review and run it manually. Each platform uses its own
+ /// native mechanism; on failure the command is still on the clipboard.
+ ///
+ private static void OpenPrefilledTerminal(string command)
+ {
+ try
+ {
+ if (OperatingSystem.IsWindows()) OpenWindowsTerminal(command);
+ else if (OperatingSystem.IsMacOS()) OpenMacTerminal(command);
+ else OpenLinuxTerminal(command);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn("Could not open a terminal for manual install; the command is on the clipboard");
+ Logger.Warn(ex);
+ }
+ }
+
+ // Windows PowerShell injects the command into its own console input buffer (WriteConsoleInput)
+ // so PSReadLine shows it pre-typed at the first prompt; -NoExit keeps the window open.
+ private static void OpenWindowsTerminal(string command)
+ {
+ string literal = command.Replace("'", "''");
+ string script = "$ErrorActionPreference = 'SilentlyContinue'\n"
+ + "$cmd = '" + literal + "'\n"
+ + CONSOLE_INJECTOR
+ + "[UniGetUIConsoleInjector]::Type($cmd)\n";
+ string encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(script));
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = CoreData.PowerShell5,
+ Arguments = $"-NoExit -ExecutionPolicy Bypass -EncodedCommand {encoded}",
+ WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ UseShellExecute = true,
+ });
+ }
+
+ // macOS: zsh's 'print -z' pushes text onto the line-editor buffer so it appears pre-typed at the
+ // next prompt without executing. We seed it from a throwaway startup file (ZDOTDIR) and open
+ // Terminal.app on a launcher script via `open`. This deliberately avoids AppleScript's `do script`,
+ // which sends an Apple event that requires Automation (TCC) permission the app usually lacks; when
+ // denied it fails silently, so the terminal opens (from `activate`) but the command is never typed.
+ [SupportedOSPlatform("macos")]
+ private static void OpenMacTerminal(string command)
+ {
+ string dir = Path.Combine(Path.GetTempPath(), "unigetui-manual-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+
+ // ' is escaped for the enclosing single-quoted zsh argument. Setting ZDOTDIR bypasses the
+ // user's normal startup files, so re-source them before seeding the buffer; then unset it so
+ // any shells the user spawns behave normally.
+ string cmdLiteral = command.Replace("'", "'\\''");
+ File.WriteAllText(Path.Combine(dir, ".zshrc"),
+ "[ -f \"$HOME/.zshenv\" ] && source \"$HOME/.zshenv\"\n"
+ + "[ -f \"$HOME/.zshrc\" ] && source \"$HOME/.zshrc\"\n"
+ + "unset ZDOTDIR\n"
+ + "print -z -- '" + cmdLiteral + "'\n");
+
+ // Launcher: re-exec an interactive zsh whose startup files come from our temp dir. Terminal
+ // runs a *.command file passed to `open` as long as it is executable.
+ string launcher = Path.Combine(dir, "launch.command");
+ string dirLiteral = dir.Replace("'", "'\\''");
+ File.WriteAllText(launcher,
+ "#!/bin/zsh\n"
+ + "ZDOTDIR='" + dirLiteral + "' exec zsh -i\n");
+ File.SetUnixFileMode(launcher,
+ UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = "open",
+ UseShellExecute = false,
+ ArgumentList = { "-a", "Terminal", launcher },
+ });
+ }
+
+ // Linux: launch the first available terminal emulator running bash, which seeds the command into
+ // its readline buffer without executing it (see BuildBashPrefillRcFile).
+ private static void OpenLinuxTerminal(string command)
+ {
+ string rcfile = BuildBashPrefillRcFile(command);
+ foreach (var (exe, prefix) in LinuxTerminals)
+ {
+ string? full = FindOnPath(exe);
+ if (full is null) continue;
+
+ var psi = new ProcessStartInfo { FileName = full, UseShellExecute = false };
+ foreach (var arg in prefix) psi.ArgumentList.Add(arg);
+ psi.ArgumentList.Add("bash");
+ psi.ArgumentList.Add("--rcfile");
+ psi.ArgumentList.Add(rcfile);
+ psi.ArgumentList.Add("-i");
+ Process.Start(psi);
+ return;
+ }
+ Logger.Warn("No supported terminal emulator was found; the command is on the clipboard");
+ }
+
+ // Candidate terminal emulators (in order of preference) and the argument(s) after which the
+ // program to run is passed as argv.
+ private static readonly (string Exe, string[] Prefix)[] LinuxTerminals =
+ [
+ ("gnome-terminal", ["--"]),
+ ("konsole", ["-e"]),
+ ("kitty", []),
+ ("alacritty", ["-e"]),
+ ("wezterm", ["start", "--"]),
+ ("xfce4-terminal", ["-x"]),
+ ("xterm", ["-e"]),
+ ];
+
+ private static string? FindOnPath(string exe)
+ {
+ string? pathVar = Environment.GetEnvironmentVariable("PATH");
+ if (pathVar is null) return null;
+ foreach (var dir in pathVar.Split(Path.PathSeparator))
+ {
+ if (string.IsNullOrEmpty(dir)) continue;
+ string candidate = Path.Combine(dir, exe);
+ if (File.Exists(candidate)) return candidate;
+ }
+ return null;
+ }
+
+ // Writes a temporary bash rcfile that seeds the command into the readline buffer without running
+ // it: bind a macro to the terminal's Device-Status-Report reply (ESC[0n), then request a report
+ // (ESC[5n); readline inserts the macro text at the first prompt.
+ private static string BuildBashPrefillRcFile(string command)
+ {
+ // \ and " are escaped for the readline macro; ' is escaped for the enclosing single-quoted
+ // bash argument (order matters: ' must be last so its backslashes aren't doubled).
+ string escaped = command
+ .Replace("\\", "\\\\")
+ .Replace("\"", "\\\"")
+ .Replace("'", "'\\''");
+ string rcfile = Path.Combine(Path.GetTempPath(), "unigetui-manual-" + Guid.NewGuid().ToString("N") + ".bashrc");
+ File.WriteAllText(rcfile,
+ "[ -f ~/.bashrc ] && source ~/.bashrc\n"
+ + "bind '\"\\e[0n\": \"" + escaped + "\"'\n"
+ + "printf '\\e[5n'\n");
+ return rcfile;
+ }
+
+ private const string CONSOLE_INJECTOR = """
+Add-Type @"
+using System;
+using System.Runtime.InteropServices;
+public static class UniGetUIConsoleInjector {
+ [DllImport("kernel32.dll", SetLastError=true)]
+ static extern IntPtr GetStdHandle(int nStdHandle);
+ [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
+ static extern bool WriteConsoleInputW(IntPtr h, INPUT_RECORD[] r, uint n, out uint w);
+ [StructLayout(LayoutKind.Explicit)]
+ public struct INPUT_RECORD { [FieldOffset(0)] public ushort EventType; [FieldOffset(4)] public KEY_EVENT_RECORD KeyEvent; }
+ [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
+ public struct KEY_EVENT_RECORD { public int bKeyDown; public ushort wRepeatCount; public ushort wVirtualKeyCode; public ushort wVirtualScanCode; public char UnicodeChar; public uint dwControlKeyState; }
+ public static void Type(string text) {
+ IntPtr h = GetStdHandle(-10);
+ var recs = new INPUT_RECORD[text.Length * 2];
+ int i = 0;
+ foreach (char c in text) {
+ recs[i].EventType = 1; recs[i].KeyEvent = new KEY_EVENT_RECORD { bKeyDown = 1, wRepeatCount = 1, UnicodeChar = c }; i++;
+ recs[i].EventType = 1; recs[i].KeyEvent = new KEY_EVENT_RECORD { bKeyDown = 0, wRepeatCount = 1, UnicodeChar = c }; i++;
+ }
+ uint w; WriteConsoleInputW(h, recs, (uint)recs.Length, out w);
+ }
+}
+"@
+
+""";
+}
diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs
index 71c945376e..f8b5a6c33e 100644
--- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs
+++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs
@@ -125,10 +125,17 @@ partial void OnFollowGlobalChanged(bool value)
[ObservableProperty] private string? _selectedProfile;
[ObservableProperty] private string _proceedButtonLabel = "";
+ [ObservableProperty] private string _manualActionLabel = "";
partial void OnSelectedProfileChanged(string? value)
{
ProceedButtonLabel = value ?? "";
+ ManualActionLabel = CurrentOp() switch
+ {
+ OperationType.Update => CoreTools.Translate("Manual update"),
+ OperationType.Uninstall => CoreTools.Translate("Manual uninstall"),
+ _ => CoreTools.Translate("Manual install"),
+ };
ApplyProfileEnableState();
if (_uiLoaded) _ = RefreshCommandPreviewAsync();
}
@@ -429,11 +436,18 @@ private void ApplyProfileEnableState()
private async Task RefreshCommandPreviewAsync()
{
if (!_uiLoaded) return;
+ CommandPreview = await BuildCurrentCommandAsync();
+ }
+
+ /// Builds the CLI command for the currently selected operation and options,
+ /// identical to the live preview. Used by the Copy / Open-in-terminal actions.
+ public async Task BuildCurrentCommandAsync()
+ {
var snap = SnapshotOptions();
var op = CurrentOp();
var applied = await InstallOptionsFactory.LoadApplicableAsync(_package, overridePackageOptions: snap);
var args = await Task.Run(() => _package.Manager.OperationHelper.GetParameters(_package, applied, op));
- CommandPreview = _package.Manager.Properties.ExecutableFriendlyName + " " + string.Join(' ', args);
+ return _package.Manager.Properties.ExecutableFriendlyName + " " + string.Join(' ', args);
}
private void Refresh() { if (_uiLoaded) _ = RefreshCommandPreviewAsync(); }
diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsControl.axaml b/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsControl.axaml
index 1a921642eb..d3a3ac5ce6 100644
--- a/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsControl.axaml
+++ b/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsControl.axaml
@@ -501,7 +501,15 @@
-
+
+
+
+
_ = ShowInstallationOptionsForPackage(SelectedItem));
ViewModel.AddToolbarSeparator();
+ ViewModel.AddToolbarButton("console", CoreTools.Translate("Manual install"),
+ () => _ = ManualInstallHelper.LaunchManualAsync(SelectedItem, OperationType.Install));
+ ViewModel.AddToolbarSeparator();
ViewModel.AddToolbarButton("info_round", CoreTools.Translate("Package details"),
() => _ = ShowDetailsForPackage(SelectedItem), showLabel: false);
ViewModel.AddToolbarSeparator();
@@ -119,6 +122,9 @@ protected override void GenerateToolBar(PackagesPageViewModel vm)
var menuInstallOptions = new MenuItem { Header = ShortcutHeader(CoreTools.Translate("Install options"), OptionsShortcut), Icon = LoadMenuIcon("options") };
menuInstallOptions.Click += (_, _) => _ = ShowInstallationOptionsForPackage(SelectedItem);
+ var menuManual = new MenuItem { Header = CoreTools.Translate("Manual install"), Icon = LoadMenuIcon("console") };
+ menuManual.Click += (_, _) => _ = ManualInstallHelper.LaunchManualAsync(SelectedItem, OperationType.Install);
+
var menuDetails = new MenuItem { Header = ShortcutHeader(CoreTools.Translate("Package details"), DetailsShortcut), Icon = LoadMenuIcon("info_round") };
menuDetails.Click += (_, _) => _ = ShowDetailsForPackage(SelectedItem);
@@ -126,6 +132,7 @@ protected override void GenerateToolBar(PackagesPageViewModel vm)
menu.Items.Add(menuInstall);
menu.Items.Add(new Separator());
menu.Items.Add(menuInstallOptions);
+ menu.Items.Add(menuManual);
menu.Items.Add(new Separator());
menu.Items.Add(_menuAsAdmin);
menu.Items.Add(_menuInteractive);
diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs
index 17bd0182db..028ef3d282 100644
--- a/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs
+++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs
@@ -32,6 +32,7 @@ public class InstalledPackagesPage : AbstractPackagesPage
private MenuItem? _menuDetails;
private MenuItem? _menuOpenInstallLocation;
private MenuItem? _menuDownloadInstaller;
+ private MenuItem? _menuManual;
private static bool _hasBackedUp;
@@ -106,6 +107,9 @@ protected override void GenerateToolBar(PackagesPageViewModel vm)
ViewModel.AddToolbarButton("options", CoreTools.Translate("Uninstall options"),
() => _ = ShowInstallationOptionsForPackage(SelectedItem), showLabel: false);
ViewModel.AddToolbarSeparator();
+ ViewModel.AddToolbarButton("console", CoreTools.Translate("Manual uninstall"),
+ () => _ = ManualInstallHelper.LaunchManualAsync(SelectedItem, OperationType.Uninstall));
+ ViewModel.AddToolbarSeparator();
ViewModel.AddToolbarButton("info_round", CoreTools.Translate("Package details"),
() => _ = ShowDetailsForPackage(SelectedItem), showLabel: false);
ViewModel.AddToolbarSeparator();
@@ -144,6 +148,9 @@ protected override void GenerateToolBar(PackagesPageViewModel vm)
};
_menuInstallationOptions.Click += (_, _) => _ = ShowInstallationOptionsForPackage(SelectedItem);
+ _menuManual = new MenuItem { Header = CoreTools.Translate("Manual uninstall"), Icon = LoadMenuIcon("console") };
+ _menuManual.Click += (_, _) => _ = ManualInstallHelper.LaunchManualAsync(SelectedItem, OperationType.Uninstall);
+
_menuOpenInstallLocation = new MenuItem
{
Header = CoreTools.Translate("Open install location"),
@@ -213,6 +220,7 @@ protected override void GenerateToolBar(PackagesPageViewModel vm)
menu.Items.Add(menuUninstall);
menu.Items.Add(new Separator());
menu.Items.Add(_menuInstallationOptions);
+ menu.Items.Add(_menuManual);
menu.Items.Add(_menuOpenInstallLocation);
menu.Items.Add(new Separator());
menu.Items.Add(_menuAsAdmin);
@@ -237,7 +245,8 @@ protected override void WhenShowingContextMenu(IPackage package)
|| _menuInstallationOptions is null || _menuReinstall is null
|| _menuUninstallThenReinstall is null || _menuIgnoreUpdates is null
|| _menuDetails is null
- || _menuOpenInstallLocation is null || _menuDownloadInstaller is null)
+ || _menuOpenInstallLocation is null || _menuDownloadInstaller is null
+ || _menuManual is null)
{
Logger.Warn("Context menu items are null on InstalledPackagesPage");
return;
@@ -246,6 +255,7 @@ protected override void WhenShowingContextMenu(IPackage package)
bool isLocal = package.Source.IsVirtualManager;
var caps = package.Manager.Capabilities;
+ _menuManual.IsEnabled = !isLocal;
_menuAsAdmin.IsEnabled = caps.CanRunAsAdmin;
_menuInteractive.IsEnabled = caps.CanRunInteractively;
_menuRemoveData.IsEnabled = caps.CanRemoveDataOnUninstall;
@@ -306,7 +316,8 @@ private static void UpdateWinGetMalfunctionBanner(bool malfunction)
banner.Message = CoreTools.Translate(
"It looks like WinGet is not working properly. Do you want to attempt to repair WinGet?");
banner.ActionButtonText = CoreTools.Translate("Repair WinGet");
- banner.ActionButtonCommand = new AsyncRelayCommand(AvaloniaPackageOperationHelper.HandleBrokenWinGetAsync);
+ if (OperatingSystem.IsWindows())
+ banner.ActionButtonCommand = new AsyncRelayCommand(AvaloniaPackageOperationHelper.HandleBrokenWinGetAsync);
banner.IsClosable = true;
banner.IsOpen = true;
}
diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs
index fa7bfe845b..68e2f1abdf 100644
--- a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs
+++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs
@@ -89,6 +89,9 @@ protected override void GenerateToolBar(PackagesPageViewModel vm)
ViewModel.AddToolbarButton("options", CoreTools.Translate("Update options"),
() => _ = ShowInstallationOptionsForPackage(SelectedItem), showLabel: false);
ViewModel.AddToolbarSeparator();
+ ViewModel.AddToolbarButton("console", CoreTools.Translate("Manual update"),
+ () => _ = ManualInstallHelper.LaunchManualAsync(SelectedItem, OperationType.Update));
+ ViewModel.AddToolbarSeparator();
ViewModel.AddToolbarButton("info_round", CoreTools.Translate("Package details"),
() => _ = ShowDetailsForPackage(SelectedItem), showLabel: false);
ViewModel.AddToolbarSeparator();
@@ -122,6 +125,9 @@ protected override void GenerateToolBar(PackagesPageViewModel vm)
};
menuUpdateOptions.Click += (_, _) => _ = ShowInstallationOptionsForPackage(SelectedItem);
+ var menuManual = new MenuItem { Header = CoreTools.Translate("Manual update"), Icon = LoadMenuIcon("console") };
+ menuManual.Click += (_, _) => _ = ManualInstallHelper.LaunchManualAsync(SelectedItem, OperationType.Update);
+
_menuOpenInstallLocation = new MenuItem
{
Header = CoreTools.Translate("Open install location"),
@@ -243,6 +249,7 @@ protected override void GenerateToolBar(PackagesPageViewModel vm)
menu.Items.Add(menuUpdate);
menu.Items.Add(new Separator());
menu.Items.Add(menuUpdateOptions);
+ menu.Items.Add(menuManual);
menu.Items.Add(_menuOpenInstallLocation);
menu.Items.Add(new Separator());
menu.Items.Add(_menuAsAdmin);