From 4d0b9bdeece53b8068fef252aaea229fce1ffb28 Mon Sep 17 00:00:00 2001 From: Gabriel Dufresne Date: Thu, 16 Apr 2026 15:30:53 -0400 Subject: [PATCH 1/7] Add "apt" and "dnf" for linux --- .../Infrastructure/AvaloniaBootstrapper.cs | 72 +++++ .../ManageIgnoredUpdatesViewModel.cs | 12 +- .../Controls/Settings/SettingsPageButton.cs | 38 ++- src/UniGetUI.Core.Data/CoreData.cs | 6 + .../Assets/Languages/lang_en.json | 2 + src/UniGetUI.Core.Tools/Tools.cs | 19 +- src/UniGetUI.Interface.Enums/Enums.cs | 2 + .../Apt.cs | 255 +++++++++++++++ .../Helpers/AptPkgDetailsHelper.cs | 152 +++++++++ .../Helpers/AptPkgOperationHelper.cs | 62 ++++ ...UniGetUI.PackageEngine.Managers.Apt.csproj | 23 ++ .../Dnf.cs | 293 ++++++++++++++++++ .../Helpers/DnfPkgDetailsHelper.cs | 148 +++++++++ .../Helpers/DnfPkgOperationHelper.cs | 58 ++++ ...UniGetUI.PackageEngine.Managers.Dnf.csproj | 23 ++ .../PackageOperations.cs | 5 +- .../SourceOperations.cs | 14 +- .../PEInterface.cs | 38 +++ .../UniGetUI.PackageEngine.PEInterface.csproj | 2 + src/UniGetUI.sln | 38 +++ src/UniGetUI/Assets/Symbols/apt.svg | 14 + src/UniGetUI/Assets/Symbols/dnf.svg | 8 + 22 files changed, 1252 insertions(+), 32 deletions(-) create mode 100644 src/UniGetUI.PackageEngine.Managers.Apt/Apt.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgDetailsHelper.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgOperationHelper.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Apt/UniGetUI.PackageEngine.Managers.Apt.csproj create mode 100644 src/UniGetUI.PackageEngine.Managers.Dnf/Dnf.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgDetailsHelper.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgOperationHelper.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Dnf/UniGetUI.PackageEngine.Managers.Dnf.csproj create mode 100644 src/UniGetUI/Assets/Symbols/apt.svg create mode 100644 src/UniGetUI/Assets/Symbols/dnf.svg diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs index b8d1876c9a..ebf757f09f 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs @@ -148,6 +148,12 @@ private static async Task LoadElevatorAsync() return; } + if (OperatingSystem.IsLinux()) + { + await LoadLinuxElevatorAsync(); + return; + } + if (SecureSettings.Get(SecureSettings.K.ForceUserGSudo)) { var res = await CoreTools.WhichAsync("gsudo.exe"); @@ -179,6 +185,72 @@ private static async Task LoadElevatorAsync() } } + [System.Runtime.Versioning.SupportedOSPlatform("linux")] + private static async Task LoadLinuxElevatorAsync() + { + // Prefer sudo over pkexec: sudo caches credentials on disk (per user, not per + // process), so the user is only prompted once per ~15-minute window regardless + // of how many packages are installed. pkexec prompts on every single invocation + // because polkit ties its authorization cache to the calling process PID. + var results = await Task.WhenAll( + CoreTools.WhichAsync("sudo"), + CoreTools.WhichAsync("pkexec"), + CoreTools.WhichAsync("zenity")); + var (sudoFound, sudoPath) = results[0]; + var (pkexecFound, pkexecPath) = results[1]; + var (zenityFound, zenityPath) = results[2]; + + if (sudoFound) + { + // Find a graphical askpass helper so sudo can prompt without a terminal. + // Most DEs (KDE, XFCE, ...) pre-set SSH_ASKPASS to their native tool; + // GNOME doesn't, so we fall back to zenity with a small wrapper script + // (zenity --password ignores positional args, so it needs the wrapper + // to forward the prompt text via --text="$1"). + string? askpass = null; + var envAskpass = Environment.GetEnvironmentVariable("SSH_ASKPASS"); + if (!string.IsNullOrEmpty(envAskpass) && File.Exists(envAskpass)) + askpass = envAskpass; + else if (zenityFound) + { + askpass = Path.Join(CoreData.UniGetUIDataDirectory, "linux-askpass.sh"); + await File.WriteAllTextAsync(askpass, + $"#!/bin/sh\n\"{zenityPath}\" --password --title=\"UniGetUI\" --text=\"$1\"\n"); + File.SetUnixFileMode(askpass, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + + if (askpass != null) + { + Environment.SetEnvironmentVariable("SUDO_ASKPASS", askpass); + CoreData.ElevatorPath = sudoPath; + CoreData.ElevatorArgs = "-A"; + Logger.Debug($"Using sudo -A with askpass '{askpass}'"); + return; + } + } + + // Fall back to pkexec when no usable sudo+askpass combination is found. + // pkexec handles its own graphical prompt via polkit but prompts every invocation. + if (pkexecFound) + { + CoreData.ElevatorPath = pkexecPath; + Logger.Warn($"Using pkexec at {pkexecPath} (prompts on every operation)"); + return; + } + + if (sudoFound) + { + CoreData.ElevatorPath = sudoPath; + Logger.Warn($"Falling back to sudo without graphical askpass at {sudoPath}"); + return; + } + + Logger.Warn("No elevation tool found (pkexec/sudo). Admin operations will fail."); + } + /// /// Checks all ready package managers for missing dependencies. /// Returns the list of dependencies whose installation was not skipped by the user. diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/ManageIgnoredUpdatesViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/ManageIgnoredUpdatesViewModel.cs index 5a740ddaf2..62100b8d20 100644 --- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/ManageIgnoredUpdatesViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/ManageIgnoredUpdatesViewModel.cs @@ -108,8 +108,9 @@ private async Task ResetAll() await entry.RemoveAsync(); } - private static string ResolveManagerIcon(string managerKey) => - (managerKey switch + private static string ResolveManagerIcon(string managerKey) + { + string name = managerKey switch { "winget" => "winget", "scoop" => "scoop", @@ -123,10 +124,11 @@ private static string ResolveManagerIcon(string managerKey) => "steam" => "steam", "gog" => "gog", "uplay" => "uplay", + "dnf" => "dnf", _ => "ms_store", - }) is var name - ? $"avares://UniGetUI.Avalonia/Assets/Symbols/{name}.svg" - : $"avares://UniGetUI.Avalonia/Assets/Symbols/ms_store.svg"; + }; + return $"avares://UniGetUI.Avalonia/Assets/Symbols/{name}.svg"; + } } public partial class IgnoredPackageEntryViewModel : ObservableObject diff --git a/src/UniGetUI.Avalonia/Views/Controls/Settings/SettingsPageButton.cs b/src/UniGetUI.Avalonia/Views/Controls/Settings/SettingsPageButton.cs index fa65be12d5..f08d53dea4 100644 --- a/src/UniGetUI.Avalonia/Views/Controls/Settings/SettingsPageButton.cs +++ b/src/UniGetUI.Avalonia/Views/Controls/Settings/SettingsPageButton.cs @@ -20,7 +20,7 @@ public IconType Icon { set => HeaderIcon = new SvgIcon { - Path = $"avares://UniGetUI.Avalonia/Assets/Symbols/{IconTypeToName(value)}.svg", + Path = IconTypeToPath(value), Width = 24, Height = 24, }; @@ -32,21 +32,25 @@ public SettingsPageButton() IsClickEnabled = true; } - private static string IconTypeToName(IconType icon) => icon switch + private static string IconTypeToPath(IconType icon) { - IconType.Chocolatey => "choco", - IconType.Package => "package", - IconType.UAC => "uac", - IconType.Update => "update", - IconType.Help => "help", - IconType.Console => "console", - IconType.Checksum => "checksum", - IconType.Download => "download", - IconType.Settings => "settings", - IconType.SaveAs => "save_as", - IconType.OpenFolder => "open_folder", - IconType.Experimental => "experimental", - IconType.ClipboardList => "clipboard_list", - _ => icon.ToString().ToLower(), - }; + string name = icon switch + { + IconType.Chocolatey => "choco", + IconType.Package => "package", + IconType.UAC => "uac", + IconType.Update => "update", + IconType.Help => "help", + IconType.Console => "console", + IconType.Checksum => "checksum", + IconType.Download => "download", + IconType.Settings => "settings", + IconType.SaveAs => "save_as", + IconType.OpenFolder => "open_folder", + IconType.Experimental => "experimental", + IconType.ClipboardList => "clipboard_list", + _ => icon.ToString().ToLower(), + }; + return $"avares://UniGetUI.Avalonia/Assets/Symbols/{name}.svg"; + } } diff --git a/src/UniGetUI.Core.Data/CoreData.cs b/src/UniGetUI.Core.Data/CoreData.cs index eb526288e6..a5ca7f794f 100644 --- a/src/UniGetUI.Core.Data/CoreData.cs +++ b/src/UniGetUI.Core.Data/CoreData.cs @@ -370,6 +370,12 @@ public static string UniGetUIExecutableFile public static string ElevatorPath = ""; + /// + /// Extra arguments to insert between the elevator binary and the elevated command. + /// For example, "-A" when using sudo with an askpass helper on Linux. + /// + public static string ElevatorArgs = ""; + /// /// This method will return the most appropriate data directory. /// If the new directory exists, it will be used. diff --git a/src/UniGetUI.Core.LanguageEngine/Assets/Languages/lang_en.json b/src/UniGetUI.Core.LanguageEngine/Assets/Languages/lang_en.json index 5006ff8daf..5a8907c791 100644 --- a/src/UniGetUI.Core.LanguageEngine/Assets/Languages/lang_en.json +++ b/src/UniGetUI.Core.LanguageEngine/Assets/Languages/lang_en.json @@ -502,6 +502,8 @@ "A repository full of tools and executables designed with Microsoft's .NET ecosystem in mind.
Contains: .NET related tools and scripts": "A repository full of tools and executables designed with Microsoft's .NET ecosystem in mind.
Contains: .NET related tools and scripts", "NuPkg (zipped manifest)": "NuPkg (zipped manifest)", "The Missing Package Manager for macOS (or Linux).
Contains: Formulae, Casks": "The Missing Package Manager for macOS (or Linux).
Contains: Formulae, Casks", + "The default package manager for Debian/Ubuntu-based Linux distributions.
Contains: Debian/Ubuntu packages": "The default package manager for Debian/Ubuntu-based Linux distributions.
Contains: Debian/Ubuntu packages", + "The default package manager for RHEL/Fedora-based Linux distributions.
Contains: RPM packages": "The default package manager for RHEL/Fedora-based Linux distributions.
Contains: RPM packages", "Node JS's package manager. Full of libraries and other utilities that orbit the javascript world
Contains: Node javascript libraries and other related utilities": "Node JS's package manager. Full of libraries and other utilities that orbit the javascript world
Contains: Node javascript libraries and other related utilities", "Python's library manager. Full of python libraries and other python-related utilities
Contains: Python libraries and related utilities": "Python's library manager. Full of python libraries and other python-related utilities
Contains: Python libraries and related utilities", "PowerShell's package manager. Find libraries and scripts to expand PowerShell capabilities
Contains: Modules, Scripts, Cmdlets": "PowerShell's package manager. Find libraries and scripts to expand PowerShell capabilities
Contains: Modules, Scripts, Cmdlets", diff --git a/src/UniGetUI.Core.Tools/Tools.cs b/src/UniGetUI.Core.Tools/Tools.cs index b7e683d1b7..d683ff8c18 100644 --- a/src/UniGetUI.Core.Tools/Tools.cs +++ b/src/UniGetUI.Core.Tools/Tools.cs @@ -505,12 +505,20 @@ public static async Task CacheUACForCurrentProcess() { _isCaching = true; Logger.Info("Caching admin rights for process id " + Environment.ProcessId); + + // When using sudo on Linux, "-Av" validates/extends the timestamp via the + // askpass helper — prompts once then caches for the sudo timeout (~15 min). + // For gsudo on Windows (or pkexec fallback) use the gsudo cache protocol. + string cacheArgs = Path.GetFileName(CoreData.ElevatorPath) == "sudo" + ? "-Av" + : "cache on --pid " + Environment.ProcessId + " -d 1"; + using Process p = new() { StartInfo = new ProcessStartInfo { FileName = CoreData.ElevatorPath, - Arguments = "cache on --pid " + Environment.ProcessId + " -d 1", + Arguments = cacheArgs, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, @@ -546,12 +554,19 @@ public static async Task ResetUACForCurrentProcess() Logger.Info( "Resetting administrator rights cache for process id " + Environment.ProcessId ); + + // When using sudo on Linux, "-K" removes all cached timestamps. + // For gsudo on Windows (or pkexec fallback) use the gsudo cache protocol. + string resetArgs = Path.GetFileName(CoreData.ElevatorPath) == "sudo" + ? "-K" + : "cache off --pid " + Environment.ProcessId; + using Process p = new() { StartInfo = new ProcessStartInfo { FileName = CoreData.ElevatorPath, - Arguments = "cache off --pid " + Environment.ProcessId, + Arguments = resetArgs, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, diff --git a/src/UniGetUI.Interface.Enums/Enums.cs b/src/UniGetUI.Interface.Enums/Enums.cs index 9e56b6d078..325cc9bc46 100644 --- a/src/UniGetUI.Interface.Enums/Enums.cs +++ b/src/UniGetUI.Interface.Enums/Enums.cs @@ -85,6 +85,8 @@ public enum IconType Rust = '\uE941', Vcpkg = '\uE942', Homebrew = '\uE943', + Apt = '\uE944', + Dnf = '\uE945', } public class NotificationArguments diff --git a/src/UniGetUI.PackageEngine.Managers.Apt/Apt.cs b/src/UniGetUI.PackageEngine.Managers.Apt/Apt.cs new file mode 100644 index 0000000000..622ca02101 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Apt/Apt.cs @@ -0,0 +1,255 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using UniGetUI.Core.Tools; +using UniGetUI.Interface.Enums; +using UniGetUI.PackageEngine.Classes.Manager; +using UniGetUI.PackageEngine.Classes.Manager.ManagerHelpers; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.ManagerClasses.Classes; +using UniGetUI.PackageEngine.ManagerClasses.Manager; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.Structs; + +namespace UniGetUI.PackageEngine.Managers.AptManager; + +public class Apt : PackageManager +{ + public Apt() + { + Dependencies = []; + + Capabilities = new ManagerCapabilities + { + CanRunAsAdmin = true, + CanSkipIntegrityChecks = true, + SupportsCustomSources = false, + SupportsProxy = ProxySupport.No, + SupportsProxyAuth = false, + }; + + Properties = new ManagerProperties + { + Name = "Apt", + Description = CoreTools.Translate( + "The default package manager for Debian/Ubuntu-based Linux distributions.
Contains: Debian/Ubuntu packages" + ), + IconId = IconType.Apt, + ColorIconId = "debian", + ExecutableFriendlyName = "apt", + InstallVerb = "install", + UpdateVerb = "install", + UninstallVerb = "remove", + DefaultSource = new ManagerSource(this, "apt", new Uri("https://packages.debian.org")), + KnownSources = [new ManagerSource(this, "apt", new Uri("https://packages.debian.org"))], + }; + + DetailsHelper = new AptPkgDetailsHelper(this); + OperationHelper = new AptPkgOperationHelper(this); + } + + // ── Executable discovery ─────────────────────────────────────────────── + + public override IReadOnlyList FindCandidateExecutableFiles() + { + var candidates = new List(CoreTools.WhichMultiple("apt")); + foreach (var path in new[] { "/usr/bin/apt", "/usr/local/bin/apt" }) + { + if (File.Exists(path) && !candidates.Contains(path)) + candidates.Add(path); + } + return candidates; + } + + protected override void _loadManagerExecutableFile( + out bool found, + out string path, + out string callArguments) + { + (found, path) = GetExecutableFile(); + callArguments = ""; + } + + protected override void _loadManagerVersion(out string version) + { + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + p.Start(); + // First line: "apt X.Y.Z (arch)" + var line = p.StandardOutput.ReadLine()?.Trim() ?? ""; + var parts = line.Split(' '); + version = parts.Length >= 2 ? parts[1] : line; + p.WaitForExit(); + } + + // ── Index refresh ────────────────────────────────────────────────────── + + public override void RefreshPackageIndexes() + { + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = "update", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.RefreshIndexes, p); + p.Start(); + logger.AddToStdOut(p.StandardOutput.ReadToEnd()); + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + } + + // ── Package listing ──────────────────────────────────────────────────── + + protected override IReadOnlyList FindPackages_UnSafe(string query) + { + var packages = new List(); + + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "apt-cache", + Arguments = $"search -- {query}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.FindPackages, p); + p.Start(); + + // Output format: " - " + string? line; + while ((line = p.StandardOutput.ReadLine()) is not null) + { + logger.AddToStdOut(line); + var dashIdx = line.IndexOf(" - ", StringComparison.Ordinal); + if (dashIdx <= 0) continue; + + var id = line[..dashIdx].Trim(); + if (id.Length == 0) continue; + + packages.Add(new Package( + CoreTools.FormatAsName(id), + id, + CoreTools.Translate("Latest"), + Properties.DefaultSource!, + this)); + } + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + return packages; + } + + protected override IReadOnlyList GetInstalledPackages_UnSafe() + { + var packages = new List(); + + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = "list --installed", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.ListInstalledPackages, p); + p.Start(); + + // Output format: "/,... [installed,...]" + // First line is "Listing..." header — skip it. + var idVersionPattern = new Regex(@"^([^/\s]+)/\S+\s+(\S+)"); + string? line; + while ((line = p.StandardOutput.ReadLine()) is not null) + { + logger.AddToStdOut(line); + var m = idVersionPattern.Match(line); + if (!m.Success) continue; + + var id = m.Groups[1].Value; + var version = m.Groups[2].Value; + packages.Add(new Package( + CoreTools.FormatAsName(id), + id, + version, + Properties.DefaultSource!, + this)); + } + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + return packages; + } + + protected override IReadOnlyList GetAvailableUpdates_UnSafe() + { + var packages = new List(); + + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = "list --upgradable", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.ListUpdates, p); + p.Start(); + + // Output format: "/,... [upgradable from: ]" + var pattern = new Regex(@"^([^/\s]+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from: ([^\]]+)\]"); + string? line; + while ((line = p.StandardOutput.ReadLine()) is not null) + { + logger.AddToStdOut(line); + var m = pattern.Match(line); + if (!m.Success) continue; + + var id = m.Groups[1].Value; + var newVersion = m.Groups[2].Value; + var oldVersion = m.Groups[3].Value; + packages.Add(new Package( + CoreTools.FormatAsName(id), + id, + oldVersion, + newVersion, + Properties.DefaultSource!, + this)); + } + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + return packages; + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgDetailsHelper.cs new file mode 100644 index 0000000000..8889f75465 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgDetailsHelper.cs @@ -0,0 +1,152 @@ +using System.Diagnostics; +using UniGetUI.Core.IconEngine; +using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.ManagerClasses.Classes; + +namespace UniGetUI.PackageEngine.Managers.AptManager; + +internal sealed class AptPkgDetailsHelper : BasePkgDetailsHelper +{ + public AptPkgDetailsHelper(Apt manager) + : base(manager) { } + + protected override void GetDetails_UnSafe(IPackageDetails details) + { + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "apt-cache", + Arguments = $"show {details.Package.Id}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + + IProcessTaskLogger logger = Manager.TaskLogger.CreateNew( + Enums.LoggableTaskType.LoadPackageDetails, p); + p.Start(); + + // apt-cache show outputs key: value pairs, one per line. + // Multi-line values are indented with a leading space. + var descLines = new List(); + bool inDescription = false; + + string? line; + while ((line = p.StandardOutput.ReadLine()) is not null) + { + logger.AddToStdOut(line); + + if (line.Length == 0) + { + // Blank line ends the current record — stop after the first record. + if (inDescription) break; + continue; + } + + if (line.StartsWith(' ') && inDescription) + { + var descLine = line.TrimStart(); + if (descLine != ".") descLines.Add(descLine); + continue; + } + + inDescription = false; + + var colonIdx = line.IndexOf(": ", StringComparison.Ordinal); + if (colonIdx <= 0) continue; + + var key = line[..colonIdx].Trim(); + var value = line[(colonIdx + 2)..].Trim(); + + switch (key) + { + case "Version": + // Already known; fill in so it's accessible if needed + break; + case "Homepage": + if (Uri.TryCreate(value, UriKind.Absolute, out var homepage)) + details.HomepageUrl = homepage; + break; + case "Description": + case "Description-en": + details.Description = value; + inDescription = true; + break; + case "Maintainer": + details.Publisher = value; + break; + case "Depends": + details.Dependencies.Clear(); + foreach (var dep in value.Split(',')) + { + var depName = dep.Trim().Split(' ')[0]; + if (depName.Length > 0) + details.Dependencies.Add(new() { Name = depName, Version = "", Mandatory = true }); + } + break; + case "Recommends": + foreach (var dep in value.Split(',')) + { + var depName = dep.Trim().Split(' ')[0]; + if (depName.Length > 0) + details.Dependencies.Add(new() { Name = depName, Version = "", Mandatory = false }); + } + break; + case "Installed-Size": + details.InstallerSize = long.TryParse(value.Replace(" kB", "").Trim(), out var kb) + ? kb * 1024 + : 0; + break; + case "Source": + details.ManifestUrl = new Uri($"https://packages.debian.org/source/stable/{value}"); + break; + } + } + + if (descLines.Count > 0) + details.Description = (details.Description ?? "") + "\n" + string.Join("\n", descLines); + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + } + + protected override CacheableIcon? GetIcon_UnSafe(IPackage package) + => throw new NotImplementedException(); + + protected override IReadOnlyList GetScreenshots_UnSafe(IPackage package) + => throw new NotImplementedException(); + + protected override string? GetInstallLocation_UnSafe(IPackage package) + { + // Debian packages install to system paths; the most reliable way + // to find the install location is to query dpkg. + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "dpkg", + Arguments = $"-L {package.Id}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + p.Start(); + var firstPath = p.StandardOutput.ReadLine()?.Trim(); + p.WaitForExit(); + + if (firstPath is not null && Directory.Exists(firstPath)) + return firstPath; + + return null; + } + + protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage package) + => throw new InvalidOperationException("APT does not support installing arbitrary versions"); +} diff --git a/src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgOperationHelper.cs new file mode 100644 index 0000000000..a8b03fdb94 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgOperationHelper.cs @@ -0,0 +1,62 @@ +using UniGetUI.Core.Logging; +using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Serializable; + +namespace UniGetUI.PackageEngine.Managers.AptManager; + +internal sealed class AptPkgOperationHelper : BasePkgOperationHelper +{ + public AptPkgOperationHelper(Apt manager) + : base(manager) { } + + protected override IReadOnlyList _getOperationParameters( + IPackage package, + InstallOptions options, + OperationType operation) + { + // apt always requires root — force elevation via InstallOptions (reference type, persists) + options.RunAsAdministrator = true; + Logger.Warn($"[Apt] Set RunAsAdministrator=true on options for package {package.Id}"); + + List parameters = + [ + operation switch + { + OperationType.Install => Manager.Properties.InstallVerb, + OperationType.Uninstall => Manager.Properties.UninstallVerb, + OperationType.Update => Manager.Properties.UpdateVerb, + _ => throw new InvalidDataException("Invalid package operation"), + }, + ]; + + if (operation == OperationType.Update) + parameters.Add("--only-upgrade"); + + parameters.Add("-y"); + parameters.Add(package.Id); + + if (options.SkipHashCheck) + parameters.Add("--allow-unauthenticated"); + + parameters.AddRange( + operation switch + { + OperationType.Update => options.CustomParameters_Update, + OperationType.Uninstall => options.CustomParameters_Uninstall, + _ => options.CustomParameters_Install, + }); + + return parameters; + } + + protected override OperationVeredict _getOperationResult( + IPackage package, + OperationType operation, + IReadOnlyList processOutput, + int returnCode) + { + return returnCode == 0 ? OperationVeredict.Success : OperationVeredict.Failure; + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.Apt/UniGetUI.PackageEngine.Managers.Apt.csproj b/src/UniGetUI.PackageEngine.Managers.Apt/UniGetUI.PackageEngine.Managers.Apt.csproj new file mode 100644 index 0000000000..00d99aa0e4 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Apt/UniGetUI.PackageEngine.Managers.Apt.csproj @@ -0,0 +1,23 @@ + + + $(SharedTargetFrameworks) + + + + + + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.PackageEngine.Managers.Dnf/Dnf.cs b/src/UniGetUI.PackageEngine.Managers.Dnf/Dnf.cs new file mode 100644 index 0000000000..96a709708a --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Dnf/Dnf.cs @@ -0,0 +1,293 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using UniGetUI.Core.Tools; +using UniGetUI.Interface.Enums; +using UniGetUI.PackageEngine.Classes.Manager; +using UniGetUI.PackageEngine.Classes.Manager.ManagerHelpers; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.ManagerClasses.Classes; +using UniGetUI.PackageEngine.ManagerClasses.Manager; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.Structs; + +namespace UniGetUI.PackageEngine.Managers.DnfManager; + +public class Dnf : PackageManager +{ + // Known RPM architectures — used to strip the trailing . from package names. + private static readonly HashSet _knownArches = + ["x86_64", "aarch64", "noarch", "i686", "i386", "ppc64le", "s390x", "src"]; + + public Dnf() + { + Dependencies = []; + + Capabilities = new ManagerCapabilities + { + CanRunAsAdmin = true, + CanSkipIntegrityChecks = true, + SupportsCustomSources = false, + SupportsProxy = ProxySupport.No, + SupportsProxyAuth = false, + }; + + Properties = new ManagerProperties + { + Name = "Dnf", + Description = CoreTools.Translate( + "The default package manager for RHEL/Fedora-based Linux distributions.
Contains: RPM packages" + ), + IconId = IconType.Dnf, + ColorIconId = "dnf", + ExecutableFriendlyName = "dnf", + InstallVerb = "install", + UpdateVerb = "upgrade", + UninstallVerb = "remove", + DefaultSource = new ManagerSource(this, "dnf", new Uri("https://fedoraproject.org/wiki/DNF")), + KnownSources = [new ManagerSource(this, "dnf", new Uri("https://fedoraproject.org/wiki/DNF"))], + }; + + DetailsHelper = new DnfPkgDetailsHelper(this); + OperationHelper = new DnfPkgOperationHelper(this); + } + + // ── Executable discovery ─────────────────────────────────────────────── + + public override IReadOnlyList FindCandidateExecutableFiles() + { + var candidates = new List(CoreTools.WhichMultiple("dnf")); + foreach (var path in new[] { "/usr/bin/dnf", "/usr/local/bin/dnf" }) + { + if (File.Exists(path) && !candidates.Contains(path)) + candidates.Add(path); + } + return candidates; + } + + protected override void _loadManagerExecutableFile( + out bool found, + out string path, + out string callArguments) + { + (found, path) = GetExecutableFile(); + callArguments = ""; + } + + protected override void _loadManagerVersion(out string version) + { + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + p.Start(); + // First line is the version number: "X.Y.Z" + version = p.StandardOutput.ReadLine()?.Trim() ?? ""; + p.WaitForExit(); + } + + // ── Index refresh ────────────────────────────────────────────────────── + + public override void RefreshPackageIndexes() + { + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = "makecache", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.RefreshIndexes, p); + p.Start(); + logger.AddToStdOut(p.StandardOutput.ReadToEnd()); + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + } + + // ── Package listing ──────────────────────────────────────────────────── + + protected override IReadOnlyList FindPackages_UnSafe(string query) + { + var packages = new List(); + + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = $"search --quiet {query}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.FindPackages, p); + p.Start(); + + // Output: ". : " + // Section headers look like "===== Name Matched: =====" — skip them. + var seen = new HashSet(StringComparer.Ordinal); + string? line; + while ((line = p.StandardOutput.ReadLine()) is not null) + { + logger.AddToStdOut(line); + if (line.StartsWith('=') || line.Length == 0) continue; + + var colonIdx = line.IndexOf(" : ", StringComparison.Ordinal); + if (colonIdx <= 0) continue; + + var nameArch = line[..colonIdx].Trim(); + var id = StripArch(nameArch); + if (id.Length == 0 || !seen.Add(id)) continue; + + packages.Add(new Package( + CoreTools.FormatAsName(id), + id, + CoreTools.Translate("Latest"), + Properties.DefaultSource!, + this)); + } + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + return packages; + } + + protected override IReadOnlyList GetInstalledPackages_UnSafe() + { + var packages = new List(); + + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = "list --installed", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.ListInstalledPackages, p); + p.Start(); + + // Output: ". - <@repo>" + // Skip header/metadata lines (e.g. "Installed Packages", + // "Last metadata expiration check: ...") by requiring a known arch suffix. + string? line; + while ((line = p.StandardOutput.ReadLine()) is not null) + { + logger.AddToStdOut(line); + var parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2 || !IsPackageLine(parts[0])) continue; + + var id = StripArch(parts[0]); + var version = parts[1]; + packages.Add(new Package( + CoreTools.FormatAsName(id), + id, + version, + Properties.DefaultSource!, + this)); + } + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + return packages; + } + + protected override IReadOnlyList GetAvailableUpdates_UnSafe() + { + var packages = new List(); + + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = "list --upgrades", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.ListUpdates, p); + p.Start(); + + // Build a lookup of currently installed versions + Dictionary installed = []; + foreach (var pkg in GetInstalledPackages()) + installed.TryAdd(pkg.Id, pkg.VersionString); + + // Output: ". - " + // Skip header/metadata lines (e.g. "Available Upgrades", + // "Last metadata expiration check: ...") by requiring a known arch suffix. + string? line; + while ((line = p.StandardOutput.ReadLine()) is not null) + { + logger.AddToStdOut(line); + var parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2 || !IsPackageLine(parts[0])) continue; + + var id = StripArch(parts[0]); + var newVersion = parts[1]; + installed.TryGetValue(id, out var oldVersion); + packages.Add(new Package( + CoreTools.FormatAsName(id), + id, + oldVersion ?? "", + newVersion, + Properties.DefaultSource!, + this)); + } + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + return packages; + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + /// + /// Strips the trailing . from a DNF package token (e.g. "vim.x86_64" → "vim"). + /// Package names that do not end in a known arch are returned unchanged. + /// + internal static string StripArch(string nameArch) + { + var dot = nameArch.LastIndexOf('.'); + if (dot > 0 && _knownArches.Contains(nameArch[(dot + 1)..])) + return nameArch[..dot]; + return nameArch; + } + + /// + /// Returns true when looks like a DNF package entry + /// (i.e. ends in a known architecture suffix such as ".x86_64" or ".noarch"). + /// Used to skip header/metadata lines in dnf list output. + /// + private static bool IsPackageLine(string token) + { + var dot = token.LastIndexOf('.'); + return dot > 0 && _knownArches.Contains(token[(dot + 1)..]); + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgDetailsHelper.cs new file mode 100644 index 0000000000..1d9c4e2338 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgDetailsHelper.cs @@ -0,0 +1,148 @@ +using System.Diagnostics; +using UniGetUI.Core.IconEngine; +using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.ManagerClasses.Classes; + +namespace UniGetUI.PackageEngine.Managers.DnfManager; + +internal sealed class DnfPkgDetailsHelper : BasePkgDetailsHelper +{ + public DnfPkgDetailsHelper(Dnf manager) + : base(manager) { } + + protected override void GetDetails_UnSafe(IPackageDetails details) + { + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Manager.Status.ExecutablePath, + Arguments = $"info {details.Package.Id}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + + IProcessTaskLogger logger = Manager.TaskLogger.CreateNew( + Enums.LoggableTaskType.LoadPackageDetails, p); + p.Start(); + + // dnf info outputs "Key : value" pairs. + // Multi-line Description values are indented. + var descLines = new List(); + bool inDescription = false; + + string? line; + while ((line = p.StandardOutput.ReadLine()) is not null) + { + logger.AddToStdOut(line); + + if (line.Length == 0) + { + if (inDescription) break; + continue; + } + + // Continuation lines for Description are indented with " : " prefix: + // " : second line of the description" + if (inDescription && line.StartsWith(' ')) + { + var contColon = line.IndexOf(" : ", StringComparison.Ordinal); + descLines.Add(contColon >= 0 ? line[(contColon + 3)..].Trim() : line.Trim()); + continue; + } + + inDescription = false; + + var colonIdx = line.IndexOf(" : ", StringComparison.Ordinal); + if (colonIdx <= 0) continue; + + var key = line[..colonIdx].Trim(); + var value = line[(colonIdx + 3)..].Trim(); + + switch (key) + { + case "URL": + if (Uri.TryCreate(value, UriKind.Absolute, out var url)) + details.HomepageUrl = url; + break; + case "Summary": + details.Description = value; + break; + case "Description": + descLines.Add(value); + inDescription = true; + break; + case "License": + details.License = value; + break; + case "Packager": + details.Publisher = value; + break; + case "Size": + // e.g. "1.5 M" or "234 k" + details.InstallerSize = ParseDnfSize(value); + break; + } + } + + if (descLines.Count > 0) + details.Description = string.Join("\n", descLines); + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + } + + protected override CacheableIcon? GetIcon_UnSafe(IPackage package) + => throw new NotImplementedException(); + + protected override IReadOnlyList GetScreenshots_UnSafe(IPackage package) + => throw new NotImplementedException(); + + protected override string? GetInstallLocation_UnSafe(IPackage package) + { + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "rpm", + Arguments = $"-ql {package.Id}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + p.Start(); + var firstPath = p.StandardOutput.ReadLine()?.Trim(); + p.WaitForExit(); + + if (firstPath is not null && Directory.Exists(firstPath)) + return firstPath; + + return null; + } + + protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage package) + => throw new InvalidOperationException("DNF does not support installing arbitrary versions"); + + private static long ParseDnfSize(string value) + { + // Format: "1.5 M", "234 k", "56 G" + var parts = value.Split(' '); + if (parts.Length < 2 || !double.TryParse(parts[0], out var num)) + return 0; + + return parts[1].ToUpperInvariant() switch + { + "K" => (long)(num * 1024), + "M" => (long)(num * 1024 * 1024), + "G" => (long)(num * 1024 * 1024 * 1024), + _ => (long)num, + }; + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgOperationHelper.cs new file mode 100644 index 0000000000..af6380aef9 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgOperationHelper.cs @@ -0,0 +1,58 @@ +using UniGetUI.Core.Logging; +using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Serializable; + +namespace UniGetUI.PackageEngine.Managers.DnfManager; + +internal sealed class DnfPkgOperationHelper : BasePkgOperationHelper +{ + public DnfPkgOperationHelper(Dnf manager) + : base(manager) { } + + protected override IReadOnlyList _getOperationParameters( + IPackage package, + InstallOptions options, + OperationType operation) + { + // dnf always requires root — force elevation via InstallOptions (reference type, persists) + options.RunAsAdministrator = true; + Logger.Warn($"[Dnf] Set RunAsAdministrator=true on options for package {package.Id}"); + + List parameters = + [ + operation switch + { + OperationType.Install => Manager.Properties.InstallVerb, + OperationType.Update => Manager.Properties.UpdateVerb, + OperationType.Uninstall => Manager.Properties.UninstallVerb, + _ => throw new InvalidDataException("Invalid package operation"), + }, + "-y", + package.Id, + ]; + + if (options.SkipHashCheck) + parameters.Add("--nogpgcheck"); + + parameters.AddRange( + operation switch + { + OperationType.Update => options.CustomParameters_Update, + OperationType.Uninstall => options.CustomParameters_Uninstall, + _ => options.CustomParameters_Install, + }); + + return parameters; + } + + protected override OperationVeredict _getOperationResult( + IPackage package, + OperationType operation, + IReadOnlyList processOutput, + int returnCode) + { + return returnCode == 0 ? OperationVeredict.Success : OperationVeredict.Failure; + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.Dnf/UniGetUI.PackageEngine.Managers.Dnf.csproj b/src/UniGetUI.PackageEngine.Managers.Dnf/UniGetUI.PackageEngine.Managers.Dnf.csproj new file mode 100644 index 0000000000..00d99aa0e4 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Dnf/UniGetUI.PackageEngine.Managers.Dnf.csproj @@ -0,0 +1,23 @@ + + + $(SharedTargetFrameworks) + + + + + + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs index e3e86131fa..cb5aa15dda 100644 --- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs +++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs @@ -114,7 +114,8 @@ protected sealed override void PrepareProcessStartInfo() { IsAdmin = true; if ( - Settings.Get(Settings.K.DoCacheAdminRights) + OperatingSystem.IsLinux() + || Settings.Get(Settings.K.DoCacheAdminRights) || Settings.Get(Settings.K.DoCacheAdminRightsForBatches) ) { @@ -123,7 +124,7 @@ protected sealed override void PrepareProcessStartInfo() FileName = CoreData.ElevatorPath; Arguments = - $"\"{Package.Manager.Status.ExecutablePath}\" {Package.Manager.Status.ExecutableCallArgs} {operation_args}"; + $"{CoreData.ElevatorArgs} \"{Package.Manager.Status.ExecutablePath}\" {Package.Manager.Status.ExecutableCallArgs} {operation_args}".TrimStart(); } else { diff --git a/src/UniGetUI.PackageEngine.Operations/SourceOperations.cs b/src/UniGetUI.PackageEngine.Operations/SourceOperations.cs index 65954aa182..7987f01ef6 100644 --- a/src/UniGetUI.PackageEngine.Operations/SourceOperations.cs +++ b/src/UniGetUI.PackageEngine.Operations/SourceOperations.cs @@ -122,7 +122,8 @@ protected override void PrepareProcessStartInfo() if (RequiresAdminRights()) { if ( - Settings.Get(Settings.K.DoCacheAdminRights) + OperatingSystem.IsLinux() + || Settings.Get(Settings.K.DoCacheAdminRights) || Settings.Get(Settings.K.DoCacheAdminRightsForBatches) ) RequestCachingOfUACPrompt(); @@ -133,10 +134,10 @@ protected override void PrepareProcessStartInfo() admin = true; process.StartInfo.FileName = CoreData.ElevatorPath; process.StartInfo.Arguments = - $"\"{exePath}\" " + ($"{CoreData.ElevatorArgs} \"{exePath}\" " + Source.Manager.Status.ExecutableCallArgs + " " - + string.Join(" ", Source.Manager.SourcesHelper.GetAddSourceParameters(Source)); + + string.Join(" ", Source.Manager.SourcesHelper.GetAddSourceParameters(Source))).TrimStart(); } else { @@ -227,7 +228,8 @@ protected override void PrepareProcessStartInfo() if (RequiresAdminRights()) { if ( - Settings.Get(Settings.K.DoCacheAdminRights) + OperatingSystem.IsLinux() + || Settings.Get(Settings.K.DoCacheAdminRights) || Settings.Get(Settings.K.DoCacheAdminRightsForBatches) ) RequestCachingOfUACPrompt(); @@ -238,13 +240,13 @@ protected override void PrepareProcessStartInfo() admin = true; process.StartInfo.FileName = CoreData.ElevatorPath; process.StartInfo.Arguments = - $"\"{exePath}\" " + ($"{CoreData.ElevatorArgs} \"{exePath}\" " + Source.Manager.Status.ExecutableCallArgs + " " + string.Join( " ", Source.Manager.SourcesHelper.GetRemoveSourceParameters(Source) - ); + )).TrimStart(); } else { diff --git a/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs b/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs index dbe0d19ef9..2145776a8a 100644 --- a/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs +++ b/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs @@ -15,6 +15,8 @@ using UniGetUI.PackageEngine.Managers.ScoopManager; using UniGetUI.PackageEngine.Managers.WingetManager; #else +using UniGetUI.PackageEngine.Managers.AptManager; +using UniGetUI.PackageEngine.Managers.DnfManager; using UniGetUI.PackageEngine.Managers.HomebrewManager; #endif @@ -41,6 +43,8 @@ public static class PEInterface public static readonly Cargo Cargo = new(); public static readonly Vcpkg Vcpkg = new(); #if !WINDOWS + public static readonly Apt Apt = new(); + public static readonly Dnf Dnf = new(); public static readonly Homebrew Homebrew = new(); #endif @@ -54,6 +58,16 @@ private static IPackageManager[] CreateManagers() managers.Add(PowerShell); #else managers.Insert(0, Homebrew); + if (OperatingSystem.IsLinux()) + { + var families = ReadLinuxDistroFamilies(); + // If /etc/os-release is unreadable, include both as a safe fallback. + bool unknown = families.Count == 0; + if (unknown || families.Contains("debian") || families.Contains("ubuntu")) + managers.Add(Apt); + if (unknown || families.Contains("fedora") || families.Contains("rhel") || families.Contains("centos")) + managers.Add(Dnf); + } #endif return [.. managers]; } @@ -97,6 +111,30 @@ public static void LoadManagers() Logger.Error(ex); } } + + /// + /// Reads ID and ID_LIKE tokens from /etc/os-release to determine the Linux distro family. + /// Returns an empty set when the file cannot be read (caller should treat that as "unknown"). + /// + [System.Runtime.Versioning.SupportedOSPlatform("linux")] + private static HashSet ReadLinuxDistroFamilies() + { + var families = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + foreach (var line in File.ReadLines("/etc/os-release")) + { + if (!line.StartsWith("ID=", StringComparison.Ordinal) && + !line.StartsWith("ID_LIKE=", StringComparison.Ordinal)) + continue; + var value = line[(line.IndexOf('=') + 1)..].Trim('"'); + foreach (var token in value.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + families.Add(token); + } + } + catch { /* /etc/os-release not readable — caller will use fallback */ } + return families; + } } public class PackageBundlesLoader_I : PackageBundlesLoader diff --git a/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj b/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj index dc9978ea57..eaf1977b95 100644 --- a/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj +++ b/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj @@ -31,6 +31,8 @@ + + diff --git a/src/UniGetUI.sln b/src/UniGetUI.sln index d64b90697c..caaf1986ff 100644 --- a/src/UniGetUI.sln +++ b/src/UniGetUI.sln @@ -59,6 +59,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UniGetUI.PackageEngine.Mana EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UniGetUI.PackageEngine.Managers.Homebrew", "UniGetUI.PackageEngine.Managers.Homebrew\UniGetUI.PackageEngine.Managers.Homebrew.csproj", "{A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniGetUI.PackageEngine.Managers.Apt", "UniGetUI.PackageEngine.Managers.Apt\UniGetUI.PackageEngine.Managers.Apt.csproj", "{D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniGetUI.PackageEngine.Managers.Dnf", "UniGetUI.PackageEngine.Managers.Dnf\UniGetUI.PackageEngine.Managers.Dnf.csproj", "{E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UniGetUI.PackageEngine.Managers.PowerShell", "UniGetUI.PackageEngine.Managers.PowerShell\UniGetUI.PackageEngine.Managers.PowerShell.csproj", "{E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UniGetUI.PackageEngine.Managers.Chocolatey", "UniGetUI.PackageEngine.Managers.Chocolatey\UniGetUI.PackageEngine.Managers.Chocolatey.csproj", "{57D094C1-6913-46BF-A657-84A5F46D4EE7}" @@ -481,6 +485,38 @@ Global {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Release|Any CPU.Build.0 = Release|Any CPU {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Release|x86.ActiveCfg = Release|Any CPU {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Release|x86.Build.0 = Release|Any CPU + {D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A}.Debug|x64.ActiveCfg = Debug|x64 + {D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A}.Debug|x64.Build.0 = Debug|x64 + {D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A}.Debug|arm64.ActiveCfg = Debug|arm64 + {D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A}.Debug|arm64.Build.0 = Debug|arm64 + {D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A}.Debug|x86.ActiveCfg = Debug|Any CPU + {D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A}.Debug|x86.Build.0 = Debug|Any CPU + {D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A}.Release|x64.ActiveCfg = Release|x64 + {D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A}.Release|x64.Build.0 = Release|x64 + {D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A}.Release|arm64.ActiveCfg = Release|arm64 + {D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A}.Release|arm64.Build.0 = Release|arm64 + {D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A}.Release|Any CPU.Build.0 = Release|Any CPU + {D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A}.Release|x86.ActiveCfg = Release|Any CPU + {D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A}.Release|x86.Build.0 = Release|Any CPU + {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Debug|x64.ActiveCfg = Debug|x64 + {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Debug|x64.Build.0 = Debug|x64 + {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Debug|arm64.ActiveCfg = Debug|arm64 + {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Debug|arm64.Build.0 = Debug|arm64 + {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Debug|x86.ActiveCfg = Debug|Any CPU + {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Debug|x86.Build.0 = Debug|Any CPU + {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Release|x64.ActiveCfg = Release|x64 + {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Release|x64.Build.0 = Release|x64 + {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Release|arm64.ActiveCfg = Release|arm64 + {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Release|arm64.Build.0 = Release|arm64 + {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Release|Any CPU.Build.0 = Release|Any CPU + {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Release|x86.ActiveCfg = Release|Any CPU + {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Release|x86.Build.0 = Release|Any CPU {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Debug|x64.ActiveCfg = Debug|x64 {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Debug|x64.Build.0 = Debug|x64 {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Debug|arm64.ActiveCfg = Debug|arm64 @@ -877,6 +913,8 @@ Global {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} {1143176D-B7F0-477C-90BB-72289068D927} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6} = {7940E867-EEBA-4AFD-9904-1536F003239C} + {D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} + {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} {57D094C1-6913-46BF-A657-84A5F46D4EE7} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} {740E2894-903D-4B94-9C32-B630593BEB16} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} diff --git a/src/UniGetUI/Assets/Symbols/apt.svg b/src/UniGetUI/Assets/Symbols/apt.svg new file mode 100644 index 0000000000..bc759bbaca --- /dev/null +++ b/src/UniGetUI/Assets/Symbols/apt.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/UniGetUI/Assets/Symbols/dnf.svg b/src/UniGetUI/Assets/Symbols/dnf.svg new file mode 100644 index 0000000000..105386fc9e --- /dev/null +++ b/src/UniGetUI/Assets/Symbols/dnf.svg @@ -0,0 +1,8 @@ + + + + + + + + From 585e89ece40c595b9ddcda6224370d14fb1f8ced Mon Sep 17 00:00:00 2001 From: Gabriel Dufresne Date: Thu, 16 Apr 2026 16:18:27 -0400 Subject: [PATCH 2/7] added pacman for arch linux --- .../ManageIgnoredUpdatesViewModel.cs | 2 + src/UniGetUI.Interface.Enums/Enums.cs | 1 + .../Helpers/PacmanPkgDetailsHelper.cs | 154 +++++++++++ .../Helpers/PacmanPkgOperationHelper.cs | 55 ++++ .../Pacman.cs | 254 ++++++++++++++++++ ...GetUI.PackageEngine.Managers.Pacman.csproj | 23 ++ .../PEInterface.cs | 4 + .../UniGetUI.PackageEngine.PEInterface.csproj | 1 + src/UniGetUI.sln | 19 ++ src/UniGetUI/Assets/Symbols/pacman.svg | 4 + 10 files changed, 517 insertions(+) create mode 100644 src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgDetailsHelper.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgOperationHelper.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Pacman/Pacman.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Pacman/UniGetUI.PackageEngine.Managers.Pacman.csproj create mode 100644 src/UniGetUI/Assets/Symbols/pacman.svg diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/ManageIgnoredUpdatesViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/ManageIgnoredUpdatesViewModel.cs index 62100b8d20..5426859ae1 100644 --- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/ManageIgnoredUpdatesViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/ManageIgnoredUpdatesViewModel.cs @@ -124,7 +124,9 @@ private static string ResolveManagerIcon(string managerKey) "steam" => "steam", "gog" => "gog", "uplay" => "uplay", + "apt" => "apt", "dnf" => "dnf", + "pacman" => "pacman", _ => "ms_store", }; return $"avares://UniGetUI.Avalonia/Assets/Symbols/{name}.svg"; diff --git a/src/UniGetUI.Interface.Enums/Enums.cs b/src/UniGetUI.Interface.Enums/Enums.cs index 325cc9bc46..f4254b6aa7 100644 --- a/src/UniGetUI.Interface.Enums/Enums.cs +++ b/src/UniGetUI.Interface.Enums/Enums.cs @@ -87,6 +87,7 @@ public enum IconType Homebrew = '\uE943', Apt = '\uE944', Dnf = '\uE945', + Pacman = '\uE946', } public class NotificationArguments diff --git a/src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgDetailsHelper.cs new file mode 100644 index 0000000000..093708f5c7 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgDetailsHelper.cs @@ -0,0 +1,154 @@ +using System.Diagnostics; +using UniGetUI.Core.IconEngine; +using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.ManagerClasses.Classes; + +namespace UniGetUI.PackageEngine.Managers.PacmanManager; + +internal sealed class PacmanPkgDetailsHelper : BasePkgDetailsHelper +{ + public PacmanPkgDetailsHelper(Pacman manager) + : base(manager) { } + + protected override void GetDetails_UnSafe(IPackageDetails details) + { + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Manager.Status.ExecutablePath, + Arguments = $"-Si {details.Package.Id}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + + IProcessTaskLogger logger = Manager.TaskLogger.CreateNew( + LoggableTaskType.LoadPackageDetails, p); + p.Start(); + + // "pacman -Si" outputs "Key : value" pairs (key padded with spaces). + // Descriptions are always single-line for pacman. + string? line; + while ((line = p.StandardOutput.ReadLine()) is not null) + { + logger.AddToStdOut(line); + if (line.Length == 0) break; // blank line separates package records + + var colonIdx = line.IndexOf(" : ", StringComparison.Ordinal); + if (colonIdx <= 0) continue; + + var key = line[..colonIdx].Trim(); + var value = line[(colonIdx + 3)..].Trim(); + if (value == "None" || value.Length == 0) continue; + + switch (key) + { + case "URL": + if (Uri.TryCreate(value, UriKind.Absolute, out var url)) + details.HomepageUrl = url; + break; + case "Description": + details.Description = value; + break; + case "Licenses": + details.License = value; + break; + case "Packager": + details.Publisher = value; + break; + case "Download Size": + details.InstallerSize = ParsePacmanSize(value); + break; + case "Depends On": + details.Dependencies.Clear(); + foreach (var dep in value.Split(" ", StringSplitOptions.RemoveEmptyEntries)) + { + var depName = dep.Split(new[] { '>', '<', '=', ':' })[0].Trim(); + if (depName.Length > 0) + details.Dependencies.Add(new() { Name = depName, Version = "", Mandatory = true }); + } + break; + case "Optional Deps": + foreach (var dep in value.Split(" ", StringSplitOptions.RemoveEmptyEntries)) + { + var depName = dep.Split(':')[0].Trim(); + if (depName.Length > 0) + details.Dependencies.Add(new() { Name = depName, Version = "", Mandatory = false }); + } + break; + } + } + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + } + + protected override CacheableIcon? GetIcon_UnSafe(IPackage package) + => throw new NotImplementedException(); + + protected override IReadOnlyList GetScreenshots_UnSafe(IPackage package) + => throw new NotImplementedException(); + + protected override string? GetInstallLocation_UnSafe(IPackage package) + { + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Manager.Status.ExecutablePath, + Arguments = $"-Ql {package.Id}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + p.Start(); + + // Output format: " " — find the first directory entry. + // Must drain stdout fully before WaitForExit to avoid a pipe-full deadlock + // on packages with thousands of file entries (e.g. linux-firmware). + string? result = null; + string? line; + while ((line = p.StandardOutput.ReadLine()) is not null) + { + if (result is not null) continue; + var spaceIdx = line.IndexOf(' '); + if (spaceIdx < 0) continue; + var path = line[(spaceIdx + 1)..].Trim(); + if (Directory.Exists(path)) + result = path; + } + + p.StandardError.ReadToEnd(); + p.WaitForExit(); + return result; + } + + protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage package) + => throw new InvalidOperationException("Pacman does not support installing arbitrary versions"); + + private static long ParsePacmanSize(string value) + { + // Format: "2.34 MiB", "234.56 KiB", "1.20 GiB" + var parts = value.Split(' '); + if (parts.Length < 2 || !double.TryParse(parts[0], + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var num)) + return 0; + + return parts[1] switch + { + "KiB" or "kB" => (long)(num * 1024), + "MiB" or "MB" => (long)(num * 1024 * 1024), + "GiB" or "GB" => (long)(num * 1024 * 1024 * 1024), + _ => (long)num, + }; + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgOperationHelper.cs new file mode 100644 index 0000000000..bf79df2f4c --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgOperationHelper.cs @@ -0,0 +1,55 @@ +using UniGetUI.Core.Logging; +using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Serializable; + +namespace UniGetUI.PackageEngine.Managers.PacmanManager; + +internal sealed class PacmanPkgOperationHelper : BasePkgOperationHelper +{ + public PacmanPkgOperationHelper(Pacman manager) + : base(manager) { } + + protected override IReadOnlyList _getOperationParameters( + IPackage package, + InstallOptions options, + OperationType operation) + { + // pacman always requires root — force elevation via InstallOptions (reference type, persists) + options.RunAsAdministrator = true; + Logger.Warn($"[Pacman] Set RunAsAdministrator=true on options for package {package.Id}"); + + List parameters = + [ + operation switch + { + OperationType.Install => Manager.Properties.InstallVerb, + OperationType.Update => Manager.Properties.UpdateVerb, + OperationType.Uninstall => Manager.Properties.UninstallVerb, + _ => throw new InvalidDataException("Invalid package operation"), + }, + "--noconfirm", + package.Id, + ]; + + parameters.AddRange( + operation switch + { + OperationType.Update => options.CustomParameters_Update, + OperationType.Uninstall => options.CustomParameters_Uninstall, + _ => options.CustomParameters_Install, + }); + + return parameters; + } + + protected override OperationVeredict _getOperationResult( + IPackage package, + OperationType operation, + IReadOnlyList processOutput, + int returnCode) + { + return returnCode == 0 ? OperationVeredict.Success : OperationVeredict.Failure; + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.Pacman/Pacman.cs b/src/UniGetUI.PackageEngine.Managers.Pacman/Pacman.cs new file mode 100644 index 0000000000..b079b7ee12 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Pacman/Pacman.cs @@ -0,0 +1,254 @@ +using System.Diagnostics; +using UniGetUI.Core.Tools; +using UniGetUI.Interface.Enums; +using UniGetUI.PackageEngine.Classes.Manager; +using UniGetUI.PackageEngine.Classes.Manager.ManagerHelpers; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.ManagerClasses.Classes; +using UniGetUI.PackageEngine.ManagerClasses.Manager; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.Structs; + +namespace UniGetUI.PackageEngine.Managers.PacmanManager; + +public class Pacman : PackageManager +{ + public Pacman() + { + Dependencies = []; + + Capabilities = new ManagerCapabilities + { + CanRunAsAdmin = true, + CanSkipIntegrityChecks = false, + SupportsCustomSources = false, + SupportsProxy = ProxySupport.No, + SupportsProxyAuth = false, + }; + + Properties = new ManagerProperties + { + Name = "Pacman", + Description = CoreTools.Translate( + "The default package manager for Arch Linux and its derivatives.
Contains: Arch Linux packages" + ), + IconId = IconType.Pacman, + ColorIconId = "pacman", + ExecutableFriendlyName = "pacman", + InstallVerb = "-S", + UpdateVerb = "-S", + UninstallVerb = "-Rs", + DefaultSource = new ManagerSource(this, "arch", new Uri("https://archlinux.org/packages/")), + KnownSources = [new ManagerSource(this, "arch", new Uri("https://archlinux.org/packages/"))], + }; + + DetailsHelper = new PacmanPkgDetailsHelper(this); + OperationHelper = new PacmanPkgOperationHelper(this); + } + + // ── Executable discovery ─────────────────────────────────────────────── + + public override IReadOnlyList FindCandidateExecutableFiles() + { + var candidates = new List(CoreTools.WhichMultiple("pacman")); + foreach (var path in new[] { "/usr/bin/pacman", "/usr/local/bin/pacman" }) + { + if (File.Exists(path) && !candidates.Contains(path)) + candidates.Add(path); + } + return candidates; + } + + protected override void _loadManagerExecutableFile( + out bool found, + out string path, + out string callArguments) + { + (found, path) = GetExecutableFile(); + callArguments = ""; + } + + protected override void _loadManagerVersion(out string version) + { + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = "-Q pacman", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + p.Start(); + // First line: "pacman X.Y.Z-N" + var line = p.StandardOutput.ReadLine()?.Trim() ?? ""; + var parts = line.Split(' '); + version = parts.Length >= 2 ? parts[1] : line; + p.StandardOutput.ReadToEnd(); + p.StandardError.ReadToEnd(); + p.WaitForExit(); + } + + // ── Index refresh ────────────────────────────────────────────────────── + + public override void RefreshPackageIndexes() + { + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = "-Sy", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.RefreshIndexes, p); + p.Start(); + logger.AddToStdOut(p.StandardOutput.ReadToEnd()); + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + } + + // ── Package listing ──────────────────────────────────────────────────── + + protected override IReadOnlyList FindPackages_UnSafe(string query) + { + var packages = new List(); + + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = $"-Ss {query}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.FindPackages, p); + p.Start(); + + // Output format: "/ [groups]\n " + // Name lines start at column 0; description lines are indented. + string? line; + while ((line = p.StandardOutput.ReadLine()) is not null) + { + logger.AddToStdOut(line); + if (line.Length == 0 || line.StartsWith(' ')) continue; + + var slashIdx = line.IndexOf('/'); + if (slashIdx < 0) continue; + + var afterSlash = line[(slashIdx + 1)..]; + var spaceIdx = afterSlash.IndexOf(' '); + if (spaceIdx <= 0) continue; + + var id = afterSlash[..spaceIdx]; + packages.Add(new Package( + CoreTools.FormatAsName(id), + id, + CoreTools.Translate("Latest"), + Properties.DefaultSource!, + this)); + } + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + return packages; + } + + protected override IReadOnlyList GetInstalledPackages_UnSafe() + { + var packages = new List(); + + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = "-Q", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.ListInstalledPackages, p); + p.Start(); + + // Output format: " " + string? line; + while ((line = p.StandardOutput.ReadLine()) is not null) + { + logger.AddToStdOut(line); + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) continue; + + packages.Add(new Package( + CoreTools.FormatAsName(parts[0]), + parts[0], + parts[1], + Properties.DefaultSource!, + this)); + } + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + return packages; + } + + protected override IReadOnlyList GetAvailableUpdates_UnSafe() + { + var packages = new List(); + + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = "-Qu", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.ListUpdates, p); + p.Start(); + + // Output format: " -> " + string? line; + while ((line = p.StandardOutput.ReadLine()) is not null) + { + logger.AddToStdOut(line); + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 4 || parts[2] != "->") continue; + + packages.Add(new Package( + CoreTools.FormatAsName(parts[0]), + parts[0], + parts[1], + parts[3], + Properties.DefaultSource!, + this)); + } + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + // pacman -Qu exits 1 when there are no upgradable packages — not an error + logger.Close(p.ExitCode == 1 && packages.Count == 0 ? 0 : p.ExitCode); + return packages; + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.Pacman/UniGetUI.PackageEngine.Managers.Pacman.csproj b/src/UniGetUI.PackageEngine.Managers.Pacman/UniGetUI.PackageEngine.Managers.Pacman.csproj new file mode 100644 index 0000000000..00d99aa0e4 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Pacman/UniGetUI.PackageEngine.Managers.Pacman.csproj @@ -0,0 +1,23 @@ + + + $(SharedTargetFrameworks) + + + + + + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs b/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs index 2145776a8a..b4880f642e 100644 --- a/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs +++ b/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs @@ -18,6 +18,7 @@ using UniGetUI.PackageEngine.Managers.AptManager; using UniGetUI.PackageEngine.Managers.DnfManager; using UniGetUI.PackageEngine.Managers.HomebrewManager; +using UniGetUI.PackageEngine.Managers.PacmanManager; #endif namespace UniGetUI.PackageEngine @@ -45,6 +46,7 @@ public static class PEInterface #if !WINDOWS public static readonly Apt Apt = new(); public static readonly Dnf Dnf = new(); + public static readonly Pacman Pacman = new(); public static readonly Homebrew Homebrew = new(); #endif @@ -67,6 +69,8 @@ private static IPackageManager[] CreateManagers() managers.Add(Apt); if (unknown || families.Contains("fedora") || families.Contains("rhel") || families.Contains("centos")) managers.Add(Dnf); + if (unknown || families.Contains("arch")) + managers.Add(Pacman); } #endif return [.. managers]; diff --git a/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj b/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj index eaf1977b95..102f07b342 100644 --- a/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj +++ b/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj @@ -33,6 +33,7 @@ + diff --git a/src/UniGetUI.sln b/src/UniGetUI.sln index caaf1986ff..a49ec88962 100644 --- a/src/UniGetUI.sln +++ b/src/UniGetUI.sln @@ -63,6 +63,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniGetUI.PackageEngine.Mana EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniGetUI.PackageEngine.Managers.Dnf", "UniGetUI.PackageEngine.Managers.Dnf\UniGetUI.PackageEngine.Managers.Dnf.csproj", "{E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniGetUI.PackageEngine.Managers.Pacman", "UniGetUI.PackageEngine.Managers.Pacman\UniGetUI.PackageEngine.Managers.Pacman.csproj", "{F3A4B5C6-D7E8-9F0A-1B2C-3D4E5F6A7B8C}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UniGetUI.PackageEngine.Managers.PowerShell", "UniGetUI.PackageEngine.Managers.PowerShell\UniGetUI.PackageEngine.Managers.PowerShell.csproj", "{E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UniGetUI.PackageEngine.Managers.Chocolatey", "UniGetUI.PackageEngine.Managers.Chocolatey\UniGetUI.PackageEngine.Managers.Chocolatey.csproj", "{57D094C1-6913-46BF-A657-84A5F46D4EE7}" @@ -517,6 +519,22 @@ Global {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Release|Any CPU.Build.0 = Release|Any CPU {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Release|x86.ActiveCfg = Release|Any CPU {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B}.Release|x86.Build.0 = Release|Any CPU + {F3A4B5C6-D7E8-9F0A-1B2C-3D4E5F6A7B8C}.Debug|x64.ActiveCfg = Debug|x64 + {F3A4B5C6-D7E8-9F0A-1B2C-3D4E5F6A7B8C}.Debug|x64.Build.0 = Debug|x64 + {F3A4B5C6-D7E8-9F0A-1B2C-3D4E5F6A7B8C}.Debug|arm64.ActiveCfg = Debug|arm64 + {F3A4B5C6-D7E8-9F0A-1B2C-3D4E5F6A7B8C}.Debug|arm64.Build.0 = Debug|arm64 + {F3A4B5C6-D7E8-9F0A-1B2C-3D4E5F6A7B8C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F3A4B5C6-D7E8-9F0A-1B2C-3D4E5F6A7B8C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F3A4B5C6-D7E8-9F0A-1B2C-3D4E5F6A7B8C}.Debug|x86.ActiveCfg = Debug|Any CPU + {F3A4B5C6-D7E8-9F0A-1B2C-3D4E5F6A7B8C}.Debug|x86.Build.0 = Debug|Any CPU + {F3A4B5C6-D7E8-9F0A-1B2C-3D4E5F6A7B8C}.Release|x64.ActiveCfg = Release|x64 + {F3A4B5C6-D7E8-9F0A-1B2C-3D4E5F6A7B8C}.Release|x64.Build.0 = Release|x64 + {F3A4B5C6-D7E8-9F0A-1B2C-3D4E5F6A7B8C}.Release|arm64.ActiveCfg = Release|arm64 + {F3A4B5C6-D7E8-9F0A-1B2C-3D4E5F6A7B8C}.Release|arm64.Build.0 = Release|arm64 + {F3A4B5C6-D7E8-9F0A-1B2C-3D4E5F6A7B8C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F3A4B5C6-D7E8-9F0A-1B2C-3D4E5F6A7B8C}.Release|Any CPU.Build.0 = Release|Any CPU + {F3A4B5C6-D7E8-9F0A-1B2C-3D4E5F6A7B8C}.Release|x86.ActiveCfg = Release|Any CPU + {F3A4B5C6-D7E8-9F0A-1B2C-3D4E5F6A7B8C}.Release|x86.Build.0 = Release|Any CPU {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Debug|x64.ActiveCfg = Debug|x64 {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Debug|x64.Build.0 = Debug|x64 {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Debug|arm64.ActiveCfg = Debug|arm64 @@ -915,6 +933,7 @@ Global {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6} = {7940E867-EEBA-4AFD-9904-1536F003239C} {D1E2F3A4-B5C6-7D8E-9F0A-1B2C3D4E5F6A} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} {E2F3A4B5-C6D7-8E9F-0A1B-2C3D4E5F6A7B} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} + {F3A4B5C6-D7E8-9F0A-1B2C-3D4E5F6A7B8C} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} {57D094C1-6913-46BF-A657-84A5F46D4EE7} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} {740E2894-903D-4B94-9C32-B630593BEB16} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} diff --git a/src/UniGetUI/Assets/Symbols/pacman.svg b/src/UniGetUI/Assets/Symbols/pacman.svg new file mode 100644 index 0000000000..c5ab7ad8da --- /dev/null +++ b/src/UniGetUI/Assets/Symbols/pacman.svg @@ -0,0 +1,4 @@ + + + + From 8df32226fd83e4ea321513a2d4992eda7894491d Mon Sep 17 00:00:00 2001 From: Gabriel Dufresne Date: Fri, 17 Apr 2026 09:55:13 -0400 Subject: [PATCH 3/7] improve robustness of Dnf and Pacman managers --- .../Dnf.cs | 26 ++++++++----- .../Helpers/DnfPkgDetailsHelper.cs | 39 ++++++++++++------- .../Helpers/PacmanPkgDetailsHelper.cs | 31 ++++++++++++++- .../Pacman.cs | 3 +- 4 files changed, 74 insertions(+), 25 deletions(-) diff --git a/src/UniGetUI.PackageEngine.Managers.Dnf/Dnf.cs b/src/UniGetUI.PackageEngine.Managers.Dnf/Dnf.cs index 96a709708a..f54325fbd9 100644 --- a/src/UniGetUI.PackageEngine.Managers.Dnf/Dnf.cs +++ b/src/UniGetUI.PackageEngine.Managers.Dnf/Dnf.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.Text.RegularExpressions; using UniGetUI.Core.Tools; using UniGetUI.Interface.Enums; using UniGetUI.PackageEngine.Classes.Manager; @@ -56,8 +55,13 @@ public Dnf() public override IReadOnlyList FindCandidateExecutableFiles() { - var candidates = new List(CoreTools.WhichMultiple("dnf")); - foreach (var path in new[] { "/usr/bin/dnf", "/usr/local/bin/dnf" }) + var candidates = new List(CoreTools.WhichMultiple("dnf5")); + foreach (var path in CoreTools.WhichMultiple("dnf")) + { + if (!candidates.Contains(path)) + candidates.Add(path); + } + foreach (var path in new[] { "/usr/bin/dnf5", "/usr/bin/dnf", "/usr/local/bin/dnf" }) { if (File.Exists(path) && !candidates.Contains(path)) candidates.Add(path); @@ -91,6 +95,8 @@ protected override void _loadManagerVersion(out string version) p.Start(); // First line is the version number: "X.Y.Z" version = p.StandardOutput.ReadLine()?.Trim() ?? ""; + p.StandardOutput.ReadToEnd(); + p.StandardError.ReadToEnd(); p.WaitForExit(); } @@ -165,7 +171,8 @@ protected override IReadOnlyList FindPackages_UnSafe(string query) logger.AddToStdErr(p.StandardError.ReadToEnd()); p.WaitForExit(); - logger.Close(p.ExitCode); + // dnf search exits 1 when no packages match the query — not an error + logger.Close(p.ExitCode == 1 && packages.Count == 0 ? 0 : p.ExitCode); return packages; } @@ -230,14 +237,15 @@ protected override IReadOnlyList GetAvailableUpdates_UnSafe() CreateNoWindow = true, }, }; - IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.ListUpdates, p); - p.Start(); - - // Build a lookup of currently installed versions + // Build lookup before starting the process — reading from its pipe + // while a second process runs risks filling the pipe buffer and deadlocking. Dictionary installed = []; - foreach (var pkg in GetInstalledPackages()) + foreach (var pkg in GetInstalledPackages_UnSafe()) installed.TryAdd(pkg.Id, pkg.VersionString); + IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.ListUpdates, p); + p.Start(); + // Output: ". - " // Skip header/metadata lines (e.g. "Available Upgrades", // "Last metadata expiration check: ...") by requiring a known arch suffix. diff --git a/src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgDetailsHelper.cs index 1d9c4e2338..faa1dabbce 100644 --- a/src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgDetailsHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgDetailsHelper.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using UniGetUI.Core.IconEngine; using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; +using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageEngine.ManagerClasses.Classes; @@ -27,7 +28,7 @@ protected override void GetDetails_UnSafe(IPackageDetails details) }; IProcessTaskLogger logger = Manager.TaskLogger.CreateNew( - Enums.LoggableTaskType.LoadPackageDetails, p); + LoggableTaskType.LoadPackageDetails, p); p.Start(); // dnf info outputs "Key : value" pairs. @@ -40,11 +41,10 @@ protected override void GetDetails_UnSafe(IPackageDetails details) { logger.AddToStdOut(line); - if (line.Length == 0) - { - if (inDescription) break; - continue; - } + // Blank line marks the end of a package block. dnf info can output multiple + // blocks (e.g. "Installed Packages" then "Available Packages") — stop after + // the first so the second block doesn't silently overwrite parsed fields. + if (line.Length == 0) break; // Continuation lines for Description are indented with " : " prefix: // " : second line of the description" @@ -118,13 +118,22 @@ protected override IReadOnlyList GetScreenshots_UnSafe(IPackage package) }, }; p.Start(); - var firstPath = p.StandardOutput.ReadLine()?.Trim(); - p.WaitForExit(); - if (firstPath is not null && Directory.Exists(firstPath)) - return firstPath; + // Must drain all stdout before WaitForExit — packages like glibc have thousands + // of file entries and will fill the pipe buffer, causing a deadlock. + string? result = null; + string? line; + while ((line = p.StandardOutput.ReadLine()) is not null) + { + if (result is not null) continue; + var path = line.Trim(); + if (Directory.Exists(path)) + result = path; + } - return null; + p.StandardError.ReadToEnd(); + p.WaitForExit(); + return result; } protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage package) @@ -133,8 +142,10 @@ protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage private static long ParseDnfSize(string value) { // Format: "1.5 M", "234 k", "56 G" - var parts = value.Split(' '); - if (parts.Length < 2 || !double.TryParse(parts[0], out var num)) + var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2 || !double.TryParse(parts[0], + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var num)) return 0; return parts[1].ToUpperInvariant() switch @@ -142,7 +153,7 @@ private static long ParseDnfSize(string value) "K" => (long)(num * 1024), "M" => (long)(num * 1024 * 1024), "G" => (long)(num * 1024 * 1024 * 1024), - _ => (long)num, + _ => (long)num, }; } } diff --git a/src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgDetailsHelper.cs index 093708f5c7..bcfdb2f8ad 100644 --- a/src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgDetailsHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgDetailsHelper.cs @@ -32,8 +32,11 @@ protected override void GetDetails_UnSafe(IPackageDetails details) p.Start(); // "pacman -Si" outputs "Key : value" pairs (key padded with spaces). - // Descriptions are always single-line for pacman. + // Multi-dep fields (Depends On, Optional Deps) wrap onto continuation lines: + // " : next-dep another-dep" + // Continuation lines have an empty key (all spaces before " : "). string? line; + string lastKey = ""; while ((line = p.StandardOutput.ReadLine()) is not null) { logger.AddToStdOut(line); @@ -46,6 +49,32 @@ protected override void GetDetails_UnSafe(IPackageDetails details) var value = line[(colonIdx + 3)..].Trim(); if (value == "None" || value.Length == 0) continue; + if (key.Length == 0) + { + // Continuation line — append to whatever field was active + switch (lastKey) + { + case "Depends On": + foreach (var dep in value.Split(" ", StringSplitOptions.RemoveEmptyEntries)) + { + var depName = dep.Split(new[] { '>', '<', '=', ':' })[0].Trim(); + if (depName.Length > 0) + details.Dependencies.Add(new() { Name = depName, Version = "", Mandatory = true }); + } + break; + case "Optional Deps": + foreach (var dep in value.Split(" ", StringSplitOptions.RemoveEmptyEntries)) + { + var depName = dep.Split(':')[0].Trim(); + if (depName.Length > 0) + details.Dependencies.Add(new() { Name = depName, Version = "", Mandatory = false }); + } + break; + } + continue; + } + + lastKey = key; switch (key) { case "URL": diff --git a/src/UniGetUI.PackageEngine.Managers.Pacman/Pacman.cs b/src/UniGetUI.PackageEngine.Managers.Pacman/Pacman.cs index b079b7ee12..862166710d 100644 --- a/src/UniGetUI.PackageEngine.Managers.Pacman/Pacman.cs +++ b/src/UniGetUI.PackageEngine.Managers.Pacman/Pacman.cs @@ -164,7 +164,8 @@ protected override IReadOnlyList FindPackages_UnSafe(string query) logger.AddToStdErr(p.StandardError.ReadToEnd()); p.WaitForExit(); - logger.Close(p.ExitCode); + // pacman -Ss exits 1 when no packages match the query — not an error + logger.Close(p.ExitCode == 1 && packages.Count == 0 ? 0 : p.ExitCode); return packages; } From 42efcf637ac98e63ffe6158f47e9915c46adb196 Mon Sep 17 00:00:00 2001 From: Gabriel Dufresne Date: Fri, 17 Apr 2026 13:26:34 -0400 Subject: [PATCH 4/7] fixed whitespace --- src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs | 2 +- .../Helpers/AptPkgOperationHelper.cs | 4 ++-- .../Helpers/DnfPkgOperationHelper.cs | 4 ++-- .../Helpers/PacmanPkgOperationHelper.cs | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs index ebf757f09f..67d3e2974b 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs @@ -196,7 +196,7 @@ private static async Task LoadLinuxElevatorAsync() CoreTools.WhichAsync("sudo"), CoreTools.WhichAsync("pkexec"), CoreTools.WhichAsync("zenity")); - var (sudoFound, sudoPath) = results[0]; + var (sudoFound, sudoPath) = results[0]; var (pkexecFound, pkexecPath) = results[1]; var (zenityFound, zenityPath) = results[2]; diff --git a/src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgOperationHelper.cs index a8b03fdb94..8e3a334f35 100644 --- a/src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgOperationHelper.cs @@ -43,9 +43,9 @@ protected override IReadOnlyList _getOperationParameters( parameters.AddRange( operation switch { - OperationType.Update => options.CustomParameters_Update, + OperationType.Update => options.CustomParameters_Update, OperationType.Uninstall => options.CustomParameters_Uninstall, - _ => options.CustomParameters_Install, + _ => options.CustomParameters_Install, }); return parameters; diff --git a/src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgOperationHelper.cs index af6380aef9..92c0ad2a5f 100644 --- a/src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgOperationHelper.cs @@ -39,9 +39,9 @@ protected override IReadOnlyList _getOperationParameters( parameters.AddRange( operation switch { - OperationType.Update => options.CustomParameters_Update, + OperationType.Update => options.CustomParameters_Update, OperationType.Uninstall => options.CustomParameters_Uninstall, - _ => options.CustomParameters_Install, + _ => options.CustomParameters_Install, }); return parameters; diff --git a/src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgOperationHelper.cs index bf79df2f4c..452425f691 100644 --- a/src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgOperationHelper.cs @@ -36,9 +36,9 @@ protected override IReadOnlyList _getOperationParameters( parameters.AddRange( operation switch { - OperationType.Update => options.CustomParameters_Update, + OperationType.Update => options.CustomParameters_Update, OperationType.Uninstall => options.CustomParameters_Uninstall, - _ => options.CustomParameters_Install, + _ => options.CustomParameters_Install, }); return parameters; From de1090e7cfe62b888ffef983b6137dc3ffe5b976 Mon Sep 17 00:00:00 2001 From: Gabriel Dufresne Date: Fri, 17 Apr 2026 14:11:05 -0400 Subject: [PATCH 5/7] fix some issue mentioned by copilot --- .../Assets/Languages/lang_en.json | 1 + src/UniGetUI.Core.Tools/Tools.cs | 33 ++++++++++++++----- .../Helpers/AptPkgDetailsHelper.cs | 22 +++++++++---- .../Helpers/AptPkgOperationHelper.cs | 2 -- .../Helpers/DnfPkgOperationHelper.cs | 2 -- .../Helpers/PacmanPkgOperationHelper.cs | 2 -- 6 files changed, 42 insertions(+), 20 deletions(-) diff --git a/src/UniGetUI.Core.LanguageEngine/Assets/Languages/lang_en.json b/src/UniGetUI.Core.LanguageEngine/Assets/Languages/lang_en.json index 5a8907c791..79b145a0a7 100644 --- a/src/UniGetUI.Core.LanguageEngine/Assets/Languages/lang_en.json +++ b/src/UniGetUI.Core.LanguageEngine/Assets/Languages/lang_en.json @@ -504,6 +504,7 @@ "The Missing Package Manager for macOS (or Linux).
Contains: Formulae, Casks": "The Missing Package Manager for macOS (or Linux).
Contains: Formulae, Casks", "The default package manager for Debian/Ubuntu-based Linux distributions.
Contains: Debian/Ubuntu packages": "The default package manager for Debian/Ubuntu-based Linux distributions.
Contains: Debian/Ubuntu packages", "The default package manager for RHEL/Fedora-based Linux distributions.
Contains: RPM packages": "The default package manager for RHEL/Fedora-based Linux distributions.
Contains: RPM packages", + "The default package manager for Arch Linux and its derivatives.
Contains: Arch Linux packages": "The default package manager for Arch Linux and its derivatives.
Contains: Arch Linux packages", "Node JS's package manager. Full of libraries and other utilities that orbit the javascript world
Contains: Node javascript libraries and other related utilities": "Node JS's package manager. Full of libraries and other utilities that orbit the javascript world
Contains: Node javascript libraries and other related utilities", "Python's library manager. Full of python libraries and other python-related utilities
Contains: Python libraries and related utilities": "Python's library manager. Full of python libraries and other python-related utilities
Contains: Python libraries and related utilities", "PowerShell's package manager. Find libraries and scripts to expand PowerShell capabilities
Contains: Modules, Scripts, Cmdlets": "PowerShell's package manager. Find libraries and scripts to expand PowerShell capabilities
Contains: Modules, Scripts, Cmdlets", diff --git a/src/UniGetUI.Core.Tools/Tools.cs b/src/UniGetUI.Core.Tools/Tools.cs index d683ff8c18..5ba7454c6f 100644 --- a/src/UniGetUI.Core.Tools/Tools.cs +++ b/src/UniGetUI.Core.Tools/Tools.cs @@ -506,11 +506,20 @@ public static async Task CacheUACForCurrentProcess() _isCaching = true; Logger.Info("Caching admin rights for process id " + Environment.ProcessId); - // When using sudo on Linux, "-Av" validates/extends the timestamp via the - // askpass helper — prompts once then caches for the sudo timeout (~15 min). - // For gsudo on Windows (or pkexec fallback) use the gsudo cache protocol. - string cacheArgs = Path.GetFileName(CoreData.ElevatorPath) == "sudo" - ? "-Av" + var elevatorName = Path.GetFileName(CoreData.ElevatorPath); + + // pkexec prompts on every invocation and has no caching protocol. + if (elevatorName == "pkexec") + { + _isCaching = false; + return; + } + + // sudo: -v validates/extends the cached timestamp. + // Prepend -A only when the SUDO_ASKPASS helper is configured. + // gsudo / UniGetUI Elevator.exe: use the gsudo cache protocol. + string cacheArgs = elevatorName == "sudo" + ? (CoreData.ElevatorArgs.Contains("-A") ? "-Av" : "-v") : "cache on --pid " + Environment.ProcessId + " -d 1"; using Process p = new() @@ -555,9 +564,17 @@ public static async Task ResetUACForCurrentProcess() "Resetting administrator rights cache for process id " + Environment.ProcessId ); - // When using sudo on Linux, "-K" removes all cached timestamps. - // For gsudo on Windows (or pkexec fallback) use the gsudo cache protocol. - string resetArgs = Path.GetFileName(CoreData.ElevatorPath) == "sudo" + var elevatorName = Path.GetFileName(CoreData.ElevatorPath); + + // pkexec prompts on every invocation and has no caching protocol. + if (elevatorName == "pkexec") + { + return; + } + + // sudo: -K removes all cached timestamps. + // gsudo / UniGetUI Elevator.exe: use the gsudo cache protocol. + string resetArgs = elevatorName == "sudo" ? "-K" : "cache off --pid " + Environment.ProcessId; diff --git a/src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgDetailsHelper.cs index 8889f75465..fd82b5f0fb 100644 --- a/src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgDetailsHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgDetailsHelper.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using UniGetUI.Core.IconEngine; using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; +using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageEngine.ManagerClasses.Classes; @@ -27,7 +28,7 @@ protected override void GetDetails_UnSafe(IPackageDetails details) }; IProcessTaskLogger logger = Manager.TaskLogger.CreateNew( - Enums.LoggableTaskType.LoadPackageDetails, p); + LoggableTaskType.LoadPackageDetails, p); p.Start(); // apt-cache show outputs key: value pairs, one per line. @@ -138,13 +139,22 @@ protected override IReadOnlyList GetScreenshots_UnSafe(IPackage package) }, }; p.Start(); - var firstPath = p.StandardOutput.ReadLine()?.Trim(); - p.WaitForExit(); - if (firstPath is not null && Directory.Exists(firstPath)) - return firstPath; + // Must drain all stdout before WaitForExit — packages with many files + // will fill the pipe buffer and deadlock if we stop reading early. + string? result = null; + string? line; + while ((line = p.StandardOutput.ReadLine()) is not null) + { + if (result is not null) continue; + var path = line.Trim(); + if (Directory.Exists(path)) + result = path; + } - return null; + p.StandardError.ReadToEnd(); + p.WaitForExit(); + return result; } protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage package) diff --git a/src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgOperationHelper.cs index 8e3a334f35..d247277757 100644 --- a/src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Apt/Helpers/AptPkgOperationHelper.cs @@ -1,4 +1,3 @@ -using UniGetUI.Core.Logging; using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; @@ -18,7 +17,6 @@ protected override IReadOnlyList _getOperationParameters( { // apt always requires root — force elevation via InstallOptions (reference type, persists) options.RunAsAdministrator = true; - Logger.Warn($"[Apt] Set RunAsAdministrator=true on options for package {package.Id}"); List parameters = [ diff --git a/src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgOperationHelper.cs index 92c0ad2a5f..4df6cf8971 100644 --- a/src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Dnf/Helpers/DnfPkgOperationHelper.cs @@ -1,4 +1,3 @@ -using UniGetUI.Core.Logging; using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; @@ -18,7 +17,6 @@ protected override IReadOnlyList _getOperationParameters( { // dnf always requires root — force elevation via InstallOptions (reference type, persists) options.RunAsAdministrator = true; - Logger.Warn($"[Dnf] Set RunAsAdministrator=true on options for package {package.Id}"); List parameters = [ diff --git a/src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgOperationHelper.cs index 452425f691..912f2fb0c9 100644 --- a/src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Pacman/Helpers/PacmanPkgOperationHelper.cs @@ -1,4 +1,3 @@ -using UniGetUI.Core.Logging; using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; @@ -18,7 +17,6 @@ protected override IReadOnlyList _getOperationParameters( { // pacman always requires root — force elevation via InstallOptions (reference type, persists) options.RunAsAdministrator = true; - Logger.Warn($"[Pacman] Set RunAsAdministrator=true on options for package {package.Id}"); List parameters = [ From 56fa09cdc5302a683ef9c6a2de6d0d1378ad6824 Mon Sep 17 00:00:00 2001 From: Gabriel Dufresne Date: Fri, 17 Apr 2026 14:18:52 -0400 Subject: [PATCH 6/7] Fixed unused method in windows --- src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs b/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs index b4880f642e..cfb219ce8e 100644 --- a/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs +++ b/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs @@ -116,10 +116,7 @@ public static void LoadManagers() } } - /// - /// Reads ID and ID_LIKE tokens from /etc/os-release to determine the Linux distro family. - /// Returns an empty set when the file cannot be read (caller should treat that as "unknown"). - /// +#if !WINDOWS [System.Runtime.Versioning.SupportedOSPlatform("linux")] private static HashSet ReadLinuxDistroFamilies() { @@ -139,6 +136,7 @@ private static HashSet ReadLinuxDistroFamilies() catch { /* /etc/os-release not readable — caller will use fallback */ } return families; } +#endif } public class PackageBundlesLoader_I : PackageBundlesLoader From ad67388eaf3fbb80f2db9bfe4fb472a9de6bc2d8 Mon Sep 17 00:00:00 2001 From: Gabriel Dufresne Date: Fri, 17 Apr 2026 14:24:01 -0400 Subject: [PATCH 7/7] updated translation metadata --- .../Assets/Data/TranslatedPercentages.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/UniGetUI.Core.LanguageEngine/Assets/Data/TranslatedPercentages.json b/src/UniGetUI.Core.LanguageEngine/Assets/Data/TranslatedPercentages.json index 7101fa3e23..12d11b5a31 100644 --- a/src/UniGetUI.Core.LanguageEngine/Assets/Data/TranslatedPercentages.json +++ b/src/UniGetUI.Core.LanguageEngine/Assets/Data/TranslatedPercentages.json @@ -6,13 +6,13 @@ "bn": "100%", "da": "100%", "el": "100%", - "eo": "100%", + "eo": "99%", "es": "100%", "es-MX": "100%", "et": "100%", "fa": "100%", "fil": "100%", - "gl": "100%", + "gl": "99%", "gu": "100%", "he": "100%", "hi": "100%", @@ -23,10 +23,10 @@ "ka": "100%", "kn": "100%", "ko": "100%", - "ku": "100%", + "ku": "99%", "lt": "100%", "mk": "100%", - "mr": "100%", + "mr": "99%", "nb": "100%", "nn": "100%", "pt_PT": "100%", @@ -58,4 +58,4 @@ "zh_CN": "100%", "zh_TW": "100%", "en": "100%" -} +}