Skip to content

Commit

Permalink
Allow TimeZoneInfo display names to use any of the installed Windows …
Browse files Browse the repository at this point in the history
…languages (dotnet#52992)
  • Loading branch information
mattjohnsonpint authored May 21, 2021
1 parent 08c801d commit b54dbaf
Show file tree
Hide file tree
Showing 5 changed files with 203 additions and 31 deletions.
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public sealed partial class TimeZoneInfo
"Zulu"
};

private static readonly TimeZoneInfo s_utcTimeZone = CreateUtcTimeZone();

private TimeZoneInfo(byte[] data, string id, bool dstDisabled)
{
_id = id;
Expand Down
149 changes: 122 additions & 27 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,9 @@ 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();
private static readonly TimeZoneInfo s_utcTimeZone = CreateUtcTimeZone();

private sealed partial class CachedData
{
private static TimeZoneInfo GetCurrentOneYearLocal()
Expand Down Expand Up @@ -736,6 +739,106 @@ 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 + 1];
char* language = stackalloc char[Interop.Kernel32.LOCALE_NAME_MAX_LENGTH + 1];
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)
{
fileMuiPath[Interop.Kernel32.MAX_PATH] = '\0';
return new string(fileMuiPath);
}

// Shouldn't get here, as there's always at least one language installed.
return string.Empty;
}

// Lookup succeeded. Check for exact match to the desired culture.
language[Interop.Kernel32.LOCALE_NAME_MAX_LENGTH] = '\0';
var lang = new string(language);
if (string.Equals(lang, cultureInfo.Name, StringComparison.OrdinalIgnoreCase))
{
fileMuiPath[Interop.Kernel32.MAX_PATH] = '\0';
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))
{
fileMuiPath[Interop.Kernel32.MAX_PATH] = '\0';
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 +849,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 +888,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 +914,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 +964,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 Down Expand Up @@ -1003,7 +1098,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
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,6 @@ private enum TimeZoneInfoResult
private const string UtcId = "UTC";
private const string LocalId = "Local";

private static readonly TimeZoneInfo s_utcTimeZone = CreateUtcTimeZone();

private static CachedData s_cachedData = new CachedData();

//
Expand Down
76 changes: 76 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,31 @@ 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 readonly CultureInfo[] s_CulturesForWindowsNlsDisplayNamesTest = WindowsUILanguageHelper.GetInstalledWin32CulturesWithUniqueLanguages();
private static bool CanTestWindowsNlsDisplayNames => RemoteExecutor.IsSupported && s_CulturesForWindowsNlsDisplayNamesTest.Length > 1;

[PlatformSpecific(TestPlatforms.Windows)]
[ConditionalFact(nameof(CanTestWindowsNlsDisplayNames))]
public static void TestWindowsNlsDisplayNames()
{
RemoteExecutor.Invoke(() =>
{
CultureInfo[] cultures = s_CulturesForWindowsNlsDisplayNamesTest;
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.DisplayName != tz2.DisplayName, $"The display name '{tz1.DisplayName}' should be different between {cultures[0].Name} and {cultures[1].Name}.");
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.DaylightName != tz2.DaylightName, $"The daylight name '{tz1.DaylightName}' should be different between {cultures[0].Name} and {cultures[1].Name}.");
}).Dispose();
}

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

// This helper class is used to retrieve information about installed OS languages from Windows.
// Its methods returns empty when run on non-Windows platforms.
private static class WindowsUILanguageHelper
{
public static CultureInfo[] GetInstalledWin32CulturesWithUniqueLanguages() =>
GetInstalledWin32Cultures()
.GroupBy(c => c.TwoLetterISOLanguageName)
.Select(g => g.First())
.ToArray();

public static CultureInfo[] GetInstalledWin32Cultures()
{
if (!OperatingSystem.IsWindows())
{
return new CultureInfo[0];
}

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);

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

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

0 comments on commit b54dbaf

Please sign in to comment.