diff --git a/doc/ReleaseNotes.md b/doc/ReleaseNotes.md index 11ee297b11..b7e0418eb9 100644 --- a/doc/ReleaseNotes.md +++ b/doc/ReleaseNotes.md @@ -35,6 +35,31 @@ For aliases requested through `--rename`, `Commands`, or `PortableCommandAlias`, This change resolves alias failures in non-symlinked scenarios, including cases where WinGet adds the install directory to `PATH` instead of creating links. Because the alias is now created as an executable hardlink in the install location, command aliases remain available and consistent even when symlink creation is skipped. +### OS version specific installers + +Manifests can now include installers that differ only by `MinimumOSVersion`, for example to declare different dependencies on different OS versions. +These are no longer rejected as duplicate installer entries, and when more than one is applicable, the installer with the highest `MinimumOSVersion` is preferred regardless of its order in the manifest. +Architecture, scope, locale and installer type preferences still take priority over `MinimumOSVersion`. + +For example, a package that needs .NET Framework 3.5 can depend on the `NetFx3` Windows feature on older OS versions, and on a standalone .NET Framework 3.5 package from `10.0.28000.0`: + +```yaml +Installers: + - Architecture: x64 + InstallerUrl: https://example.com/setup.exe + InstallerSha256: + Dependencies: + WindowsFeatures: + - NetFx3 + - Architecture: x64 + InstallerUrl: https://example.com/setup.exe + InstallerSha256: + MinimumOSVersion: 10.0.28000.0 + Dependencies: + PackageDependencies: + - PackageIdentifier: Microsoft.DotNet.Framework.Runtime.3 +``` + ### Minor Bug Fixes * Fixed an issue where `winget search --id ` could fail to return a Microsoft Store package unless `--exact` was also provided. * Updated NUnit to v4 diff --git a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj index 4051f4c994..14d646bfdb 100644 --- a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj +++ b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj @@ -614,6 +614,9 @@ true + + true + true @@ -683,6 +686,9 @@ true + + true + true diff --git a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters index f29961ab30..b28220852b 100644 --- a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters +++ b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters @@ -579,6 +579,9 @@ TestData + + TestData + TestData @@ -681,6 +684,9 @@ TestData + + TestData + TestData diff --git a/src/AppInstallerCLITests/ManifestComparator.cpp b/src/AppInstallerCLITests/ManifestComparator.cpp index 986af6ac48..687bf964c3 100644 --- a/src/AppInstallerCLITests/ManifestComparator.cpp +++ b/src/AppInstallerCLITests/ManifestComparator.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include @@ -950,3 +952,82 @@ TEST_CASE("ManifestComparator_InstallerCompatibilitySet_Weaker_Than_Architecture RequireInstaller(result, target); } + +TEST_CASE("ManifestComparator_OSVersionCompare", "[manifest_comparator]") +{ + Manifest manifest; + ManifestInstaller noMinimum; + ManifestInstaller older; + ManifestInstaller newer; + + // Installer order should not matter; the installer with the highest applicable MinOSVersion is preferred. + SECTION("Ascending") + { + noMinimum = AddInstaller(manifest, Architecture::Neutral, InstallerTypeEnum::Exe, ScopeEnum::Unknown, ""); + older = AddInstaller(manifest, Architecture::Neutral, InstallerTypeEnum::Exe, ScopeEnum::Unknown, "10.0.0.0"); + newer = AddInstaller(manifest, Architecture::Neutral, InstallerTypeEnum::Exe, ScopeEnum::Unknown, "10.0.10240.0"); + } + SECTION("Descending") + { + newer = AddInstaller(manifest, Architecture::Neutral, InstallerTypeEnum::Exe, ScopeEnum::Unknown, "10.0.10240.0"); + older = AddInstaller(manifest, Architecture::Neutral, InstallerTypeEnum::Exe, ScopeEnum::Unknown, "10.0.0.0"); + noMinimum = AddInstaller(manifest, Architecture::Neutral, InstallerTypeEnum::Exe, ScopeEnum::Unknown, ""); + } + + ManifestComparator mc(GetManifestComparatorOptions(ManifestComparatorTestContext{}, {})); + auto [result, inapplicabilities] = mc.GetPreferredInstaller(manifest); + + RequireInstaller(result, newer); + REQUIRE(inapplicabilities.size() == 0); +} + +TEST_CASE("ManifestComparator_OSVersionCompare_InapplicableIgnored", "[manifest_comparator]") +{ + Manifest manifest; + ManifestInstaller noMinimum = AddInstaller(manifest, Architecture::Neutral, InstallerTypeEnum::Exe, ScopeEnum::Unknown, ""); + AddInstaller(manifest, Architecture::Neutral, InstallerTypeEnum::Exe, ScopeEnum::Unknown, "10.0.99999.0"); + + ManifestComparator mc(GetManifestComparatorOptions(ManifestComparatorTestContext{}, {})); + auto [result, inapplicabilities] = mc.GetPreferredInstaller(manifest); + + RequireInstaller(result, noMinimum); + RequireInapplicabilities(inapplicabilities, { InapplicabilityFlags::OSVersion }); +} + +TEST_CASE("ManifestComparator_OSVersion_Weaker_Than_Architecture", "[manifest_comparator]") +{ + Manifest manifest; + ManifestInstaller target = AddInstaller(manifest, GetSystemArchitecture(), InstallerTypeEnum::Exe, ScopeEnum::Unknown, ""); + ManifestInstaller foil = AddInstaller(manifest, Architecture::Neutral, InstallerTypeEnum::Exe, ScopeEnum::Unknown, "10.0.10240.0"); + + ManifestComparator mc(GetManifestComparatorOptions(ManifestComparatorTestContext{}, {})); + auto [result, inapplicabilities] = mc.GetPreferredInstaller(manifest); + + RequireInstaller(result, target); +} + +TEST_CASE("ManifestComparator_OSVersion_NetFx3", "[manifest_comparator]") +{ + // The NetFx3 Windows feature is not available from 10.0.28000, where .NET Framework 3.5 is a standalone package instead. + Manifest manifest = YamlParser::CreateFromPath(TestDataFile("Manifest-Good-InstallerUniqueness-DiffMinOSVersion.yaml")); + + ManifestComparator mc(GetManifestComparatorOptions(ManifestComparatorTestContext{}, {})); + auto [result, inapplicabilities] = mc.GetPreferredInstaller(manifest); + + REQUIRE(result); + + if (AppInstaller::Runtime::IsCurrentOSVersionGreaterThanOrEqual(Version{ "10.0.28000.0" })) + { + REQUIRE(result->MinOSVersion == "10.0.28000.0"); + REQUIRE(result->Dependencies.HasExactDependency(DependencyType::Package, "Microsoft.DotNet.Framework.Runtime.3")); + REQUIRE(!result->Dependencies.HasAnyOf(DependencyType::WindowsFeature)); + REQUIRE(inapplicabilities.size() == 0); + } + else + { + REQUIRE(result->MinOSVersion.empty()); + REQUIRE(result->Dependencies.HasExactDependency(DependencyType::WindowsFeature, "NetFx3")); + REQUIRE(!result->Dependencies.HasAnyOf(DependencyType::Package)); + RequireInapplicabilities(inapplicabilities, { InapplicabilityFlags::OSVersion }); + } +} diff --git a/src/AppInstallerCLITests/TestData/Manifest-Bad-InstallerUniqueness-SameMinOSVersion.yaml b/src/AppInstallerCLITests/TestData/Manifest-Bad-InstallerUniqueness-SameMinOSVersion.yaml new file mode 100644 index 0000000000..cd01d0e2b9 --- /dev/null +++ b/src/AppInstallerCLITests/TestData/Manifest-Bad-InstallerUniqueness-SameMinOSVersion.yaml @@ -0,0 +1,31 @@ +# Bad Manifest. Uniqueness installers same type, arch, locale, scope and MinimumOSVersion. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.0.0.schema.json + +PackageIdentifier: AppInstallerCliTest.TestExeInstaller +PackageVersion: 1.0.0.0 +PackageLocale: en-US +PackageName: AppInstaller Test Exe Installer +ShortDescription: AppInstaller Test Exe Installer +Publisher: Microsoft Corporation +License: Test +InstallerType: exe +InstallerSwitches: + Silent: /silent + SilentWithProgress: /silentwithprogress +Installers: + - Architecture: x64 + InstallerUrl: https://ThisIsNotUsed + InstallerSha256: 98B67758CEAFFCBB3FE47838FD0A8D7BD581C2650842D6B2B0E0D49A23270CCD + MinimumOSVersion: 10.0.28000.0 + Dependencies: + WindowsFeatures: + - NetFx3 + - Architecture: x64 + InstallerUrl: https://ThisIsNotUsed + InstallerSha256: 98B67758CEAFFCBB3FE47838FD0A8D7BD581C2650842D6B2B0E0D49A23270CCD + MinimumOSVersion: 10.0.28000 + Dependencies: + PackageDependencies: + - PackageIdentifier: Microsoft.DotNet.Framework.Runtime.3 +ManifestType: singleton +ManifestVersion: 1.0.0 diff --git a/src/AppInstallerCLITests/TestData/Manifest-Good-InstallerUniqueness-DiffMinOSVersion.yaml b/src/AppInstallerCLITests/TestData/Manifest-Good-InstallerUniqueness-DiffMinOSVersion.yaml new file mode 100644 index 0000000000..e7f03daff0 --- /dev/null +++ b/src/AppInstallerCLITests/TestData/Manifest-Good-InstallerUniqueness-DiffMinOSVersion.yaml @@ -0,0 +1,32 @@ +# Good Manifest. Uniqueness installers same type, arch, locale and scope but different MinimumOSVersion. +# Models a package that needs the NetFx3 Windows feature on older OS versions, and the standalone +# .NET Framework 3.5 package on OS versions where the feature is no longer available. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.0.0.schema.json + +PackageIdentifier: AppInstallerCliTest.TestExeInstaller +PackageVersion: 1.0.0.0 +PackageLocale: en-US +PackageName: AppInstaller Test Exe Installer +ShortDescription: AppInstaller Test Exe Installer +Publisher: Microsoft Corporation +License: Test +InstallerType: exe +InstallerSwitches: + Silent: /silent + SilentWithProgress: /silentwithprogress +Installers: + - Architecture: x64 + InstallerUrl: https://ThisIsNotUsed + InstallerSha256: 98B67758CEAFFCBB3FE47838FD0A8D7BD581C2650842D6B2B0E0D49A23270CCD + Dependencies: + WindowsFeatures: + - NetFx3 + - Architecture: x64 + InstallerUrl: https://ThisIsNotUsed + InstallerSha256: 98B67758CEAFFCBB3FE47838FD0A8D7BD581C2650842D6B2B0E0D49A23270CCD + MinimumOSVersion: 10.0.28000.0 + Dependencies: + PackageDependencies: + - PackageIdentifier: Microsoft.DotNet.Framework.Runtime.3 +ManifestType: singleton +ManifestVersion: 1.0.0 diff --git a/src/AppInstallerCLITests/YamlManifest.cpp b/src/AppInstallerCLITests/YamlManifest.cpp index c9d353861e..eea678b4f5 100644 --- a/src/AppInstallerCLITests/YamlManifest.cpp +++ b/src/AppInstallerCLITests/YamlManifest.cpp @@ -865,6 +865,7 @@ TEST_CASE("ReadGoodManifests", "[ManifestValidation]") { "Manifest-Good-InstallerUniqueness-DefaultLang.yaml" }, { "Manifest-Good-InstallerUniqueness-DiffLangs.yaml" }, { "Manifest-Good-InstallerUniqueness-DiffScope.yaml" }, + { "Manifest-Good-InstallerUniqueness-DiffMinOSVersion.yaml" }, { "Manifest-Good-Minimum.yaml" }, { "Manifest-Good-Minimum-InstallerType.yaml" }, { "Manifest-Good-Switches.yaml" }, @@ -921,6 +922,7 @@ TEST_CASE("ReadBadManifests", "[ManifestValidation]") { "Manifest-Bad-InstallerUniqueness-DefaultScope.yaml", "Duplicate installer entry found." }, { "Manifest-Bad-InstallerUniqueness-DefaultValues.yaml", "Duplicate installer entry found." }, { "Manifest-Bad-InstallerUniqueness-SameLang.yaml", "Duplicate installer entry found." }, + { "Manifest-Bad-InstallerUniqueness-SameMinOSVersion.yaml", "Duplicate installer entry found." }, { "Manifest-Bad-LicenseMissing.yaml", "Missing required property 'License'" }, { "Manifest-Bad-NameMissing.yaml", "Missing required property 'Name'" }, { "Manifest-Bad-PublisherMissing.yaml", "Missing required property 'Publisher'" }, diff --git a/src/AppInstallerCommonCore/Manifest/ManifestComparator.cpp b/src/AppInstallerCommonCore/Manifest/ManifestComparator.cpp index fc4ddd09e4..70aa92ea37 100644 --- a/src/AppInstallerCommonCore/Manifest/ManifestComparator.cpp +++ b/src/AppInstallerCommonCore/Manifest/ManifestComparator.cpp @@ -48,9 +48,9 @@ namespace AppInstaller::Manifest } }; - struct OSVersionFilter : public details::FilterField + struct OSVersionComparator : public details::ComparisonField { - OSVersionFilter() : details::FilterField("OS Version") {} + OSVersionComparator() : details::ComparisonField("OS Version") {} InapplicabilityFlags IsApplicable(const ManifestInstaller& installer) override { @@ -68,6 +68,34 @@ namespace AppInstaller::Manifest result += installer.MinOSVersion; return result; } + + details::ComparisonResult IsFirstBetter(const ManifestInstaller& first, const ManifestInstaller& second) override + { + // Both installers are applicable to the current OS at this point. The one with the higher MinOSVersion + // targets the current OS more specifically, so it should be preferred over one meant for older OS versions. + if (IsMinOSVersionHigher(first, second)) + { + return details::ComparisonResult::WeakPositive; + } + + return details::ComparisonResult::Negative; + } + + private: + static bool IsMinOSVersionHigher(const ManifestInstaller& first, const ManifestInstaller& second) + { + if (first.MinOSVersion.empty()) + { + return false; + } + + if (second.MinOSVersion.empty()) + { + return true; + } + + return Utility::Version(first.MinOSVersion) > Utility::Version(second.MinOSVersion); + } }; struct MachineArchitectureComparator : public details::ComparisonField @@ -735,8 +763,6 @@ namespace AppInstaller::Manifest ManifestComparator::ManifestComparator(const Options& options) { - // Filters based on installer's MinOSVersion - AddFilter(std::make_unique()); // Filters out portable installers if they are not supported by the system AddFilter(std::make_unique()); // Filters based on the scope of a currently installed package @@ -777,6 +803,9 @@ namespace AppInstaller::Manifest // Only applies when preference exists: // Weak if first is in preference list and second is not AddComparator(InstallerTypeComparator::Create(options)); + // Filters out installers whose MinOSVersion is higher than the current OS version. + // Weak if first has a higher MinOSVersion than second (it targets the current OS more specifically) + AddComparator(std::make_unique()); } InstallerAndInapplicabilities ManifestComparator::GetPreferredInstaller(const Manifest& manifest) diff --git a/src/AppInstallerCommonCore/Manifest/ManifestValidation.cpp b/src/AppInstallerCommonCore/Manifest/ManifestValidation.cpp index 71f946871a..fc6ae46f5c 100644 --- a/src/AppInstallerCommonCore/Manifest/ManifestValidation.cpp +++ b/src/AppInstallerCommonCore/Manifest/ManifestValidation.cpp @@ -243,7 +243,8 @@ namespace AppInstaller::Manifest auto defaultLocErrors = ValidateManifestLocalization(manifest.DefaultLocalization, !options.FullValidation); std::move(defaultLocErrors.begin(), defaultLocErrors.end(), std::inserter(resultErrors, resultErrors.end())); - // Comparison function to check duplicate installer entry. {installerType, arch, language and scope} combination is the key. + // Comparison function to check duplicate installer entry. {installerType, arch, language, scope and minOSVersion} combination is the key. + // MinOSVersion is part of the key so that a manifest can provide different installers (or dependencies) for different OS versions. // Todo: use the comparator from ManifestComparator when that one is fully implemented. auto installerCmp = [](const ManifestInstaller& in1, const ManifestInstaller& in2) { @@ -277,6 +278,22 @@ namespace AppInstaller::Manifest return in1.Scope < in2.Scope; } + // An empty MinOSVersion is treated as the lowest possible version. + if (in1.MinOSVersion.empty() != in2.MinOSVersion.empty()) + { + return in1.MinOSVersion.empty(); + } + + if (!in1.MinOSVersion.empty()) + { + Utility::Version minOSVersion1{ in1.MinOSVersion }; + Utility::Version minOSVersion2{ in2.MinOSVersion }; + if (minOSVersion1 != minOSVersion2) + { + return minOSVersion1 < minOSVersion2; + } + } + return false; }; @@ -300,7 +317,7 @@ namespace AppInstaller::Manifest { AICLI_LOG(Core, Error, << "Duplicate installer: Type [" << InstallerTypeToString(installer.EffectiveInstallerType()) << "], Architecture [" << Utility::ToString(installer.Arch) << "], Locale [" << installer.Locale << - "], Scope [" << ScopeToString(installer.Scope) << "]"); + "], Scope [" << ScopeToString(installer.Scope) << "], MinOSVersion [" << installer.MinOSVersion << "]"); resultErrors.emplace_back(ManifestError::DuplicateInstallerEntry); duplicateInstallerFound = true;