commit 558f3f48a3b9ef2a1b0b6a9e9b138681988260d4 parent 9399b6a2c63d25190cff455700bc9f70c5afbeec Author: yao-msft <50888816+yao-msft@users.noreply.github.com> Date: Fri, 30 Apr 2021 17:47:43 -0700 Multiple locale support for winget workflows (#903) Diffstat:
43 files changed, 929 insertions(+), 62 deletions(-)
diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt @@ -26,6 +26,8 @@ authz autocomplete auxdata azureedge +bcp +bcp47 bcrypt binver Bitmask @@ -50,6 +52,7 @@ cnt codepage COMMANDBARFLYOUT Commandline +comparand conemu config Configurability @@ -95,6 +98,7 @@ dll dllexport docx dotnet +downlevel downloader dword DWORDLONG @@ -203,6 +207,9 @@ json junit langutil lastwritetime +LCID +LCIDTo +LEN Linux LLVM llvmorg @@ -232,6 +239,7 @@ MINORVERSION mkdir monostate motw +mrm msbuild msdata MSDN @@ -373,6 +381,7 @@ simplesave simpletest sizeof sln +SNAME snprintf sourced Specv @@ -537,3 +546,4 @@ XTOKEN yaml yml yy +zh diff --git a/doc/Settings.md b/doc/Settings.md @@ -67,6 +67,18 @@ The `scope` behavior affects the choice between installing a package for the cur }, ``` +### Locale + +The `locale` behavior affects the choice of installer based on installer locale. The matching parameter is `--locale`, and uses bcp47 language tag. + +```json + "installBehavior": { + "preferences": { + "locale": [ "en-US", "fr-FR" ] + } + }, +``` + ## Telemetry The `telemetry` settings control whether winget writes ETW events that may be sent to Microsoft on a default installation of Windows. diff --git a/schemas/JSON/settings/settings.schema.0.2.json b/schemas/JSON/settings/settings.schema.0.2.json @@ -44,6 +44,17 @@ "machine" ], "default": "user" + }, + "locale": { + "description": "The locales of a package install", + "type": "array", + "items": { + "type": "string", + "pattern": "^([a-zA-Z]{2}|[iI]-[a-zA-Z]+|[xX]-[a-zA-Z]{1,8})(-[a-zA-Z]{1,8})*$", + "maxLength": 20 + }, + "minItems": 1, + "maxItems": 10 } } }, diff --git a/src/AppInstallerCLICore/Argument.cpp b/src/AppInstallerCLICore/Argument.cpp @@ -43,8 +43,8 @@ namespace AppInstaller::CLI return Argument{ "interactive", 'i', Args::Type::Interactive, Resource::String::InteractiveArgumentDescription, ArgumentType::Flag }; case Args::Type::Silent: return Argument{ "silent", 'h', Args::Type::Silent, Resource::String::SilentArgumentDescription, ArgumentType::Flag }; - case Args::Type::Language: - return Argument{ "lang", 'a', Args::Type::Language, Resource::String::LanguageArgumentDescription, ArgumentType::Standard, Argument::Visibility::Hidden }; + case Args::Type::Locale: + return Argument{ "locale", NoAlias, Args::Type::Locale, Resource::String::LocaleArgumentDescription, ArgumentType::Standard }; case Args::Type::Log: return Argument{ "log", 'o', Args::Type::Log, Resource::String::LogArgumentDescription, ArgumentType::Standard }; case Args::Type::Override: diff --git a/src/AppInstallerCLICore/Commands/InstallCommand.cpp b/src/AppInstallerCLICore/Commands/InstallCommand.cpp @@ -34,7 +34,7 @@ namespace AppInstaller::CLI Argument::ForType(Args::Type::Exact), Argument::ForType(Args::Type::Interactive), Argument::ForType(Args::Type::Silent), - Argument::ForType(Args::Type::Language), + Argument::ForType(Args::Type::Locale), Argument::ForType(Args::Type::Log), Argument::ForType(Args::Type::Override), Argument::ForType(Args::Type::InstallLocation), @@ -67,7 +67,7 @@ namespace AppInstaller::CLI context << Workflow::CompleteWithSingleSemanticsForValue(valueType); break; - case Args::Type::Language: + case Args::Type::Locale: // May well move to CompleteWithSingleSemanticsForValue, // but for now output nothing. context << @@ -107,6 +107,14 @@ namespace AppInstaller::CLI throw CommandException(Resource::String::InvalidArgumentValueError, s_ArgumentName_Scope, { "user"_lis, "machine"_lis }); } } + + if (execArgs.Contains(Args::Type::Locale)) + { + if (!Locale::IsWellFormedBcp47Tag(execArgs.GetArg(Args::Type::Locale))) + { + throw CommandException(Resource::String::InvalidArgumentValueErrorWithoutValidValues, Argument::ForType(Args::Type::Locale).Name(), {}); + } + } } void InstallCommand::ExecuteInternal(Context& context) const diff --git a/src/AppInstallerCLICore/Commands/UpgradeCommand.cpp b/src/AppInstallerCLICore/Commands/UpgradeCommand.cpp @@ -37,7 +37,6 @@ namespace AppInstaller::CLI Argument::ForType(Args::Type::Exact), Argument::ForType(Args::Type::Interactive), Argument::ForType(Args::Type::Silent), - Argument::ForType(Args::Type::Language), Argument::ForType(Args::Type::Log), Argument::ForType(Args::Type::Override), Argument::ForType(Args::Type::InstallLocation), @@ -88,12 +87,6 @@ namespace AppInstaller::CLI context << Workflow::CompleteWithSingleSemanticsForValueUsingExistingSource(valueType); break; - case Execution::Args::Type::Language: - // May well move to CompleteWithSingleSemanticsForValue, - // but for now output nothing. - context << - Workflow::CompleteWithEmptySet; - break; } } diff --git a/src/AppInstallerCLICore/ExecutionArgs.h b/src/AppInstallerCLICore/ExecutionArgs.h @@ -33,7 +33,7 @@ namespace AppInstaller::CLI::Execution // Install behavior Interactive, Silent, - Language, + Locale, Log, Override, //Override args are (and the only args) directly passed to installer InstallLocation, diff --git a/src/AppInstallerCLICore/ExecutionContextData.h b/src/AppInstallerCLICore/ExecutionContextData.h @@ -50,7 +50,7 @@ namespace AppInstaller::CLI::Execution Max }; - struct PackagesToInstall + struct PackageToInstall { std::shared_ptr<Repository::IPackageVersion> PackageVersion; PackageCollection::Package PackageRequest; @@ -169,7 +169,7 @@ namespace AppInstaller::CLI::Execution template <> struct DataMapping<Data::PackagesToInstall> { - using value_t = std::vector<PackagesToInstall>; + using value_t = std::vector<PackageToInstall>; }; template <> diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h @@ -100,13 +100,14 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(InvalidAliasError); WINGET_DEFINE_RESOURCE_STRINGID(InvalidArgumentSpecifierError); WINGET_DEFINE_RESOURCE_STRINGID(InvalidArgumentValueError); + WINGET_DEFINE_RESOURCE_STRINGID(InvalidArgumentValueErrorWithoutValidValues); WINGET_DEFINE_RESOURCE_STRINGID(InvalidJsonFile); WINGET_DEFINE_RESOURCE_STRINGID(InvalidNameError); - WINGET_DEFINE_RESOURCE_STRINGID(LanguageArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(LicenseAgreement); WINGET_DEFINE_RESOURCE_STRINGID(Links); WINGET_DEFINE_RESOURCE_STRINGID(ListCommandLongDescription); WINGET_DEFINE_RESOURCE_STRINGID(ListCommandShortDescription); + WINGET_DEFINE_RESOURCE_STRINGID(LocaleArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(LocationArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(LogArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(Logs); diff --git a/src/AppInstallerCLICore/Workflows/ImportExportFlow.cpp b/src/AppInstallerCLICore/Workflows/ImportExportFlow.cpp @@ -239,7 +239,7 @@ namespace AppInstaller::CLI::Workflow void SearchPackagesForImport(Execution::Context& context) { const auto& sources = context.Get<Execution::Data::Sources>(); - std::vector<Execution::PackagesToInstall> packagesToInstall = {}; + std::vector<Execution::PackageToInstall> packagesToInstall = {}; bool foundAll = true; // Look for the packages needed from each source independently. diff --git a/src/AppInstallerCLICore/Workflows/InstallFlow.cpp b/src/AppInstallerCLICore/Workflows/InstallFlow.cpp @@ -644,7 +644,7 @@ namespace AppInstaller::CLI::Workflow toLog ? static_cast<std::string>(toLog->GetProperty(PackageVersionProperty::Name)) : "", toLog ? static_cast<std::string>(toLog->GetProperty(PackageVersionProperty::Version)) : "", toLog ? static_cast<std::string_view>(toLogMetadata[PackageVersionMetadata::Publisher]) : "", - toLog ? static_cast<std::string_view>(toLogMetadata[PackageVersionMetadata::Locale]) : "" + toLog ? static_cast<std::string_view>(toLogMetadata[PackageVersionMetadata::InstalledLocale]) : "" ); } } diff --git a/src/AppInstallerCLICore/Workflows/ManifestComparator.cpp b/src/AppInstallerCLICore/Workflows/ManifestComparator.cpp @@ -132,7 +132,7 @@ namespace AppInstaller::CLI::Workflow bool IsApplicable(const Manifest::ManifestInstaller& installer) override { - // We have to assume the an unknown scope will match our required scope, or the entire catalog would stop working for upgrade. + // We have to assume the unknown scope will match our required scope, or the entire catalog would stop working for upgrade. return installer.Scope == Manifest::ScopeEnum::Unknown || installer.Scope == m_requirement; } @@ -216,6 +216,173 @@ namespace AppInstaller::CLI::Workflow Manifest::ScopeEnum m_preference; Manifest::ScopeEnum m_requirement; }; + + struct InstalledLocaleComparator : public details::ComparisonField + { + InstalledLocaleComparator(std::string installedLocale) : + details::ComparisonField("Installed Locale"), m_installedLocale(std::move(installedLocale)) {} + + static std::unique_ptr<InstalledLocaleComparator> Create(const Repository::IPackageVersion::Metadata& installationMetadata) + { + // Check for an existing install and require a compatible locale. + auto installerLocaleItr = installationMetadata.find(Repository::PackageVersionMetadata::InstalledLocale); + if (installerLocaleItr != installationMetadata.end()) + { + return std::make_unique<InstalledLocaleComparator>(installerLocaleItr->second); + } + + return {}; + } + + bool IsApplicable(const Manifest::ManifestInstaller& installer) override + { + // We have to assume an unknown installer locale will match our installed locale, or the entire catalog would stop working for upgrade. + return installer.Locale.empty() || Locale::GetDistanceOfLanguage(m_installedLocale, installer.Locale) >= Locale::MinimumDistanceScoreAsCompatibleMatch; + } + + std::string ExplainInapplicable(const Manifest::ManifestInstaller& installer) override + { + std::string result = "Installer locale is not compatible with currently installed locale: "; + result += installer.Locale; + result += " not compatible with "; + result += m_installedLocale; + return result; + } + + bool IsFirstBetter(const Manifest::ManifestInstaller& first, const Manifest::ManifestInstaller& second) override + { + double firstScore = first.Locale.empty() ? Locale::UnknownLanguageDistanceScore : Locale::GetDistanceOfLanguage(m_installedLocale, first.Locale); + double secondScore = second.Locale.empty() ? Locale::UnknownLanguageDistanceScore : Locale::GetDistanceOfLanguage(m_installedLocale, second.Locale); + + return firstScore > secondScore; + } + + private: + std::string m_installedLocale; + }; + + struct LocaleComparator : public details::ComparisonField + { + LocaleComparator(std::vector<std::string> preference, std::vector<std::string> requirement) : + details::ComparisonField("Locale"), m_preference(std::move(preference)), m_requirement(std::move(requirement)) + { + m_requirementAsString = GetLocalesListAsString(m_requirement); + m_preferenceAsString = GetLocalesListAsString(m_preference); + AICLI_LOG(CLI, Verbose, << "Locale Comparator created with Required Locales: " << m_requirementAsString << " , Preferred Locales: " << m_preferenceAsString); + } + + static std::unique_ptr<LocaleComparator> Create(const Execution::Args& args) + { + std::vector<std::string> preference; + std::vector<std::string> requirement; + + // Preference will come from winget settings or Preferred Languages settings. winget settings takes precedence. + preference = Settings::User().Get<Settings::Setting::InstallLocalePreference>(); + if (preference.empty()) + { + preference = Locale::GetUserPreferredLanguages(); + } + + // Requirement may come from args or settings; args overrides settings. + if (args.Contains(Execution::Args::Type::Locale)) + { + requirement.emplace_back(args.GetArg(Execution::Args::Type::Locale)); + } + else + { + requirement = Settings::User().Get<Settings::Setting::InstallLocaleRequirement>(); + } + + if (!preference.empty() || !requirement.empty()) + { + return std::make_unique<LocaleComparator>(preference, requirement); + } + else + { + return {}; + } + } + + bool IsApplicable(const Manifest::ManifestInstaller& installer) override + { + if (m_requirement.empty()) + { + return true; + } + + for (auto const& requiredLocale : m_requirement) + { + if (Locale::GetDistanceOfLanguage(requiredLocale, installer.Locale) >= Locale::MinimumDistanceScoreAsPerfectMatch) + { + return true; + } + } + + return false; + } + + std::string ExplainInapplicable(const Manifest::ManifestInstaller& installer) override + { + std::string result = "Installer locale does not match required locale: "; + result += installer.Locale; + result += "Required locales: "; + result += m_requirementAsString; + return result; + } + + bool IsFirstBetter(const Manifest::ManifestInstaller& first, const Manifest::ManifestInstaller& second) override + { + if (m_preference.empty()) + { + return false; + } + + for (auto const& preferredLocale : m_preference) + { + double firstScore = first.Locale.empty() ? Locale::UnknownLanguageDistanceScore : Locale::GetDistanceOfLanguage(preferredLocale, first.Locale); + double secondScore = second.Locale.empty() ? Locale::UnknownLanguageDistanceScore : Locale::GetDistanceOfLanguage(preferredLocale, second.Locale); + + if (firstScore >= Locale::MinimumDistanceScoreAsCompatibleMatch || secondScore >= Locale::MinimumDistanceScoreAsCompatibleMatch) + { + return firstScore > secondScore; + } + } + + // At this point, the installer locale matches no preference. + // if first is unknown and second is no match for sure, we might prefer unknown one. + return first.Locale.empty() && !second.Locale.empty(); + } + + private: + std::vector<std::string> m_preference; + std::vector<std::string> m_requirement; + std::string m_requirementAsString; + std::string m_preferenceAsString; + + std::string GetLocalesListAsString(const std::vector<std::string>& locales) + { + std::string result = "["; + + bool first = true; + for (auto const& locale : locales) + { + if (first) + { + first = false; + } + else + { + result += ", "; + } + + result += locale; + } + + result += ']'; + + return result; + } + }; } ManifestComparator::ManifestComparator(const Execution::Args& args, const Repository::IPackageVersion::Metadata& installationMetadata) @@ -226,6 +393,17 @@ namespace AppInstaller::CLI::Workflow // Filter order is not important, but comparison order determines priority. // TODO: There are improvements to be made here around ordering, especially in the context of implicit vs explicit vs command line preferences. AddComparator(InstalledTypeComparator::Create(installationMetadata)); + + auto installedLocaleComparator = InstalledLocaleComparator::Create(installationMetadata); + if (installedLocaleComparator) + { + AddComparator(std::move(installedLocaleComparator)); + } + else + { + AddComparator(LocaleComparator::Create(args)); + } + AddComparator(ScopeComparator::Create(args)); AddComparator(std::make_unique<MachineArchitectureComparator>()); } diff --git a/src/AppInstallerCLICore/Workflows/ShellExecuteInstallerHandler.cpp b/src/AppInstallerCLICore/Workflows/ShellExecuteInstallerHandler.cpp @@ -94,12 +94,6 @@ namespace AppInstaller::CLI::Workflow installerArgs += experienceArgsItr->second; } - // Construct language arg if necessary. - if (context.Args.Contains(Execution::Args::Type::Language) && installerSwitches.find(InstallerSwitchType::Language) != installerSwitches.end()) - { - installerArgs += ' ' + installerSwitches.at(InstallerSwitchType::Language); - } - // Construct log path arg. if (installerSwitches.find(InstallerSwitchType::Log) != installerSwitches.end()) { diff --git a/src/AppInstallerCLICore/Workflows/UpdateFlow.cpp b/src/AppInstallerCLICore/Workflows/UpdateFlow.cpp @@ -45,7 +45,7 @@ namespace AppInstaller::CLI::Workflow } // Since we already did installer selection, just populate the context Data - manifest.ApplyLocale(); + manifest.ApplyLocale(installer->Locale); context.Add<Execution::Data::Manifest>(std::move(manifest)); context.Add<Execution::Data::PackageVersion>(std::move(packageVersion)); context.Add<Execution::Data::Installer>(std::move(installer)); diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -528,7 +528,14 @@ namespace AppInstaller::CLI::Workflow } Logging::Telemetry().LogManifestFields(manifest->Id, manifest->DefaultLocalization.Get<Manifest::Localization::PackageName>(), manifest->Version); - manifest->ApplyLocale(); + + std::string targetLocale; + if (context.Args.Contains(Execution::Args::Type::Locale)) + { + targetLocale = context.Args.GetArg(Execution::Args::Type::Locale); + } + manifest->ApplyLocale(targetLocale); + context.Add<Execution::Data::Manifest>(std::move(manifest.value())); context.Add<Execution::Data::PackageVersion>(std::move(requestedVersion)); } @@ -576,7 +583,14 @@ namespace AppInstaller::CLI::Workflow { Manifest::Manifest manifest = Manifest::YamlParser::CreateFromPath(Utility::ConvertToUTF16(context.Args.GetArg(Execution::Args::Type::Manifest))); Logging::Telemetry().LogManifestFields(manifest.Id, manifest.DefaultLocalization.Get<Manifest::Localization::PackageName>(), manifest.Version); - manifest.ApplyLocale(); + + std::string targetLocale; + if (context.Args.Contains(Execution::Args::Type::Locale)) + { + targetLocale = context.Args.GetArg(Execution::Args::Type::Locale); + } + manifest.ApplyLocale(targetLocale); + context.Add<Execution::Data::Manifest>(std::move(manifest)); }; } diff --git a/src/AppInstallerCLICore/pch.h b/src/AppInstallerCLICore/pch.h @@ -63,3 +63,4 @@ #include <winget/ExperimentalFeature.h> #include <winget/LocIndependent.h> #include <winget/ManifestYamlParser.h> +#include <winget/Locale.h> diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -298,8 +298,9 @@ They can be configured through the settings file 'winget settings'.</value> <data name="InvalidNameError" xml:space="preserve"> <value>Argument name was not recognized for the current command</value> </data> - <data name="LanguageArgumentDescription" xml:space="preserve"> - <value>Language to install (if supported)</value> + <data name="LocaleArgumentDescription" xml:space="preserve"> + <value>Locale to use (BCP47 format)</value> + <comment>{Locked="BCP47"}</comment> </data> <data name="LicenseAgreement" xml:space="preserve"> <value>License Agreement</value> @@ -916,4 +917,8 @@ Configuration is disabled due to Group Policy.</value> <value>Allowed source</value> <comment>A source that the user is allowed to add.</comment> </data> -</root> + <data name="InvalidArgumentValueErrorWithoutValidValues" xml:space="preserve"> + <value>The value provided for the `%1` argument is invalid</value> + <comment>{Locked="%1"} The value will be replaced with the argument name</comment> + </data> +</root>+ \ No newline at end of file diff --git a/src/AppInstallerCLITests/ARPChanges.cpp b/src/AppInstallerCLITests/ARPChanges.cpp @@ -153,7 +153,7 @@ struct TestContext : public Context auto metadata = version->GetMetadata(); REQUIRE(metadata[PackageVersionMetadata::Publisher] == ARPPublisher); - REQUIRE(metadata[PackageVersionMetadata::Locale] == ARPLanguage); + REQUIRE(metadata[PackageVersionMetadata::InstalledLocale] == ARPLanguage); } else { diff --git a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj @@ -364,6 +364,9 @@ <CopyFileToFolders Include="TestData\Manifest-Bad-InstallerUniqueness.yaml"> <DeploymentContent>true</DeploymentContent> </CopyFileToFolders> + <CopyFileToFolders Include="TestData\Manifest-Bad-InvalidLocale.yaml"> + <DeploymentContent>true</DeploymentContent> + </CopyFileToFolders> <CopyFileToFolders Include="TestData\Manifest-Bad-InvalidManifestVersionValue.yaml"> <DeploymentContent>true</DeploymentContent> </CopyFileToFolders> @@ -430,6 +433,9 @@ <CopyFileToFolders Include="TestData\Manifest-Good-Minimum.yaml"> <DeploymentContent>true</DeploymentContent> </CopyFileToFolders> + <CopyFileToFolders Include="TestData\Manifest-Good-MultiLocale.yaml"> + <DeploymentContent>true</DeploymentContent> + </CopyFileToFolders> <CopyFileToFolders Include="TestData\Manifest-Good-Switches.yaml"> <DeploymentContent>true</DeploymentContent> </CopyFileToFolders> diff --git a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters @@ -234,6 +234,9 @@ <CopyFileToFolders Include="TestData\Manifest-Bad-InstallerUniqueness-SameLang.yaml"> <Filter>TestData</Filter> </CopyFileToFolders> + <CopyFileToFolders Include="TestData\Manifest-Bad-InvalidLocale.yaml"> + <Filter>TestData</Filter> + </CopyFileToFolders> <CopyFileToFolders Include="TestData\Manifest-Bad-InvalidManifestVersionValue.yaml"> <Filter>TestData</Filter> </CopyFileToFolders> @@ -300,6 +303,9 @@ <CopyFileToFolders Include="TestData\Manifest-Good-Minimum-InstallerType.yaml"> <Filter>TestData</Filter> </CopyFileToFolders> + <CopyFileToFolders Include="TestData\Manifest-Good-MultiLocale.yaml"> + <Filter>TestData</Filter> + </CopyFileToFolders> <CopyFileToFolders Include="TestData\Manifest-Good-Switches.yaml"> <Filter>TestData</Filter> </CopyFileToFolders> diff --git a/src/AppInstallerCLITests/ManifestComparator.cpp b/src/AppInstallerCLITests/ManifestComparator.cpp @@ -17,13 +17,14 @@ using namespace AppInstaller::Utility; using Manifest = ::AppInstaller::Manifest::Manifest; -const ManifestInstaller& AddInstaller(Manifest& manifest, Architecture architecture, InstallerTypeEnum installerType, ScopeEnum scope = ScopeEnum::Unknown, std::string minOSVersion = {}) +const ManifestInstaller& AddInstaller(Manifest& manifest, Architecture architecture, InstallerTypeEnum installerType, ScopeEnum scope = ScopeEnum::Unknown, std::string minOSVersion = {}, std::string locale = {}) { ManifestInstaller toAdd; toAdd.Arch = architecture; toAdd.InstallerType = installerType; toAdd.Scope = scope; toAdd.MinOSVersion = minOSVersion; + toAdd.Locale = locale; manifest.Installers.emplace_back(std::move(toAdd)); @@ -37,6 +38,7 @@ void RequireInstaller(const std::optional<ManifestInstaller>& actual, const Mani REQUIRE(actual->InstallerType == expected.InstallerType); REQUIRE(actual->Scope == expected.Scope); REQUIRE(actual->MinOSVersion == expected.MinOSVersion); + REQUIRE(actual->Locale == expected.Locale); } TEST_CASE("ManifestComparator_OSFilter_Low", "[manifest_comparator]") @@ -275,3 +277,130 @@ TEST_CASE("ManifestComparator_ScopeCompare", "[manifest_comparator]") RequireInstaller(result, machine); } } + +TEST_CASE("ManifestComparator_InstalledLocaleComparator_Uknown", "[manifest_comparator]") +{ + Manifest manifest; + ManifestInstaller unknown = AddInstaller(manifest, Architecture::Neutral, InstallerTypeEnum::Msi, ScopeEnum::User, "", ""); + ManifestInstaller enGB = AddInstaller(manifest, Architecture::Neutral, InstallerTypeEnum::Msi, ScopeEnum::User, "", "en-GB"); + + SECTION("Nothing Installed en-US preference") + { + TestUserSettings settings; + settings.Set<Setting::InstallLocalePreference>({ "en-US" }); + + ManifestComparator mc({}, {}); + auto result = mc.GetPreferredInstaller(manifest); + + // Only because it is first + RequireInstaller(result, enGB); + } + SECTION("en-US Installed") + { + IPackageVersion::Metadata metadata; + metadata[PackageVersionMetadata::InstalledLocale] = "en-US"; + + ManifestComparator mc({}, metadata); + auto result = mc.GetPreferredInstaller(manifest); + + RequireInstaller(result, enGB); + } + SECTION("zh-CN Installed") + { + IPackageVersion::Metadata metadata; + metadata[PackageVersionMetadata::InstalledLocale] = "zh-CN"; + + ManifestComparator mc({}, metadata); + auto result = mc.GetPreferredInstaller(manifest); + + RequireInstaller(result, unknown); + } +} + +TEST_CASE("ManifestComparator_InstalledLocaleComparator", "[manifest_comparator]") +{ + Manifest manifest; + ManifestInstaller frFR = AddInstaller(manifest, Architecture::Neutral, InstallerTypeEnum::Msi, ScopeEnum::User, "", "fr-FR"); + ManifestInstaller enGB = AddInstaller(manifest, Architecture::Neutral, InstallerTypeEnum::Msi, ScopeEnum::User, "", "en-GB"); + + SECTION("Nothing Installed en-US preference") + { + TestUserSettings settings; + settings.Set<Setting::InstallLocalePreference>({ "en-US" }); + + ManifestComparator mc({}, {}); + auto result = mc.GetPreferredInstaller(manifest); + + // Only because it is first + RequireInstaller(result, enGB); + } + SECTION("en-US Installed") + { + IPackageVersion::Metadata metadata; + metadata[PackageVersionMetadata::InstalledLocale] = "en-US"; + + ManifestComparator mc({}, metadata); + auto result = mc.GetPreferredInstaller(manifest); + + RequireInstaller(result, enGB); + } + SECTION("zh-CN Installed") + { + IPackageVersion::Metadata metadata; + metadata[PackageVersionMetadata::InstalledLocale] = "zh-CN"; + + ManifestComparator mc({}, metadata); + auto result = mc.GetPreferredInstaller(manifest); + + REQUIRE(!result); + } +} + +TEST_CASE("ManifestComparator_LocaleComparator", "[manifest_comparator]") +{ + Manifest manifest; + ManifestInstaller unknown = AddInstaller(manifest, Architecture::Neutral, InstallerTypeEnum::Msi, ScopeEnum::User, "", ""); + ManifestInstaller frFR = AddInstaller(manifest, Architecture::Neutral, InstallerTypeEnum::Msi, ScopeEnum::User, "", "fr-FR"); + ManifestInstaller enGB = AddInstaller(manifest, Architecture::Neutral, InstallerTypeEnum::Msi, ScopeEnum::User, "", "en-GB"); + + SECTION("en-GB Required") + { + Args args; + args.AddArg(Args::Type::Locale, "en-GB"s); + + ManifestComparator mc(args, {}); + auto result = mc.GetPreferredInstaller(manifest); + + RequireInstaller(result, enGB); + } + SECTION("zh-CN Required") + { + Args args; + args.AddArg(Args::Type::Locale, "zh-CN"s); + + ManifestComparator mc(args, {}); + auto result = mc.GetPreferredInstaller(manifest); + + REQUIRE(!result); + } + SECTION("en-US Preference") + { + TestUserSettings settings; + settings.Set<Setting::InstallLocalePreference>({ "en-US" }); + + ManifestComparator mc({}, {}); + auto result = mc.GetPreferredInstaller(manifest); + + RequireInstaller(result, enGB); + } + SECTION("zh-CN Preference") + { + TestUserSettings settings; + settings.Set<Setting::InstallLocalePreference>({ "zh-CN" }); + + ManifestComparator mc({}, {}); + auto result = mc.GetPreferredInstaller(manifest); + + RequireInstaller(result, unknown); + } +}+ \ No newline at end of file diff --git a/src/AppInstallerCLITests/TestData/Manifest-Bad-InvalidLocale.yaml b/src/AppInstallerCLITests/TestData/Manifest-Bad-InvalidLocale.yaml @@ -0,0 +1,16 @@ +# Bad manifest. Invalid locale. +PackageIdentifier: microsoft.msixsdk +PackageVersion: 1.0.0 +PackageName: MSIX SDK +Publisher: Microsoft +InstallerType: zip +License: Test +ShortDescription: Test invalid locale +Installers: + - Architecture: x86 + InstallerUrl: https://rubengustorage.blob.core.windows.net/publiccontainer/msixsdk-x86.zip + InstallerSha256: 98B67758CEAFFCBB3FE47838FD0A8D7BD581C2650842D6B2B0E0D49A23270CCD + +PackageLocale: In-val-id-Lo-ca-le +ManifestType: singleton +ManifestVersion: 1.0.0 diff --git a/src/AppInstallerCLITests/TestData/Manifest-Good-MultiLocale.yaml b/src/AppInstallerCLITests/TestData/Manifest-Good-MultiLocale.yaml @@ -0,0 +1,41 @@ +PackageIdentifier: AppInstallerCliTest.MultiLocaleTest +PackageVersion: 1.0.0.0 +PackageLocale: es-MX +PackageName: es-MX package name +Publisher: es-MX publisher +Moniker: AICLITestExe +License: Test +InstallerType: exe +Installers: + - Architecture: x64 + InstallerUrl: https://ThisIsNotUsed + InstallerSha256: 65DB2F2AC2686C7F2FD69D4A4C6683B888DC55BFA20A0E32CA9F838B51689A3B + InstallerSwitches: + Custom: /unknownLocale + SilentWithProgress: /silentwithprogress + Silent: /silence + - Architecture: x64 + InstallerUrl: https://ThisIsNotUsed + InstallerSha256: 65DB2F2AC2686C7F2FD69D4A4C6683B888DC55BFA20A0E32CA9F838B51689A3B + InstallerLocale: en-GB + InstallerSwitches: + Custom: /en-GB + SilentWithProgress: /silentwithprogress + Silent: /silence + - Architecture: x64 + InstallerUrl: https://ThisIsNotUsed + InstallerSha256: 65DB2F2AC2686C7F2FD69D4A4C6683B888DC55BFA20A0E32CA9F838B51689A3B + InstallerLocale: fr-FR + InstallerSwitches: + Custom: /fr-FR + SilentWithProgress: /silentwithprogress + Silent: /silence +Localization: + - PackageLocale: en-GB + PackageName: en-GB package name + Publisher: en-GB publisher + - PackageLocale: fr-FR + PackageName: fr-FR package name + +ManifestType: merged +ManifestVersion: 1.0.0 diff --git a/src/AppInstallerCLITests/WorkFlow.cpp b/src/AppInstallerCLITests/WorkFlow.cpp @@ -1429,3 +1429,95 @@ TEST_CASE("VerifyInstallerTrustLevelAndUpdateInstallerFileMotw", "[DownloadInsta INFO(updateMotwOutput.str()); } + +TEST_CASE("InstallFlowMultiLocale_RequirementNotSatisfied", "[InstallFlow][workflow]") +{ + TestCommon::TempFile installResultPath("TestExeInstalled.txt"); + + std::ostringstream installOutput; + TestContext context{ installOutput, std::cin }; + context.Args.AddArg(Execution::Args::Type::Manifest, TestDataFile("Manifest-Good-MultiLocale.yaml").GetPath().u8string()); + context.Args.AddArg(Execution::Args::Type::Locale, "en-US"sv); + + InstallCommand install({}); + install.Execute(context); + INFO(installOutput.str()); + + REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_NO_APPLICABLE_INSTALLER); + + // Verify Installer was not called + REQUIRE(!std::filesystem::exists(installResultPath.GetPath())); +} + +TEST_CASE("InstallFlowMultiLocale_RequirementSatisfied", "[InstallFlow][workflow]") +{ + TestCommon::TempFile installResultPath("TestExeInstalled.txt"); + + std::ostringstream installOutput; + TestContext context{ installOutput, std::cin }; + OverrideForShellExecute(context); + context.Args.AddArg(Execution::Args::Type::Manifest, TestDataFile("Manifest-Good-MultiLocale.yaml").GetPath().u8string()); + context.Args.AddArg(Execution::Args::Type::Locale, "fr-FR"sv); + + InstallCommand install({}); + install.Execute(context); + INFO(installOutput.str()); + + // Verify Installer is called and parameters are passed in. + REQUIRE(std::filesystem::exists(installResultPath.GetPath())); + std::ifstream installResultFile(installResultPath.GetPath()); + REQUIRE(installResultFile.is_open()); + std::string installResultStr; + std::getline(installResultFile, installResultStr); + REQUIRE(installResultStr.find("/fr-FR") != std::string::npos); +} + +TEST_CASE("InstallFlowMultiLocale_PreferenceNoBetterLocale", "[InstallFlow][workflow]") +{ + TestCommon::TempFile installResultPath("TestExeInstalled.txt"); + + std::ostringstream installOutput; + TestContext context{ installOutput, std::cin }; + OverrideForShellExecute(context); + context.Args.AddArg(Execution::Args::Type::Manifest, TestDataFile("Manifest-Good-MultiLocale.yaml").GetPath().u8string()); + + TestUserSettings settings; + settings.Set<AppInstaller::Settings::Setting::InstallLocalePreference>({ "zh-CN" }); + + InstallCommand install({}); + install.Execute(context); + INFO(installOutput.str()); + + // Verify Installer is called and parameters are passed in. + REQUIRE(std::filesystem::exists(installResultPath.GetPath())); + std::ifstream installResultFile(installResultPath.GetPath()); + REQUIRE(installResultFile.is_open()); + std::string installResultStr; + std::getline(installResultFile, installResultStr); + REQUIRE(installResultStr.find("/unknown") != std::string::npos); +} + +TEST_CASE("InstallFlowMultiLocale_PreferenceWithBetterLocale", "[InstallFlow][workflow]") +{ + TestCommon::TempFile installResultPath("TestExeInstalled.txt"); + + std::ostringstream installOutput; + TestContext context{ installOutput, std::cin }; + OverrideForShellExecute(context); + context.Args.AddArg(Execution::Args::Type::Manifest, TestDataFile("Manifest-Good-MultiLocale.yaml").GetPath().u8string()); + + TestUserSettings settings; + settings.Set<AppInstaller::Settings::Setting::InstallLocalePreference>({ "en-US" }); + + InstallCommand install({}); + install.Execute(context); + INFO(installOutput.str()); + + // Verify Installer is called and parameters are passed in. + REQUIRE(std::filesystem::exists(installResultPath.GetPath())); + std::ifstream installResultFile(installResultPath.GetPath()); + REQUIRE(installResultFile.is_open()); + std::string installResultStr; + std::getline(installResultFile, installResultStr); + REQUIRE(installResultStr.find("/en-GB") != std::string::npos); +}+ \ No newline at end of file diff --git a/src/AppInstallerCLITests/YamlManifest.cpp b/src/AppInstallerCLITests/YamlManifest.cpp @@ -245,6 +245,7 @@ TEST_CASE("ReadBadManifests", "[ManifestValidation]") { "Manifest-Bad-PackageFamilyNameOnMSI.yaml", "The specified installer type does not support PackageFamilyName. Field: InstallerType Value: Msi" }, { "Manifest-Bad-ProductCodeOnMSIX.yaml", "The specified installer type does not support ProductCode. Field: InstallerType Value: Msix" }, { "Manifest-Bad-InvalidUpdateBehavior.yaml", "Invalid field value. Field: UpdateBehavior" }, + { "Manifest-Bad-InvalidLocale.yaml", "The locale value is not a well formed bcp47 language tag." }, }; for (auto const& testCase : TestCases) @@ -572,4 +573,27 @@ TEST_CASE("MultifileManifestInputValidation", "[ManifestValidation]") std::vector<YamlManifestInfo> input = { v1VersionManifest, v1InstallerManifest }; REQUIRE_THROWS_MATCHES(YamlParser::ParseManifest(input), ManifestException, ManifestExceptionMatcher("The multi file manifest is incomplete")); } +} + +TEST_CASE("ManifestApplyLocale", "[ManifestValidation]") +{ + Manifest manifest = YamlParser::CreateFromPath(TestDataFile("Manifest-Good-MultiLocale.yaml")); + + // No better alternative locale, default is used. + manifest.ApplyLocale("zh-CN"); + REQUIRE(manifest.CurrentLocalization.Locale == "es-MX"); + REQUIRE(manifest.CurrentLocalization.Get<Localization::PackageName>() == "es-MX package name"); + REQUIRE(manifest.CurrentLocalization.Get<Localization::Publisher>() == "es-MX publisher"); + + // en-US results in en-GB, which is better than default. + manifest.ApplyLocale("en-US"); + REQUIRE(manifest.CurrentLocalization.Locale == "en-GB"); + REQUIRE(manifest.CurrentLocalization.Get<Localization::PackageName>() == "en-GB package name"); + REQUIRE(manifest.CurrentLocalization.Get<Localization::Publisher>() == "en-GB publisher"); + + // fr-FR results in fr-FR, but only package name is localized. + manifest.ApplyLocale("fr-FR"); + REQUIRE(manifest.CurrentLocalization.Locale == "fr-FR"); + REQUIRE(manifest.CurrentLocalization.Get<Localization::PackageName>() == "fr-FR package name"); + REQUIRE(manifest.CurrentLocalization.Get<Localization::Publisher>() == "es-MX publisher"); } \ No newline at end of file diff --git a/src/AppInstallerCommonCore/AppInstallerCommonCore.vcxproj b/src/AppInstallerCommonCore/AppInstallerCommonCore.vcxproj @@ -286,6 +286,7 @@ <ClInclude Include="Public\winget\ExperimentalFeature.h" /> <ClInclude Include="Public\winget\ExtensionCatalog.h" /> <ClInclude Include="Public\winget\JsonSchemaValidation.h" /> + <ClInclude Include="Public\winget\Locale.h" /> <ClInclude Include="Public\winget\LocIndependent.h" /> <ClInclude Include="Public\winget\Manifest.h" /> <ClInclude Include="Public\winget\ManifestInstaller.h" /> @@ -336,6 +337,7 @@ </ClCompile> <ClCompile Include="JsonSchemaValidation.cpp" /> <ClCompile Include="JsonUtil.cpp" /> + <ClCompile Include="Locale.cpp" /> <ClCompile Include="Manifest\Manifest.cpp" /> <ClCompile Include="Manifest\ManifestCommon.cpp" /> <ClCompile Include="Manifest\ManifestValidation.cpp" /> diff --git a/src/AppInstallerCommonCore/AppInstallerCommonCore.vcxproj.filters b/src/AppInstallerCommonCore/AppInstallerCommonCore.vcxproj.filters @@ -165,6 +165,9 @@ <ClInclude Include="Public\winget\Resources.h"> <Filter>Public\winget</Filter> </ClInclude> + <ClInclude Include="Public\winget\Locale.h"> + <Filter>Public\winget</Filter> + </ClInclude> <ClInclude Include="DODownloader.h"> <Filter>Header Files</Filter> </ClInclude> @@ -281,6 +284,9 @@ <ClCompile Include="GroupPolicy.cpp"> <Filter>Source Files</Filter> </ClCompile> + <ClCompile Include="Locale.cpp"> + <Filter>Source Files</Filter> + </ClCompile> <ClCompile Include="DODownloader.cpp"> <Filter>Source Files</Filter> </ClCompile> diff --git a/src/AppInstallerCommonCore/JsonUtil.cpp b/src/AppInstallerCommonCore/JsonUtil.cpp @@ -45,5 +45,27 @@ namespace AppInstaller::Utility return value; } + template<> + std::optional<std::vector<std::string>> GetValue(const Json::Value& node) + { + std::vector<std::string> result; + + if (node.isArray()) + { + for (const Json::Value& entry : node) + { + if (!entry.isString()) + { + return std::nullopt; + } + + result.emplace_back(entry.asString()); + } + + return result; + } + + return std::nullopt; + } } diff --git a/src/AppInstallerCommonCore/JsonUtil.h b/src/AppInstallerCommonCore/JsonUtil.h @@ -6,6 +6,7 @@ #include <optional> #include <string> +#include <vector> namespace AppInstaller::Utility { @@ -21,4 +22,6 @@ namespace AppInstaller::Utility template<> std::optional<bool> GetValue<bool>(const Json::Value& node); + template<> + std::optional<std::vector<std::string>> GetValue<std::vector<std::string>>(const Json::Value& node); } diff --git a/src/AppInstallerCommonCore/Locale.cpp b/src/AppInstallerCommonCore/Locale.cpp @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "winget/Locale.h" +#include "AppInstallerStrings.h" +#include "AppInstallerLogging.h" + +namespace AppInstaller::Locale +{ + namespace + { + constexpr int MAX_LOCALE_SNAME_LEN = 85; + + // We will just leak this. The module is shared as both functions will always be together. + HMODULE g_bcp47 = (HMODULE)(-1); + typedef bool(WINAPI* IsWellFormedTagFunc)(PCWSTR); + typedef HRESULT(WINAPI* GetDistanceOfClosestLanguageInListFunc)(PCWSTR, PCWSTR, wchar_t, double*); + + HMODULE LoadBcp47ModuleFrom(_In_ PCWSTR moduleName) + { + HMODULE module = LoadLibraryExW(moduleName, nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32); + if (module != nullptr) + { + // All BCP47 APIs we are interested are always exported from the same dll together. So we just pick anyone for probe. + IsWellFormedTagFunc func = (IsWellFormedTagFunc)(GetProcAddress(module, "IsWellFormedTag")); + if (func != nullptr) + { + return module; + } + FreeLibrary(module); + } + + return nullptr; + } + + HMODULE LoadBcp47Module() + { + HMODULE module = LoadBcp47ModuleFrom(L"bcp47mrm.dll"); + if (module == nullptr) + { + // In downlevel OS, the API is exposed by bcp47langs.dll. + module = LoadBcp47ModuleFrom(L"bcp47langs.dll"); + } + + return module; + } + + void InitializeBcp47Module() + { + HMODULE comparand = (HMODULE)(-1); + if (InterlockedCompareExchangePointer(reinterpret_cast<PVOID*>(&g_bcp47), comparand, comparand) == comparand) + { + HMODULE module = LoadBcp47Module(); + InterlockedExchangePointer(reinterpret_cast<PVOID*>(&g_bcp47), module); + } + } + } + + bool IsWellFormedBcp47Tag(std::string_view bcp47Tag) + { + // Before new SDK is released, we need to use LoadLibrary/GetProcAddress + InitializeBcp47Module(); + + if (g_bcp47 == nullptr) + { + // Didn't find an implementation. Just return true. + AICLI_LOG(Core, Warning, << "bcp47 module not found."); + return true; + } + + IsWellFormedTagFunc func = (IsWellFormedTagFunc)(GetProcAddress(g_bcp47, "IsWellFormedTag")); + if (func != nullptr) + { + auto wBcp47Tag = Utility::ConvertToUTF16(bcp47Tag); + return func(wBcp47Tag.c_str()); + } + + // Should not reach here. + return TRUE; + } + + double GetDistanceOfLanguage(std::string_view target, std::string_view available) + { + // Before new SDK is released, we need to use LoadLibrary/GetProcAddress + InitializeBcp47Module(); + + if (g_bcp47 == nullptr) + { + // Didn't find an implementation. Just return 0 as no match. + AICLI_LOG(Core, Warning, << "bcp47 module not found."); + return 0; + } + + GetDistanceOfClosestLanguageInListFunc func = + (GetDistanceOfClosestLanguageInListFunc)(GetProcAddress(g_bcp47, "GetDistanceOfClosestLanguageInList")); + if (func != nullptr) + { + double distance = 0; + auto wTarget = Utility::ConvertToUTF16(target); + auto wAvailable = Utility::ConvertToUTF16(available); + + // Do not check HRESULT because the method returns ERROR_NO_MATCH on no match, which is a valid case. + (void)func(wTarget.c_str(), wAvailable.c_str(), L';' /* Not used, we compare one at a time */, &distance); + return distance; + } + + // Should not reach here. + return 0; + } + + std::vector<std::string> GetUserPreferredLanguages() + { + std::vector<std::string> result; + + for (const auto& lang : winrt::Windows::System::UserProfile::GlobalizationPreferences::Languages()) + { + result.emplace_back(Utility::ConvertToUTF8(lang)); + } + + return result; + } + + std::string LocaleIdToBcp47Tag(LCID localeId) + { + WCHAR localeName[MAX_LOCALE_SNAME_LEN] = {0}; + int ret = LCIDToLocaleName( + localeId, + localeName, + MAX_LOCALE_SNAME_LEN, + LOCALE_ALLOW_NEUTRAL_NAMES); + + if (ret <= 0) + { + return {}; + } + + return Utility::ConvertToUTF8(std::wstring(localeName)); + } +} diff --git a/src/AppInstallerCommonCore/Manifest/Manifest.cpp b/src/AppInstallerCommonCore/Manifest/Manifest.cpp @@ -2,13 +2,48 @@ // Licensed under the MIT License. #include "pch.h" #include "winget/Manifest.h" +#include "winget/Locale.h" +#include "winget/UserSettings.h" namespace AppInstaller::Manifest { - void Manifest::ApplyLocale(const std::string&) + void Manifest::ApplyLocale(const std::string& locale) { - // TODO: need more work in locale processing CurrentLocalization = DefaultLocalization; + + // Get target locale from Preferred Languages settings if applicable + std::vector<std::string> targetLocales; + if (locale.empty()) + { + targetLocales = Locale::GetUserPreferredLanguages(); + } + else + { + targetLocales.emplace_back(locale); + } + + for (auto const& targetLocale : targetLocales) + { + const ManifestLocalization* bestLocalization = nullptr; + double bestScore = Locale::GetDistanceOfLanguage(targetLocale, DefaultLocalization.Locale); + + for (auto const& localization : Localizations) + { + double score = Locale::GetDistanceOfLanguage(targetLocale, localization.Locale); + if (score > bestScore) + { + bestLocalization = &localization; + bestScore = score; + } + } + + // If there's better locale than default And is compatible with target locale, merge and return; + if (bestLocalization != nullptr && bestScore >= Locale::MinimumDistanceScoreAsCompatibleMatch) + { + CurrentLocalization.ReplaceOrMergeWith(*bestLocalization); + break; + } + } } std::vector<string_t> Manifest::GetAggregatedTags() const diff --git a/src/AppInstallerCommonCore/Manifest/ManifestValidation.cpp b/src/AppInstallerCommonCore/Manifest/ManifestValidation.cpp @@ -2,6 +2,7 @@ // Licensed under the MIT License. #include "pch.h" #include "winget/ManifestValidation.h" +#include "winget/Locale.h" namespace AppInstaller::Manifest { @@ -25,6 +26,11 @@ namespace AppInstaller::Manifest resultErrors.emplace_back(ManifestError::InvalidFieldValue, "Version", manifest.Version); } + if (!manifest.DefaultLocalization.Locale.empty() && !Locale::IsWellFormedBcp47Tag(manifest.DefaultLocalization.Locale)) + { + resultErrors.emplace_back(ManifestError::InvalidBcp47Value, "PackageLocale", manifest.DefaultLocalization.Locale); + } + // Comparison function to check duplicate installer entry. {installerType, arch, language and scope} combination is the key. // Todo: use the comparator from ManifestComparator when that one is fully implemented. auto installerCmp = [](const ManifestInstaller& in1, const ManifestInstaller& in2) @@ -134,6 +140,20 @@ namespace AppInstaller::Manifest { resultErrors.emplace_back(ManifestError::InvalidFieldValue, "Url", installer.Url); } + + if (!installer.Locale.empty() && !Locale::IsWellFormedBcp47Tag(installer.Locale)) + { + resultErrors.emplace_back(ManifestError::InvalidBcp47Value, "InstallerLocale", installer.Locale); + } + } + + // Validate localizations + for (auto const& localization : manifest.Localizations) + { + if (!localization.Locale.empty() && !Locale::IsWellFormedBcp47Tag(localization.Locale)) + { + resultErrors.emplace_back(ManifestError::InvalidBcp47Value, "PackageLocale", localization.Locale); + } } return resultErrors; diff --git a/src/AppInstallerCommonCore/Public/winget/Locale.h b/src/AppInstallerCommonCore/Public/winget/Locale.h @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include <string> +#include <vector> + +namespace AppInstaller::Locale +{ + static constexpr double MinimumDistanceScoreAsPerfectMatch = 1.0; + static constexpr double MinimumDistanceScoreAsCompatibleMatch = 0.9; + static constexpr double UnknownLanguageDistanceScore = 0.0; + + // Check if a bcp47 language tag is well formed + bool IsWellFormedBcp47Tag(std::string_view bcp47Tag); + + // Get a score of language distance between target and available. The return value range is 0 to 1. + // With 1 meaning perfect match and 0 meaning no match. + double GetDistanceOfLanguage(std::string_view target, std::string_view available); + + // Get the list of user Preferred Languages from settings. Returns an empty vector in rare cases of failure. + std::vector<std::string> GetUserPreferredLanguages(); + + // Get the bcp47 tag from a locale id. Returns empty string if conversion can not be performed. + std::string LocaleIdToBcp47Tag(LCID localeId); +}+ \ No newline at end of file diff --git a/src/AppInstallerCommonCore/Public/winget/ManifestLocalization.h b/src/AppInstallerCommonCore/Public/winget/ManifestLocalization.h @@ -87,6 +87,16 @@ namespace AppInstaller::Manifest } } + void ReplaceOrMergeWith(const ManifestLocalization& other) + { + for (auto const& entry : other.m_data) + { + this->m_data[entry.first] = entry.second; + } + + this->Locale = other.Locale; + } + private: std::map<Localization, details::LocalizationVariant> m_data; }; diff --git a/src/AppInstallerCommonCore/Public/winget/ManifestValidation.h b/src/AppInstallerCommonCore/Public/winget/ManifestValidation.h @@ -38,6 +38,7 @@ namespace AppInstaller::Manifest const char* const UnsupportedMultiFileManifestType = "The multi file manifest should not contain file with the particular ManifestType."; const char* const InconsistentMultiFileManifestDefaultLocale = "DefaultLocale value in version manifest does not match PackageLocale value in defaultLocale manifest."; const char* const FieldFailedToProcess = "Failed to process field."; + const char* const InvalidBcp47Value = "The locale value is not a well formed bcp47 language tag."; } struct ValidationError diff --git a/src/AppInstallerCommonCore/Public/winget/UserSettings.h b/src/AppInstallerCommonCore/Public/winget/UserSettings.h @@ -78,6 +78,8 @@ namespace AppInstaller::Settings InstallScopeRequirement, NetworkDownloader, NetworkDOProgressTimeoutInSeconds, + InstallLocalePreference, + InstallLocaleRequirement, Max }; @@ -100,7 +102,7 @@ namespace AppInstaller::Settings { \ using json_t = _json_; \ using value_t = _value_; \ - static constexpr value_t DefaultValue = _default_; \ + inline static const value_t DefaultValue = _default_; \ static constexpr std::string_view Path = _path_; \ static std::optional<value_t> Validate(const json_t& value); \ static constexpr ValuePolicy Policy = _valuePolicy_; \ @@ -126,6 +128,8 @@ namespace AppInstaller::Settings SETTINGMAPPING_SPECIALIZATION(Setting::InstallScopeRequirement, std::string, ScopePreference, ScopePreference::None, ".installBehavior.requirements.scope"sv); SETTINGMAPPING_SPECIALIZATION(Setting::NetworkDownloader, std::string, InstallerDownloader, InstallerDownloader::Default, ".network.downloader"sv); SETTINGMAPPING_SPECIALIZATION(Setting::NetworkDOProgressTimeoutInSeconds, uint32_t, std::chrono::seconds, 20s, ".network.doProgressTimeoutInSeconds"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::InstallLocalePreference, std::vector<std::string>, std::vector<std::string>, {}, ".installBehavior.preferences.locale"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::InstallLocaleRequirement, std::vector<std::string>, std::vector<std::string>, {}, ".installBehavior.requirements.locale"sv); // Used to deduce the SettingVariant type; making a variant that includes std::monostate and all SettingMapping types. template <size_t... I> diff --git a/src/AppInstallerCommonCore/UserSettings.cpp b/src/AppInstallerCommonCore/UserSettings.cpp @@ -1,12 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. #include "pch.h" -#include <AppInstallerRuntime.h> +#include "AppInstallerRuntime.h" #include "AppInstallerLanguageUtilities.h" #include "AppInstallerLogging.h" #include "JsonUtil.h" #include "winget/Settings.h" #include "winget/UserSettings.h" +#include "winget/Locale.h" namespace AppInstaller::Settings { @@ -43,6 +44,31 @@ namespace AppInstaller::Settings return convertedValue; } + template<> + inline std::string GetValueString(std::vector<std::string> value) + { + std::string convertedValue = "["; + + bool first = true; + for (auto const& entry : value) + { + if (first) + { + first = false; + } + else + { + convertedValue += ", "; + } + + convertedValue += entry; + } + + convertedValue += ']'; + + return convertedValue; + } + std::optional<Json::Value> ParseFile(const StreamDefinition& setting, std::vector<UserSettings::Warning>& warnings) { auto stream = GetSettingStream(setting); @@ -228,6 +254,24 @@ namespace AppInstaller::Settings return SettingMapping<Setting::InstallScopePreference>::Validate(value); } + WINGET_VALIDATE_SIGNATURE(InstallLocalePreference) + { + for (auto const& entry : value) + { + if (!Locale::IsWellFormedBcp47Tag(entry)) + { + return {}; + } + } + + return value; + } + + WINGET_VALIDATE_SIGNATURE(InstallLocaleRequirement) + { + return SettingMapping<Setting::InstallLocalePreference>::Validate(value); + } + WINGET_VALIDATE_SIGNATURE(NetworkDownloader) { static constexpr std::string_view s_downloader_default = "default"; diff --git a/src/AppInstallerCommonCore/pch.h b/src/AppInstallerCommonCore/pch.h @@ -71,6 +71,7 @@ #include <winrt/Windows.Storage.h> #include <winrt/Windows.Storage.Streams.h> #include <winrt/Windows.System.Profile.h> +#include <winrt/Windows.System.UserProfile.h> #include <winrt/Windows.Web.Http.h> #include <winrt/Windows.Web.Http.Headers.h> #include <winrt/Windows.Web.Http.Filters.h> diff --git a/src/AppInstallerRepositoryCore/Microsoft/ARPHelper.cpp b/src/AppInstallerRepositoryCore/Microsoft/ARPHelper.cpp @@ -155,7 +155,7 @@ namespace AppInstaller::Repository::Microsoft return Utility::Version::CreateUnknown().ToString(); } - void ARPHelper::AddMetadataIfPresent(const Registry::Key& key, const std::wstring& name, SQLiteIndex& index, SQLiteIndex::IdType manifestId, PackageVersionMetadata metadata) + void ARPHelper::AddMetadataIfPresent(const Registry::Key& key, const std::wstring& name, SQLiteIndex& index, SQLiteIndex::IdType manifestId, PackageVersionMetadata metadata) const { auto value = key[name]; if (value) @@ -172,9 +172,17 @@ namespace AppInstaller::Repository::Microsoft } else if (value->GetType() == Registry::Value::Type::DWord) { - std::ostringstream strstr; - strstr << value->GetValue<Registry::Value::Type::DWord>(); - valueString = strstr.str(); + DWORD dwordValue = value->GetValue<Registry::Value::Type::DWord>(); + if (name == Language) + { + valueString = Locale::LocaleIdToBcp47Tag(dwordValue); + } + else + { + std::ostringstream strstr; + strstr << dwordValue; + valueString = strstr.str(); + } } if (!valueString.empty()) @@ -319,8 +327,7 @@ namespace AppInstaller::Repository::Microsoft AddMetadataIfPresent(arpKey, QuietUninstallString, index, manifestId, PackageVersionMetadata::SilentUninstallCommand); // Pick up Language to enable proper selection of language for upgrade. - // TODO: Determine if InnoSetupLanguage represents the same concept and pick it up if language is not present. - AddMetadataIfPresent(arpKey, Language, index, manifestId, PackageVersionMetadata::Locale); + AddMetadataIfPresent(arpKey, Language, index, manifestId, PackageVersionMetadata::InstalledLocale); // Pick up WindowsInstaller to determine if this is an MSI install. // TODO: Could also determine Inno (and maybe other types) through detecting other keys here. diff --git a/src/AppInstallerRepositoryCore/Microsoft/ARPHelper.h b/src/AppInstallerRepositoryCore/Microsoft/ARPHelper.h @@ -15,42 +15,42 @@ namespace AppInstaller::Repository::Microsoft struct ARPHelper { // See https://docs.microsoft.com/en-us/windows/win32/msi/uninstall-registry-key for details. - std::wstring SubKeyPath{ L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall" }; + const std::wstring SubKeyPath{ L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall" }; // REG_SZ - std::wstring DisplayName{ L"DisplayName" }; + const std::wstring DisplayName{ L"DisplayName" }; // REG_SZ - std::wstring Publisher{ L"Publisher" }; + const std::wstring Publisher{ L"Publisher" }; // REG_SZ - std::wstring DisplayVersion{ L"DisplayVersion" }; + const std::wstring DisplayVersion{ L"DisplayVersion" }; // REG_DWORD (ex. 0xMMmmbbbb, M[ajor], m[inor], b[uild]) - std::wstring Version{ L"Version" }; + const std::wstring Version{ L"Version" }; // REG_DWORD - std::wstring VersionMajor{ L"VersionMajor" }; + const std::wstring VersionMajor{ L"VersionMajor" }; // REG_DWORD - std::wstring VersionMinor{ L"VersionMinor" }; + const std::wstring VersionMinor{ L"VersionMinor" }; // REG_DWORD - std::wstring MajorVersion{ L"MajorVersion" }; + const std::wstring MajorVersion{ L"MajorVersion" }; // REG_DWORD - std::wstring MinorVersion{ L"MinorVersion" }; + const std::wstring MinorVersion{ L"MinorVersion" }; // REG_SZ - std::wstring URLInfoAbout{ L"URLInfoAbout" }; + const std::wstring URLInfoAbout{ L"URLInfoAbout" }; // REG_SZ - std::wstring HelpLink{ L"HelpLink" }; + const std::wstring HelpLink{ L"HelpLink" }; // REG_SZ - std::wstring InstallLocation{ L"InstallLocation" }; + const std::wstring InstallLocation{ L"InstallLocation" }; // REG_DWORD (ex. 1033 [en-us]) - std::wstring Language{ L"Language" }; + const std::wstring Language{ L"Language" }; // REG_SZ (ex. "english") - std::wstring InnoSetupLanguage{ L"Inno Setup: Language" }; + const std::wstring InnoSetupLanguage{ L"Inno Setup: Language" }; // REG_EXPAND_SZ - std::wstring UninstallString{ L"UninstallString" }; + const std::wstring UninstallString{ L"UninstallString" }; // REG_EXPAND_SZ - std::wstring QuietUninstallString{ L"QuietUninstallString" }; + const std::wstring QuietUninstallString{ L"QuietUninstallString" }; // REG_DWORD (bool, true indicates MSI) - std::wstring WindowsInstaller{ L"WindowsInstaller" }; + const std::wstring WindowsInstaller{ L"WindowsInstaller" }; // REG_DWORD (bool) - std::wstring SystemComponent{ L"SystemComponent" }; + const std::wstring SystemComponent{ L"SystemComponent" }; // Gets the registry key associated with the given scope and architecture on this platform. // May return an empty key if there is no valid location (bad combination or not found). @@ -67,7 +67,7 @@ namespace AppInstaller::Repository::Microsoft std::string DetermineVersion(const Registry::Key& arpKey) const; // Reads a value and adds it to the metadata if it exists. - static void AddMetadataIfPresent(const Registry::Key& key, const std::wstring& name, SQLiteIndex& index, SQLiteIndex::IdType manifestId, PackageVersionMetadata metadata); + void AddMetadataIfPresent(const Registry::Key& key, const std::wstring& name, SQLiteIndex& index, SQLiteIndex::IdType manifestId, PackageVersionMetadata metadata) const; // Populates the index with the ARP entries from the given scope (machine/user). // Handles all of the architectures for the given scope. diff --git a/src/AppInstallerRepositoryCore/Public/AppInstallerRepositorySearch.h b/src/AppInstallerRepositoryCore/Public/AppInstallerRepositorySearch.h @@ -145,7 +145,7 @@ namespace AppInstaller::Repository // The publisher of the package Publisher, // The locale of the package - Locale, + InstalledLocale, }; // Convert a PackageVersionMetadata to a string. diff --git a/src/AppInstallerRepositoryCore/RepositorySource.cpp b/src/AppInstallerRepositoryCore/RepositorySource.cpp @@ -1148,6 +1148,8 @@ namespace AppInstaller::Repository case PackageVersionMetadata::InstalledLocation: return "InstalledLocation"sv; case PackageVersionMetadata::StandardUninstallCommand: return "StandardUninstallCommand"sv; case PackageVersionMetadata::SilentUninstallCommand: return "SilentUninstallCommand"sv; + case PackageVersionMetadata::Publisher: return "Publisher"sv; + case PackageVersionMetadata::InstalledLocale: return "InstalledLocale"sv; default: return "Unknown"sv; } } diff --git a/src/AppInstallerRepositoryCore/pch.h b/src/AppInstallerRepositoryCore/pch.h @@ -27,6 +27,7 @@ #include <AppInstallerVersions.h> #include <winget/ExtensionCatalog.h> #include <winget/ExperimentalFeature.h> +#include <winget/Locale.h> #include <winget/Settings.h> #include <winget/UserSettings.h> #include <winget/Yaml.h>