diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/PackageDetailsViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/PackageDetailsViewModel.cs index 9271fceaa9..d43edb20f1 100644 --- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/PackageDetailsViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/PackageDetailsViewModel.cs @@ -241,6 +241,11 @@ public async Task LoadDetailsAsync() _ = LoadIconAsync(); _ = LoadScreenshotsAsync(); + await RefreshDetailsAsync(); + } + + public async Task RefreshDetailsAsync() + { var details = Package.Details; if (!details.IsPopulated) await details.Load(); diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/PackageDetailsWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/PackageDetailsWindow.axaml.cs index 9a12ee0e54..8a4ce87bf8 100644 --- a/src/UniGetUI.Avalonia/Views/DialogPages/PackageDetailsWindow.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/DialogPages/PackageDetailsWindow.axaml.cs @@ -106,7 +106,7 @@ public PackageDetailsWindow( MainActionButton.Click += (_, _) => OnMainAction(); ActionVariantsButton.Flyout = BuildActionFlyout(); - InstallOptionsSaveButton.Click += (_, _) => _ = SaveInstallOptionsAsync(); + InstallOptionsSaveButton.Click += (_, _) => _ = SaveInstallOptionsAndRefreshAsync(); ContributeButton.Click += (_, _) => OpenUrl(ContributeUrl); PrevScreenshotButton.Click += (_, _) => NavigateScreenshot(-1); NextScreenshotButton.Click += (_, _) => NavigateScreenshot(1); @@ -157,6 +157,12 @@ private async Task InitInstallOptionsAsync() InstallOptionsHolder.Content = embed; } + private async Task SaveInstallOptionsAndRefreshAsync() + { + await SaveInstallOptionsAsync(); + await _vm.RefreshDetailsAsync(); + } + private async Task SaveInstallOptionsAsync() { if (_installVm is null || _installOpts is null) return; diff --git a/src/UniGetUI.PackageEngine.Interfaces/ManagerHelpers/IPackageOperationHelper.cs b/src/UniGetUI.PackageEngine.Interfaces/ManagerHelpers/IPackageOperationHelper.cs index 423aa6594b..eadddb92d1 100644 --- a/src/UniGetUI.PackageEngine.Interfaces/ManagerHelpers/IPackageOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Interfaces/ManagerHelpers/IPackageOperationHelper.cs @@ -54,5 +54,11 @@ public void ApplyElevationRequirements( InstallOptions options, OperationType operation ); + + public void ApplyOperationOverrides( + IPackage package, + InstallOptions options, + OperationType operation + ); } } diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs index ae556152cf..1e047c520b 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; using System.Text.Json; using Devolutions.Pinget.Core; using UniGetUI.Core.Data; @@ -7,6 +8,8 @@ using UniGetUI.Core.Tools; using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageEngine.ManagerClasses.Classes; +using UniGetUI.PackageEngine.PackageClasses; +using InstallOptions = UniGetUI.PackageEngine.Serializable.InstallOptions; namespace UniGetUI.PackageEngine.Managers.WingetManager; @@ -15,6 +18,8 @@ internal interface IPingetPackageDetailsProvider bool LoadPackageDetails(IPackageDetails details, INativeTaskLogger logger); } +internal readonly record struct InstallerPreference(string? Scope, string? Architecture); + internal sealed class PingetCliPackageDetailsProvider(string cliExecutablePath) : IPingetPackageDetailsProvider { @@ -23,11 +28,17 @@ public bool LoadPackageDetails(IPackageDetails details, INativeTaskLogger logger try { PackageQuery query = PingetPackageDetailsProvider.CreateQuery(details.Package); + InstallerPreference preference = + PingetPackageDetailsProvider.GetConfiguredInstallerPreference(details.Package); logger.Log("Loading WinGet installer metadata with bundled Pinget CLI"); logger.Log($" Query: {PingetPackageDetailsProvider.FormatQueryForLog(query)}"); + logger.Log( + $" Installer preference applied to the returned manifest: Scope={preference.Scope}, " + + $"Architecture={preference.Architecture}" + ); string output = RunShow(query, logger); - ApplyManifestJson(details, output); + ApplyManifestJson(details, output, preference); return true; } catch (Exception ex) @@ -198,8 +209,14 @@ private string RunPinget(IReadOnlyList arguments, INativeTaskLogger logg return output; } - private static void ApplyManifestJson(IPackageDetails details, string output) + internal static void ApplyManifestJson( + IPackageDetails details, + string output, + InstallerPreference preference = default, + Func? installerSizeResolver = null + ) { + installerSizeResolver ??= CoreTools.GetFileSizeAsLong; using JsonDocument document = JsonDocument.Parse(output); JsonElement manifest = document.RootElement; JsonElement installer = default; @@ -207,7 +224,8 @@ private static void ApplyManifestJson(IPackageDetails details, string output) manifest.TryGetProperty("Installers", out JsonElement installers) && installers.ValueKind == JsonValueKind.Array && installers.GetArrayLength() > 0 - && (installer = installers[0]).ValueKind == JsonValueKind.Object; + && (installer = SelectInstaller(manifest, installers, preference)).ValueKind + == JsonValueKind.Object; SetIfMissing(value => details.Author = value, details.Author, GetString(manifest, "Author")); SetIfMissing( @@ -260,17 +278,112 @@ private static void ApplyManifestJson(IPackageDetails details, string output) if (TryCreateUri(GetString(installer, "InstallerUrl"), out Uri? installerUri)) { details.InstallerUrl = installerUri; - details.InstallerSize = CoreTools.GetFileSizeAsLong(installerUri); + details.InstallerSize = installerSizeResolver(installerUri); } } } + private static JsonElement SelectInstaller( + JsonElement manifest, + JsonElement installers, + InstallerPreference preference + ) + { + return SelectInstaller(manifest, installers, preference.Scope, preference.Architecture) + ?? SelectInstaller(manifest, installers, null, null) + ?? installers[0]; + } + + private static JsonElement? SelectInstaller( + JsonElement manifest, + JsonElement installers, + string? scope, + string? architecture + ) + { + string? rootScope = GetString(manifest, "Scope"); + string? rootMinimumOsVersion = GetString(manifest, "MinimumOSVersion"); + IReadOnlyList? rootPlatforms = GetStringArray(manifest, "Platform"); + string systemArchitecture = PingetPackageDetailsProvider.CurrentArchitecture(); + string[] preferredArchitectures = PingetPackageDetailsProvider.PreferredArchitectures( + systemArchitecture + ); + + JsonElement? selected = null; + int selectedRank = int.MinValue; + foreach (JsonElement candidate in installers.EnumerateArray()) + { + if (candidate.ValueKind != JsonValueKind.Object) + continue; + + if ( + !PingetPackageDetailsProvider.MatchesCurrentPlatform( + GetStringArray(candidate, "Platform") ?? rootPlatforms + ) + || !PingetPackageDetailsProvider.MatchesCurrentOsVersion( + GetString(candidate, "MinimumOSVersion") ?? rootMinimumOsVersion + ) + ) + continue; + + string? candidateScope = GetString(candidate, "Scope") ?? rootScope; + if ( + scope is not null + && candidateScope is not null + && !candidateScope.Equals(scope, StringComparison.OrdinalIgnoreCase) + ) + continue; + + string? candidateArchitecture = GetString(candidate, "Architecture"); + if ( + candidateArchitecture is not null + && !( + architecture is not null + ? candidateArchitecture.Equals(architecture, StringComparison.OrdinalIgnoreCase) + : preferredArchitectures.Contains( + candidateArchitecture, + StringComparer.OrdinalIgnoreCase + ) + ) + ) + continue; + + int rank = PingetPackageDetailsProvider.ArchitectureRank( + candidateArchitecture, + architecture ?? systemArchitecture, + preferredArchitectures + ); + if (rank > selectedRank) + { + selected = candidate; + selectedRank = rank; + } + } + + return selected; + } + private static string? GetString(JsonElement element, string propertyName) => element.TryGetProperty(propertyName, out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + private static IReadOnlyList? GetStringArray(JsonElement element, string propertyName) + { + if ( + !element.TryGetProperty(propertyName, out JsonElement value) + || value.ValueKind != JsonValueKind.Array + ) + return null; + + return value + .EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.String) + .Select(item => item.GetString()!) + .ToArray(); + } + private static void SetIfMissing( Action setValue, string? currentValue, @@ -313,29 +426,54 @@ internal sealed class PingetPackageDetailsProvider : IPingetPackageDetailsProvid { private readonly Func _showPackage; private readonly Func _installerSizeResolver; + private readonly Func _installerPreferenceResolver; public PingetPackageDetailsProvider() : this(ShowWithRepository) { } internal PingetPackageDetailsProvider( Func showPackage, - Func? installerSizeResolver = null + Func? installerSizeResolver = null, + Func? installerPreferenceResolver = null ) { _showPackage = showPackage; _installerSizeResolver = installerSizeResolver ?? CoreTools.GetFileSizeAsLong; + _installerPreferenceResolver = + installerPreferenceResolver ?? GetConfiguredInstallerPreference; } public bool LoadPackageDetails(IPackageDetails details, INativeTaskLogger logger) { try { - PackageQuery query = CreateQuery(details.Package); + InstallerPreference preference = _installerPreferenceResolver(details.Package); + PackageQuery query = CreateQuery(details.Package) with + { + InstallScope = preference.Scope, + InstallerArchitecture = preference.Architecture, + }; logger.Log("Loading WinGet installer metadata with Pinget"); logger.Log($" Query: {FormatQueryForLog(query)}"); Logger.Info($"Loading WinGet installer metadata with Pinget. Query: {FormatQueryForLog(query)}"); ShowResult result = _showPackage(query); + if ( + result.SelectedInstaller is null + && PreferenceExcludesAnInstaller(result.Manifest.Installers, preference) + ) + { + logger.Log( + " No installer matches the configured scope/architecture, retrying without them" + ); + result = _showPackage( + query with + { + InstallScope = null, + InstallerArchitecture = null, + } + ); + } foreach (string warning in result.Warnings) { @@ -396,6 +534,115 @@ internal static PackageQuery CreateQuery(IPackage package, string? version = nul return query; } + internal static InstallerPreference GetConfiguredInstallerPreference(IPackage package) + { + InstallOptions options = InstallOptionsFactory.LoadForPackage(package); + if (!options.OverridesNextLevelOpts) + options = InstallOptionsFactory.LoadForManager(package.Manager); + + return new InstallerPreference( + WinGetPkgOperationHelper.GetEffectiveScope(package, options), + WinGetPkgOperationHelper.GetEffectiveArchitecture( + package, + options, + forUpdate: package.IsUpgradable + ) + ); + } + + private static bool PreferenceExcludesAnInstaller( + IEnumerable installers, + InstallerPreference preference + ) => + installers.Any(installer => + ( + preference.Scope is not null + && installer.Scope is not null + && !installer.Scope.Equals(preference.Scope, StringComparison.OrdinalIgnoreCase) + ) + || ( + preference.Architecture is not null + && installer.Architecture is not null + && !installer.Architecture.Equals( + preference.Architecture, + StringComparison.OrdinalIgnoreCase + ) + ) + ); + + internal static bool MatchesCurrentPlatform(IReadOnlyList? platforms) => + platforms is null + || platforms.Count == 0 + || !OperatingSystem.IsWindows() + || platforms.Contains("Windows.Desktop", StringComparer.OrdinalIgnoreCase); + + internal static bool MatchesCurrentOsVersion( + string? minimumOsVersion, + string? currentOsVersion = null + ) + { + if (string.IsNullOrWhiteSpace(minimumOsVersion)) + return true; + + currentOsVersion ??= OperatingSystem.IsWindows() + ? Environment.OSVersion.Version.ToString() + : null; + if (string.IsNullOrWhiteSpace(currentOsVersion)) + return true; + + if ( + TryParseOsVersion(minimumOsVersion, out Version? minimum) + && TryParseOsVersion(currentOsVersion, out Version? current) + ) + return current >= minimum; + + return minimumOsVersion.Equals(currentOsVersion, StringComparison.OrdinalIgnoreCase); + } + + private static bool TryParseOsVersion(string value, [NotNullWhen(true)] out Version? version) => + Version.TryParse( + string.Join('.', value.Split('.', StringSplitOptions.RemoveEmptyEntries).Take(4)), + out version + ); + + internal static string CurrentArchitecture() => + RuntimeInformation.OSArchitecture switch + { + System.Runtime.InteropServices.Architecture.X64 => "x64", + System.Runtime.InteropServices.Architecture.X86 => "x86", + System.Runtime.InteropServices.Architecture.Arm64 => "arm64", + _ => "neutral", + }; + + internal static string[] PreferredArchitectures(string systemArchitecture) => + systemArchitecture switch + { + "arm64" => ["arm64", "neutral", "x64", "x86"], + "x64" => ["x64", "neutral", "x86"], + "x86" => ["x86", "neutral"], + _ => ["neutral"], + }; + + internal static int ArchitectureRank( + string? installerArchitecture, + string preferredArchitecture, + string[] preferredArchitectures + ) + { + if (installerArchitecture is null) + return 0; + if (installerArchitecture.Equals(preferredArchitecture, StringComparison.OrdinalIgnoreCase)) + return preferredArchitectures.Length + 2; + if (installerArchitecture.Equals("neutral", StringComparison.OrdinalIgnoreCase)) + return preferredArchitectures.Length + 1; + + int index = Array.FindIndex( + preferredArchitectures, + candidate => candidate.Equals(installerArchitecture, StringComparison.OrdinalIgnoreCase) + ); + return index < 0 ? -1 : preferredArchitectures.Length - index; + } + internal static void ApplyShowResult( IPackageDetails details, ShowResult result, @@ -628,6 +875,8 @@ internal static string FormatQueryForLog(PackageQuery query) $"Name={query.Name}", $"Source={query.Source}", $"Exact={query.Exact}", + $"Scope={query.InstallScope}", + $"Architecture={query.InstallerArchitecture}", }.Where(part => !part.EndsWith("=")) ); } diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs index 6e30f2a6fd..44080f66b6 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs @@ -64,6 +64,50 @@ private static string Selector(string value, string description) public WinGetPkgOperationHelper(WinGet manager) : base(manager) { } + internal static string? GetEffectiveScope(IPackage package, InstallOptions options) + { + if (package.OverridenOptions.WinGet_DropArchAndScope) + return null; + + return (package.OverridenOptions.Scope ?? options.InstallationScope) switch + { + PackageScope.User => PackageScope.User, + PackageScope.Machine => PackageScope.Machine, + _ => null, + }; + } + + internal static string? GetUpdateArchitectureOverride(IPackage package) + { + if (package.Name.Contains("64-bit") || package.Id.ToLower().Contains("x64")) + return Architecture.x64; + + if (package.Name.Contains("32-bit") || package.Id.ToLower().Contains("x86")) + return Architecture.x86; + + return null; + } + + internal static string? GetEffectiveArchitecture( + IPackage package, + InstallOptions options, + bool forUpdate = false + ) + { + if (package.OverridenOptions.WinGet_DropArchAndScope) + return null; + + string architecture = + (forUpdate ? GetUpdateArchitectureOverride(package) : null) ?? options.Architecture; + return architecture switch + { + Architecture.x86 => Architecture.x86, + Architecture.x64 => Architecture.x64, + Architecture.arm64 => Architecture.arm64, + _ => null, + }; + } + protected override IReadOnlyList _getOperationParameters( IPackage package, InstallOptions options, @@ -99,16 +143,9 @@ OperationType operation // package.OverridenInstallationOptions.Scope is meaningless in WinGet packages. Default is unspecified, hence the _ => []. // WinGet_DropArchAndScope is set after an "update not applicable" failure to retry without the scope/architecture constraints. - if (!package.OverridenOptions.WinGet_DropArchAndScope) + if (GetEffectiveScope(package, options) is { } scope) { - parameters.AddRange( - (package.OverridenOptions.Scope ?? options.InstallationScope) switch - { - PackageScope.User => ["--scope", "user"], - PackageScope.Machine => ["--scope", "machine"], - _ => [], - } - ); + parameters.AddRange(["--scope", scope]); } if ( @@ -140,14 +177,7 @@ operation is OperationType.Uninstall if (operation is OperationType.Update) { - if (package.Name.Contains("64-bit") || package.Id.ToLower().Contains("x64")) - { - options.Architecture = Architecture.x64; - } - else if (package.Name.Contains("32-bit") || package.Id.ToLower().Contains("x86")) - { - options.Architecture = Architecture.x86; - } + ApplyOperationOverrides(package, options, operation); parameters.Add("--include-unknown"); if (!usePinget) @@ -176,17 +206,9 @@ operation is OperationType.Uninstall if (options.SkipHashCheck) parameters.Add("--ignore-security-hash"); - if (!package.OverridenOptions.WinGet_DropArchAndScope) + if (GetEffectiveArchitecture(package, options) is { } architecture) { - parameters.AddRange( - options.Architecture switch - { - Architecture.x86 => ["--architecture", "x86"], - Architecture.x64 => ["--architecture", "x64"], - Architecture.arm64 => ["--architecture", "arm64"], - _ => [], - } - ); + parameters.AddRange(["--architecture", architecture]); } } @@ -215,6 +237,21 @@ operation is OperationType.Uninstall /// ) and the agent-broker path, so that the /// requested elevation matches regardless of where the operation runs. /// + public override void ApplyOperationOverrides( + IPackage package, + InstallOptions options, + OperationType operation + ) + { + if ( + operation is OperationType.Update + && GetUpdateArchitectureOverride(package) is { } updateArchitecture + ) + { + options.Architecture = updateArchitecture; + } + } + public override void ApplyElevationRequirements( IPackage package, InstallOptions options, @@ -333,13 +370,10 @@ operation is OperationType.Uninstall if (operation is OperationType.Update && !package.OverridenOptions.WinGet_DropArchAndScope) { var options = InstallOptionsFactory.LoadApplicable(package); - bool hasScope = - (package.OverridenOptions.Scope ?? options.InstallationScope) - is PackageScope.User or PackageScope.Machine; - bool hasArch = - options.Architecture is Architecture.x86 or Architecture.x64 or Architecture.arm64; - - if (hasScope || hasArch) + if ( + GetEffectiveScope(package, options) is not null + || GetEffectiveArchitecture(package, options, forUpdate: true) is not null + ) { Logger.Warn( $"Update for {package.Id} reported as not applicable; retrying without the scope/architecture constraints" diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs index cc8f27cba0..6da2d939e9 100644 --- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs +++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs @@ -446,6 +446,7 @@ private async Task PerformBrokerOperation() // elevation, mirroring the local execution path where this runs as part of // building the process parameters. Package.Manager.OperationHelper.ApplyElevationRequirements(Package, Options, Role); + Package.Manager.OperationHelper.ApplyOperationOverrides(Package, Options, Role); bool requestElevated = RequiresAdminRights(); _ranElevated = requestElevated ? 1 : 0; diff --git a/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/NullPackageManager.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/NullPackageManager.cs index de90f4338f..cf780edf6d 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/NullPackageManager.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/NullPackageManager.cs @@ -190,5 +190,11 @@ public void ApplyElevationRequirements( InstallOptions options, OperationType operation ) => throw new NotImplementedException(); + + public void ApplyOperationOverrides( + IPackage package, + InstallOptions options, + OperationType operation + ) => throw new NotImplementedException(); } } diff --git a/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Helpers/BasePkgOperationHelper.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Helpers/BasePkgOperationHelper.cs index 96fe227bf4..91d296399a 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Helpers/BasePkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Helpers/BasePkgOperationHelper.cs @@ -150,4 +150,11 @@ public virtual void ApplyElevationRequirements( OperationType operation ) { } + + public virtual void ApplyOperationOverrides( + IPackage package, + InstallOptions options, + OperationType operation + ) + { } } diff --git a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/InstallOptionsFactory.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/InstallOptionsFactory.cs index 682d4706f5..df66857fa7 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/InstallOptionsFactory.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/InstallOptionsFactory.cs @@ -21,6 +21,10 @@ public static class InstallOptionsFactory public const string PackageIdPlaceholder = "%PACKAGE%"; public const string PackageNamePlaceholder = "%NAME%"; + private static int _savedGeneration; + + public static int SavedGeneration => Volatile.Read(ref _savedGeneration); + public static bool IsIdentityScopedOptionsFile(string fileName) => StoragePath.IsIdentityScoped(fileName); @@ -235,6 +239,7 @@ private static void _saveToDisk(InstallOptions options, string key) } _optionsCache[key] = options.Copy(); + Interlocked.Increment(ref _savedGeneration); string fileContents = options.AsJsonString(); File.WriteAllText(filePath, fileContents); diff --git a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/PackageDetails.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/PackageDetails.cs index b105439498..f8169cb18a 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/PackageDetails.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/PackageDetails.cs @@ -17,7 +17,12 @@ public class PackageDetails : IPackageDetails /// Whether this PackageDetails instance has valid data or not. /// To load valid data, make use of the `Load()` method /// - public bool IsPopulated { get; private set; } + public bool IsPopulated => + _populated && _populatedGeneration == InstallOptionsFactory.SavedGeneration; + + private const int MaxLoadAttempts = 3; + private bool _populated; + private int _populatedGeneration; /// /// The description of the package @@ -125,8 +130,20 @@ private async Task LoadCore() { try { - await Task.Run(() => Package.Manager.DetailsHelper.GetDetails(this)); - IsPopulated = true; + for (int attempt = 0; ; attempt++) + { + int generation = InstallOptionsFactory.SavedGeneration; + await Task.Run(() => Package.Manager.DetailsHelper.GetDetails(this)); + if ( + generation == InstallOptionsFactory.SavedGeneration + || attempt == MaxLoadAttempts - 1 + ) + { + _populatedGeneration = generation; + _populated = true; + break; + } + } } catch (Exception ex) { diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageOperationHelper.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageOperationHelper.cs index 90ffd9a0dd..af6692ee5c 100644 --- a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageOperationHelper.cs @@ -25,6 +25,17 @@ OperationType operation ElevationRequirementsAction?.Invoke(package, options, operation); } + public Action? OperationOverridesAction { get; set; } + + public override void ApplyOperationOverrides( + IPackage package, + InstallOptions options, + OperationType operation + ) + { + OperationOverridesAction?.Invoke(package, options, operation); + } + protected override IReadOnlyList _getOperationParameters( IPackage package, InstallOptions options, diff --git a/src/UniGetUI.PackageEngine.Tests/PackageDetailsLoadTests.cs b/src/UniGetUI.PackageEngine.Tests/PackageDetailsLoadTests.cs index 00143f2e52..1ad1d89805 100644 --- a/src/UniGetUI.PackageEngine.Tests/PackageDetailsLoadTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/PackageDetailsLoadTests.cs @@ -1,3 +1,7 @@ +using UniGetUI.Core.Data; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.Serializable; using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; namespace UniGetUI.PackageEngine.Tests; @@ -45,4 +49,78 @@ public async Task LoadRunsAgainOnceThePreviousLoadCompleted() Assert.Equal(2, fetches); } + + [Fact] + public async Task ALoadJoinedAfterAnOptionsSaveReturnsDetailsFetchedUnderTheNewOptions() + { + string testRoot = Path.Combine(Path.GetTempPath(), nameof(PackageDetailsLoadTests), Guid.NewGuid().ToString("N")); + CoreData.TEST_DataDirectoryOverride = Path.Combine(testRoot, "Data"); + try + { + Directory.CreateDirectory(CoreData.UniGetUIInstallationOptionsDirectory); + int fetches = 0; + var firstFetchStarted = new TaskCompletionSource(); + var releaseFirstFetch = new TaskCompletionSource(); + var manager = new PackageManagerBuilder() + .ConfigureDetails(helper => + helper.PopulateDetails = details => + { + int fetch = Interlocked.Increment(ref fetches); + if (fetch == 1) + { + firstFetchStarted.SetResult(); + releaseFirstFetch.Task.Wait(); + } + details.InstallerUrl = new Uri($"https://example.test/fetch-{fetch}.exe"); + }) + .Build(); + var package = new PackageBuilder().WithManager(manager).WithId("Contoso.Test").Build(); + + Task first = package.Details.Load(); + await firstFetchStarted.Task; + InstallOptionsFactory.SaveForPackage(new InstallOptions { InstallationScope = PackageScope.Machine }, package); + Task joined = package.Details.Load(); + releaseFirstFetch.SetResult(); + await Task.WhenAll(first, joined); + + Assert.Same(first, joined); + Assert.Equal(2, fetches); + Assert.Equal("https://example.test/fetch-2.exe", package.Details.InstallerUrl?.ToString()); + Assert.True(package.Details.IsPopulated); + } + finally + { + CoreData.TEST_DataDirectoryOverride = null; + if (Directory.Exists(testRoot)) + Directory.Delete(testRoot, recursive: true); + } + } + + [Fact] + public async Task SavingInstallOptionsMarksLoadedDetailsAsStale() + { + string testRoot = Path.Combine(Path.GetTempPath(), nameof(PackageDetailsLoadTests), Guid.NewGuid().ToString("N")); + CoreData.TEST_DataDirectoryOverride = Path.Combine(testRoot, "Data"); + try + { + Directory.CreateDirectory(CoreData.UniGetUIInstallationOptionsDirectory); + var manager = new PackageManagerBuilder().Build(); + var package = new PackageBuilder().WithManager(manager).WithId("Contoso.Test").Build(); + + await package.Details.Load(); + Assert.True(package.Details.IsPopulated); + + InstallOptionsFactory.SaveForPackage(new InstallOptions { InstallationScope = PackageScope.Machine }, package); + Assert.False(package.Details.IsPopulated); + + await package.Details.Load(); + Assert.True(package.Details.IsPopulated); + } + finally + { + CoreData.TEST_DataDirectoryOverride = null; + if (Directory.Exists(testRoot)) + Directory.Delete(testRoot, recursive: true); + } + } } diff --git a/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs b/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs index bdbba32f3b..0e064f1755 100644 --- a/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs @@ -22,6 +22,7 @@ using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; using UniGetUI.PackageEngine.Tests.Infrastructure.Fakes; using UniGetUI.PackageOperations; +using BrokerApiArchitecture = Devolutions.Now.Policy.Api.Architecture; using BrokerApiCancelResponse = Devolutions.Now.Policy.Api.CancelResponse; using BrokerApiCapabilitiesResponse = Devolutions.Now.Policy.Api.CapabilitiesResponse; using BrokerApiConstants = Devolutions.Now.Policy.Api.BrokerApi; @@ -1751,6 +1752,26 @@ public async Task BrokeredOperationConsultsElevationRequirementsAndRequestsEleva Assert.Contains(result.Output, line => line.Item1.Contains("Elevation: Elevated")); } + [Fact] + public async Task BrokeredOperationAppliesTheManagerOperationOverrides() + { + var transport = new ScriptedBrokerTransport + { + StatusAfterCancel = BrokerApiOperationStatus.Completed, + CompletedExitCode = 0, + }; + + var result = await RunBrokeredOperationWithOutput( + transport, + configureOperationHelper: helper => helper.OperationOverridesAction = + (_, options, _) => options.Architecture = Architecture.x64); + + Assert.Equal(OperationVeredict.Success, result.Veredict); + Assert.Equal( + BrokerApiArchitecture.X64, + DeserializeExecuteRequest(transport).Package.Architecture); + } + [Fact] public async Task BrokeredOperationRequestsStandardElevationByDefault() { @@ -2208,6 +2229,7 @@ private Task HandleCancelRequest() { Manager = BrokerApiManagerName.Chocolatey, Operations = [BrokerApiOperation.Install, BrokerApiOperation.Update, BrokerApiOperation.Uninstall], + Architectures = [BrokerApiArchitecture.X86, BrokerApiArchitecture.X64], SupportsCustomParameters = true, SupportsCustomInstallLocation = true, SupportsCaptureOutput = true, diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs index 60815660c0..96cfe3d1d3 100644 --- a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs @@ -993,6 +993,299 @@ public void PingetPackageDetailsProviderMapsShowResultToPackageDetails() Assert.Contains("utility", details.Tags); } + [Fact] + public void PingetPackageDetailsProviderSelectsTheInstallerOfTheConfiguredScope() + { + var details = new PackageDetails(CreateContosoToolPackage()); + List queries = []; + var provider = new PingetPackageDetailsProvider( + query => + { + queries.Add(query); + return CreateScopedPingetShowResult(query.InstallScope); + }, + installerSizeResolver: _ => 1234, + installerPreferenceResolver: _ => new(PackageScope.Machine, Architecture.x64) + ); + + provider.LoadPackageDetails(details, new TestNativeTaskLogger()); + + PackageQuery query = Assert.Single(queries); + Assert.Equal(PackageScope.Machine, query.InstallScope); + Assert.Equal(Architecture.x64, query.InstallerArchitecture); + Assert.Equal("https://example.test/machine.exe", details.InstallerUrl?.ToString()); + } + + [Fact] + public void PingetPackageDetailsProviderRetriesWithoutPreferenceWhenItExcludesEveryInstaller() + { + var details = new PackageDetails(CreateContosoToolPackage()); + List queries = []; + var provider = new PingetPackageDetailsProvider( + query => + { + queries.Add(query); + ShowResult result = CreateScopedPingetShowResult(null, [PackageScope.User]); + return query.InstallScope is null + ? result + : result with { SelectedInstaller = null }; + }, + installerSizeResolver: _ => 1234, + installerPreferenceResolver: _ => new(PackageScope.Machine, Architecture.x64) + ); + + provider.LoadPackageDetails(details, new TestNativeTaskLogger()); + + Assert.Equal(2, queries.Count); + Assert.Null(queries[1].InstallScope); + Assert.Null(queries[1].InstallerArchitecture); + Assert.Equal("https://example.test/user.exe", details.InstallerUrl?.ToString()); + } + + [Fact] + public void PingetPackageDetailsProviderDoesNotRetryWhenThePreferenceExcludedNothing() + { + var details = new PackageDetails(CreateContosoToolPackage()); + int lookups = 0; + var provider = new PingetPackageDetailsProvider( + _ => + { + lookups++; + return CreatePingetShowResult() with { SelectedInstaller = null }; + }, + installerSizeResolver: _ => 1234, + installerPreferenceResolver: _ => new(PackageScope.Machine, Architecture.x64) + ); + + provider.LoadPackageDetails(details, new TestNativeTaskLogger()); + + Assert.Equal(1, lookups); + } + + [Theory] + [InlineData(PackageScope.Machine, Architecture.x64, "https://example.test/x64-machine.exe")] + [InlineData(PackageScope.User, Architecture.x64, "https://example.test/x64-user.exe")] + [InlineData(PackageScope.Machine, Architecture.arm64, "https://example.test/arm64-machine.exe")] + public void PingetCliPackageDetailsProviderSelectsTheInstallerOfTheConfiguredPreference( + string scope, + string architecture, + string expectedUrl + ) + { + var details = new PackageDetails(CreateContosoToolPackage()); + const string manifestJson = """ + { + "PackageIdentifier": "Contoso.Tool", + "Installers": [ + { "Architecture": "x64", "Scope": "user", "InstallerUrl": "https://example.test/x64-user.exe" }, + { "Architecture": "arm64", "Scope": "machine", "InstallerUrl": "https://example.test/arm64-machine.exe" }, + { "Architecture": "x64", "Scope": "machine", "InstallerUrl": "https://example.test/x64-machine.exe" } + ] + } + """; + + PingetCliPackageDetailsProvider.ApplyManifestJson( + details, + manifestJson, + new InstallerPreference(scope, architecture), + installerSizeResolver: _ => 0 + ); + + Assert.Equal(expectedUrl, details.InstallerUrl?.ToString()); + } + + [Fact] + public void PingetCliPackageDetailsProviderTreatsAnInstallerWithoutScopeAsMatching() + { + var details = new PackageDetails(CreateContosoToolPackage()); + const string manifestJson = """ + { + "PackageIdentifier": "Contoso.Tool", + "Installers": [ + { "Architecture": "x64", "InstallerUrl": "https://example.test/x64-any.exe" }, + { "Architecture": "arm64", "Scope": "machine", "InstallerUrl": "https://example.test/arm64-machine.exe" } + ] + } + """; + + PingetCliPackageDetailsProvider.ApplyManifestJson( + details, + manifestJson, + new InstallerPreference(PackageScope.Machine, Architecture.x64), + installerSizeResolver: _ => 0 + ); + + Assert.Equal("https://example.test/x64-any.exe", details.InstallerUrl?.ToString()); + } + + [Fact] + public void PingetCliPackageDetailsProviderFallsBackWhenThePreferenceExcludesEveryInstaller() + { + var details = new PackageDetails(CreateContosoToolPackage()); + const string manifestJson = """ + { + "PackageIdentifier": "Contoso.Tool", + "Installers": [ + { "Architecture": "x64", "Scope": "user", "InstallerUrl": "https://example.test/x64-user.exe" } + ] + } + """; + + PingetCliPackageDetailsProvider.ApplyManifestJson( + details, + manifestJson, + new InstallerPreference(PackageScope.Machine, Architecture.x86), + installerSizeResolver: _ => 0 + ); + + Assert.Equal("https://example.test/x64-user.exe", details.InstallerUrl?.ToString()); + } + + [Fact] + public void PingetCliPackageDetailsProviderResolvesTheManifestRootScope() + { + var details = new PackageDetails(CreateContosoToolPackage()); + const string manifestJson = """ + { + "PackageIdentifier": "Contoso.Tool", + "Scope": "user", + "Installers": [ + { "Architecture": "x64", "InstallerUrl": "https://example.test/x64-inherited-user.exe" }, + { "Architecture": "x64", "Scope": "machine", "InstallerUrl": "https://example.test/x64-machine.exe" } + ] + } + """; + + PingetCliPackageDetailsProvider.ApplyManifestJson( + details, + manifestJson, + new InstallerPreference(PackageScope.Machine, Architecture.x64), + installerSizeResolver: _ => 0 + ); + + Assert.Equal("https://example.test/x64-machine.exe", details.InstallerUrl?.ToString()); + } + + [Fact] + public void PingetCliPackageDetailsProviderSkipsInstallersThatNeedANewerWindows() + { + var details = new PackageDetails(CreateContosoToolPackage()); + const string manifestJson = """ + { + "PackageIdentifier": "Contoso.Tool", + "Installers": [ + { "Architecture": "x86", "InstallerUrl": "https://example.test/x86.exe" }, + { "Architecture": "x64", "MinimumOSVersion": "99.0.0.0", "InstallerUrl": "https://example.test/x64-future.exe" } + ] + } + """; + + PingetCliPackageDetailsProvider.ApplyManifestJson( + details, + manifestJson, + installerSizeResolver: _ => 0 + ); + + Assert.Equal("https://example.test/x86.exe", details.InstallerUrl?.ToString()); + } + + [Fact] + public void PingetCliPackageDetailsProviderSkipsInstallersForAnotherPlatform() + { + var details = new PackageDetails(CreateContosoToolPackage()); + const string manifestJson = """ + { + "PackageIdentifier": "Contoso.Tool", + "Installers": [ + { "Architecture": "x64", "Platform": ["Windows.Universal"], "InstallerUrl": "https://example.test/universal.exe" }, + { "Architecture": "x64", "Platform": ["Windows.Desktop"], "InstallerUrl": "https://example.test/desktop.exe" } + ] + } + """; + + PingetCliPackageDetailsProvider.ApplyManifestJson( + details, + manifestJson, + new InstallerPreference(null, Architecture.x64), + installerSizeResolver: _ => 0 + ); + + Assert.Equal("https://example.test/desktop.exe", details.InstallerUrl?.ToString()); + } + + [Theory] + [InlineData("10.0.19045.0", "10.0.22000.0", false)] + [InlineData("10.0.22631.0", "10.0.22000.0", true)] + [InlineData("10.0.19045.0", null, true)] + [InlineData("10.0.19045.0", "10.0.19045", true)] + public void MatchesCurrentOsVersionComparesNumerically( + string currentOsVersion, + string? minimumOsVersion, + bool expected + ) + { + Assert.Equal( + expected, + PingetPackageDetailsProvider.MatchesCurrentOsVersion(minimumOsVersion, currentOsVersion) + ); + } + + [Theory] + [InlineData("Contoso Tool 32-bit", "Contoso.Tool", false, Architecture.x64)] + [InlineData("Contoso Tool 32-bit", "Contoso.Tool", true, Architecture.x86)] + [InlineData("Contoso Tool", "Contoso.Tool.x64", true, Architecture.x64)] + [InlineData("Contoso Tool", "Contoso.Tool", true, Architecture.x64)] + public void GetEffectiveArchitectureAppliesTheUpdateOverrideOnlyForUpdates( + string name, + string id, + bool forUpdate, + string expected + ) + { + var package = new PackageBuilder() + .WithManager(new WinGet()) + .WithName(name) + .WithId(id) + .WithVersion("1.0.0") + .Build(); + var options = new InstallOptions { Architecture = Architecture.x64 }; + + Assert.Equal( + expected, + WinGetPkgOperationHelper.GetEffectiveArchitecture(package, options, forUpdate) + ); + } + + [Theory] + [InlineData(OperationType.Update, Architecture.x86)] + [InlineData(OperationType.Install, Architecture.x64)] + public void ApplyOperationOverridesForcesThePackageArchitectureOnlyOnUpdate( + OperationType operation, + string expected + ) + { + var manager = new WinGet(); + var package = new PackageBuilder() + .WithManager(manager) + .WithName("Contoso Tool 32-bit") + .WithId("Contoso.Tool") + .WithVersion("1.0.0") + .Build(); + var options = new InstallOptions { Architecture = Architecture.x64 }; + + manager.OperationHelper.ApplyOperationOverrides(package, options, operation); + + Assert.Equal(expected, options.Architecture); + } + + private static Package CreateContosoToolPackage() => + new PackageBuilder() + .WithManager(new WinGet()) + .WithName("Contoso Tool") + .WithId("Contoso.Tool") + .WithVersion("1.0.0") + .Build(); + [Fact] public void TryGetInstallerUrlsCollectsEveryInstallerUrlWithoutDuplicates() { @@ -2777,6 +3070,29 @@ private static ShowResult CreatePingetShowResult( }; } + private static ShowResult CreateScopedPingetShowResult( + string? requestedScope, + IReadOnlyList? scopes = null + ) + { + ShowResult result = CreatePingetShowResult(); + Installer template = result.Manifest.Installers[0]; + List installers = + [ + .. (scopes ?? [PackageScope.User, PackageScope.Machine]).Select(scope => + template with { Scope = scope, Url = $"https://example.test/{scope}.exe" } + ), + ]; + + return result with + { + Manifest = result.Manifest with { Installers = installers }, + SelectedInstaller = installers.FirstOrDefault(installer => + requestedScope is null || installer.Scope == requestedScope + ), + }; + } + public enum LocalSourceKind { MicrosoftStore,