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
5 changes: 4 additions & 1 deletion src/Languages/lang_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
227 changes: 227 additions & 0 deletions src/UniGetUI.Avalonia/Infrastructure/ManualInstallHelper.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// "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.
/// </summary>
internal static class ManualInstallHelper
{
/// <summary>Builds the CLI command for a package (identical to the install-options preview),
/// using its currently applicable options; null for virtual/local packages.</summary>
private static async Task<string?> 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);
}

/// <summary>Entry point for the "Manual install/update/uninstall" menu and toolbar actions.</summary>
public static async Task LaunchManualAsync(IPackage? package, OperationType operation)
{
var command = await BuildCommandForPackageAsync(package, operation);
if (!string.IsNullOrWhiteSpace(command))
await LaunchManualAsync(command);
}

/// <summary>Copies the command to the clipboard and opens a terminal with it pre-typed at the prompt.</summary>
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);
}

/// <summary>
/// Opens an interactive terminal with <paramref name="command"/> 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.
/// </summary>
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);
}
}
"@

""";
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -429,11 +436,18 @@ private void ApplyProfileEnableState()
private async Task RefreshCommandPreviewAsync()
{
if (!_uiLoaded) return;
CommandPreview = await BuildCurrentCommandAsync();
}

/// <summary>Builds the CLI command for the currently selected operation and options,
/// identical to the live preview. Used by the Copy / Open-in-terminal actions.</summary>
public async Task<string> 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(); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,15 @@
<!-- ── Command-line preview ── -->
<Border Background="{DynamicResource AppDialogPanelBackground}" CornerRadius="8" Padding="12">
<StackPanel Spacing="6">
<TextBlock Text="{Binding CommandPreviewLabel}" Opacity="0.7"/>
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
<TextBlock Grid.Column="0" Text="{Binding CommandPreviewLabel}" Opacity="0.7"
VerticalAlignment="Center"/>
<Button Grid.Column="1" x:Name="ManualButton"
Content="{Binding ManualActionLabel}"
automation:AutomationProperties.Name="{Binding ManualActionLabel}"
Padding="10,4" CornerRadius="4"
Click="Manual_Click"/>
</Grid>
<Border Background="{DynamicResource AppDialogDarkBackground}" CornerRadius="4" Padding="8">
<SelectableTextBlock Text="{Binding CommandPreview}"
automation:AutomationProperties.Name="{Binding CommandPreviewLabel}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using UniGetUI.Avalonia.Infrastructure;
using UniGetUI.Avalonia.ViewModels;

namespace UniGetUI.Avalonia.Views.DialogPages;
Expand Down Expand Up @@ -32,4 +33,13 @@ private void KillProcessBox_KeyDown(object? sender, KeyEventArgs e)
if (e.Key is Key.Return or Key.Enter or Key.OemComma)
ViewModel.AddKillProcessCommand.Execute(null);
}

// Opens a terminal with the generated command pre-typed at the prompt, ready to run by hand.
private async void Manual_Click(object? sender, RoutedEventArgs e)
{
var command = await ViewModel.BuildCurrentCommandAsync();
if (string.IsNullOrWhiteSpace(command)) return;

await ManualInstallHelper.LaunchManualAsync(command);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ protected override void GenerateToolBar(PackagesPageViewModel vm)
ViewModel.AddToolbarButton("options", CoreTools.Translate("Install options"),
() => _ = 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();
Expand Down Expand Up @@ -119,13 +122,17 @@ 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);

var menu = new ContextMenu();
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);
Expand Down
Loading
Loading