Skip to content

Commit 02ef1dc

Browse files
committed
Add safe localization support
1 parent 7a22c52 commit 02ef1dc

16 files changed

Lines changed: 1715 additions & 33 deletions

App.xaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<ResourceDictionary.MergedDictionaries>
1111
<ui:ThemesDictionary Theme="Dark"/>
1212
<ui:ControlsDictionary/>
13+
<ResourceDictionary Source="Locales/en-US.xaml"/>
1314
<ResourceDictionary Source="Themes/FluentDark.xaml"/>
1415
</ResourceDictionary.MergedDictionaries>
1516
<helpers:BytesToMbConverter x:Key="BytesToMbConverter"/>

App.xaml.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,10 +220,12 @@ protected override void OnStartup(StartupEventArgs e)
220220
{
221221
var settingsService = this.ServiceProvider.GetRequiredService<IApplicationSettingsService>();
222222
var themeService = this.ServiceProvider.GetRequiredService<IThemeService>();
223+
var localizationService = this.ServiceProvider.GetRequiredService<ILocalizationService>();
223224

224225
Task.Run(async () => await settingsService.LoadSettingsAsync()).GetAwaiter().GetResult();
225226
var settings = settingsService.Settings;
226227
loadedSettings = settings;
228+
localizationService.ApplyLanguage(settings.Language);
227229
effectiveStartMinimized = startMinimized || settings.StartMinimized;
228230
var useDarkTheme = settings.HasUserThemePreference
229231
? settings.UseDarkTheme

Locales/en-US.xaml

Lines changed: 529 additions & 0 deletions
Large diffs are not rendered by default.

Locales/zh-CN.xaml

Lines changed: 529 additions & 0 deletions
Large diffs are not rendered by default.

Models/ApplicationSettingsModel.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ public partial class ApplicationSettingsModel : ObservableObject, IModel
151151
[ObservableProperty]
152152
private bool hasUserThemePreference = false;
153153

154+
[ObservableProperty]
155+
private string language = LocalizationService.DefaultLanguage;
156+
154157
// Monitoring Settings
155158
[ObservableProperty]
156159
private int pollingIntervalMs = 5000;
@@ -249,6 +252,7 @@ public void CopyFrom(ApplicationSettingsModel other)
249252
this.ClearMasksOnClose = other.ClearMasksOnClose;
250253
this.UseDarkTheme = other.UseDarkTheme;
251254
this.HasUserThemePreference = other.HasUserThemePreference;
255+
this.Language = LocalizationService.NormalizeLanguage(other.Language);
252256

253257
// Monitoring Settings
254258
this.PollingIntervalMs = other.PollingIntervalMs;

Services/ApplicationSettingsService.cs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -246,16 +246,18 @@ public void ValidateAndFixSettings()
246246
this.settings.MaxNotificationHistoryItems = 1000;
247247
}
248248

249-
// Validate custom icon path
250-
if (this.settings.UseCustomTrayIcon && !string.IsNullOrEmpty(this.settings.CustomTrayIconPath))
251-
{
252-
if (!File.Exists(this.settings.CustomTrayIconPath))
253-
{
249+
// Validate custom icon path
250+
if (this.settings.UseCustomTrayIcon && !string.IsNullOrEmpty(this.settings.CustomTrayIconPath))
251+
{
252+
if (!File.Exists(this.settings.CustomTrayIconPath))
253+
{
254254
this.logger.LogWarning("Custom tray icon file not found: {Path}", this.settings.CustomTrayIconPath);
255-
this.settings.UseCustomTrayIcon = false;
256-
}
257-
}
258-
}
255+
this.settings.UseCustomTrayIcon = false;
256+
}
257+
}
258+
259+
this.settings.Language = LocalizationService.NormalizeLanguage(this.settings.Language);
260+
}
259261

260262
public async Task ExportSettingsAsync(string filePath)
261263
{

Services/ILocalizationService.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* ThreadPilot - Advanced Windows Process and Power Plan Manager
3+
* Copyright (C) 2025 Prime Build
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, version 3 only.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU Affero General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU Affero General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
namespace ThreadPilot.Services
18+
{
19+
/// <summary>
20+
/// Service for managing application localization and display language.
21+
/// </summary>
22+
public interface ILocalizationService
23+
{
24+
/// <summary>
25+
/// Gets the current display language.
26+
/// </summary>
27+
string CurrentLanguage { get; }
28+
29+
/// <summary>
30+
/// Event fired when the active language changes.
31+
/// </summary>
32+
event EventHandler<string>? LanguageChanged;
33+
34+
/// <summary>
35+
/// Applies the specified display language.
36+
/// </summary>
37+
void ApplyLanguage(string? language);
38+
39+
/// <summary>
40+
/// Gets the localized string for the specified key.
41+
/// </summary>
42+
string GetString(string key);
43+
}
44+
}

Services/LocalizationService.cs

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
/*
2+
* ThreadPilot - Advanced Windows Process and Power Plan Manager
3+
* Copyright (C) 2025 Prime Build
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, version 3 only.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU Affero General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU Affero General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
namespace ThreadPilot.Services
18+
{
19+
using System;
20+
using System.Collections.Generic;
21+
using System.Windows;
22+
using Microsoft.Extensions.Logging;
23+
24+
/// <summary>
25+
/// Service for managing application localization and display language.
26+
/// </summary>
27+
public class LocalizationService : ILocalizationService
28+
{
29+
public const string DefaultLanguage = "en-US";
30+
public const string SimplifiedChineseLanguage = "zh-CN";
31+
32+
private const string EnUsDictionaryPath = "Locales/en-US.xaml";
33+
private const string ZhCnDictionaryPath = "Locales/zh-CN.xaml";
34+
35+
private readonly ILogger<LocalizationService> logger;
36+
private readonly IReadOnlyDictionary<string, string>? englishStrings;
37+
private readonly IReadOnlyDictionary<string, string>? chineseStrings;
38+
private ResourceDictionary? activeLocaleDictionary;
39+
private ResourceDictionary? englishFallbackDictionary;
40+
private Uri? activeLocaleUri;
41+
42+
public string CurrentLanguage { get; private set; } = DefaultLanguage;
43+
44+
public event EventHandler<string>? LanguageChanged;
45+
46+
public LocalizationService(ILogger<LocalizationService> logger)
47+
: this(logger, englishStrings: null, chineseStrings: null)
48+
{
49+
}
50+
51+
public LocalizationService(
52+
ILogger<LocalizationService> logger,
53+
IReadOnlyDictionary<string, string>? englishStrings,
54+
IReadOnlyDictionary<string, string>? chineseStrings)
55+
{
56+
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
57+
this.englishStrings = englishStrings;
58+
this.chineseStrings = chineseStrings;
59+
}
60+
61+
public static string NormalizeLanguage(string? language)
62+
{
63+
if (string.Equals(language, SimplifiedChineseLanguage, StringComparison.OrdinalIgnoreCase))
64+
{
65+
return SimplifiedChineseLanguage;
66+
}
67+
68+
return DefaultLanguage;
69+
}
70+
71+
public void ApplyLanguage(string? language)
72+
{
73+
var normalizedLanguage = NormalizeLanguage(language);
74+
var targetUri = new Uri(GetDictionaryPath(normalizedLanguage), UriKind.Relative);
75+
76+
this.CurrentLanguage = normalizedLanguage;
77+
78+
var appResources = System.Windows.Application.Current?.Resources;
79+
if (appResources == null)
80+
{
81+
this.activeLocaleUri = targetUri;
82+
this.LanguageChanged?.Invoke(this, normalizedLanguage);
83+
return;
84+
}
85+
86+
try
87+
{
88+
ResourceDictionary? matchingDictionary = null;
89+
for (var i = appResources.MergedDictionaries.Count - 1; i >= 0; i--)
90+
{
91+
var dictionary = appResources.MergedDictionaries[i];
92+
var source = dictionary.Source?.OriginalString;
93+
if (IsLocaleDictionary(source))
94+
{
95+
if (matchingDictionary == null &&
96+
string.Equals(source, targetUri.OriginalString, StringComparison.OrdinalIgnoreCase))
97+
{
98+
matchingDictionary = dictionary;
99+
continue;
100+
}
101+
102+
appResources.MergedDictionaries.RemoveAt(i);
103+
}
104+
}
105+
106+
if (matchingDictionary != null)
107+
{
108+
appResources.MergedDictionaries.Remove(matchingDictionary);
109+
appResources.MergedDictionaries.Insert(0, matchingDictionary);
110+
this.activeLocaleDictionary = matchingDictionary;
111+
}
112+
else
113+
{
114+
var nextDictionary = new ResourceDictionary { Source = targetUri };
115+
appResources.MergedDictionaries.Insert(0, nextDictionary);
116+
this.activeLocaleDictionary = nextDictionary;
117+
}
118+
119+
this.activeLocaleUri = targetUri;
120+
this.logger.LogInformation("Applied display language {Language}", normalizedLanguage);
121+
this.LanguageChanged?.Invoke(this, normalizedLanguage);
122+
}
123+
catch (Exception ex)
124+
{
125+
this.logger.LogError(ex, "Failed to apply language {Language}", normalizedLanguage);
126+
}
127+
}
128+
129+
public string GetString(string key)
130+
{
131+
if (string.IsNullOrWhiteSpace(key))
132+
{
133+
return string.Empty;
134+
}
135+
136+
if (this.TryGetStringFromOverrides(this.CurrentLanguage, key, out var localized))
137+
{
138+
return localized;
139+
}
140+
141+
if (this.TryGetStringFromApplicationResources(key, out localized))
142+
{
143+
return localized;
144+
}
145+
146+
if (this.activeLocaleDictionary != null && TryGetString(this.activeLocaleDictionary, key, out localized))
147+
{
148+
return localized;
149+
}
150+
151+
if (this.CurrentLanguage != DefaultLanguage &&
152+
this.TryGetStringFromOverrides(DefaultLanguage, key, out localized))
153+
{
154+
return localized;
155+
}
156+
157+
if (this.CurrentLanguage != DefaultLanguage &&
158+
this.TryGetStringFromEnglishFallbackDictionary(key, out localized))
159+
{
160+
return localized;
161+
}
162+
163+
return key;
164+
}
165+
166+
private static string GetDictionaryPath(string language)
167+
{
168+
return language == SimplifiedChineseLanguage ? ZhCnDictionaryPath : EnUsDictionaryPath;
169+
}
170+
171+
private static bool IsLocaleDictionary(string? source)
172+
{
173+
return !string.IsNullOrWhiteSpace(source) &&
174+
(source.EndsWith(EnUsDictionaryPath, StringComparison.OrdinalIgnoreCase) ||
175+
source.EndsWith(ZhCnDictionaryPath, StringComparison.OrdinalIgnoreCase));
176+
}
177+
178+
private static bool TryGetString(ResourceDictionary dictionary, string key, out string value)
179+
{
180+
if (dictionary.Contains(key) && dictionary[key] is string text && !string.IsNullOrEmpty(text))
181+
{
182+
value = text;
183+
return true;
184+
}
185+
186+
value = string.Empty;
187+
return false;
188+
}
189+
190+
private bool TryGetStringFromOverrides(string language, string key, out string value)
191+
{
192+
var source = language == SimplifiedChineseLanguage ? this.chineseStrings : this.englishStrings;
193+
if (source != null && source.TryGetValue(key, out var text) && !string.IsNullOrEmpty(text))
194+
{
195+
value = text;
196+
return true;
197+
}
198+
199+
value = string.Empty;
200+
return false;
201+
}
202+
203+
private bool TryGetStringFromApplicationResources(string key, out string value)
204+
{
205+
value = string.Empty;
206+
var app = System.Windows.Application.Current;
207+
if (app == null)
208+
{
209+
return false;
210+
}
211+
212+
try
213+
{
214+
if (app.Dispatcher.CheckAccess())
215+
{
216+
return TryGetApplicationResourceValue(app, key, out value);
217+
}
218+
219+
var found = false;
220+
var dispatcherValue = string.Empty;
221+
app.Dispatcher.Invoke(() =>
222+
{
223+
found = TryGetApplicationResourceValue(app, key, out dispatcherValue);
224+
});
225+
value = dispatcherValue;
226+
return found;
227+
}
228+
catch (Exception ex)
229+
{
230+
this.logger.LogDebug(ex, "Failed to read localized resource {Key}", key);
231+
return false;
232+
}
233+
}
234+
235+
private static bool TryGetApplicationResourceValue(System.Windows.Application app, string key, out string value)
236+
{
237+
if (app.Resources.Contains(key) && app.Resources[key] is string text && !string.IsNullOrEmpty(text))
238+
{
239+
value = text;
240+
return true;
241+
}
242+
243+
value = string.Empty;
244+
return false;
245+
}
246+
247+
private bool TryGetStringFromEnglishFallbackDictionary(string key, out string value)
248+
{
249+
value = string.Empty;
250+
try
251+
{
252+
this.englishFallbackDictionary ??= new ResourceDictionary
253+
{
254+
Source = new Uri(EnUsDictionaryPath, UriKind.Relative),
255+
};
256+
return TryGetString(this.englishFallbackDictionary, key, out value);
257+
}
258+
catch (Exception ex)
259+
{
260+
this.logger.LogDebug(ex, "Failed to load English fallback localization dictionary");
261+
return false;
262+
}
263+
}
264+
}
265+
}

0 commit comments

Comments
 (0)