diff --git a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs
index 5131024daa..4affb62113 100644
--- a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs
+++ b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs
@@ -105,6 +105,9 @@ public enum K
DisableReleaseNotesOnUpdate,
LastKnownBuildNumber,
DisableAutoSoftwareRenderingOnGpuLessHosts,
+ // NOTE: Set this to true to delegate package operations to Devolutions Agent broker
+ // instead of using local UAC elevation. Change default here when ready for production.
+ UseAgentBroker,
Test1,
Test2,
@@ -221,6 +224,7 @@ public static string ResolveKey(K key)
K.DisableReleaseNotesOnUpdate => "DisableReleaseNotesOnUpdate",
K.LastKnownBuildNumber => "LastKnownBuildNumber",
K.DisableAutoSoftwareRenderingOnGpuLessHosts => "DisableAutoSoftwareRenderingOnGpuLessHosts",
+ K.UseAgentBroker => "UseAgentBroker",
K.Test1 => "TestSetting1",
K.Test2 => "TestSetting2",
diff --git a/src/UniGetUI.PackageEngine.AgentBroker/BrokerRequestBuilder.cs b/src/UniGetUI.PackageEngine.AgentBroker/BrokerRequestBuilder.cs
new file mode 100644
index 0000000000..7f369087ea
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.AgentBroker/BrokerRequestBuilder.cs
@@ -0,0 +1,169 @@
+using Devolutions.Now.Policy.Api;
+using Devolutions.Now.Policy.Client;
+using UniGetUI.PackageEngine.Enums;
+using UniGetUI.PackageEngine.Interfaces;
+using UniGetUI.PackageEngine.Serializable;
+// Aliased to avoid clashing with UniGetUI.PackageEngine.Enums.Architecture.
+using BrokerArchitecture = Devolutions.Now.Policy.Api.Architecture;
+
+namespace UniGetUI.PackageEngine.AgentBroker;
+
+///
+/// Builds broker protocol requests from UniGetUI domain objects.
+/// Maps IPackage + InstallOptions + OperationType into the canonical
+/// consumed by the Devolutions Agent broker.
+///
+public static class BrokerRequestBuilder
+{
+ /// Build a broker request from UniGetUI package operation parameters.
+ ///
+ /// The install location resolved by the caller for this specific operation (e.g. the
+ /// registry-detected portable location for WinGet updates), or null to omit it.
+ ///
+ public static PackageOperationRequest Build(
+ IPackage package,
+ InstallOptions options,
+ OperationType role,
+ string? effectiveInstallLocation = null)
+ {
+ // WinGet_DropArchAndScope is set after an "update not applicable" result to retry
+ // without the scope/architecture constraints; mirror the local WinGet behavior so
+ // the AutoRetry does not rebuild the same constrained request indefinitely.
+ bool dropArchAndScope = package.OverridenOptions.WinGet_DropArchAndScope;
+
+ return new PackageOperationRequest
+ {
+ RequestId = BrokerClient.GenerateRequestId(),
+ CreatedAt = DateTimeOffset.UtcNow,
+ Operation = MapOperation(role),
+ Manager = MapManagerName(package.Manager.Name),
+ CaptureOutput = true,
+ Source = new RequestSource
+ {
+ Name = package.Source.Name,
+ Url = package.Source.Url?.ToString(),
+ },
+ Package = new RequestPackage
+ {
+ Id = package.Id,
+ Version = string.IsNullOrEmpty(options.Version) ? null : options.Version,
+ Architecture = dropArchAndScope ? null : MapArchitecture(options.Architecture),
+ },
+ Options = new RequestOptions
+ {
+ // The per-package scope override takes precedence over the saved options,
+ // matching the local WinGet execution path.
+ Scope = dropArchAndScope
+ ? null
+ : MapScope(package.OverridenOptions.Scope ?? options.InstallationScope),
+ Interactive = options.InteractiveInstallation,
+ SkipHashCheck = options.SkipHashCheck,
+ PreRelease = options.PreRelease,
+ CustomParameters = GetCustomParameters(options, role),
+ CustomInstallLocation = NullIfEmpty(effectiveInstallLocation),
+ // Kill/pre/post actions are owned by the broker for brokered operations:
+ // they are sent in the request (and skipped locally) so that policy is
+ // evaluated before anything runs and actions never execute twice.
+ KillBeforeOperation = options.KillBeforeOperation ?? [],
+ PreOperationCommand = GetPreCommand(options, role),
+ PostOperationCommand = GetPostCommand(options, role),
+ UninstallPrevious = role is OperationType.Update && options.UninstallPreviousVersionsOnUpdate,
+ // NOTE: SkipMinorUpdates is UniGetUI loader-side filtering and must not be
+ // mapped to the broker's NoUpgrade option.
+ },
+ };
+ }
+
+ private static Operation MapOperation(OperationType role) => role switch
+ {
+ OperationType.Install => Operation.Install,
+ OperationType.Update => Operation.Update,
+ OperationType.Uninstall => Operation.Uninstall,
+ _ => throw new ArgumentException($"Unsupported operation type: {role}"),
+ };
+
+ ///
+ /// Maps UniGetUI manager names to the broker protocol canonical managers.
+ /// PowerShell 5 and PowerShell 7 are modeled as separate managers.
+ ///
+ private static ManagerName MapManagerName(string managerName)
+ {
+ if (managerName.Equals("Winget", StringComparison.OrdinalIgnoreCase))
+ {
+ return ManagerName.Winget;
+ }
+
+ if (managerName.Equals("PowerShell", StringComparison.OrdinalIgnoreCase))
+ {
+ return ManagerName.PowerShell;
+ }
+
+ if (managerName.Equals("PowerShell7", StringComparison.OrdinalIgnoreCase) ||
+ managerName.Equals("pwsh", StringComparison.OrdinalIgnoreCase))
+ {
+ return ManagerName.PowerShell7;
+ }
+
+ throw new ArgumentException($"Unsupported manager for the broker: {managerName}");
+ }
+
+ private static Scope? MapScope(string? scope)
+ {
+ if (string.IsNullOrEmpty(scope))
+ {
+ return null;
+ }
+
+ return scope.ToLowerInvariant() switch
+ {
+ "user" => Scope.User,
+ "machine" => Scope.Machine,
+ "global" => Scope.Machine,
+ _ => null,
+ };
+ }
+
+ private static BrokerArchitecture? MapArchitecture(string? architecture)
+ {
+ if (string.IsNullOrEmpty(architecture))
+ {
+ return null;
+ }
+
+ return architecture.ToLowerInvariant() switch
+ {
+ "x86" => BrokerArchitecture.X86,
+ "x64" => BrokerArchitecture.X64,
+ "arm64" => BrokerArchitecture.Arm64,
+ "neutral" => BrokerArchitecture.Neutral,
+ _ => null,
+ };
+ }
+
+ private static List GetCustomParameters(InstallOptions options, OperationType role) => role switch
+ {
+ OperationType.Install => options.CustomParameters_Install ?? [],
+ OperationType.Update => options.CustomParameters_Update ?? [],
+ OperationType.Uninstall => options.CustomParameters_Uninstall ?? [],
+ _ => [],
+ };
+
+ private static string? GetPreCommand(InstallOptions options, OperationType role) => role switch
+ {
+ OperationType.Install => NullIfEmpty(options.PreInstallCommand),
+ OperationType.Update => NullIfEmpty(options.PreUpdateCommand),
+ OperationType.Uninstall => NullIfEmpty(options.PreUninstallCommand),
+ _ => null,
+ };
+
+ private static string? GetPostCommand(InstallOptions options, OperationType role) => role switch
+ {
+ OperationType.Install => NullIfEmpty(options.PostInstallCommand),
+ OperationType.Update => NullIfEmpty(options.PostUpdateCommand),
+ OperationType.Uninstall => NullIfEmpty(options.PostUninstallCommand),
+ _ => null,
+ };
+
+ private static string? NullIfEmpty(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? null : value;
+}
diff --git a/src/UniGetUI.PackageEngine.AgentBroker/UniGetUI.PackageEngine.AgentBroker.csproj b/src/UniGetUI.PackageEngine.AgentBroker/UniGetUI.PackageEngine.AgentBroker.csproj
new file mode 100644
index 0000000000..78cfbe8c51
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.AgentBroker/UniGetUI.PackageEngine.AgentBroker.csproj
@@ -0,0 +1,25 @@
+
+
+
+ $(SharedTargetFrameworks)
+ UniGetUI.PackageEngine.AgentBroker
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs
index 3b8c481be7..779a9d955b 100644
--- a/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs
+++ b/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs
@@ -112,22 +112,10 @@ operation is OperationType.Uninstall
if (!usePinget)
{
- // For portable packages, always preserve the actual current install
- // location read from the registry. A stale CustomInstallLocation in the
- // saved InstallOptions would otherwise leave --location off and cause
- // WinGet to uninstall the portable from its custom path and reinstall to
- // the default portable root, silently deleting the original directory.
- var detectedLocation = TryGetPortableInstallLocation(package);
- if (detectedLocation is not null)
+ var effectiveLocation = GetEffectiveUpdateLocation(package, options);
+ if (effectiveLocation is not null)
{
- parameters.AddRange(["--location", $"\"{detectedLocation}\""]);
- }
- else if (
- options.CustomInstallLocation != ""
- && Settings.Get(Settings.K.WinGetForceLocationOnUpdate)
- )
- {
- parameters.AddRange(["--location", $"\"{options.CustomInstallLocation}\""]);
+ parameters.AddRange(["--location", $"\"{effectiveLocation}\""]);
}
}
}
@@ -492,6 +480,33 @@ public static string ResolveReportedInstalledVersion(string id, string? reported
public static bool IsUnknownVersion(string? version)
=> version is null or "" or "Unknown";
+ ///
+ /// Resolves the install location that should be used for a WinGet update.
+ /// For portable packages, always preserves the actual current install location read
+ /// from the registry. A stale CustomInstallLocation in the saved InstallOptions would
+ /// otherwise leave the location off and cause WinGet to uninstall the portable from
+ /// its custom path and reinstall to the default portable root, silently deleting the
+ /// original directory. The saved value is only honored under WinGetForceLocationOnUpdate.
+ ///
+ internal static string? GetEffectiveUpdateLocation(IPackage package, InstallOptions options)
+ {
+ var detectedLocation = TryGetPortableInstallLocation(package);
+ if (detectedLocation is not null)
+ {
+ return detectedLocation;
+ }
+
+ if (
+ options.CustomInstallLocation != ""
+ && Settings.Get(Settings.K.WinGetForceLocationOnUpdate)
+ )
+ {
+ return options.CustomInstallLocation;
+ }
+
+ return null;
+ }
+
///
/// For portable WinGet packages, reads the current install location from the Windows registry
/// ARP entry (written by WinGet at install time). Returns null if the package is not portable
diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs
index ae369b13f7..bbc087dd77 100644
--- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs
+++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs
@@ -1,9 +1,11 @@
+using System.Text;
using UniGetUI.Core.Classes;
using UniGetUI.Core.Data;
using UniGetUI.Core.Logging;
using UniGetUI.Core.SettingsEngine;
using UniGetUI.Core.Tools;
using UniGetUI.Interface.Enums;
+using UniGetUI.PackageEngine.AgentBroker;
using UniGetUI.PackageEngine.Classes.Packages.Classes;
using UniGetUI.PackageEngine.Enums;
using UniGetUI.PackageEngine.Interfaces;
@@ -11,6 +13,12 @@
using UniGetUI.PackageEngine.PackageLoader;
using UniGetUI.PackageEngine.Serializable;
using UniGetUI.PackageOperations;
+using BrokerClient = Devolutions.Now.Policy.Client.BrokerClient;
+using BrokerClientErrorKind = Devolutions.Now.Policy.Client.BrokerClientErrorKind;
+using BrokerClientException = Devolutions.Now.Policy.Client.BrokerClientException;
+using BrokerClientOptions = Devolutions.Now.Policy.Client.BrokerClientOptions;
+using BrokerElevation = Devolutions.Now.Policy.Api.Elevation;
+using BrokerOperationStatus = Devolutions.Now.Policy.Api.OperationStatus;
#if WINDOWS
using UniGetUI.PackageEngine.Managers.WingetManager;
#endif
@@ -38,8 +46,8 @@ public PackageOperation(
)
: base(
!IgnoreParallelInstalls,
- _getPreInstallOps(options, role, req),
- _getPostInstallOps(options, role, package)
+ _getPreInstallOps(package, options, role, req),
+ _getPostInstallOps(package, options, role)
)
{
Package = package;
@@ -150,6 +158,196 @@ protected sealed override void PrepareProcessStartInfo()
);
}
+ ///
+ /// Override to intercept operations and route through the Devolutions Agent broker
+ /// when the UseAgentBroker setting is enabled and the manager supports it (WinGet only for now).
+ /// Falls back to process-based execution otherwise.
+ ///
+ protected override async Task PerformOperation()
+ {
+ if (!ShouldUseAgentBroker())
+ {
+ return await base.PerformOperation();
+ }
+
+ return await PerformBrokerOperation();
+ }
+
+ ///
+ /// Determines whether this operation should be routed through the agent broker.
+ ///
+ private bool ShouldUseAgentBroker()
+ {
+ // NOTE: Change this condition to enable agent broker by default when ready.
+ // Currently opt-in via settings.
+ bool eligible = IsBrokerEligible(Package);
+ Logger.Info($"[AgentBroker] ShouldUseAgentBroker check: eligible={eligible}, manager={Package.Manager.Name}, virtualSource={Package.Source.IsVirtualManager}");
+ return eligible;
+ }
+
+ ///
+ /// Whether a package operation is eligible for broker routing. Only WinGet is
+ /// supported in this iteration, and virtual/local sources are excluded: the agent
+ /// command builder always emits --source from the request, while the local WinGet
+ /// path deliberately omits it for virtual sources (e.g. the Local PC source).
+ ///
+ private static bool IsBrokerEligible(IPackage package) =>
+ Settings.Get(Settings.K.UseAgentBroker)
+ && IsWinGetManager(package.Manager)
+ && !package.Source.IsVirtualManager;
+
+ ///
+ /// Perform the package operation through the Devolutions Agent broker.
+ /// Sends the request over named pipe and interprets the response.
+ ///
+ private async Task PerformBrokerOperation()
+ {
+ Line("Routing operation through Devolutions Agent broker...", LineType.Information);
+
+ using var client = CreateBrokerClient(RequiresAdminRights());
+
+ // Check broker availability.
+ if (!await client.IsAvailable(CancellationToken))
+ {
+ Line("Agent broker is not available, falling back to local execution.", LineType.Information);
+ Line("Note: kill/pre/post operation actions were delegated to the broker and will not run for this fallback execution.", LineType.Information);
+ Logger.Warn("[AgentBroker] Broker not available, falling back to process execution");
+ return await base.PerformOperation();
+ }
+
+ // Resolve the install location the same way the local WinGet path does, so the
+ // portable-install safeguard (registry-detected location) is not bypassed.
+ string? effectiveInstallLocation = GetBrokerEffectiveInstallLocation();
+
+ // Build the broker request.
+ var request = BrokerRequestBuilder.Build(Package, Options, Role, effectiveInstallLocation);
+
+ Line($"Sending request to broker: {request.RequestId}", LineType.VerboseDetails);
+ Line($" Package: {request.Package.Id} ({request.Operation})", LineType.VerboseDetails);
+ Line($" Manager: {request.Manager}", LineType.VerboseDetails);
+ Line($" User: {GetEffectiveUser()}", LineType.VerboseDetails);
+
+ try
+ {
+ // Send to broker and poll until completion, honoring operation cancellation.
+ var status = await client.ExecuteAndWait(request, CancellationToken);
+
+ // Log status details.
+ Line($"Broker status: {status.Status}, exitCode={status.ExitCode}", LineType.Information);
+ if (!string.IsNullOrWhiteSpace(status.Message))
+ {
+ Line($" Message: {status.Message}", LineType.Information);
+ }
+ var output = DisplayBrokerOutput(status.Stdout);
+
+ if (status.Status == BrokerOperationStatus.Completed)
+ {
+ var veredict = await GetProcessVeredict(status.ExitCode ?? -1, output);
+ if (veredict is OperationVeredict.Success)
+ {
+ Line("Operation completed successfully via agent broker.", LineType.Information);
+ }
+ else if (!string.IsNullOrWhiteSpace(status.Message))
+ {
+ Metadata.FailureMessage = status.Message;
+ }
+
+ return veredict;
+ }
+
+ // Operation failed — surface a user-visible error.
+ string reason = status.Message ?? $"Exit code: {status.ExitCode}";
+ Line($"Operation failed via broker: {reason}", LineType.Error);
+ Metadata.FailureTitle = CoreTools.Translate("Operation denied or failed via broker");
+ Metadata.FailureMessage = reason;
+ return OperationVeredict.Failure;
+ }
+ catch (OperationCanceledException)
+ {
+ Line("Broker operation was canceled.", LineType.Error);
+ return OperationVeredict.Canceled;
+ }
+ catch (BrokerClientException ex)
+ {
+ Line($"Broker operation failed: {ex.Message}", LineType.Error);
+ Logger.Error($"[AgentBroker] Broker operation failed: {ex}");
+ Metadata.FailureTitle = CoreTools.Translate(GetBrokerFailureTitle(ex.Kind));
+ Metadata.FailureMessage = ex.Message;
+ return OperationVeredict.Failure;
+ }
+ }
+
+ private List DisplayBrokerOutput(string? encodedStdout)
+ {
+ List output = [];
+ if (string.IsNullOrWhiteSpace(encodedStdout))
+ {
+ return output;
+ }
+
+ string decoded;
+ try
+ {
+ decoded = Encoding.UTF8.GetString(Convert.FromBase64String(encodedStdout));
+ }
+ catch (FormatException ex)
+ {
+ Logger.Error($"[AgentBroker] Broker returned invalid base64 stdout: {ex}");
+ Line("Broker returned captured output in an invalid format.", LineType.Error);
+ return output;
+ }
+
+ foreach (var line in decoded.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'))
+ {
+ if (line.Length == 0)
+ {
+ continue;
+ }
+
+ output.Add(line);
+ Line(line, LineType.Information);
+ }
+
+ return output;
+ }
+
+ private static BrokerClient CreateBrokerClient(bool requestedElevation) =>
+ new(
+ new BrokerClientOptions
+ {
+ RequestedElevation = requestedElevation
+ ? BrokerElevation.Elevated
+ : BrokerElevation.Standard,
+ EffectiveUser = GetEffectiveUser(),
+ ClientExecutablePath = Environment.ProcessPath,
+ ClientVersion =
+ System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString()
+ ?? "0.0.0",
+ }
+ )
+ {
+ Trace = message => Logger.Info($"[AgentBroker] {message}"),
+ };
+
+ private static string GetEffectiveUser()
+ {
+ if (string.IsNullOrWhiteSpace(Environment.UserDomainName))
+ {
+ return Environment.UserName;
+ }
+
+ return $"{Environment.UserDomainName}\\{Environment.UserName}";
+ }
+
+ private static string GetBrokerFailureTitle(BrokerClientErrorKind kind) =>
+ kind switch
+ {
+ BrokerClientErrorKind.PolicyDenied => "Operation denied by policy",
+ BrokerClientErrorKind.UnsupportedCapability => "Operation unsupported by broker",
+ BrokerClientErrorKind.BrokerUnavailable or BrokerClientErrorKind.Timeout => "Broker communication error",
+ _ => "Operation failed via broker",
+ };
+
protected sealed override Task GetProcessVeredict(
int ReturnCode,
List Output
@@ -169,6 +367,31 @@ private static bool IsWinGetManager(IPackageManager manager)
#endif
}
+ ///
+ /// Resolves the install location to send in a broker request, matching the local
+ /// execution path: for updates this uses the WinGet portable-install safeguard
+ /// (registry-detected location, saved value only under WinGetForceLocationOnUpdate);
+ /// for installs the configured custom location; for uninstalls nothing.
+ ///
+ private string? GetBrokerEffectiveInstallLocation()
+ {
+ switch (Role)
+ {
+ case OperationType.Update:
+#if WINDOWS
+ return WinGetPkgOperationHelper.GetEffectiveUpdateLocation(Package, Options);
+#else
+ return null;
+#endif
+ case OperationType.Install:
+ return string.IsNullOrWhiteSpace(Options.CustomInstallLocation)
+ ? null
+ : Options.CustomInstallLocation;
+ default:
+ return null;
+ }
+ }
+
protected async Task ResolveInstalledPackageSnapshotAsync(
string fallbackVersion,
bool preferFallbackVersionWhenMissing = false
@@ -238,6 +461,7 @@ public override Task GetOperationIcon()
}
private static IReadOnlyList _getPreInstallOps(
+ IPackage package,
InstallOptions opts,
OperationType role,
AbstractOperation? preReq = null
@@ -247,6 +471,12 @@ private static IReadOnlyList _getPreInstallOps(
if (preReq is not null)
l.Add(new(preReq, true));
+ // For brokered operations the kill/pre/post actions are owned by the broker:
+ // they are carried in the broker request so that policy is evaluated before
+ // anything runs, and must not also be executed locally.
+ if (IsBrokerEligible(package))
+ return l;
+
foreach (var process in opts.KillBeforeOperation)
l.Add(new InnerOperation(new KillProcessOperation(process), mustSucceed: false));
@@ -268,13 +498,18 @@ private static IReadOnlyList _getPreInstallOps(
}
private static IReadOnlyList _getPostInstallOps(
+ IPackage package,
InstallOptions opts,
- OperationType role,
- IPackage package
+ OperationType role
)
{
List l = new();
+ // See _getPreInstallOps: brokered operations delegate post actions (including
+ // uninstall-previous) to the broker via the request options.
+ if (IsBrokerEligible(package))
+ return l;
+
if (role is OperationType.Install && opts.PostInstallCommand.Any())
l.Add(new(new PrePostOperation(opts.PostInstallCommand), false));
else if (role is OperationType.Update && opts.PostUpdateCommand.Any())
diff --git a/src/UniGetUI.PackageEngine.Operations/UniGetUI.PackageEngine.Operations.csproj b/src/UniGetUI.PackageEngine.Operations/UniGetUI.PackageEngine.Operations.csproj
index 6b664b6de2..0c8c3cbec3 100644
--- a/src/UniGetUI.PackageEngine.Operations/UniGetUI.PackageEngine.Operations.csproj
+++ b/src/UniGetUI.PackageEngine.Operations/UniGetUI.PackageEngine.Operations.csproj
@@ -9,6 +9,7 @@
+
diff --git a/src/UniGetUI.PackageEngine.Tests/BrokerRequestBuilderTests.cs b/src/UniGetUI.PackageEngine.Tests/BrokerRequestBuilderTests.cs
new file mode 100644
index 0000000000..b0495e7e4b
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Tests/BrokerRequestBuilderTests.cs
@@ -0,0 +1,197 @@
+using Devolutions.Now.Policy.Api;
+using UniGetUI.PackageEngine.AgentBroker;
+using UniGetUI.PackageEngine.Serializable;
+using UniGetUI.PackageEngine.Tests.Infrastructure.Builders;
+using OperationType = UniGetUI.PackageEngine.Enums.OperationType;
+using PackageScope = UniGetUI.PackageEngine.Enums.PackageScope;
+using UniGetUIArchitecture = UniGetUI.PackageEngine.Enums.Architecture;
+
+namespace UniGetUI.PackageEngine.Tests;
+
+public class BrokerRequestBuilderTests
+{
+ private static UniGetUI.PackageEngine.PackageClasses.Package BuildWinGetPackage()
+ => new PackageBuilder()
+ .WithManager(new PackageManagerBuilder().WithName("Winget").Build())
+ .WithId("Contoso.Test")
+ .Build();
+
+ [Theory]
+ [InlineData(OperationType.Install, Operation.Install)]
+ [InlineData(OperationType.Update, Operation.Update)]
+ [InlineData(OperationType.Uninstall, Operation.Uninstall)]
+ public void Build_MapsOperationType(OperationType role, Operation expected)
+ {
+ var request = BrokerRequestBuilder.Build(BuildWinGetPackage(), new InstallOptions(), role);
+ Assert.Equal(expected, request.Operation);
+ }
+
+ [Theory]
+ [InlineData("Winget", ManagerName.Winget)]
+ [InlineData("PowerShell", ManagerName.PowerShell)]
+ [InlineData("PowerShell7", ManagerName.PowerShell7)]
+ public void Build_MapsSupportedManagers(string managerName, ManagerName expected)
+ {
+ var package = new PackageBuilder()
+ .WithManager(new PackageManagerBuilder().WithName(managerName).Build())
+ .Build();
+
+ var request = BrokerRequestBuilder.Build(package, new InstallOptions(), OperationType.Install);
+ Assert.Equal(expected, request.Manager);
+ }
+
+ [Fact]
+ public void Build_ThrowsForUnsupportedManager()
+ {
+ var package = new PackageBuilder()
+ .WithManager(new PackageManagerBuilder().WithName("Scoop").Build())
+ .Build();
+
+ Assert.Throws(
+ () => BrokerRequestBuilder.Build(package, new InstallOptions(), OperationType.Install));
+ }
+
+ [Fact]
+ public void Build_UsesSavedInstallationScope()
+ {
+ var options = new InstallOptions { InstallationScope = PackageScope.Machine };
+ var request = BrokerRequestBuilder.Build(BuildWinGetPackage(), options, OperationType.Install);
+ Assert.Equal(Scope.Machine, request.Options.Scope);
+ }
+
+ [Fact]
+ public void Build_PackageScopeOverride_TakesPrecedenceOverSavedScope()
+ {
+ var package = BuildWinGetPackage();
+ package.OverridenOptions.Scope = PackageScope.User;
+ var options = new InstallOptions { InstallationScope = PackageScope.Machine };
+
+ var request = BrokerRequestBuilder.Build(package, options, OperationType.Install);
+ Assert.Equal(Scope.User, request.Options.Scope);
+ }
+
+ [Fact]
+ public void Build_DropArchAndScopeRetry_OmitsScopeAndArchitecture()
+ {
+ var package = BuildWinGetPackage();
+ package.OverridenOptions.WinGet_DropArchAndScope = true;
+ var options = new InstallOptions
+ {
+ InstallationScope = PackageScope.Machine,
+ Architecture = UniGetUIArchitecture.x64,
+ };
+
+ var request = BrokerRequestBuilder.Build(package, options, OperationType.Update);
+
+ Assert.Null(request.Options.Scope);
+ Assert.Null(request.Package.Architecture);
+ }
+
+ [Theory]
+ [InlineData("x86", Architecture.X86)]
+ [InlineData("x64", Architecture.X64)]
+ [InlineData("arm64", Architecture.Arm64)]
+ public void Build_MapsArchitecture(string architecture, Architecture expected)
+ {
+ var options = new InstallOptions { Architecture = architecture };
+ var request = BrokerRequestBuilder.Build(BuildWinGetPackage(), options, OperationType.Install);
+ Assert.Equal(expected, request.Package.Architecture);
+ }
+
+ [Theory]
+ [InlineData(OperationType.Install, "--install-param")]
+ [InlineData(OperationType.Update, "--update-param")]
+ [InlineData(OperationType.Uninstall, "--uninstall-param")]
+ public void Build_SelectsCustomParametersForRole(OperationType role, string expected)
+ {
+ var options = new InstallOptions
+ {
+ CustomParameters_Install = ["--install-param"],
+ CustomParameters_Update = ["--update-param"],
+ CustomParameters_Uninstall = ["--uninstall-param"],
+ };
+
+ var request = BrokerRequestBuilder.Build(BuildWinGetPackage(), options, role);
+ Assert.Equal([expected], request.Options.CustomParameters);
+ }
+
+ [Theory]
+ [InlineData(OperationType.Install, "pre-install.cmd", "post-install.cmd")]
+ [InlineData(OperationType.Update, "pre-update.cmd", "post-update.cmd")]
+ [InlineData(OperationType.Uninstall, "pre-uninstall.cmd", "post-uninstall.cmd")]
+ public void Build_SelectsPrePostCommandsForRole(OperationType role, string expectedPre, string expectedPost)
+ {
+ var options = new InstallOptions
+ {
+ PreInstallCommand = "pre-install.cmd",
+ PostInstallCommand = "post-install.cmd",
+ PreUpdateCommand = "pre-update.cmd",
+ PostUpdateCommand = "post-update.cmd",
+ PreUninstallCommand = "pre-uninstall.cmd",
+ PostUninstallCommand = "post-uninstall.cmd",
+ };
+
+ var request = BrokerRequestBuilder.Build(BuildWinGetPackage(), options, role);
+ Assert.Equal(expectedPre, request.Options.PreOperationCommand);
+ Assert.Equal(expectedPost, request.Options.PostOperationCommand);
+ }
+
+ [Fact]
+ public void Build_UsesEffectiveInstallLocation_NotSavedOptions()
+ {
+ var options = new InstallOptions { CustomInstallLocation = @"C:\stale\location" };
+
+ var request = BrokerRequestBuilder.Build(
+ BuildWinGetPackage(), options, OperationType.Update, @"C:\actual\portable\location");
+
+ Assert.Equal(@"C:\actual\portable\location", request.Options.CustomInstallLocation);
+ }
+
+ [Fact]
+ public void Build_OmitsInstallLocation_WhenNoneResolved()
+ {
+ var options = new InstallOptions { CustomInstallLocation = @"C:\stale\location" };
+ var request = BrokerRequestBuilder.Build(BuildWinGetPackage(), options, OperationType.Update);
+ Assert.Null(request.Options.CustomInstallLocation);
+ }
+
+ [Fact]
+ public void Build_DoesNotMapSkipMinorUpdatesToNoUpgrade()
+ {
+ var options = new InstallOptions { SkipMinorUpdates = true };
+ var request = BrokerRequestBuilder.Build(BuildWinGetPackage(), options, OperationType.Update);
+ Assert.False(request.Options.NoUpgrade);
+ }
+
+ [Theory]
+ [InlineData(OperationType.Install, false)]
+ [InlineData(OperationType.Update, true)]
+ [InlineData(OperationType.Uninstall, false)]
+ public void Build_SetsUninstallPreviousOnlyForUpdates(OperationType role, bool expected)
+ {
+ var options = new InstallOptions { UninstallPreviousVersionsOnUpdate = true };
+ var request = BrokerRequestBuilder.Build(BuildWinGetPackage(), options, role);
+ Assert.Equal(expected, request.Options.UninstallPrevious);
+ }
+
+ [Fact]
+ public void Build_CarriesKillBeforeOperationProcesses()
+ {
+ var options = new InstallOptions { KillBeforeOperation = ["app.exe", "helper.exe"] };
+ var request = BrokerRequestBuilder.Build(BuildWinGetPackage(), options, OperationType.Install);
+ Assert.Equal(["app.exe", "helper.exe"], request.Options.KillBeforeOperation);
+ }
+
+ [Fact]
+ public void Build_MapsSourceAndPackageIdentity()
+ {
+ var package = BuildWinGetPackage();
+ var options = new InstallOptions { Version = "1.2.3" };
+
+ var request = BrokerRequestBuilder.Build(package, options, OperationType.Install);
+
+ Assert.Equal("Contoso.Test", request.Package.Id);
+ Assert.Equal("1.2.3", request.Package.Version);
+ Assert.Equal(package.Source.Name, request.Source.Name);
+ }
+}
diff --git a/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj b/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj
index 43a92033f1..ceb35a850c 100644
--- a/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj
+++ b/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj
@@ -39,6 +39,7 @@
+
diff --git a/src/UniGetUI.Windows.slnx b/src/UniGetUI.Windows.slnx
index 05479163f1..f16b60873c 100644
--- a/src/UniGetUI.Windows.slnx
+++ b/src/UniGetUI.Windows.slnx
@@ -118,6 +118,10 @@
+
+
+
+