Skip to content

Commit 340610d

Browse files
Add safe localization support
Adds English/Chinese localization infrastructure with en-US as default, zh-CN as optional language, safe fallback behavior, settings integration, localized notifications, and expanded test coverage.
1 parent 7a22c52 commit 340610d

17 files changed

Lines changed: 2018 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: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
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+
this.ApplyLanguageDictionary(appResources, targetUri);
89+
this.activeLocaleUri = targetUri;
90+
this.logger.LogInformation("Applied display language {Language}", normalizedLanguage);
91+
this.LanguageChanged?.Invoke(this, normalizedLanguage);
92+
}
93+
catch (Exception ex)
94+
{
95+
this.logger.LogError(ex, "Failed to apply language {Language}", normalizedLanguage);
96+
}
97+
}
98+
99+
public string GetString(string key)
100+
{
101+
if (string.IsNullOrWhiteSpace(key))
102+
{
103+
return string.Empty;
104+
}
105+
106+
if (this.TryGetStringFromOverrides(this.CurrentLanguage, key, out var localized))
107+
{
108+
return localized;
109+
}
110+
111+
if (this.TryGetStringFromApplicationResources(key, out localized))
112+
{
113+
return localized;
114+
}
115+
116+
if (this.activeLocaleDictionary != null && TryGetString(this.activeLocaleDictionary, key, out localized))
117+
{
118+
return localized;
119+
}
120+
121+
if (this.CurrentLanguage != DefaultLanguage &&
122+
this.TryGetStringFromOverrides(DefaultLanguage, key, out localized))
123+
{
124+
return localized;
125+
}
126+
127+
if (this.CurrentLanguage != DefaultLanguage &&
128+
this.TryGetStringFromEnglishFallbackDictionary(key, out localized))
129+
{
130+
return localized;
131+
}
132+
133+
return key;
134+
}
135+
136+
private void ApplyLanguageDictionary(ResourceDictionary appResources, Uri targetUri)
137+
{
138+
ResourceDictionary? matchingDictionary = null;
139+
for (var i = appResources.MergedDictionaries.Count - 1; i >= 0; i--)
140+
{
141+
var dictionary = appResources.MergedDictionaries[i];
142+
var source = dictionary.Source?.OriginalString;
143+
if (IsLocaleDictionary(source))
144+
{
145+
if (matchingDictionary == null &&
146+
string.Equals(source, targetUri.OriginalString, StringComparison.OrdinalIgnoreCase))
147+
{
148+
matchingDictionary = dictionary;
149+
continue;
150+
}
151+
152+
appResources.MergedDictionaries.RemoveAt(i);
153+
}
154+
}
155+
156+
if (matchingDictionary != null)
157+
{
158+
appResources.MergedDictionaries.Remove(matchingDictionary);
159+
appResources.MergedDictionaries.Insert(0, matchingDictionary);
160+
this.activeLocaleDictionary = matchingDictionary;
161+
}
162+
else
163+
{
164+
var nextDictionary = new ResourceDictionary { Source = targetUri };
165+
appResources.MergedDictionaries.Insert(0, nextDictionary);
166+
this.activeLocaleDictionary = nextDictionary;
167+
}
168+
}
169+
170+
private static string GetDictionaryPath(string language)
171+
{
172+
return language == SimplifiedChineseLanguage ? ZhCnDictionaryPath : EnUsDictionaryPath;
173+
}
174+
175+
private static bool IsLocaleDictionary(string? source)
176+
{
177+
return !string.IsNullOrWhiteSpace(source) &&
178+
(source.EndsWith(EnUsDictionaryPath, StringComparison.OrdinalIgnoreCase) ||
179+
source.EndsWith(ZhCnDictionaryPath, StringComparison.OrdinalIgnoreCase));
180+
}
181+
182+
private static bool TryGetString(ResourceDictionary dictionary, string key, out string value)
183+
{
184+
if (dictionary.Contains(key) && dictionary[key] is string text && !string.IsNullOrEmpty(text))
185+
{
186+
value = text;
187+
return true;
188+
}
189+
190+
value = string.Empty;
191+
return false;
192+
}
193+
194+
private bool TryGetStringFromOverrides(string language, string key, out string value)
195+
{
196+
var source = language == SimplifiedChineseLanguage ? this.chineseStrings : this.englishStrings;
197+
if (source != null && source.TryGetValue(key, out var text) && !string.IsNullOrEmpty(text))
198+
{
199+
value = text;
200+
return true;
201+
}
202+
203+
value = string.Empty;
204+
return false;
205+
}
206+
207+
private bool TryGetStringFromApplicationResources(string key, out string value)
208+
{
209+
value = string.Empty;
210+
var app = System.Windows.Application.Current;
211+
if (app == null)
212+
{
213+
return false;
214+
}
215+
216+
try
217+
{
218+
if (app.Dispatcher.CheckAccess())
219+
{
220+
return TryGetApplicationResourceValue(app, key, out value);
221+
}
222+
223+
var found = false;
224+
var dispatcherValue = string.Empty;
225+
app.Dispatcher.Invoke(() =>
226+
{
227+
found = TryGetApplicationResourceValue(app, key, out dispatcherValue);
228+
});
229+
value = dispatcherValue;
230+
return found;
231+
}
232+
catch (Exception ex)
233+
{
234+
this.logger.LogDebug(ex, "Failed to read localized resource {Key}", key);
235+
return false;
236+
}
237+
}
238+
239+
private static bool TryGetApplicationResourceValue(System.Windows.Application app, string key, out string value)
240+
{
241+
if (app.Resources.Contains(key) && app.Resources[key] is string text && !string.IsNullOrEmpty(text))
242+
{
243+
value = text;
244+
return true;
245+
}
246+
247+
value = string.Empty;
248+
return false;
249+
}
250+
251+
private bool TryGetStringFromEnglishFallbackDictionary(string key, out string value)
252+
{
253+
value = string.Empty;
254+
try
255+
{
256+
this.englishFallbackDictionary ??= new ResourceDictionary
257+
{
258+
Source = new Uri(EnUsDictionaryPath, UriKind.Relative),
259+
};
260+
return TryGetString(this.englishFallbackDictionary, key, out value);
261+
}
262+
catch (Exception ex)
263+
{
264+
this.logger.LogDebug(ex, "Failed to load English fallback localization dictionary");
265+
return false;
266+
}
267+
}
268+
}
269+
}

0 commit comments

Comments
 (0)