Skip to content

Commit 8a7f5cb

Browse files
committed
#18: Localize all strings in all preferred locales, not just the most-preferred locale. This is necessary because installing a language pack doesn't always install all MUI files, which leads to Windows falling back to strings from the original OS installation language.
1 parent 4d13e40 commit 8a7f5cb

File tree

3 files changed

+236
-224
lines changed

3 files changed

+236
-224
lines changed
Lines changed: 50 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,51 @@
1-
<!--EXTERNAL_PROPERTIES: GITHUB_ACTIONS-->
2-
<Project Sdk="Microsoft.NET.Sdk">
3-
4-
<PropertyGroup>
5-
<OutputType>WinExe</OutputType>
6-
<TargetFramework>net8.0-windows</TargetFramework>
7-
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
8-
<ImplicitUsings>enable</ImplicitUsings>
9-
<Nullable>enable</Nullable>
10-
<Version>0.2.1</Version>
11-
<Authors>Ben Hutchison</Authors>
12-
<Copyright>© 2024 $(Authors)</Copyright>
13-
<Company>$(Authors)</Company>
14-
<RollForward>latestMajor</RollForward>
15-
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
16-
<ApplicationManifest>app.manifest</ApplicationManifest>
17-
<ApplicationIcon>YubiKey.ico</ApplicationIcon>
18-
<NeutralLanguage>en</NeutralLanguage>
19-
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
20-
<DebugType>embedded</DebugType>
21-
</PropertyGroup>
22-
23-
<ItemGroup>
24-
<Content Include="YubiKey.ico" />
25-
</ItemGroup>
26-
27-
<ItemGroup>
28-
<PackageReference Include="McMaster.Extensions.CommandLineUtils" Version="4.1.1" />
29-
<PackageReference Include="mwinapi" Version="0.3.0.5" />
30-
<PackageReference Include="NLog" Version="5.4.0" />
31-
<PackageReference Include="System.Management" Version="9.0.4" />
32-
<PackageReference Include="ThrottleDebounce" Version="2.0.1" />
33-
<PackageReference Include="Workshell.PE.Resources" Version="4.0.0.147" />
34-
</ItemGroup>
35-
36-
<ItemGroup>
37-
<FrameworkReference Include="Microsoft.WindowsDesktop.App" /> <!-- UseWindowsForms is insufficient to refer to UIAutomationClient -->
38-
</ItemGroup>
39-
40-
<ItemGroup>
41-
<Compile Update="Resources\Strings.Designer.cs" DesignTime="True" AutoGen="True" DependentUpon="Strings.resx" />
42-
<EmbeddedResource Update="Resources\Strings.resx" Generator="ResXFileCodeGenerator" LastGenOutput="Strings.Designer.cs" />
43-
</ItemGroup>
44-
45-
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true' or '$(Configuration)' == 'Release'">
46-
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
47-
</PropertyGroup>
48-
1+
<!--EXTERNAL_PROPERTIES: GITHUB_ACTIONS-->
2+
<Project Sdk="Microsoft.NET.Sdk">
3+
4+
<PropertyGroup>
5+
<OutputType>WinExe</OutputType>
6+
<TargetFramework>net8.0-windows</TargetFramework>
7+
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
8+
<ImplicitUsings>enable</ImplicitUsings>
9+
<Nullable>enable</Nullable>
10+
<Version>0.3.0</Version>
11+
<Authors>Ben Hutchison</Authors>
12+
<Copyright>© 2025 $(Authors)</Copyright>
13+
<Company>$(Authors)</Company>
14+
<RollForward>latestMajor</RollForward>
15+
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
16+
<ApplicationManifest>app.manifest</ApplicationManifest>
17+
<ApplicationIcon>YubiKey.ico</ApplicationIcon>
18+
<NeutralLanguage>en</NeutralLanguage>
19+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
20+
<DebugType>embedded</DebugType>
21+
<LangVersion>latest</LangVersion>
22+
<SelfContained>false</SelfContained>
23+
</PropertyGroup>
24+
25+
<ItemGroup>
26+
<Content Include="YubiKey.ico" />
27+
</ItemGroup>
28+
29+
<ItemGroup>
30+
<PackageReference Include="McMaster.Extensions.CommandLineUtils" Version="4.1.1" />
31+
<PackageReference Include="mwinapi" Version="0.3.0.5" />
32+
<PackageReference Include="NLog" Version="5.4.0" />
33+
<PackageReference Include="System.Management" Version="9.0.4" />
34+
<PackageReference Include="ThrottleDebounce" Version="2.0.1" />
35+
<PackageReference Include="Workshell.PE.Resources" Version="4.0.0.147" />
36+
</ItemGroup>
37+
38+
<ItemGroup>
39+
<FrameworkReference Include="Microsoft.WindowsDesktop.App" /> <!-- UseWindowsForms is insufficient to refer to UIAutomationClient -->
40+
</ItemGroup>
41+
42+
<ItemGroup>
43+
<Compile Update="Resources\Strings.Designer.cs" DesignTime="True" AutoGen="True" DependentUpon="Strings.resx" />
44+
<EmbeddedResource Update="Resources\Strings.resx" Generator="ResXFileCodeGenerator" LastGenOutput="Strings.Designer.cs" />
45+
</ItemGroup>
46+
47+
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true' or '$(Configuration)' == 'Release'">
48+
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
49+
</PropertyGroup>
50+
4951
</Project>

AuthenticatorChooser/I18N.cs

Lines changed: 55 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -19,78 +19,88 @@ public enum Key {
1919

2020
}
2121

22-
private const uint MUI_LANGUAGE_NAME = 8;
22+
public static readonly IReadOnlyList<string> LOCALE_NAMES = ((List<string>) [CultureInfo.CurrentCulture.Name, CultureInfo.CurrentUICulture.Name]).Concat(getCurrentSystemLocaleNames()).Distinct()
23+
.ToList();
2324

24-
public static string userLocaleName { get; } = CultureInfo.CurrentUICulture.Name;
25-
public static string systemLocaleName { get; } = getCurrentSystemLocaleName();
26-
private static CultureInfo systemCulture { get; } = CultureInfo.GetCultureInfo(systemLocaleName);
27-
28-
private static readonly FrozenDictionary<Key, IList<string>> STRINGS;
29-
private static readonly StringComparer STRING_COMPARER = StringComparer.CurrentCulture;
25+
private static readonly FrozenDictionary<Key, IList<string>> STRINGS;
26+
private static readonly StringComparer STRING_COMPARER = StringComparer.CurrentCulture;
27+
private static readonly IDictionary<string, PortableExecutableImage?> DLL_CACHE = new Dictionary<string, PortableExecutableImage?>();
28+
private static readonly IDictionary<(string, int), StringTable?> STRING_TABLE_CACHE = new Dictionary<(string, int), StringTable?>();
3029

3130
static I18N() {
3231
StringTableResource.Register();
33-
3432
string systemRoot = Environment.GetEnvironmentVariable("SystemRoot") ?? "C:\\Windows";
35-
// #2: CredentialUIBroker.exe runs as the current user
36-
IList<string?> fidoCredProvStrings = getPeFileStrings(Path.Combine(systemRoot, "System32", userLocaleName, "fidocredprov.dll.mui"), [
37-
(15, 230), // Security key
38-
(15, 231), // Smartphone; also appears in webauthn.dll.mui string table 4 entries 50 and 56
39-
(15, 232) // Windows
40-
]);
41-
42-
// #2: CryptSvc runs as NETWORK SERVICE
43-
IList<string?> webauthnStrings = getPeFileStrings(Path.Combine(systemRoot, "System32", systemLocaleName, "webauthn.dll.mui"), [
44-
(4, 53) // Sign In With Your Passkey title; entry 63 has the same value, not sure which one is used
45-
]);
4633

4734
STRINGS = new Dictionary<Key, IList<string>> {
48-
[Key.SECURITY_KEY] = getUniqueNonNullStrings(Strings.securityKey, fidoCredProvStrings[0]),
49-
[Key.SMARTPHONE] = getUniqueNonNullStrings(Strings.smartphone, fidoCredProvStrings[1]),
50-
[Key.WINDOWS] = getUniqueNonNullStrings(Strings.windows, fidoCredProvStrings[2]),
51-
[Key.SIGN_IN_WITH_YOUR_PASSKEY] = getUniqueNonNullStrings(Strings.ResourceManager.GetString(nameof(Strings.signInWithYourPasskey), systemCulture),
52-
webauthnStrings[0]),
35+
[Key.SECURITY_KEY] = getStrings(nameof(Strings.securityKey), fidoCredProvMuiPath, 15, 230), // Security key
36+
[Key.SMARTPHONE] = getStrings(nameof(Strings.smartphone), fidoCredProvMuiPath, 15, 231), // Smartphone; also appears in webauthn.dll.mui string table 4 entries 50 and 56
37+
[Key.WINDOWS] = getStrings(nameof(Strings.windows), fidoCredProvMuiPath, 15, 232), // Windows
38+
[Key.SIGN_IN_WITH_YOUR_PASSKEY] = getStrings(nameof(Strings.signInWithYourPasskey), webAuthnMuiPath, 4, 53) // Sign In With Your Passkey title; entry 63 has the same value
5339
}.ToFrozenDictionary();
5440

55-
static IList<string> getUniqueNonNullStrings(params string?[] strings) => strings.Compact().Distinct(STRING_COMPARER).ToList();
41+
foreach (PortableExecutableImage? dllFile in DLL_CACHE.Values) {
42+
dllFile?.Dispose();
43+
}
44+
45+
STRING_TABLE_CACHE.Clear();
46+
DLL_CACHE.Clear();
47+
48+
string fidoCredProvMuiPath(string locale) => Path.Combine(systemRoot, "System32", locale, "fidocredprov.dll.mui");
49+
string webAuthnMuiPath(string locale) => Path.Combine(systemRoot, "System32", locale, "webauthn.dll.mui");
5650
}
5751

5852
public static IEnumerable<string> getStrings(Key key) => STRINGS[key];
5953

60-
private static IList<string?> getPeFileStrings(string peFile, IList<(int stringTableId, int stringTableEntryId)> queries) {
61-
try {
62-
using PortableExecutableImage file = PortableExecutableImage.FromFile(peFile);
54+
// #18: The most-preferred language pack can be missing MUI files if it was installed after Windows, so always fall back to all other preferred languages
55+
private static IList<string> getStrings(string compiledResourceName, Func<string, string> libraryPath, int stringTableId, int stringTableEntryId) =>
56+
LOCALE_NAMES.SelectMany(locale => (List<string?>) [
57+
Strings.ResourceManager.GetString(compiledResourceName, CultureInfo.GetCultureInfo(locale)),
58+
getPeFileString(libraryPath(locale), stringTableId, stringTableEntryId)
59+
]).Compact().Distinct(STRING_COMPARER).ToList();
6360

64-
IDictionary<int, StringTable?> stringTableCache = new Dictionary<int, StringTable?>(queries.Count);
65-
ResourceType? stringTables = ResourceCollection.Get(file).FirstOrDefault(type => type.Id == ResourceType.String);
66-
IList<string?> results = new List<string?>(queries.Count);
61+
private static string? getPeFileString(string peFile, int stringTableId, int stringTableEntryId) {
62+
try {
63+
if (!STRING_TABLE_CACHE.TryGetValue((peFile, stringTableId), out StringTable? stringTable)) {
64+
if (!DLL_CACHE.TryGetValue(peFile, out PortableExecutableImage? file)) {
65+
try {
66+
file = PortableExecutableImage.FromFile(peFile);
67+
} catch (FileNotFoundException) { } catch (DirectoryNotFoundException) { }
68+
DLL_CACHE.Add(peFile, file);
69+
}
6770

68-
foreach ((int stringTableId, int stringTableEntryId) in queries) {
69-
if (!stringTableCache.TryGetValue(stringTableId, out StringTable? stringTable)) {
71+
if (file != null) {
72+
ResourceType? stringTables = ResourceCollection.Get(file).FirstOrDefault(type => type.Id == ResourceType.String);
7073
StringTableResource? stringTableResource = stringTables?.FirstOrDefault(resource => resource.Id == stringTableId) as StringTableResource;
7174
stringTable = stringTableResource?.GetTable(stringTableResource.Languages[0]); // #2: use the table's language, not always English
72-
73-
stringTableCache[stringTableId] = stringTable;
7475
}
7576

76-
results.Add(stringTable?.FirstOrDefault(entry => entry.Id == stringTableEntryId)?.Value);
77+
STRING_TABLE_CACHE.Add((peFile, stringTableId), stringTable);
7778
}
7879

79-
return results;
80-
} catch (FileNotFoundException) { } catch (DirectoryNotFoundException) { } catch (PortableExecutableImageException) { }
81-
82-
return Enumerable.Repeat<string?>(null, queries.Count).ToList();
80+
return stringTable?.FirstOrDefault(entry => entry.Id == stringTableEntryId)?.Value;
81+
} catch (PortableExecutableImageException) {
82+
return null;
83+
}
8384
}
8485

85-
private static unsafe string getCurrentSystemLocaleName() {
86-
int bufferSize = 0;
86+
private static unsafe string[] getCurrentSystemLocaleNames() {
87+
const uint MUI_LANGUAGE_NAME = 8;
88+
int bufferSize = 0;
8789
getSystemPreferredUILanguages(MUI_LANGUAGE_NAME, out _, null, ref bufferSize);
8890
char[] buffer = new char[bufferSize];
91+
uint languageCount;
8992
fixed (char* bufferStart = &buffer[0]) {
90-
getSystemPreferredUILanguages(MUI_LANGUAGE_NAME, out _, bufferStart, ref bufferSize);
93+
getSystemPreferredUILanguages(MUI_LANGUAGE_NAME, out languageCount, bufferStart, ref bufferSize);
94+
}
95+
var resultsBuffer = new ReadOnlySpan<char>(buffer, 0, bufferSize);
96+
// #18: Get all preferred languages, not just the first one, in case the most-preferred language pack is missing MUI files
97+
var resultsSplit = new Range[languageCount];
98+
resultsBuffer.Trim('\0').Split(resultsSplit, '\0'); // ReadOnlySpan.Split will leave delimiters intact if the destination span length is 1, which sucks, so trim early
99+
string[] results = new string[languageCount];
100+
for (int i = 0; i < languageCount; i++) {
101+
results[i] = resultsBuffer[resultsSplit[i]].ToString();
91102
}
92-
var results = new ReadOnlySpan<char>(buffer, 0, bufferSize);
93-
return new string(results[..results.IndexOf('\0')]); // only return the first language name, even if buffer contains more than one (null-delimited)
103+
return results;
94104
}
95105

96106
/// <summary>

0 commit comments

Comments
 (0)