From 5ecc5e04d18c4c82c4f6707e96280b08700156b1 Mon Sep 17 00:00:00 2001 From: Dimitriy Ryazantcev Date: Mon, 20 Feb 2023 17:14:49 +0200 Subject: [PATCH] Fix InputLanguage.FromCulture() for languages without LANGID value Many supported Windows languages do not have a unique pre-assigned LANGID to identify them and instead use a value 0x1000 (LOCALE_CUSTOM_UNSPECIFIED) since Windows 8. Windows tries to allocate and provide a unique transient value (from a pool four values: 0x2000, 0x2400, 0x2800, 0x2C00) in these cases in the HKL lowerword to distinguish between different languages via the old Win32 APIs, but these values can't be used to identify the keyboard layout directly, and in result we can't get the InputLanguage correctly in the FromCulture method. Aquire corresponging IETF BCP 47 language tag with a call to a proper API and compare locale names instead of LANGID to fix these corner cases. I tried to use CultureInfo constructor with LANGID parameter but it turns out that the LCIDToLocaleName API, which is used inside CultureInfo, may return incorrect language tags for transient language identifiers. For example, it returns "nqo-GN" and "jv-Java-ID" instead of the "nqo" and "jv-Java"(as seen in the Get-WinUserLanguageList PowerShell cmdlet). I had to use undocumented registry keys to to extract proper language tag from a LANGID. This workaround was approved by a Windows team. https://github.com/dotnet/winforms/pull/8573#issuecomment-1542600949 --- .../src/System/Windows/Forms/InputLanguage.cs | 78 ++++++++++++---- .../InputLanguageChangedEventArgsTests.cs | 10 -- .../InputLanguageChangingEventArgsTests.cs | 10 -- .../Windows/Forms/InputLanguageTests.cs | 91 ++++++++++++++++--- 4 files changed, 138 insertions(+), 51 deletions(-) diff --git a/src/System.Windows.Forms/src/System/Windows/Forms/InputLanguage.cs b/src/System.Windows.Forms/src/System/Windows/Forms/InputLanguage.cs index 2f9bd83d9c7..8ecc03a7510 100644 --- a/src/System.Windows.Forms/src/System/Windows/Forms/InputLanguage.cs +++ b/src/System.Windows.Forms/src/System/Windows/Forms/InputLanguage.cs @@ -27,7 +27,7 @@ internal InputLanguage(IntPtr handle) /// /// Returns the culture of the current input language. /// - public CultureInfo Culture => new CultureInfo(PARAM.LOWORD(_handle)); + public CultureInfo Culture => new CultureInfo(LanguageTag); /// /// Gets or sets the input language for the current thread. @@ -146,17 +146,9 @@ internal string LayoutId foreach (string subKeyName in key.GetSubKeyNames()) { using RegistryKey? subKey = key.OpenSubKey(subKeyName); - if (subKey is null) - { - continue; - } - - if (subKey.GetValue("Layout Id") is not string subKeyLayoutId) - { - continue; - } - - if (layoutId == Convert.ToInt32(subKeyLayoutId, 16)) + if (subKey is not null + && subKey.GetValue("Layout Id") is string subKeyLayoutId + && Convert.ToInt32(subKeyLayoutId, 16) == layoutId) { Debug.Assert(subKeyName.Length == 8, $"unexpected key length in registry: {subKey.Name}"); return subKeyName.ToUpperInvariant(); @@ -180,6 +172,62 @@ internal string LayoutId } } + private static readonly int[] s_transientLangIds = + { + 0x2000, // LOCALE_TRANSIENT_KEYBOARD1 + 0x2400, // LOCALE_TRANSIENT_KEYBOARD2 + 0x2800, // LOCALE_TRANSIENT_KEYBOARD3 + 0x2C00 // LOCALE_TRANSIENT_KEYBOARD4 + }; + + private static string s_userProfileRegistryPath => @"Control Panel\International\User Profile"; + + /// + /// Returns the BCP 47 language + /// tag of the current input language. + /// + private string LanguageTag + { + get + { + // According to the GetKeyboardLayout API function docs low word of HKL contains input language identifier. + int langId = PARAM.LOWORD(_handle); + + // We need to convert the language identifier to a language tag, because they are deprecated and may have a + // transient value. + // https://learn.microsoft.com/globalization/locale/other-locale-names#lcid + // https://learn.microsoft.com/windows/win32/winmsg/wm-inputlangchange#remarks + // + // It turns out that the LCIDToLocaleName API, which is used inside CultureInfo, may return incorrect + // language tags for transient language identifiers. For example, it returns "nqo-GN" and "jv-Java-ID" + // instead of the "nqo" and "jv-Java" (as seen in the Get-WinUserLanguageList PowerShell cmdlet). + // + // Try to extract proper language tag from registry as a workaround approved by a Windows team. + // https://github.com/dotnet/winforms/pull/8573#issuecomment-1542600949 + // + // NOTE: this logic may break in future versions of Windows since it is not documented. + if (s_transientLangIds.Contains(langId)) + { + using RegistryKey? key = Registry.CurrentUser.OpenSubKey(s_userProfileRegistryPath); + if (key is not null && key.GetValue("Languages") is string[] languages) + { + foreach (string language in languages) + { + using RegistryKey? subKey = key.OpenSubKey(language); + if (subKey is not null + && subKey.GetValue("TransientLangId") is int transientLangId + && transientLangId == langId) + { + return language; + } + } + } + } + + return CultureInfo.GetCultureInfo(langId).Name; + } + } + /// /// Creates an InputLanguageChangedEventArgs given a windows message. /// @@ -213,13 +261,9 @@ public override bool Equals(object? value) { ArgumentNullException.ThrowIfNull(culture); - // KeyboardLayoutId is the LCID for built-in cultures, but it is the CU-preferred keyboard language for - // custom cultures. - int lcid = culture.KeyboardLayoutId; - foreach (InputLanguage? lang in InstalledInputLanguages) { - if ((unchecked((int)(long)lang!._handle) & 0xFFFF) == lcid) + if (lang!.Culture.Equals(culture)) { return lang; } diff --git a/src/System.Windows.Forms/tests/UnitTests/System/Windows/Forms/InputLanguageChangedEventArgsTests.cs b/src/System.Windows.Forms/tests/UnitTests/System/Windows/Forms/InputLanguageChangedEventArgsTests.cs index 599365034e0..a9bef590bac 100644 --- a/src/System.Windows.Forms/tests/UnitTests/System/Windows/Forms/InputLanguageChangedEventArgsTests.cs +++ b/src/System.Windows.Forms/tests/UnitTests/System/Windows/Forms/InputLanguageChangedEventArgsTests.cs @@ -35,7 +35,6 @@ public static IEnumerable Ctor_NoSuchCultureInfo_TestData() { yield return new object[] { CultureInfo.InvariantCulture }; yield return new object[] { new CultureInfo("en") }; - yield return new object[] { new UnknownKeyboardCultureInfo() }; } [Theory] @@ -72,13 +71,4 @@ public void Ctor_NullInputLanguage_ThrowsNullReferenceException() { Assert.Throws("inputLanguage", () => new InputLanguageChangedEventArgs((InputLanguage)null, 0)); } - - private class UnknownKeyboardCultureInfo : CultureInfo - { - public UnknownKeyboardCultureInfo() : base("en-US") - { - } - - public override int KeyboardLayoutId => int.MaxValue; - } } diff --git a/src/System.Windows.Forms/tests/UnitTests/System/Windows/Forms/InputLanguageChangingEventArgsTests.cs b/src/System.Windows.Forms/tests/UnitTests/System/Windows/Forms/InputLanguageChangingEventArgsTests.cs index c28841cc7e2..b16c9b1f3aa 100644 --- a/src/System.Windows.Forms/tests/UnitTests/System/Windows/Forms/InputLanguageChangingEventArgsTests.cs +++ b/src/System.Windows.Forms/tests/UnitTests/System/Windows/Forms/InputLanguageChangingEventArgsTests.cs @@ -36,7 +36,6 @@ public static IEnumerable Ctor_NoSuchCultureInfo_TestData() { yield return new object[] { CultureInfo.InvariantCulture }; yield return new object[] { new CultureInfo("en") }; - yield return new object[] { new UnknownKeyboardCultureInfo() }; } [Theory] @@ -73,13 +72,4 @@ public void Ctor_NullInputLanguage_ThrowsNullReferenceException() { Assert.Throws("inputLanguage", () => new InputLanguageChangingEventArgs((InputLanguage)null, true)); } - - private class UnknownKeyboardCultureInfo : CultureInfo - { - public UnknownKeyboardCultureInfo() : base("en-US") - { - } - - public override int KeyboardLayoutId => int.MaxValue; - } } diff --git a/src/System.Windows.Forms/tests/UnitTests/System/Windows/Forms/InputLanguageTests.cs b/src/System.Windows.Forms/tests/UnitTests/System/Windows/Forms/InputLanguageTests.cs index b8c64341ad1..228c0e877f6 100644 --- a/src/System.Windows.Forms/tests/UnitTests/System/Windows/Forms/InputLanguageTests.cs +++ b/src/System.Windows.Forms/tests/UnitTests/System/Windows/Forms/InputLanguageTests.cs @@ -95,8 +95,8 @@ public void InputLanguage_FromCulture_Roundtrip_Success() [Fact] public void InputLanguage_FromCulture_NoSuchCulture_ReturnsNull() { - var unknownCulture = new UnknownKeyboardCultureInfo(); - Assert.Null(InputLanguage.FromCulture(unknownCulture)); + var invariantCulture = CultureInfo.InvariantCulture; + Assert.Null(InputLanguage.FromCulture(invariantCulture)); } [Fact] @@ -126,11 +126,36 @@ public static IEnumerable InputLanguageLayoutId_TestData() [MemberData(nameof(InputLanguageLayoutId_TestData))] public void InputLanguage_InputLanguageLayoutId_Expected(int langId, int device, string languageTag, string layoutId, string layoutName) { - var language = new InputLanguage(PARAM.FromLowHigh(langId, device)); - Assert.Equal(languageTag, language.Culture.Name); - Assert.Equal(layoutId, language.LayoutId); - Assert.Equal(layoutName, language.LayoutName); - VerifyInputLanguage(language); + InputLanguage language = new(PARAM.FromLowHigh(langId, device)); + VerifyInputLanguage(language, languageTag, layoutId, layoutName); + } + + public static IEnumerable SupplementalInputLanguages_TestData() + { + yield return new object[] { "got-Goth", "000C0C00", "Gothic" }; + yield return new object[] { "jv-Java", "00110C00", "Javanese" }; + yield return new object[] { "nqo", "00090C00", "N’Ko" }; + yield return new object[] { "zgh-Tfng", "0000105F", "Tifinagh (Basic)" }; + } + + [Theory] + [MemberData(nameof(SupplementalInputLanguages_TestData))] + public void InputLanguage_FromCulture_SupplementalInputLanguages_Expected(string languageTag, string layoutId, string layoutName) + { + // Also installs default keyboard layout for this language + // https://learn.microsoft.com/windows-hardware/manufacture/desktop/default-input-locales-for-windows-language-packs + InstallUserLanguage(languageTag); + + try + { + CultureInfo culture = new(languageTag); + InputLanguage language = InputLanguage.FromCulture(culture); + VerifyInputLanguage(language, languageTag, layoutId, layoutName); + } + finally + { + UninstallUserLanguage(languageTag); + } } [Theory] @@ -138,7 +163,7 @@ public void InputLanguage_InputLanguageLayoutId_Expected(int langId, int device, [InlineData(0xffff, 0x0409)] public void InputLanguage_Culture_ThrowsArgumentException(int langId, int device) { - var language = new InputLanguage(PARAM.FromLowHigh(langId, device)); + InputLanguage language = new(PARAM.FromLowHigh(langId, device)); Assert.ThrowsAny(() => language.Culture); } @@ -147,10 +172,19 @@ public void InputLanguage_Culture_ThrowsArgumentException(int langId, int device [InlineData(0x0409, 0xffff)] public void InputLanguage_LayoutName_UnknownExpected(int langId, int device) { - var language = new InputLanguage(PARAM.FromLowHigh(langId, device)); + InputLanguage language = new(PARAM.FromLowHigh(langId, device)); Assert.Equal(SR.UnknownInputLanguageLayout, language.LayoutName); } + private static void VerifyInputLanguage(InputLanguage language, string languageTag, string layoutId, string layoutName) + { + Assert.NotNull(language); + Assert.NotEqual(IntPtr.Zero, language.Handle); + Assert.Equal(languageTag, language.Culture.Name); + Assert.Equal(layoutId, language.LayoutId); + Assert.Equal(layoutName, language.LayoutName); + } + private static void VerifyInputLanguage(InputLanguage language) { Assert.NotEqual(IntPtr.Zero, language.Handle); @@ -161,12 +195,41 @@ private static void VerifyInputLanguage(InputLanguage language) Assert.DoesNotContain('\0', language.LayoutName); } - private class UnknownKeyboardCultureInfo : CultureInfo + private static void RunPowerShellScript(string path) { - public UnknownKeyboardCultureInfo() : base("en-US") - { - } + using Process process = new(); + + process.StartInfo.FileName = "powershell.exe"; + process.StartInfo.Arguments = $"-NoProfile -ExecutionPolicy ByPass -File \"{path}\""; + + process.Start(); + process.WaitForExit(); + } + + private static void InstallUserLanguage(string languageTag) + { + string file = Path.Combine(Path.GetTempPath(), $"install-language-{languageTag}.ps1"); + string script = $$""" + $list = Get-WinUserLanguageList + $list.Add("{{languageTag}}") + Set-WinUserLanguageList $list -force + """; + + using TempFile tempFile = new(file, script); + RunPowerShellScript(tempFile.Path); + } + + private static void UninstallUserLanguage(string languageTag) + { + string file = Path.Combine(Path.GetTempPath(), $"uninstall-language-{languageTag}.ps1"); + string script = $$""" + $list = Get-WinUserLanguageList + $item = $list | Where-Object {$_.LanguageTag -like "{{languageTag}}"} + $list.Remove($item) + Set-WinUserLanguageList $list -force + """; - public override int KeyboardLayoutId => int.MaxValue; + using TempFile tempFile = new(file, script); + RunPowerShellScript(tempFile.Path); } }