diff --git a/doc/ReleaseNotes.md b/doc/ReleaseNotes.md index a468b60c74..9a870cf82a 100644 --- a/doc/ReleaseNotes.md +++ b/doc/ReleaseNotes.md @@ -1,5 +1,13 @@ ## New in v1.30 +## New Features + +### Output locale override + +Added a persistent `output.locale` setting to override winget interface language using a BCP47 tag. + +Usage: add `"output": { "locale": "de-DE" }` to `settings.json`. + ### `--ignore-unavailable` flag for `install` Added a new `--ignore-unavailable` flag to the `install` command. When installing multiple packages, this flag allows the operation to continue with the remaining packages instead of failing entirely when one or more packages are not found in the configured sources. This brings the same behavior previously available with `import --ignore-unavailable` to direct multi-package installs. diff --git a/doc/Settings.md b/doc/Settings.md index 0dfe03b3e4..9f6d6bbe90 100644 --- a/doc/Settings.md +++ b/doc/Settings.md @@ -388,6 +388,23 @@ The `interactivity` settings control whether winget may show interactive prompts If set to true, the `interactivity.disable` setting will prevent any interactive prompt from being shown. +## Output + +The `output` settings control how winget presents CLI output. + +### locale + +The `locale` setting selects the language used to resolve winget interface strings by BCP47 tag (for example, `en-US`). If this setting is missing or invalid, winget uses the default Windows resource resolution behavior for the current process. + +> [!NOTE] +> This only affects winget interface strings. It does not change package metadata localization, installer selection behavior, or package-wide language settings. + +```json + "output": { + "locale": "en-US" + }, +``` + ## Experimental Features To allow work to be done and distributed to early adopters for feedback, settings can be used to enable "experimental" features. diff --git a/doc/windows/package-manager/winget/settings.md b/doc/windows/package-manager/winget/settings.md index a4fc50e16d..ef5961793c 100644 --- a/doc/windows/package-manager/winget/settings.md +++ b/doc/windows/package-manager/winget/settings.md @@ -98,6 +98,25 @@ The `locale` behavior affects the choice of installer based on installer locale. }, ``` +### Output + +The `output` settings affect winget interface output behavior. + +#### locale + +The `locale` setting selects winget interface language using a supported locale value. If not specified, winget uses the default Windows resource resolution behavior for the current process. + +Supported values: `en-US`, `de-DE`, `es-ES`, `fr-FR`, `it-IT`, `ja-JP`, `ko-KR`, `pt-BR`, `ru-RU`, `zh-CN`, `zh-TW`. + +> [!NOTE] +> This setting only affects winget interface strings and does not affect package metadata localization, installer locale selection, or package-wide language settings. + +```json + "output": { + "locale": "en-US" + }, +``` + ### Telemetry The `telemetry` settings control whether winget writes ETW events that may be sent to Microsoft on a default installation of Windows. @@ -133,4 +152,3 @@ The `downloader` setting controls which code is used when downloading packages. ## Enabling Experimental features To discover which experimental features are available, go to [https://aka.ms/winget-settings](https://aka.ms/winget-settings) where you can see the experimental features available to you. - diff --git a/schemas/JSON/settings/settings.schema.0.2.json b/schemas/JSON/settings/settings.schema.0.2.json index 06d6314d91..c47e291aec 100644 --- a/schemas/JSON/settings/settings.schema.0.2.json +++ b/schemas/JSON/settings/settings.schema.0.2.json @@ -378,6 +378,23 @@ "descending" ], "default": "ascending" + }, + "locale": { + "description": "Overrides winget interface output language using a supported locale", + "type": "string", + "enum": [ + "en-US", + "de-DE", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + "ko-KR", + "pt-BR", + "ru-RU", + "zh-CN", + "zh-TW" + ] } } } diff --git a/src/AppInstallerCLICore/Core.cpp b/src/AppInstallerCLICore/Core.cpp index a5890bd9cf..88e151da3f 100644 --- a/src/AppInstallerCLICore/Core.cpp +++ b/src/AppInstallerCLICore/Core.cpp @@ -10,6 +10,7 @@ #include "COMContext.h" #include #include +#include #include "Public/ShutdownMonitoring.h" #ifndef AICLI_DISABLE_TEST_HOOKS @@ -78,6 +79,24 @@ namespace AppInstaller::CLI main.Wait = WaitOnMainWaitEvent; ShutdownMonitoring::ServerShutdownSynchronization::AddComponent(main); } + + std::string ApplyOutputLocaleOverride() + { + std::string localeTag{ Settings::User().Get() }; + + if (localeTag.empty()) + { + return {}; + } + + if (!AppInstaller::Resource::SetLanguageOverride(localeTag)) + { + AICLI_LOG(CLI, Warning, << "Failed to apply output locale override from settings: " << localeTag); + return {}; + } + + return localeTag; + } } int CoreMain(int argc, wchar_t const** argv) try @@ -89,7 +108,6 @@ namespace AppInstaller::CLI std::signal(SIGABRT, abort_signal_handler); init_apartment(); - #ifndef AICLI_DISABLE_TEST_HOOKS // We have to do this here so the auto minidump config initialization gets caught Logging::OutputDebugStringLogger::Add(); @@ -120,6 +138,12 @@ namespace AppInstaller::CLI Logging::OutputDebugStringLogger::Remove(); Logging::EnableWilFailureTelemetry(); + std::string outputLocaleOverride = ApplyOutputLocaleOverride(); + if (!outputLocaleOverride.empty()) + { + AICLI_LOG(CLI, Info, << "Applied output locale override from settings: " << outputLocaleOverride); + } + // Set output to UTF8 ConsoleOutputCPRestore utf8CP(CP_UTF8); diff --git a/src/AppInstallerCLIE2ETests/Helpers/WinGetSettingsHelper.cs b/src/AppInstallerCLIE2ETests/Helpers/WinGetSettingsHelper.cs index 3f99bde3ce..2a9c85cfbb 100644 --- a/src/AppInstallerCLIE2ETests/Helpers/WinGetSettingsHelper.cs +++ b/src/AppInstallerCLIE2ETests/Helpers/WinGetSettingsHelper.cs @@ -270,6 +270,26 @@ public static void ConfigureLoggingLevel(string level) SetWingetSettings(settingsJson); } + /// + /// Configure output locale. + /// + /// Output locale to set; null or empty removes the value. + public static void ConfigureOutputLocale(string locale) + { + JObject settingsJson = GetJsonSettingsObject("output"); + + if (string.IsNullOrEmpty(locale)) + { + settingsJson["output"]["locale"]?.Parent?.Remove(); + } + else + { + settingsJson["output"]["locale"] = new JValue(locale); + } + + SetWingetSettings(settingsJson); + } + /// /// Configure experimental features. /// diff --git a/src/AppInstallerCLIE2ETests/Settings.cs b/src/AppInstallerCLIE2ETests/Settings.cs new file mode 100644 index 0000000000..44b36526ec --- /dev/null +++ b/src/AppInstallerCLIE2ETests/Settings.cs @@ -0,0 +1,62 @@ +// ----------------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +// +// ----------------------------------------------------------------------------- + +namespace AppInstallerCLIE2ETests +{ + using AppInstallerCLIE2ETests.Helpers; + using NUnit.Framework; + + /// + /// Tests user settings behavior. + /// + public class Settings + { + /// + /// Reset settings before these tests run. + /// + [OneTimeSetUp] + public void OneTimeSetup() + { + WinGetSettingsHelper.InitializeWingetSettings(); + } + + /// + /// Reset settings after these tests complete. + /// + [OneTimeTearDown] + public void OneTimeTearDown() + { + WinGetSettingsHelper.InitializeWingetSettings(); + } + + /// + /// Verifies that changing output.locale changes help output. + /// + [Test] + public void OutputLocaleChangesHelpOutput() + { + WinGetSettingsHelper.ConfigureOutputLocale("en-US"); + var english = RunHelpCommand(); + + WinGetSettingsHelper.ConfigureOutputLocale("ru-RU"); + var russian = RunHelpCommand(); + + Assert.That(russian.StdOut, Is.Not.EqualTo(english.StdOut)); + + WinGetSettingsHelper.ConfigureOutputLocale("en-US"); + var englishAgain = RunHelpCommand(); + Assert.That(englishAgain.StdOut, Is.EqualTo(english.StdOut)); + } + + // Shared command helper for settings tests that validate global CLI output. + private static TestCommon.RunCommandResult RunHelpCommand() + { + var result = TestCommon.RunAICLICommand(string.Empty, "-?"); + Assert.That(result.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK)); + return result; + } + } +} diff --git a/src/AppInstallerCLIPackage/AppInstallerCLIPackage.wapproj b/src/AppInstallerCLIPackage/AppInstallerCLIPackage.wapproj index 738507f0b0..020eb52aad 100644 --- a/src/AppInstallerCLIPackage/AppInstallerCLIPackage.wapproj +++ b/src/AppInstallerCLIPackage/AppInstallerCLIPackage.wapproj @@ -187,15 +187,25 @@ Designer + + + + + + + + + + diff --git a/src/AppInstallerCLITests/UserSettings.cpp b/src/AppInstallerCLITests/UserSettings.cpp index c4d63e0905..8f9e31e150 100644 --- a/src/AppInstallerCLITests/UserSettings.cpp +++ b/src/AppInstallerCLITests/UserSettings.cpp @@ -925,6 +925,64 @@ TEST_CASE("SettingOutputSortDirection", "[settings]") } } +TEST_CASE("SettingOutputLocale", "[settings]") +{ + auto again = DeleteUserSettingsFiles(); + + SECTION("Default value") + { + UserSettingsTest userSettingTest; + + REQUIRE(userSettingTest.Get().empty()); + REQUIRE(userSettingTest.GetWarnings().size() == 0); + } + SECTION("Valid locale") + { + std::string_view json = R"({ "output": { "locale": "en-US" } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + REQUIRE(userSettingTest.Get() == "en-US"sv); + REQUIRE(userSettingTest.GetWarnings().size() == 0); + } + SECTION("Case insensitive locale") + { + std::string_view json = R"({ "output": { "locale": "EN-us" } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + REQUIRE(userSettingTest.Get() == "en-US"sv); + REQUIRE(userSettingTest.GetWarnings().size() == 0); + } + SECTION("Unsupported locale") + { + std::string_view json = R"({ "output": { "locale": "el-GR" } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + REQUIRE(userSettingTest.Get().empty()); + REQUIRE(userSettingTest.GetWarnings().size() == 1); + } + SECTION("Invalid locale") + { + std::string_view json = R"({ "output": { "locale": "en_US.UTF-8" } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + REQUIRE(userSettingTest.Get().empty()); + REQUIRE(userSettingTest.GetWarnings().size() == 1); + } + SECTION("Wrong type") + { + std::string_view json = R"({ "output": { "locale": ["en-US"] } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + REQUIRE(userSettingTest.Get().empty()); + REQUIRE(userSettingTest.GetWarnings().size() == 1); + } +} + TEST_CASE("ConvertToSortField", "[settings]") { SECTION("Valid values - lowercase") diff --git a/src/AppInstallerCommonCore/Public/winget/UserSettings.h b/src/AppInstallerCommonCore/Public/winget/UserSettings.h index 0b194d3ce9..52fcf2c46e 100644 --- a/src/AppInstallerCommonCore/Public/winget/UserSettings.h +++ b/src/AppInstallerCommonCore/Public/winget/UserSettings.h @@ -144,6 +144,7 @@ namespace AppInstaller::Settings // Output behavior OutputSortOrder, OutputSortDirection, + OutputLocale, #ifndef AICLI_DISABLE_TEST_HOOKS // Debug EnableSelfInitiatedMinidump, @@ -242,6 +243,7 @@ namespace AppInstaller::Settings // Output behavior SETTINGMAPPING_SPECIALIZATION(Setting::OutputSortOrder, std::vector, std::vector, std::vector{}, ".output.sortOrder"sv); SETTINGMAPPING_SPECIALIZATION(Setting::OutputSortDirection, std::string, SortDirection, SortDirection::Ascending, ".output.sortDirection"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::OutputLocale, std::string, std::string, std::string{}, ".output.locale"sv); // Used to deduce the SettingVariant type; making a variant that includes std::monostate and all SettingMapping types. template diff --git a/src/AppInstallerCommonCore/UserSettings.cpp b/src/AppInstallerCommonCore/UserSettings.cpp index a7f9bb3b93..876cb4467d 100644 --- a/src/AppInstallerCommonCore/UserSettings.cpp +++ b/src/AppInstallerCommonCore/UserSettings.cpp @@ -12,6 +12,8 @@ #include "AppInstallerArchitecture.h" #include "winget/Locale.h" +#include + namespace AppInstaller::Settings { using namespace std::string_view_literals; @@ -213,6 +215,34 @@ namespace AppInstaller::Settings return path; } + + static constexpr std::array s_supportedOutputLocales = + { + "en-US"sv, + "de-DE"sv, + "es-ES"sv, + "fr-FR"sv, + "it-IT"sv, + "ja-JP"sv, + "ko-KR"sv, + "pt-BR"sv, + "ru-RU"sv, + "zh-CN"sv, + "zh-TW"sv, + }; + + std::optional NormalizeSupportedLocale(std::string_view localeTag) + { + for (const auto& supportedLocale : s_supportedOutputLocales) + { + if (Utility::CaseInsensitiveEquals(localeTag, supportedLocale)) + { + return supportedLocale; + } + } + + return {}; + } } std::optional ConvertToSortField(std::string_view value) @@ -561,6 +591,17 @@ namespace AppInstaller::Settings return {}; } + + WINGET_VALIDATE_SIGNATURE(OutputLocale) + { + auto normalizedLocale = NormalizeSupportedLocale(value); + if (normalizedLocale) + { + return std::string{ normalizedLocale.value() }; + } + + return {}; + } } #ifndef AICLI_DISABLE_TEST_HOOKS diff --git a/src/AppInstallerSharedLib/Public/winget/Resources.h b/src/AppInstallerSharedLib/Public/winget/Resources.h index 8c5da1175f..800ce9b399 100644 --- a/src/AppInstallerSharedLib/Public/winget/Resources.h +++ b/src/AppInstallerSharedLib/Public/winget/Resources.h @@ -101,6 +101,10 @@ namespace AppInstaller LocString(LocString&&) = default; LocString& operator=(LocString&&) = default; }; + + // Sets the language override used to resolve winget interface strings. + // An empty value clears the override and restores default resource resolution. + bool SetLanguageOverride(std::string_view localeTag); } namespace StringResource @@ -146,4 +150,3 @@ namespace AppInstaller return Utility::LocIndString{ Utility::Format(Resolve(), std::forward(args)...) }; } } - diff --git a/src/AppInstallerSharedLib/Resources.cpp b/src/AppInstallerSharedLib/Resources.cpp index fa33d99cc4..eb446557ba 100644 --- a/src/AppInstallerSharedLib/Resources.cpp +++ b/src/AppInstallerSharedLib/Resources.cpp @@ -5,6 +5,8 @@ #include "Public/AppInstallerLogging.h" #include "Public/AppInstallerStrings.h" #include "Public/AppInstallerErrors.h" +#include +#include namespace AppInstaller { @@ -63,7 +65,7 @@ namespace AppInstaller struct Loader { // Gets the singleton instance of the resource loader. - static const Loader& Instance() + static Loader& Instance() { static Loader instance; return instance; @@ -72,6 +74,8 @@ namespace AppInstaller // Gets the string resource value. std::string ResolveString(std::wstring_view resKey) const { + auto lock = m_lock.lock_shared(); + if (resKey.empty()) { return {}; @@ -89,6 +93,8 @@ namespace AppInstaller // Gets the string resource value or nothing if not present. std::optional TryResolveString(std::wstring_view resKey) const { + auto lock = m_lock.lock_shared(); + if (!resKey.empty() && m_wingetLoader) { try @@ -106,20 +112,69 @@ namespace AppInstaller return {}; } + bool SetLanguageOverride(std::wstring_view localeTag) + { + auto lock = m_lock.lock_exclusive(); + + if (localeTag == m_localeOverride) + { + return true; + } + + try + { + m_wingetLoader = CreateLoader(localeTag); + m_localeOverride = localeTag; + return true; + } + catch (const winrt::hresult_error& hre) + { + AICLI_LOG(CLI, Error, << "Failure applying resource locale override (" << Utility::ConvertToUTF8(localeTag) << ") with error: " << hre.code()); + } + + return false; + } + private: winrt::Windows::ApplicationModel::Resources::ResourceLoader m_wingetLoader; + winrt::hstring m_localeOverride; + mutable wil::srwlock m_lock; + + static winrt::Windows::ApplicationModel::Resources::ResourceLoader CreateLoader(std::wstring_view localeTag) + { + if (!localeTag.empty()) + { + winrt::Windows::ApplicationModel::Resources::Core::ResourceContext::SetGlobalQualifierValue(L"Language", localeTag); + } + else + { + auto qualifierNames = winrt::single_threaded_vector(); + qualifierNames.Append(L"Language"); + winrt::Windows::ApplicationModel::Resources::Core::ResourceContext::ResetGlobalQualifierValues(qualifierNames); + } + + // The default constructor of ResourceLoader throws a winrt::hresult_error exception + // when resource.pri is not found. ResourceLoader::GetForViewIndependentUse also throws + // a winrt::hresult_error but for reasons unknown it only gets caught when running on the + // debugger. Running without a debugger will result in a crash that not even adding a + // catch all will fix. To provide a good error message we call the default constructor + // before calling GetForViewIndependentUse. + [[maybe_unused]] auto defaultLoader = winrt::Windows::ApplicationModel::Resources::ResourceLoader(); + + return winrt::Windows::ApplicationModel::Resources::ResourceLoader::GetForViewIndependentUse(L"winget"); + } Loader() : m_wingetLoader(nullptr) { try { - // The default constructor of ResourceLoader throws a winrt::hresult_error exception - // when resource.pri is not found. ResourceLoader::GetForViewIndependentUse also throws - // a winrt::hresult_error but for reasons unknown it only gets caught when running on the - // debugger. Running without a debugger will result in a crash that not even adding a - // catch all will fix. To provide a good error message we call the default constructor - // before calling GetForViewIndependentUse. - m_wingetLoader = winrt::Windows::ApplicationModel::Resources::ResourceLoader(); + // Do not call CreateLoader({}) here. CreateLoader calls ResetGlobalQualifierValues + // before ResourceLoader(), which changes WinRT resource system state and breaks the + // safety guard below. When running unpackaged (e.g. during the build's + // WinGetGenerateDSCv3Manifests step), ResourceLoader() must throw its catchable + // hresult_error first to prevent execution from reaching GetForViewIndependentUse, + // which fast-fails with an uncatchable crash (0xC0000409) when not under a debugger. + [[maybe_unused]] auto defaultLoader = winrt::Windows::ApplicationModel::Resources::ResourceLoader(); m_wingetLoader = winrt::Windows::ApplicationModel::Resources::ResourceLoader::GetForViewIndependentUse(L"winget"); } catch (const winrt::hresult_error& hre) @@ -130,6 +185,11 @@ namespace AppInstaller } } }; + + bool SetLanguageOverride(std::string_view localeTag) + { + return Loader::Instance().SetLanguageOverride(Utility::ConvertToUTF16(localeTag)); + } } namespace StringResource