From 985f704b4a841df83a6b1f5ca8927413c554539d Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 15 Oct 2026 11:17:58 -0400 Subject: [PATCH 1/4] Honor the configured WinGet scope and architecture in package details Install and update pass --scope and --architecture from the applicable install options, but package details queried Pinget with neither. Pinget then picked the first installer applicable to the system architecture, and many manifests list the user installer before the machine one (Microsoft.VisualStudioCode: VSCodeUserSetup before VSCodeSetup). The details "Installer URL" field and every "Download installer" entry point read Details.InstallerUrl, so a user configured for Machine scope saw and downloaded the per-user installer while updates correctly used the machine one. The effective scope/architecture rule (package override, then options, dropped after an "update not applicable" retry) is hoisted into WinGetPkgOperationHelper so the operation command line and package details cannot drift again. Details now query Pinget with both values, and retry without them only when they actually excluded an installer, mirroring the native GetApplicableInstaller fallback. The bundled Pinget CLI provider (system winget CLI mode) used to take Installers[0] outright, since the Rust show --output json emits the raw manifest without a selected installer. It now mirrors Pinget's selection: an installer without a Scope matches any scope, architecture is filtered and ranked by system preference, and an unsatisfiable preference falls back to the unconstrained pick. Fixes #5519 --- .../PingetPackageDetailsProvider.cs | 192 +++++++++++++++++- .../Helpers/WinGetPkgOperationHelper.cs | 61 +++--- .../WinGetManagerTests.cs | 179 ++++++++++++++++ 3 files changed, 400 insertions(+), 32 deletions(-) diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs index ae556152cf..fc6f219a19 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(installers, preference)).ValueKind + == JsonValueKind.Object; SetIfMissing(value => details.Author = value, details.Author, GetString(manifest, "Author")); SetIfMissing( @@ -260,11 +278,76 @@ 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 installers, + InstallerPreference preference + ) + { + return SelectInstaller(installers, preference.Scope, preference.Architecture) + ?? SelectInstaller(installers, null, null) + ?? installers[0]; + } + + private static JsonElement? SelectInstaller( + JsonElement installers, + string? scope, + string? architecture + ) + { + 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; + + string? candidateScope = GetString(candidate, "Scope"); + 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 @@ -313,29 +396,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 +504,76 @@ 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) + ); + } + + 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 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 +806,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..626f0c50eb 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs @@ -64,6 +64,33 @@ 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? GetEffectiveArchitecture(IPackage package, InstallOptions options) + { + if (package.OverridenOptions.WinGet_DropArchAndScope) + return null; + + return options.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 +126,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 ( @@ -176,17 +196,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]); } } @@ -333,13 +345,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) 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.Tests/WinGetManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs index 60815660c0..0464aab3b4 100644 --- a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs @@ -993,6 +993,162 @@ 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()); + } + + 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 +2933,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, From 59daace8bec846998469b14527c3957ee1742bbd Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 15 Oct 2026 11:59:32 -0400 Subject: [PATCH 2/4] Resolve WinGet installer eligibility like Pinget and updates do --- .../PingetPackageDetailsProvider.cs | 79 +++++++++++- .../Helpers/WinGetPkgOperationHelper.cs | 31 +++-- .../WinGetManagerTests.cs | 115 ++++++++++++++++++ 3 files changed, 211 insertions(+), 14 deletions(-) diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs index fc6f219a19..1e047c520b 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs @@ -224,7 +224,7 @@ internal static void ApplyManifestJson( manifest.TryGetProperty("Installers", out JsonElement installers) && installers.ValueKind == JsonValueKind.Array && installers.GetArrayLength() > 0 - && (installer = SelectInstaller(installers, preference)).ValueKind + && (installer = SelectInstaller(manifest, installers, preference)).ValueKind == JsonValueKind.Object; SetIfMissing(value => details.Author = value, details.Author, GetString(manifest, "Author")); @@ -284,21 +284,26 @@ internal static void ApplyManifestJson( } private static JsonElement SelectInstaller( + JsonElement manifest, JsonElement installers, InstallerPreference preference ) { - return SelectInstaller(installers, preference.Scope, preference.Architecture) - ?? SelectInstaller(installers, null, null) + 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 @@ -311,7 +316,17 @@ InstallerPreference preference if (candidate.ValueKind != JsonValueKind.Object) continue; - string? candidateScope = GetString(candidate, "Scope"); + 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 @@ -354,6 +369,21 @@ architecture is not null ? 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, @@ -512,7 +542,11 @@ internal static InstallerPreference GetConfiguredInstallerPreference(IPackage pa return new InstallerPreference( WinGetPkgOperationHelper.GetEffectiveScope(package, options), - WinGetPkgOperationHelper.GetEffectiveArchitecture(package, options) + WinGetPkgOperationHelper.GetEffectiveArchitecture( + package, + options, + forUpdate: package.IsUpgradable + ) ); } @@ -536,6 +570,41 @@ preference.Architecture is not null ) ); + 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 { diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs index 626f0c50eb..b948deb704 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs @@ -77,12 +77,29 @@ public WinGetPkgOperationHelper(WinGet manager) }; } - internal static string? GetEffectiveArchitecture(IPackage package, InstallOptions options) + 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; - return options.Architecture switch + string architecture = + (forUpdate ? GetUpdateArchitectureOverride(package) : null) ?? options.Architecture; + return architecture switch { Architecture.x86 => Architecture.x86, Architecture.x64 => Architecture.x64, @@ -160,13 +177,9 @@ 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")) + if (GetUpdateArchitectureOverride(package) is { } updateArchitecture) { - options.Architecture = Architecture.x86; + options.Architecture = updateArchitecture; } parameters.Add("--include-unknown"); @@ -347,7 +360,7 @@ operation is OperationType.Uninstall var options = InstallOptionsFactory.LoadApplicable(package); if ( GetEffectiveScope(package, options) is not null - || GetEffectiveArchitecture(package, options) is not null + || GetEffectiveArchitecture(package, options, forUpdate: true) is not null ) { Logger.Warn( diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs index 0464aab3b4..a56da6bee9 100644 --- a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs @@ -1141,6 +1141,121 @@ public void PingetCliPackageDetailsProviderFallsBackWhenThePreferenceExcludesEve 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) + ); + } + private static Package CreateContosoToolPackage() => new PackageBuilder() .WithManager(new WinGet()) From 5451dacd9343e5167352b0408bfc344b696c4b04 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 15 Oct 2026 13:14:33 -0400 Subject: [PATCH 3/4] Apply update architecture overrides to brokered runs and refresh stale details --- .../ManagerHelpers/IPackageOperationHelper.cs | 6 ++++ .../Helpers/WinGetPkgOperationHelper.cs | 20 +++++++++--- .../PackageOperations.cs | 1 + .../Manager/Classes/NullPackageManager.cs | 6 ++++ .../Manager/Helpers/BasePkgOperationHelper.cs | 7 ++++ .../Packages/Classes/InstallOptionsFactory.cs | 5 +++ .../Packages/Classes/PackageDetails.cs | 10 ++++-- .../Fakes/TestPackageOperationHelper.cs | 11 +++++++ .../PackageDetailsLoadTests.cs | 32 +++++++++++++++++++ .../PackageOperationsTests.cs | 22 +++++++++++++ .../WinGetManagerTests.cs | 22 +++++++++++++ 11 files changed, 136 insertions(+), 6 deletions(-) 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/Helpers/WinGetPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs index b948deb704..44080f66b6 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs @@ -177,10 +177,7 @@ operation is OperationType.Uninstall if (operation is OperationType.Update) { - if (GetUpdateArchitectureOverride(package) is { } updateArchitecture) - { - options.Architecture = updateArchitecture; - } + ApplyOperationOverrides(package, options, operation); parameters.Add("--include-unknown"); if (!usePinget) @@ -240,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, 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..062d0523f0 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/PackageDetails.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/PackageDetails.cs @@ -17,7 +17,11 @@ 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 bool _populated; + private int _populatedGeneration; /// /// The description of the package @@ -125,8 +129,10 @@ private async Task LoadCore() { try { + int generation = InstallOptionsFactory.SavedGeneration; await Task.Run(() => Package.Manager.DetailsHelper.GetDetails(this)); - IsPopulated = true; + _populatedGeneration = generation; + _populated = true; } 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..f691702771 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,32 @@ public async Task LoadRunsAgainOnceThePreviousLoadCompleted() Assert.Equal(2, fetches); } + + [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 a56da6bee9..96cfe3d1d3 100644 --- a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs @@ -1256,6 +1256,28 @@ string expected ); } + [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()) From 10530d7863b89640783b877c70b781ee2d4a4c6d Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 15 Oct 2026 13:31:58 -0400 Subject: [PATCH 4/4] Refresh package details when options change mid-load or in the open window --- .../DialogPages/PackageDetailsViewModel.cs | 5 ++ .../DialogPages/PackageDetailsWindow.axaml.cs | 8 +++- .../Packages/Classes/PackageDetails.cs | 19 ++++++-- .../PackageDetailsLoadTests.cs | 46 +++++++++++++++++++ 4 files changed, 73 insertions(+), 5 deletions(-) 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.PackageManagerClasses/Packages/Classes/PackageDetails.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/PackageDetails.cs index 062d0523f0..f8169cb18a 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/PackageDetails.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/PackageDetails.cs @@ -20,6 +20,7 @@ public class PackageDetails : IPackageDetails public bool IsPopulated => _populated && _populatedGeneration == InstallOptionsFactory.SavedGeneration; + private const int MaxLoadAttempts = 3; private bool _populated; private int _populatedGeneration; @@ -129,10 +130,20 @@ private async Task LoadCore() { try { - int generation = InstallOptionsFactory.SavedGeneration; - await Task.Run(() => Package.Manager.DetailsHelper.GetDetails(this)); - _populatedGeneration = generation; - _populated = 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/PackageDetailsLoadTests.cs b/src/UniGetUI.PackageEngine.Tests/PackageDetailsLoadTests.cs index f691702771..1ad1d89805 100644 --- a/src/UniGetUI.PackageEngine.Tests/PackageDetailsLoadTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/PackageDetailsLoadTests.cs @@ -50,6 +50,52 @@ 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() {