Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow TimeZoneInfo display names to use any of the installed Windows languages #52992

Merged
merged 10 commits into from
May 21, 2021
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ internal static partial class Interop
{
internal static partial class Kernel32
{
internal const uint MUI_PREFERRED_UI_LANGUAGES = 0x10;
internal const uint MUI_USER_PREFERRED_UI_LANGUAGES = 0x10;
internal const uint MUI_USE_INSTALLED_LANGUAGES = 0x20;

[DllImport(Libraries.Kernel32, CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)]
internal static extern unsafe bool GetFileMUIPath(uint dwFlags, string pcwszFilePath, char* pwszLanguage, ref int pcchLanguage, char* pwszFileMUIPath, ref int pcchFileMUIPath, ref long pululEnumerator);
internal static extern unsafe bool GetFileMUIPath(uint dwFlags, string pcwszFilePath, char* pwszLanguage, ref uint pcchLanguage, char* pwszFileMUIPath, ref uint pcchFileMUIPath, ref ulong pululEnumerator);
}
}
146 changes: 118 additions & 28 deletions src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Win32.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ public sealed partial class TimeZoneInfo
private const int MaxKeyLength = 255;
private const string InvariantUtcStandardDisplayName = "Coordinated Universal Time";

private static readonly Dictionary<string, string> s_FileMuiPathCache = new();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: s_fileMuiPathCache


private sealed partial class CachedData
{
private static TimeZoneInfo GetCurrentOneYearLocal()
Expand Down Expand Up @@ -736,6 +738,102 @@ private static bool TryCompareTimeZoneInformationToRegistry(in TIME_ZONE_INFORMA
}
}

/// <summary>
/// Helper function for getting the MUI path for a given resource and culture, using a cache to prevent duplicating work.
/// Searches the installed OS languages for either an exact matching culture, or one that has the same parent.
/// If not found, uses the preferred default OS UI language, to align with prior behavior.
/// </summary>
private static string GetCachedFileMuiPath(string filePath, CultureInfo cultureInfo)
{
string? result;
string cacheKey = $"{cultureInfo.Name};{filePath}";

lock (s_FileMuiPathCache)
{
if (s_FileMuiPathCache.TryGetValue(cacheKey, out result))
{
return result;
}
}

result = GetFileMuiPath(filePath, cultureInfo);

lock (s_FileMuiPathCache)
{
s_FileMuiPathCache.TryAdd(cacheKey, result);
}

return result;
}

/// <summary>
/// Helper function for getting the MUI path for a given resource and culture.
/// Searches the installed OS languages for either an exact matching culture, or one that has the same parent.
/// If not found, uses the preferred default OS UI language, to align with prior behavior.
/// </summary>
private static unsafe string GetFileMuiPath(string filePath, CultureInfo cultureInfo)
{
char* fileMuiPath = stackalloc char[Interop.Kernel32.MAX_PATH];
char* language = stackalloc char[Interop.Kernel32.LOCALE_NAME_MAX_LENGTH];
uint fileMuiPathLength = Interop.Kernel32.MAX_PATH;
uint languageLength = Interop.Kernel32.LOCALE_NAME_MAX_LENGTH;
ulong enumerator = 0;

while (true)
{
// Search all installed languages. The enumerator is re-used between loop iterations.
bool succeeded = Interop.Kernel32.GetFileMUIPath(
Interop.Kernel32.MUI_USE_INSTALLED_LANGUAGES,
filePath, language, ref languageLength,
fileMuiPath, ref fileMuiPathLength, ref enumerator);

if (!succeeded)
{
// Recurse to search using the parent of the desired culture.
if (cultureInfo.Parent.Name != string.Empty)
{
return GetFileMuiPath(filePath, cultureInfo.Parent);
}

// Final fallback, using the preferred installed UI language.
enumerator = 0;
succeeded = Interop.Kernel32.GetFileMUIPath(
Interop.Kernel32.MUI_USER_PREFERRED_UI_LANGUAGES,
filePath, language, ref languageLength,
fileMuiPath, ref fileMuiPathLength, ref enumerator);

if (succeeded)
{
return new string(fileMuiPath);
}

// Shouldn't get here, as there's always at least one language installed.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would ideally be a Debug.Fail.

return string.Empty;
}

// Lookup succeeded. Check for exact match to the desired culture.
var lang = new string(language);
if (string.Equals(lang, cultureInfo.Name, StringComparison.OrdinalIgnoreCase))
Comment on lines +818 to +819
Copy link
Member

@stephentoub stephentoub May 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't need to allocate the string just to do this comparison. Presumably exact match is common and we'd want to delay the creation of lang until after this if block? This can instead be:

ReadOnlySpan<char> lang = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(language);
if (lang.Equals(cultureInfo.Name, StringComparison.OrdinalIgnoreCase)

Then below if we need lang for the GetCultureInfo call, that can just be CultureInfo.GetCultureInfo(lang.ToString());.

{
return new string(fileMuiPath);
}

// Check for match of any parent of the language returned to the desired culture.
var ci = CultureInfo.GetCultureInfo(lang);
while (ci.Parent.Name != string.Empty)
{
if (ci.Parent.Name.Equals(cultureInfo.Name, StringComparison.OrdinalIgnoreCase))
{
return new string(fileMuiPath);
}

ci = ci.Parent;
}

// Not found yet. Continue with next iteration.
}
}

/// <summary>
/// Helper function for retrieving a localized string resource via MUI.
/// The function expects a string in the form: "@resource.dll, -123"
Expand All @@ -746,13 +844,16 @@ private static bool TryCompareTimeZoneInformationToRegistry(in TIME_ZONE_INFORMA
/// If a localized resource file exists, we LoadString resource ID "123" and
/// return it to our caller.
/// </summary>
private static string TryGetLocalizedNameByMuiNativeResource(string resource)
private static string GetLocalizedNameByMuiNativeResource(string resource, CultureInfo? cultureInfo = null)
{
if (string.IsNullOrEmpty(resource))
{
return string.Empty;
}

// Use the current UI culture when culture not specified
cultureInfo ??= CultureInfo.CurrentUICulture;

// parse "@tzres.dll, -100"
//
// filePath = "C:\Windows\System32\tzres.dll"
Expand Down Expand Up @@ -782,34 +883,23 @@ private static string TryGetLocalizedNameByMuiNativeResource(string resource)
return string.Empty;
}

if (!int.TryParse(resources[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int resourceId))
// Get the MUI File Path
string fileMuiPath = GetCachedFileMuiPath(filePath, cultureInfo);
if (fileMuiPath == string.Empty)
{
// not likely, but we could not resolve a MUI path
return string.Empty;
}
resourceId = -resourceId;

try
{
unsafe
{
char* fileMuiPath = stackalloc char[Interop.Kernel32.MAX_PATH];
int fileMuiPathLength = Interop.Kernel32.MAX_PATH;
int languageLength = 0;
long enumerator = 0;

bool succeeded = Interop.Kernel32.GetFileMUIPath(
Interop.Kernel32.MUI_PREFERRED_UI_LANGUAGES,
filePath, null /* language */, ref languageLength,
fileMuiPath, ref fileMuiPathLength, ref enumerator);
return succeeded ?
TryGetLocalizedNameByNativeResource(new string(fileMuiPath, 0, fileMuiPathLength), resourceId) :
string.Empty;
}
}
catch (EntryPointNotFoundException)
// Get the resource ID
if (!int.TryParse(resources[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int resourceId))
{
return string.Empty;
}
resourceId = -resourceId;

// Finally, get the resource from the resource path
return GetLocalizedNameByNativeResource(fileMuiPath, resourceId);
}

/// <summary>
Expand All @@ -819,7 +909,7 @@ private static string TryGetLocalizedNameByMuiNativeResource(string resource)
/// "resource.dll" is a language-specific resource DLL.
/// If the localized resource DLL exists, LoadString(resource) is returned.
/// </summary>
private static unsafe string TryGetLocalizedNameByNativeResource(string filePath, int resource)
private static unsafe string GetLocalizedNameByNativeResource(string filePath, int resource)
{
IntPtr handle = IntPtr.Zero;
try
Expand Down Expand Up @@ -869,17 +959,17 @@ private static void GetLocalizedNamesByRegistryKey(RegistryKey key, out string?
// try to load the strings from the native resource DLL(s)
if (!string.IsNullOrEmpty(displayNameMuiResource))
{
displayName = TryGetLocalizedNameByMuiNativeResource(displayNameMuiResource);
displayName = GetLocalizedNameByMuiNativeResource(displayNameMuiResource);
}

if (!string.IsNullOrEmpty(standardNameMuiResource))
{
standardName = TryGetLocalizedNameByMuiNativeResource(standardNameMuiResource);
standardName = GetLocalizedNameByMuiNativeResource(standardNameMuiResource);
}

if (!string.IsNullOrEmpty(daylightNameMuiResource))
{
daylightName = TryGetLocalizedNameByMuiNativeResource(daylightNameMuiResource);
daylightName = GetLocalizedNameByMuiNativeResource(daylightNameMuiResource);
}

// fallback to using the standard registry keys
Expand All @@ -901,7 +991,7 @@ private static void GetLocalizedNamesByRegistryKey(RegistryKey key, out string?
/// Helper function that takes a string representing a time_zone_name registry key name
/// and returns a TimeZoneInfo instance.
/// </summary>
private static TimeZoneInfoResult TryGetTimeZoneFromLocalMachine(string id, out TimeZoneInfo? value, out Exception? e)
private static TimeZoneInfoResult GetTimeZoneFromLocalMachine(string id, out TimeZoneInfo? value, out Exception? e)
{
e = null;

Expand Down Expand Up @@ -1003,7 +1093,7 @@ private static string GetUtcStandardDisplayName()
// try to load the string from the native resource DLL(s)
if (!string.IsNullOrEmpty(standardNameMuiResource))
{
standardDisplayName = TryGetLocalizedNameByMuiNativeResource(standardNameMuiResource);
standardDisplayName = GetLocalizedNameByMuiNativeResource(standardNameMuiResource);
}

// fallback to using the standard registry key
Expand Down
70 changes: 70 additions & 0 deletions src/libraries/System.Runtime/tests/System/TimeZoneInfoTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Runtime.Serialization.Formatters.Binary;
using System.Text.RegularExpressions;
using Microsoft.DotNet.RemoteExecutor;
Expand Down Expand Up @@ -2720,7 +2721,30 @@ public static void TestNameWithInvariantCulture()
Assert.True(pacific.DisplayName.IndexOf("Pacific", StringComparison.OrdinalIgnoreCase) >= 0, $"'{pacific.DisplayName}' is not the expected display name for Pacific time zone");

}).Dispose();
}

private static bool CanTestWindowsNlsDisplayNames => s_isWindows && RemoteExecutor.IsSupported && WindowsUILanguageHelper.GetInstalledWin32CulturesWithUniqueLanguages().Length > 1;
mattjohnsonpint marked this conversation as resolved.
Show resolved Hide resolved
mattjohnsonpint marked this conversation as resolved.
Show resolved Hide resolved

[PlatformSpecific(TestPlatforms.Windows)]
[ConditionalFact(nameof(CanTestWindowsNlsDisplayNames))]
public static void TestWindowsNlsDisplayNames()
{
RemoteExecutor.Invoke(() =>
{
var cultures = WindowsUILanguageHelper.GetInstalledWin32CulturesWithUniqueLanguages();
mattjohnsonpint marked this conversation as resolved.
Show resolved Hide resolved

CultureInfo.CurrentUICulture = cultures[0];
TimeZoneInfo.ClearCachedData();
TimeZoneInfo tz1 = TimeZoneInfo.FindSystemTimeZoneById(s_strPacific);

CultureInfo.CurrentUICulture = cultures[1];
TimeZoneInfo.ClearCachedData();
TimeZoneInfo tz2 = TimeZoneInfo.FindSystemTimeZoneById(s_strPacific);

Assert.True(tz1.StandardName != tz2.DisplayName, $"The display name '{tz1.DisplayName}' should be different between {cultures[0].Name} and {cultures[1].Name}.");
mattjohnsonpint marked this conversation as resolved.
Show resolved Hide resolved
mattjohnsonpint marked this conversation as resolved.
Show resolved Hide resolved
Assert.True(tz1.StandardName != tz2.StandardName, $"The standard name '{tz1.StandardName}' should be different between {cultures[0].Name} and {cultures[1].Name}.");
Assert.True(tz1.StandardName != tz2.DaylightName, $"The daylight name '{tz1.DaylightName}' should be different between {cultures[0].Name} and {cultures[1].Name}.");
mattjohnsonpint marked this conversation as resolved.
Show resolved Hide resolved
}).Dispose();
}

[Theory]
Expand Down Expand Up @@ -2983,5 +3007,51 @@ private static void VerifyCustomTimeZoneException<TException>(string id, TimeSpa
}
});
}

// This helper class is used to retrieve information about installed OS languages from Windows.
// It should only be using in Windows platform-specific tests.
private static class WindowsUILanguageHelper
{
public static CultureInfo[] GetInstalledWin32CulturesWithUniqueLanguages() =>
GetInstalledWin32Cultures()
mattjohnsonpint marked this conversation as resolved.
Show resolved Hide resolved
.GroupBy(c => c.TwoLetterISOLanguageName)
.Select(g => g.First())
.ToArray();

public static CultureInfo[] GetInstalledWin32Cultures()
{
var context = new EnumContext();
EnumUILanguagesProc proc = EnumUiLanguagesCallback;

EnumUILanguages(proc, MUI_ALL_INSTALLED_LANGUAGES | MUI_LANGUAGE_NAME, context);
GC.KeepAlive(proc);
return context.InstalledCultures.ToArray();
}

private static bool EnumUiLanguagesCallback(IntPtr lpUiLanguageString, EnumContext lParam)
{
var cultureName = Marshal.PtrToStringUni(lpUiLanguageString);
if (cultureName != null)
{
lParam.InstalledCultures.Add(CultureInfo.GetCultureInfo(cultureName));
return true;
}

return false;
}

private const uint MUI_LANGUAGE_NAME = 0x8;
private const uint MUI_ALL_INSTALLED_LANGUAGES = 0x20;

[DllImport("Kernel32.dll", CharSet = CharSet.Auto)]
private static extern bool EnumUILanguages(EnumUILanguagesProc lpUILanguageEnumProc, uint dwFlags, EnumContext lParam);
mattjohnsonpint marked this conversation as resolved.
Show resolved Hide resolved

private delegate bool EnumUILanguagesProc(IntPtr lpUILanguageString, EnumContext lParam);

private class EnumContext
{
public readonly List<CultureInfo> InstalledCultures = new();
}
}
}
}