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 @@ + + + +