diff --git a/doc/ReleaseNotes.md b/doc/ReleaseNotes.md index 4b81aabb85..6dd17f6eed 100644 --- a/doc/ReleaseNotes.md +++ b/doc/ReleaseNotes.md @@ -1,6 +1,12 @@ ## New in v1.30 -Nothing yet. +## 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`. ## Bug Fixes 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..f1523d2f81 100644 --- a/src/AppInstallerSharedLib/Resources.cpp +++ b/src/AppInstallerSharedLib/Resources.cpp @@ -5,6 +5,9 @@ #include "Public/AppInstallerLogging.h" #include "Public/AppInstallerStrings.h" #include "Public/AppInstallerErrors.h" +#include +#include +#include namespace AppInstaller { @@ -63,7 +66,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 +75,8 @@ namespace AppInstaller // Gets the string resource value. std::string ResolveString(std::wstring_view resKey) const { + std::lock_guard guard(m_lock); + if (resKey.empty()) { return {}; @@ -89,6 +94,8 @@ namespace AppInstaller // Gets the string resource value or nothing if not present. std::optional TryResolveString(std::wstring_view resKey) const { + std::lock_guard guard(m_lock); + if (!resKey.empty() && m_wingetLoader) { try @@ -106,20 +113,69 @@ namespace AppInstaller return {}; } + bool SetLanguageOverride(std::wstring_view localeTag) + { + std::lock_guard guard(m_lock); + + 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 std::mutex 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 +186,11 @@ namespace AppInstaller } } }; + + bool SetLanguageOverride(std::string_view localeTag) + { + return Loader::Instance().SetLanguageOverride(Utility::ConvertToUTF16(localeTag)); + } } namespace StringResource