diff --git a/App.xaml b/App.xaml
index 592dae1..58f3c0d 100644
--- a/App.xaml
+++ b/App.xaml
@@ -10,6 +10,7 @@
+
diff --git a/App.xaml.cs b/App.xaml.cs
index 9aa438f..4916d2a 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -220,10 +220,12 @@ protected override void OnStartup(StartupEventArgs e)
{
var settingsService = this.ServiceProvider.GetRequiredService();
var themeService = this.ServiceProvider.GetRequiredService();
+ var localizationService = this.ServiceProvider.GetRequiredService();
Task.Run(async () => await settingsService.LoadSettingsAsync()).GetAwaiter().GetResult();
var settings = settingsService.Settings;
loadedSettings = settings;
+ localizationService.ApplyLanguage(settings.Language);
effectiveStartMinimized = startMinimized || settings.StartMinimized;
var useDarkTheme = settings.HasUserThemePreference
? settings.UseDarkTheme
diff --git a/Locales/en-US.xaml b/Locales/en-US.xaml
new file mode 100644
index 0000000..41967ab
--- /dev/null
+++ b/Locales/en-US.xaml
@@ -0,0 +1,529 @@
+
+
+
+ ThreadPilot - Process & Power Plan Manager
+ Administrator Privileges Recommended
+ ThreadPilot is running with limited privileges. Some features may not be available.
+ Administrator access is required for:
+ • Modifying power plan configurations
+ • Changing process CPU affinity and priority
+ • Applying system-level optimizations and tweaks
+ • Managing protected processes
+ Continue Without Elevation
+ Request Elevation
+
+
+ Process Management
+ CPU Masks
+ Power Plans
+ Rules
+ Diagnostics
+ ThreadPilot Activity
+ Tweaks
+ Settings
+
+
+ Startup minimized
+ Enable Startup minimized when you want ThreadPilot to start silently in the tray on future Windows sign-ins.
+ Recommended
+ Don't show again
+ Open Settings
+
+
+ ThreadPilot Activity
+ Shows actions performed by ThreadPilot, including applied rules, affinity changes, priority changes, power plan changes, settings changes, tweaks, optimizations, and safe failure messages.
+ Search:
+ Category:
+ Level:
+ From:
+ To:
+ Refresh
+ Clear Display
+ Export Activity
+ Cleanup Diagnostic Files
+ Open Diagnostic Folder
+
+
+ Time
+ Status
+ Category
+ Message
+ Details
+ ID
+ Copy Entry
+ Refresh
+ No log entries to display
+ Adjust filters or refresh logs to load recent ThreadPilot activity.
+
+
+ Activity Details
+ Timestamp:
+ Status:
+ Category:
+ Message:
+ Details:
+ Correlation ID:
+ No log entry selected
+ Files:
+ Size:
+ Debug Diagnostics
+ Max Size (MB):
+ Retention (Days):
+ Save Settings
+
+
+ CPU Masks
+ Create reusable CPU affinity presets for per-process use.
+ Selecting a mask here only edits the preset. It does not change CPU affinity until you apply it to a process or save it in a process rule.
+ New
+ Delete
+ Duplicate Mask
+ Mask Information
+ Name:
+ Description:
+ Enabled
+ Default preset
+ Pre-selected when ThreadPilot needs a fallback mask or when creating per-process rules. It does not apply CPU affinity automatically.
+ Disable to temporarily hide this mask
+ Select CPUs
+ Toggle CPUs to edit this mask preset. Changes are saved automatically and do not affect running processes. All Cores is the protected default preset.
+ Mask names, descriptions, CPU selections, and options are saved automatically.
+ Create a new CPU mask
+ Delete selected mask
+ CPUs
+ CPU
+
+
+ Optional Diagnostics
+ Diagnostics are optional and intended for troubleshooting. For in-game overlays and detailed performance graphs, use dedicated tools.
+ Quick tips:
+ 1. Open diagnostics only when you need a focused troubleshooting snapshot.
+ 2. Review hotspots only as a hint before creating automation rules.
+ 3. Stop live metrics when you are done troubleshooting.
+ Continue to Diagnostics
+
+ Active Processes
+ Top CPU and memory consumers with one-click rule creation.
+ Search processes by name
+ Rule-backed only
+ Actionable only
+ Rule Impact
+ Create/Update Rule from Selection
+ Create or update an automation rule from the selected hotspot process
+
+ Stability Timeline
+ Clear History
+ Clear the displayed metrics and timeline history
+ Core Snapshot
+ Per-core utilization for quick imbalance checks.
+ Toggle Core Panel
+ Toggle Process Panel
+ Start Live Metrics
+ Start live metrics collection for this dashboard
+ Stop Live Metrics
+ Pause live metrics collection; background automation is separate
+ Refresh
+ Refresh the current dashboard snapshot
+
+ Global Power Plan
+ Memory Used
+ CPU %
+ Mem %
+ Threads
+ Events
+ Severity
+ Detail
+ Time
+ Category
+ Process
+ Selected hotspot process
+ Metrics
+ Processes
+
+
+ Power Plans
+ Manage the active Windows power plan and import custom .pow profiles
+ Windows Power Plans
+ Select the Windows plan to make active.
+ Set as Active Windows Plan
+ Change the active Windows power plan to the selected plan
+ Refresh Plans
+ Refresh the list of available power plans
+ Custom Power Plans
+ Local .pow files ready to import into Windows.
+ Import Selected .pow
+ Import the selected custom .pow file into Windows
+ Add .pow File
+ Add a new custom power plan file to the custom library
+ Delete
+ Active
+
+
+ Rules & Automation
+ Automation monitoring watches process start/stop events and applies configured power plan, CPU mask, and priority rules.
+ Rule Editor
+ Create a rule from an executable or running process to automate power plan, CPU mask, and priority behavior.
+ Executable Match:
+ Match by Path
+ Match processes by full path instead of just executable name
+ Browse for Executable
+ Click to select an executable file (.exe) from your computer
+ Choose an executable. Match by path is more precise; matching by name applies to any process with that executable name.
+ Use Running Process:
+ Select a currently running process to use its executable
+ Use Selected Process
+ Use the executable from the selected running process
+ Rule Power Plan (global switch):
+ When this rule matches, Windows switches the global active power plan.
+ Rule CPU Mask:
+ Optional: Select a CPU mask to apply when this process starts
+ Rule Priority
+ Optional: Select the process priority to apply when this process starts
+ Association Priority:
+ Higher priority associations take precedence when multiple match
+ Add Rule
+ Update Selected Rule
+ Update the selected association
+ Clear Selection
+ Clear the selected executable
+
+ Current Rules
+ Define per-process rules. Power plans are global; masks and priorities apply to matching processes.
+ Remove
+ Remove the selected association
+ Default Power Plan (fallback when no rule matches)
+ Power plan to use when no associated processes are running:
+ Set Default Power Plan
+
+ Automation Monitoring Settings
+ Enable Event-Based Automation (WMI)
+ Enable Automation Fallback Polling
+ Polling Interval (seconds):
+ Power Plan Change Delay (ms):
+ Prevent Duplicate Power Plan Changes
+ Save Configuration
+ No automation rules yet
+ Status:
+ Start Automation Monitoring
+ Stop Automation Monitoring
+
+ Applied to each matching process when the automation rule runs.
+ Applied only to matching processes; it does not change the global Windows power plan.
+ Selected Executable:
+ Description:
+ Automation Service
+ Rules
+ Executable
+ Power Plan
+ CPU Mask
+ Priority
+ Description
+ Process Priority:
+
+
+ Process Management
+ Search, filter, and control active process configurations
+ Search processes by name
+ Hide Windows system processes
+ Hide System Processes
+ Hide processes with very low CPU usage
+ Hide Idle Processes
+ Show only applications with visible windows
+ Active Apps Only
+ Lock process list
+ Pause process list refresh and sorting updates while you work with the current list. Background monitoring and saved-rule auto-apply continue while ThreadPilot is running.
+ Sort by:
+ Choose how to sort the process list
+
+ Selected Process
+ No process selected
+ Select a process from the table to enable affinity, priority, power plan, and rule actions.
+ Power Plan / Pending Settings
+ Choose the power plan to activate manually or include with quick apply
+ Set Power Plan
+ Activate the selected Windows power plan
+
+ Pending core mask
+ Select a mask to stage it. This does not change OS affinity until Apply Affinity is clicked.
+ Apply Affinity
+ Apply the pending core mask to the selected process and verify the Windows affinity state
+ Apply Affinity and Save as Rule
+ Save current process settings as a rule. These settings will be automatically applied when this process starts in the future.
+
+ Set CPU Priority
+ Apply the selected priority to the process
+ Enforce Priority by Registry
+ Apply priority through Windows registry. Process must be restarted for changes to take effect. This setting persists across system reboots.
+ Set Priority
+ Realtime priority is blocked by ThreadPilot because it can make Windows unstable or unresponsive.
+ Realtime (blocked)
+
+ Disable Idle Server
+ Prevents the system from entering idle state while this process is running. Useful for games and performance-critical applications.
+
+ Profile / Rule
+ Enter a profile name to save or load settings
+ Save Profile
+ Save current CPU affinity and priority as a reusable profile
+ Load Profile
+ Load a previously saved profile
+ Save Current Settings as Rule
+
+ Last ThreadPilot action:
+ Refresh
+ Refresh the process list
+ Load More
+ Load more processes
+
+ Process Name
+ Window Title
+ CPU Usage
+ Memory Usage
+
+ Affinity staged in the UI. It is not applied until Apply Affinity is clicked.
+ Affinity currently reported by Windows for the selected process
+ Read-only preview of current or pending CPU selection
+ Open CPU Masks tab to create a new custom mask
+ Shows whether Hyper-Threading (Intel) or SMT (AMD) is present and active on this system
+
+ Copy Process Info
+ Open Executable Location
+ Clear CPU Sets
+ Apply Pending Settings
+ Apply the pending affinity and selected power plan to the selected process
+ Rules and changes are applied by ThreadPilot only when configured.
+
+ Above Normal
+ Below Normal
+ High
+ Idle
+ Low
+ Normal
+ Realtime
+ Batch
+ Custom
+
+ Has Window
+ Window
+ PID
+ ID
+ Memory (MB)
+ Affinity
+ Priority
+ Rules
+ Name
+ Set Memory Priority
+ Very Low
+ Medium
+ Refresh Process Info
+
+
+ Application Settings
+ Configure notifications, system tray, and application behavior
+ Notifications
+ Enable notifications
+ Notification Level
+ All
+ Warnings/Errors only
+ Silent
+ Enable balloon notifications (system tray)
+ Enable toast notifications (Windows 10+)
+ Notification Types
+ Power plan changes
+ Process monitoring events
+ Error notifications
+ Success notifications
+ Duration (ms):
+ Test Notification
+
+ System Tray
+ Show tray icon
+ Minimize to tray
+ Close to tray (instead of exit)
+ Start minimized
+ Enable pending settings apply from tray
+ Enable automation monitoring controls from tray
+ Show detailed tooltips
+
+ Basic Preferences
+ Autostart
+ Start with Windows
+ Automatically start ThreadPilot when Windows starts
+ Low impact mode in background
+ Lower ThreadPilot's process priority while minimized or hidden to tray.
+
+ Appearance
+ Use dark theme
+ Applies a global Dark/Light theme across all tabs
+
+ Language
+ 简体中文 (Simplified Chinese)
+ English (English)
+ Applies a global display language across the entire application interface.
+
+ Rules & automation
+ Saved rule auto-apply
+ Apply saved rules when matching processes start
+ Applies enabled saved rules while ThreadPilot is running. This does not install a Windows Service and does not use registry/IFEO persistence.
+ Enable WMI process events
+ Use Windows Management Instrumentation for process start/stop events
+ Enable fallback polling
+ Use polling as backup when WMI process events fail
+ Polling interval (ms):
+ Fallback polling (ms):
+
+ Advanced
+ Enable notification history
+ Max history items:
+ Auto-hide notifications
+ Enable notification sound
+ Enable debug logging
+ Enable performance counters
+
+ About
+ Version:
+ Copyright © 2026 PrimeBuild
+ Translator: Ylimhs
+ License: AGPLv3
+ Check for updates
+ Checks on demand using the official GitHub Releases.
+
+ Reset to Defaults
+ Export Configuration
+ Import Configuration
+ Save Settings
+ ThreadPilot
+
+
+ ThreadPilot Settings
+ Unsaved Settings
+ You have unsaved changes. Save before closing, discard the changes, or cancel to keep editing.
+ Cancel
+ Discard
+ Save
+
+
+ System Tweaks & Optimizations
+ Configure Windows system optimizations and performance tweaks
+ Refresh All
+ Refresh all tweak states
+ Toggle this Windows tweak
+ Status
+
+
+ Core Parking
+ Controls CPU core parking for power management
+ C-States
+ Controls CPU C-States for power management
+ SysMain Service
+ Windows Superfetch/SysMain service for memory management
+ Prefetch
+ Windows Prefetch feature for faster application loading
+ Power Throttling
+ Windows Power Throttling for energy efficiency
+ HPET
+ High Precision Event Timer for system timing
+ High Scheduling Category
+ High scheduling priority for gaming applications
+ Menu Show Delay
+ Delay before showing context menus
+
+
+ Ready
+ Refreshing system tweaks...
+ Last refreshed: {0}
+ Failed to refresh system tweaks
+ Refresh failed
+ Toggling {0}...
+ {0} enabled successfully
+ {0} disabled successfully
+ {0} has been enabled
+ {0} has been disabled
+ System Tweak Updated
+ Failed to toggle {0}
+ Failed to enable {0}
+ Failed to disable {0}
+ System Tweak Failed
+ Failed to {0} {1}
+ Error toggling {0}
+ Error toggling {0}: {1}
+ enable
+ disable
+ enabled
+ disabled
+ Enabled
+ Disabled
+ Not Available
+ Loading system tweaks...
+ System tweaks loaded successfully
+
+
+ Open Dashboard
+ Open Diagnostics
+ Pause Automation Monitoring
+ Resume Automation Monitoring
+ System Status
+ No process selected
+ Selected: {0}
+ Apply Pending Settings to Selected Process
+ Apply Pending Settings to {0}
+ 🔋 Power Plans
+ 📋 Profiles
+ Settings
+ Exit
+ No profiles available
+ Power Plan: {0}
+ Automation WMI Error
+ Automation Active
+ Automation Disabled
+ Unknown
+ 💻 CPU: {0:F1}% | RAM: {1:F1}% | {2}
+
+
+ Windows denied this change. The process may require administrator rights or may be protected.
+ The process appears protected by anti-cheat or process protection. ThreadPilot will not try to bypass it.
+ Administrator mode may help with normal permission issues, but cannot bypass anti-cheat or protected process restrictions.
+ This CPU selection cannot be safely represented by legacy affinity APIs on this topology. CPU Sets are required for this selection.
+ This CPU selection does not match the current CPU topology. Review or recreate the preset.
+ The process exited before ThreadPilot could apply the change.
+ Windows CPU Sets are unavailable or rejected this selection. ThreadPilot will use a safe fallback only when possible.
+ High priority can improve responsiveness for some workloads but may reduce system responsiveness.
+ Realtime priority is blocked by ThreadPilot because it can make Windows unstable or unresponsive.
+ Persistent launch-time priority may be supported for normal processes, but it does not bypass protected process or anti-cheat restrictions.
+ Applies saved rules when a matching process starts. Some protected or anti-cheat processes may reject changes. Administrator mode can help with normal permission issues but cannot bypass protection.
+ The process appears protected by anti-cheat or process protection. ThreadPilot will not try to override it.
+
+
+ Power Plan Changed
+ Power plan changed from '{0}' to '{1}'
+ Power plan changed to '{0}' for process '{1}'
+ Process Monitoring Enabled
+ Process Monitoring Disabled
+ CPU Affinity Applied
+ CPU affinity set for '{0}': {1}
+ Game Boost Activated
+ Game Boost mode activated for {0}
+ Game Boost Deactivated
+ Game Boost mode deactivated after {0}
+ Process Monitor Error
+ Affinity blocked
+ Priority blocked
+
+
+ ThreadPilot requires administrator privileges to manage process affinity and power plans.
Would you like to restart the application with administrator privileges?
+ Administrator Privileges Required
+ Failed to restart with administrator privileges. Please manually run ThreadPilot as administrator.
+ Elevation Failed
+ Running with Administrator privileges
+ Running with limited privileges
+
+
+ Could not query Core Parking value via powercfg
+ Could not query C-States value via powercfg
+ Prefetch registry key not found
+ Power Throttling not available on this system
+ PriorityControl registry key not found
+ Desktop registry key not found
+
diff --git a/Locales/zh-CN.xaml b/Locales/zh-CN.xaml
new file mode 100644
index 0000000..3a9ce20
--- /dev/null
+++ b/Locales/zh-CN.xaml
@@ -0,0 +1,529 @@
+
+
+
+ ThreadPilot - 进程与电源计划管理器
+ 推荐以管理员权限运行
+ ThreadPilot 当前正以受限权限运行。某些功能可能无法使用。
+ 需要管理员访问权限以执行以下操作:
+ • 修改电源计划配置
+ • 更改进程 CPU 关联性和优先级
+ • 应用系统级优化和调整
+ • 管理受保护的进程
+ 不提升权限继续
+ 请求权限提升
+
+
+ 进程管理
+ CPU 掩码
+ 电源计划
+ 自动化规则
+ 性能诊断
+ 活动日志
+ 系统微调
+ 应用设置
+
+
+ 启动时最小化
+ 开启此项后,ThreadPilot 会在 Windows 登录时静默启动至系统托盘。
+ 推荐
+ 不再显示
+ 打开设置
+
+
+ ThreadPilot 活动日志
+ 显示 ThreadPilot 执行的操作,包括已应用规则、关联性更改、优先级更改、电源计划更改、设置更改、调整、优化以及安全故障消息。
+ 搜索:
+ 类别:
+ 级别:
+ 从:
+ 至:
+ 刷新
+ 清除显示
+ 导出活动
+ 清理诊断文件
+ 打开诊断文件夹
+
+
+ 时间
+ 状态
+ 类别
+ 消息
+ 详情
+ ID
+ 复制条目
+ 刷新
+ 没有要显示的日志条目
+ 调整过滤器或刷新日志以加载最近的 ThreadPilot 活动。
+
+
+ 活动详情
+ 时间戳:
+ 状态:
+ 类别:
+ 消息:
+ 详情:
+ 相关性 ID:
+ 未选择日志条目
+ 文件:
+ 大小:
+ 调试诊断
+ 最大大小 (MB):
+ 保留天数 (天):
+ 保存设置
+
+
+ CPU 掩码
+ 创建可复用的 CPU 关联性预设以供每个进程使用。
+ 在此处选择掩码仅编辑预设。在将其应用于进程或保存在进程规则中之前,它不会更改 CPU 关联性。
+ 新建
+ 删除
+ 复制掩码
+ 掩码信息
+ 名称:
+ 描述:
+ 已启用
+ 默认预设
+ 当 ThreadPilot 需要后备掩码或创建针对每个进程的规则时预先选择。它不会自动应用 CPU 关联性。
+ 禁用以暂时隐藏此掩码
+ 选择 CPU 核心
+ 切换 CPU 以编辑此掩码预设。更改将自动保存,且不会影响正在运行的进程。“所有核心”是受保护的默认预设。
+ 掩码名称、描述、CPU 选择和选项会自动保存。
+ 创建新的 CPU 掩码
+ 删除选定的掩码
+ CPU 核心
+ CPU
+
+
+ 可选性能诊断
+ 诊断是可选的,仅用于故障排除。对于游戏内覆盖层和详细的性能图表,请使用专用工具。
+ 快速提示:
+ 1. 仅在需要针对性的故障排除快照时才打开诊断。
+ 2. 在创建自动化规则之前,仅将热点查看作为提示。
+ 3. 故障排除完成后,停止实时指标收集。
+ 继续前往诊断
+
+ 活动进程
+ 通过一键规则创建来监控 CPU 和内存的占用大户。
+ 按名称搜索进程
+ 仅限包含规则的
+ 仅限可操作的
+ 规则影响
+ 基于选择创建/更新规则
+ 为选定的热点进程创建或更新自动化规则
+
+ 稳定性时间线
+ 清除历史记录
+ 清除显示的指标和时间轴历史记录
+ 核心快照
+ 单核利用率,用于快速的不平衡检查。
+ 切换核心面板
+ 切换进程面板
+ 开始实时指标
+ 为此控制面板启动实时指标收集
+ 停止实时指标
+ 暂停实时指标收集;后台自动化会单独运行
+ 刷新
+ 刷新当前的仪表板快照
+
+ 全局电源计划
+ 已用内存
+ CPU %
+ 内存 %
+ 线程数
+ 事件
+ 严重性
+ 详情
+ 时间
+ 类别
+ 进程
+ 选定的热点进程
+ 指标
+ 进程
+
+
+ 电源计划管理
+ 管理活动的 Windows 电源计划并导入自定义 .pow 配置文件
+ Windows 电源计划
+ 选择要激活的 Windows 电源计划。
+ 设为活动 Windows 电源计划
+ 将活动的 Windows 电源计划更改为所选计划
+ 刷新计划
+ 刷新可用电源计划的列表
+ 自定义电源计划
+ 已准备好导入 Windows 的本地 .pow 文件。
+ 导入选中的 .pow
+ 将所选的自定义 .pow 文件导入 Windows
+ 添加 .pow 文件
+ 向自定义库添加新的自定义电源计划文件
+ 删除
+ 活动
+
+
+ 规则与自动化
+ 自动化监控监视进程的启动/停止事件,并应用配置的电源计划、CPU 掩码和优先级规则。
+ 规则编辑器
+ 从可执行文件或运行中的进程创建规则,以实现电源计划、CPU 掩码和优先级行为的自动化。
+ 可执行文件匹配:
+ 按路径匹配
+ 按完整路径而不是仅按可执行文件名称匹配进程
+ 浏览可执行文件
+ 点击以从您的计算机选择可执行文件 (.exe)
+ 选择可执行文件。按路径匹配更精确;按名称匹配适用于任何具有该可执行文件名称的进程。
+ 使用运行中的进程:
+ 选择当前运行的进程以使用其可执行文件
+ 使用所选进程
+ 使用所选运行中进程的可执行文件
+ 规则电源计划 (全局切换):
+ 当此规则匹配时,Windows 将切换全局活动电源计划。
+ 规则 CPU 掩码:
+ 可选: 选择在该进程启动时要应用的 CPU 掩码
+ 规则优先级
+ 可选: 选择在该进程启动时要应用的进程优先级
+ 关联优先级:
+ 当有多个匹配时,优先级较高的关联优先使用
+ 添加规则
+ 更新选定规则
+ 更新所选的关联
+ 清除选择
+ 清除选定的可执行文件
+
+ 当前规则
+ 定义针对每个进程的规则。电源计划是全局的;掩码和优先级适用于匹配的进程。
+ 删除
+ 删除所选关联
+ 默认电源计划 (当无规则匹配时的后备方案)
+ 当没有关联的进程在运行时要使用的电源计划:
+ 设置默认电源计划
+
+ 自动化监控设置
+ 启用基于事件的自动化 (WMI)
+ 启用自动化后备轮询
+ 轮询间隔 (秒):
+ 电源计划切换延迟 (毫秒):
+ 防止重复更改电源计划
+ 保存配置
+ 尚无自动化规则
+ 状态:
+ 启动自动化监控
+ 停止自动化监控
+
+ 在运行自动化规则时应用于每个匹配的进程。
+ 仅应用于匹配的进程;它不会更改全局 Windows 电源计划。
+ 选定的可执行文件:
+ 描述:
+ 自动化服务
+ 规则
+ 可执行文件
+ 电源计划
+ CPU 掩码
+ 优先级
+ 描述
+ 进程优先级:
+
+
+ 系统进程管理
+ 搜索、过滤和控制活动的进程配置
+ 按名称搜索进程
+ 隐藏 Windows 系统进程
+ 隐藏系统进程
+ 隐藏 CPU 使用率极低的进程
+ 隐藏空闲进程
+ 仅显示具有可见窗口的应用程序
+ 仅限活动应用
+ 锁定进程列表
+ 在您处理当前列表时暂停进程列表刷新和排序更新。ThreadPilot 运行时,后台监控和保存的规则自动应用仍将继续。
+ 排序方式:
+ 选择如何对进程列表进行排序
+
+ 选定的进程
+ 未选择进程
+ 从表格中选择一个进程以启用关联性、优先级、电源计划和规则操作。
+ 电源计划 / 待处理设置
+ 选择要手动激活的电源计划,或包含在快速应用中
+ 设置电源计划
+ 激活所选的 Windows 电源计划
+
+ 待处理的核心掩码
+ 选择一个掩码来暂存它。在点击“应用关联性”之前,这不会更改操作系统关联性。
+ 应用关联性
+ 将待处理的核心掩码应用于选定的进程,并验证 Windows 关联性状态
+ 应用关联性并保存为规则
+ 将当前进程设置保存为规则。以后此进程启动时将自动应用这些设置。
+
+ 设置 CPU 优先级
+ 将所选优先级应用于进程
+ 通过注册表强制执行优先级
+ 通过 Windows 注册表应用优先级。必须重新启动进程才能使更改生效。此设置在系统重启后仍然有效。
+ 设置优先级
+ 实时优先级已被 ThreadPilot 阻止,因为它可能会使 Windows 不稳定或无响应。
+ 实时 (已阻止)
+
+ 禁用空闲服务
+ 在此进程运行时阻止系统进入空闲状态。适用于游戏和对性能要求严苛的应用程序。
+
+ 配置文件 / 规则
+ 输入配置文件名称以保存或加载设置
+ 保存配置文件
+ 将当前 CPU 关联性和优先级保存为可复用的配置文件
+ 加载配置文件
+ 加载以前保存的配置文件
+ 保存当前设置为规则
+
+ 最后一次 ThreadPilot 操作:
+ 刷新
+ 刷新进程列表
+ 加载更多
+ 加载更多进程
+
+ 进程名称
+ 窗口标题
+ CPU 使用率
+ 内存使用
+
+ 关联性已暂存在 UI 中。在点击“应用关联性”之前不会应用。
+ Windows 当前报告的所选进程的关联性
+ 当前或待处理 CPU 选择的只读预览
+ 打开 CPU 掩码选项卡以创建新的自定义掩码
+ 显示此系统上是否具有并启用了超线程 (Intel) 或 SMT (AMD)
+
+ 复制进程信息
+ 打开可执行文件位置
+ 清除 CPU 集
+ 应用待处理设置
+ 将待处理的关联性和选定的电源计划应用于选定的进程
+ ThreadPilot 仅在配置后才应用规则和更改。
+
+ 高于正常
+ 低于正常
+ 高
+ 空闲
+ 低
+ 正常
+ 实时
+ 批处理
+ 自定义
+
+ 有窗口
+ 窗口
+ PID
+ ID
+ 内存 (MB)
+ 关联性
+ 优先级
+ 规则
+ 名称
+ 设置内存优先级
+ 极低
+ 中
+ 刷新进程信息
+
+
+ ThreadPilot 系统设置
+ 配置通知、系统托盘和应用程序行为
+ 系统通知
+ 启用应用通知
+ 通知级别
+ 全部显示
+ 仅警告与错误
+ 静音模式
+ 启用系统托盘气泡通知
+ 启用 Windows 吐司通知 (Windows 10+)
+ 通知事件类别
+ 电源计划变更
+ 进程监控事件
+ 错误与异常通知
+ 操作成功提示
+ 持续显示时间 (毫秒):
+ 测试通知发送
+
+ 系统托盘栏
+ 显示托盘图标
+ 最小化到托盘
+ 关闭主窗口时最小化到托盘
+ 开机启动时最小化
+ 允许从托盘菜单应用待处理设置
+ 允许从托盘菜单控制自动化监控
+ 托盘图标显示详细鼠标提示
+
+ 基本偏好设置
+ 系统开机启动
+ 随 Windows 开机启动
+ 在 Windows 系统启动时自动开启 ThreadPilot
+ 后台低系统影响模式
+ 在最小化或隐藏至托盘时自动降低 ThreadPilot 的进程优先级。
+
+ 外观主题
+ 启用暗黑主题
+ 在所有选项卡上全局应用 亮色/暗色 视觉风格
+
+ 显示语言
+ 简体中文 (Simplified Chinese)
+ English (English)
+ 在整个应用程序界面应用全局的语言设置。
+
+ 规则与自动化
+ 保存规则自动应用
+ 在匹配的进程启动时自动应用已保存规则
+ 在 ThreadPilot 运行时自动应用已启用的保存规则。这不会注册 Windows 服务,也不使用注册表/IFEO 进行常驻。
+ 启用 WMI 进程启动监控
+ 使用 Windows Management Instrumentation 来监听进程启动/退出事件
+ 启用后备轮询监控
+ 如果 WMI 进程事件订阅失败,自动启用定时轮询作为备份
+ 轮询间隔时间 (毫秒):
+ 后备轮询间隔时间 (毫秒):
+
+ 高级与调试设置
+ 启用通知历史记录
+ 最大历史条目数:
+ 自动渐隐通知气泡
+ 播放提示音
+ 开启调试诊断日志记录
+ 启用内部性能计数器
+
+ 关于应用
+ 软件版本:
+ 版权所有 © 2026 PrimeBuild
+ 翻译人员:Ylimhs
+ 软件授权: AGPLv3
+ 检查新版本
+ 按需通过官方 GitHub Releases API 检索最新稳定版本。
+
+ 重置为默认值
+ 导出配置包
+ 导入配置包
+ 保存并应用设置
+ ThreadPilot
+
+
+ ThreadPilot 设置
+ 未保存的设置
+ 您有尚未保存的更改。请选择在关闭前保存、放弃修改或取消并返回编辑。
+ 取消
+ 放弃更改
+ 保存
+
+
+ 系统微调与优化
+ 配置 Windows 系统级优化项和性能微调选项
+ 刷新状态
+ 刷新所有微调选项的状态
+ 切换此 Windows 系统微调项
+ 当前状态
+
+
+ 核心休眠 (Core Parking)
+ 控制 CPU 核心休眠以进行电源管理
+ C-States 状态
+ 控制 CPU C-States 以进行节能电源管理
+ SysMain 服务
+ Windows Superfetch/SysMain 服务,用于内存管理
+ 预取 (Prefetch)
+ Windows Prefetch 功能,用于加速应用程序加载
+ 电源限流 (Power Throttling)
+ Windows 电源限流功能,用于能效优化
+ HPET 定时器
+ 高精度事件计时器,用于系统精准定时
+ 高调度优先级类别
+ 为游戏应用程序提供高调度优先级
+ 菜单显示延迟
+ 显示上下文菜单之前的延迟时间
+
+
+ 就绪
+ 正在刷新系统微调选项...
+ 上次刷新时间: {0}
+ 刷新系统微调失败
+ 刷新失败
+ 正在切换 {0}...
+ {0} 已成功启用
+ {0} 已成功禁用
+ {0} 已启用
+ {0} 已禁用
+ 系统微调已更新
+ 无法切换 {0}
+ 未能启用 {0}
+ 未能禁用 {0}
+ 系统微调失败
+ 未能{0}{1}
+ 切换 {0} 出错
+ 切换 {0} 时出错: {1}
+ 启用
+ 禁用
+ 启用
+ 禁用
+ 已启用
+ 已禁用
+ 不可用
+ 正在加载系统微调...
+ 系统微调已成功加载
+
+
+ 打开主控制面板
+ 打开性能诊断
+ 暂停自动化监控
+ 恢复自动化监控
+ 系统状态
+ 未选择进程
+ 已选择: {0}
+ 应用待处理设置到所选进程
+ 应用待处理设置到 {0}
+ 🔋 电源计划
+ 📋 配置文件
+ 应用设置
+ 退出程序
+ 无可用配置文件
+ 电源计划: {0}
+ 自动化 WMI 错误
+ 自动化监控已启用
+ 自动化监控已禁用
+ 未知
+ 💻 CPU: {0:F1}% | 内存: {1:F1}% | {2}
+
+
+ Windows 拒绝了此更改。该进程可能需要管理员权限或受保护。
+ 该进程似乎受到反作弊系统或进程保护的保护。ThreadPilot 不会尝试绕过它。
+ 管理员模式可能有助于解决普通的权限问题,但无法绕过反作弊系统或受保护的进程限制。
+ 在此拓扑上,此 CPU 选择无法由传统关联性 API 安全地表示。此选择需要使用 CPU 集。
+ 此 CPU 选择与当前 CPU 拓扑不匹配。请检查或重新创建预设。
+ 在 ThreadPilot 应用更改之前,进程已退出。
+ Windows CPU 集不可用或拒绝了此选择。ThreadPilot 将仅在可能时使用 safe 后备方案。
+ 高优先级可以提高某些工作负载的响应速度,但可能会降低系统整体响应速度。
+ 实时优先级已被 ThreadPilot 阻止,因为它可能会使 Windows 不稳定或无响应。
+ 普通进程可能支持持久的启动时优先级,但这不会绕过受保护进程或反作弊系统的限制。
+ 在匹配的进程启动时应用保存的规则。某些受保护或反作弊进程可能会拒绝更改。管理员模式有助于解决普通权限问题,但无法绕过进程保护。
+ 该进程似乎受到反作弊系统或进程保护的保护。ThreadPilot 不会尝试对其进行改写。
+
+
+ 电源计划已更改
+ 电源计划已从“{0}”更改为“{1}”
+ 已为进程“{1}”将电源计划切换为“{0}”
+ 进程监控已启用
+ 进程监控已禁用
+ CPU 关联性已应用
+ 已为“{0}”设置 CPU 关联性: {1}
+ 游戏加速已启用
+ 已为 {0} 启用游戏加速模式
+ 游戏加速已关闭
+ 游戏加速模式已关闭,持续时间: {0}
+ 进程监控器错误
+ 关联性应用被阻止
+ 优先级应用被阻止
+
+
+ ThreadPilot 需要管理员权限来管理进程关联性和电源计划。
您想以管理员权限重新启动应用程序吗?
+ 需要管理员权限
+ 无法以管理员权限重新启动。请手动以管理员身份运行 ThreadPilot。
+ 权限提升失败
+ 以管理员权限运行中
+ 以受限权限运行中
+
+
+ 无法通过 powercfg 查询核心停车值
+ 无法通过 powercfg 查询 C-States 值
+ 未找到预取注册表键
+ 此系统上不支持电源限流
+ 未找到 PriorityControl 注册表键
+ 未找到 Desktop 注册表键
+
diff --git a/Models/ApplicationSettingsModel.cs b/Models/ApplicationSettingsModel.cs
index 1875e4e..40e6c78 100644
--- a/Models/ApplicationSettingsModel.cs
+++ b/Models/ApplicationSettingsModel.cs
@@ -151,6 +151,9 @@ public partial class ApplicationSettingsModel : ObservableObject, IModel
[ObservableProperty]
private bool hasUserThemePreference = false;
+ [ObservableProperty]
+ private string language = LocalizationService.DefaultLanguage;
+
// Monitoring Settings
[ObservableProperty]
private int pollingIntervalMs = 5000;
@@ -249,6 +252,7 @@ public void CopyFrom(ApplicationSettingsModel other)
this.ClearMasksOnClose = other.ClearMasksOnClose;
this.UseDarkTheme = other.UseDarkTheme;
this.HasUserThemePreference = other.HasUserThemePreference;
+ this.Language = LocalizationService.NormalizeLanguage(other.Language);
// Monitoring Settings
this.PollingIntervalMs = other.PollingIntervalMs;
diff --git a/Services/ApplicationSettingsService.cs b/Services/ApplicationSettingsService.cs
index dc7a7f5..02d52b7 100644
--- a/Services/ApplicationSettingsService.cs
+++ b/Services/ApplicationSettingsService.cs
@@ -246,16 +246,18 @@ public void ValidateAndFixSettings()
this.settings.MaxNotificationHistoryItems = 1000;
}
- // Validate custom icon path
- if (this.settings.UseCustomTrayIcon && !string.IsNullOrEmpty(this.settings.CustomTrayIconPath))
- {
- if (!File.Exists(this.settings.CustomTrayIconPath))
- {
+ // Validate custom icon path
+ if (this.settings.UseCustomTrayIcon && !string.IsNullOrEmpty(this.settings.CustomTrayIconPath))
+ {
+ if (!File.Exists(this.settings.CustomTrayIconPath))
+ {
this.logger.LogWarning("Custom tray icon file not found: {Path}", this.settings.CustomTrayIconPath);
- this.settings.UseCustomTrayIcon = false;
- }
- }
- }
+ this.settings.UseCustomTrayIcon = false;
+ }
+ }
+
+ this.settings.Language = LocalizationService.NormalizeLanguage(this.settings.Language);
+ }
public async Task ExportSettingsAsync(string filePath)
{
diff --git a/Services/ILocalizationService.cs b/Services/ILocalizationService.cs
new file mode 100644
index 0000000..5361e75
--- /dev/null
+++ b/Services/ILocalizationService.cs
@@ -0,0 +1,44 @@
+/*
+ * ThreadPilot - Advanced Windows Process and Power Plan Manager
+ * Copyright (C) 2025 Prime Build
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, version 3 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+namespace ThreadPilot.Services
+{
+ ///
+ /// Service for managing application localization and display language.
+ ///
+ public interface ILocalizationService
+ {
+ ///
+ /// Gets the current display language.
+ ///
+ string CurrentLanguage { get; }
+
+ ///
+ /// Event fired when the active language changes.
+ ///
+ event EventHandler? LanguageChanged;
+
+ ///
+ /// Applies the specified display language.
+ ///
+ void ApplyLanguage(string? language);
+
+ ///
+ /// Gets the localized string for the specified key.
+ ///
+ string GetString(string key);
+ }
+}
diff --git a/Services/LocalizationService.cs b/Services/LocalizationService.cs
new file mode 100644
index 0000000..9e3270b
--- /dev/null
+++ b/Services/LocalizationService.cs
@@ -0,0 +1,269 @@
+/*
+ * ThreadPilot - Advanced Windows Process and Power Plan Manager
+ * Copyright (C) 2025 Prime Build
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, version 3 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Windows;
+ using Microsoft.Extensions.Logging;
+
+ ///
+ /// Service for managing application localization and display language.
+ ///
+ public class LocalizationService : ILocalizationService
+ {
+ public const string DefaultLanguage = "en-US";
+ public const string SimplifiedChineseLanguage = "zh-CN";
+
+ private const string EnUsDictionaryPath = "Locales/en-US.xaml";
+ private const string ZhCnDictionaryPath = "Locales/zh-CN.xaml";
+
+ private readonly ILogger logger;
+ private readonly IReadOnlyDictionary? englishStrings;
+ private readonly IReadOnlyDictionary? chineseStrings;
+ private ResourceDictionary? activeLocaleDictionary;
+ private ResourceDictionary? englishFallbackDictionary;
+ private Uri? activeLocaleUri;
+
+ public string CurrentLanguage { get; private set; } = DefaultLanguage;
+
+ public event EventHandler? LanguageChanged;
+
+ public LocalizationService(ILogger logger)
+ : this(logger, englishStrings: null, chineseStrings: null)
+ {
+ }
+
+ public LocalizationService(
+ ILogger logger,
+ IReadOnlyDictionary? englishStrings,
+ IReadOnlyDictionary? chineseStrings)
+ {
+ this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ this.englishStrings = englishStrings;
+ this.chineseStrings = chineseStrings;
+ }
+
+ public static string NormalizeLanguage(string? language)
+ {
+ if (string.Equals(language, SimplifiedChineseLanguage, StringComparison.OrdinalIgnoreCase))
+ {
+ return SimplifiedChineseLanguage;
+ }
+
+ return DefaultLanguage;
+ }
+
+ public void ApplyLanguage(string? language)
+ {
+ var normalizedLanguage = NormalizeLanguage(language);
+ var targetUri = new Uri(GetDictionaryPath(normalizedLanguage), UriKind.Relative);
+
+ this.CurrentLanguage = normalizedLanguage;
+
+ var appResources = System.Windows.Application.Current?.Resources;
+ if (appResources == null)
+ {
+ this.activeLocaleUri = targetUri;
+ this.LanguageChanged?.Invoke(this, normalizedLanguage);
+ return;
+ }
+
+ try
+ {
+ this.ApplyLanguageDictionary(appResources, targetUri);
+ this.activeLocaleUri = targetUri;
+ this.logger.LogInformation("Applied display language {Language}", normalizedLanguage);
+ this.LanguageChanged?.Invoke(this, normalizedLanguage);
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogError(ex, "Failed to apply language {Language}", normalizedLanguage);
+ }
+ }
+
+ public string GetString(string key)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ return string.Empty;
+ }
+
+ if (this.TryGetStringFromOverrides(this.CurrentLanguage, key, out var localized))
+ {
+ return localized;
+ }
+
+ if (this.TryGetStringFromApplicationResources(key, out localized))
+ {
+ return localized;
+ }
+
+ if (this.activeLocaleDictionary != null && TryGetString(this.activeLocaleDictionary, key, out localized))
+ {
+ return localized;
+ }
+
+ if (this.CurrentLanguage != DefaultLanguage &&
+ this.TryGetStringFromOverrides(DefaultLanguage, key, out localized))
+ {
+ return localized;
+ }
+
+ if (this.CurrentLanguage != DefaultLanguage &&
+ this.TryGetStringFromEnglishFallbackDictionary(key, out localized))
+ {
+ return localized;
+ }
+
+ return key;
+ }
+
+ private void ApplyLanguageDictionary(ResourceDictionary appResources, Uri targetUri)
+ {
+ ResourceDictionary? matchingDictionary = null;
+ for (var i = appResources.MergedDictionaries.Count - 1; i >= 0; i--)
+ {
+ var dictionary = appResources.MergedDictionaries[i];
+ var source = dictionary.Source?.OriginalString;
+ if (IsLocaleDictionary(source))
+ {
+ if (matchingDictionary == null &&
+ string.Equals(source, targetUri.OriginalString, StringComparison.OrdinalIgnoreCase))
+ {
+ matchingDictionary = dictionary;
+ continue;
+ }
+
+ appResources.MergedDictionaries.RemoveAt(i);
+ }
+ }
+
+ if (matchingDictionary != null)
+ {
+ appResources.MergedDictionaries.Remove(matchingDictionary);
+ appResources.MergedDictionaries.Insert(0, matchingDictionary);
+ this.activeLocaleDictionary = matchingDictionary;
+ }
+ else
+ {
+ var nextDictionary = new ResourceDictionary { Source = targetUri };
+ appResources.MergedDictionaries.Insert(0, nextDictionary);
+ this.activeLocaleDictionary = nextDictionary;
+ }
+ }
+
+ private static string GetDictionaryPath(string language)
+ {
+ return language == SimplifiedChineseLanguage ? ZhCnDictionaryPath : EnUsDictionaryPath;
+ }
+
+ private static bool IsLocaleDictionary(string? source)
+ {
+ return !string.IsNullOrWhiteSpace(source) &&
+ (source.EndsWith(EnUsDictionaryPath, StringComparison.OrdinalIgnoreCase) ||
+ source.EndsWith(ZhCnDictionaryPath, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static bool TryGetString(ResourceDictionary dictionary, string key, out string value)
+ {
+ if (dictionary.Contains(key) && dictionary[key] is string text && !string.IsNullOrEmpty(text))
+ {
+ value = text;
+ return true;
+ }
+
+ value = string.Empty;
+ return false;
+ }
+
+ private bool TryGetStringFromOverrides(string language, string key, out string value)
+ {
+ var source = language == SimplifiedChineseLanguage ? this.chineseStrings : this.englishStrings;
+ if (source != null && source.TryGetValue(key, out var text) && !string.IsNullOrEmpty(text))
+ {
+ value = text;
+ return true;
+ }
+
+ value = string.Empty;
+ return false;
+ }
+
+ private bool TryGetStringFromApplicationResources(string key, out string value)
+ {
+ value = string.Empty;
+ var app = System.Windows.Application.Current;
+ if (app == null)
+ {
+ return false;
+ }
+
+ try
+ {
+ if (app.Dispatcher.CheckAccess())
+ {
+ return TryGetApplicationResourceValue(app, key, out value);
+ }
+
+ var found = false;
+ var dispatcherValue = string.Empty;
+ app.Dispatcher.Invoke(() =>
+ {
+ found = TryGetApplicationResourceValue(app, key, out dispatcherValue);
+ });
+ value = dispatcherValue;
+ return found;
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogDebug(ex, "Failed to read localized resource {Key}", key);
+ return false;
+ }
+ }
+
+ private static bool TryGetApplicationResourceValue(System.Windows.Application app, string key, out string value)
+ {
+ if (app.Resources.Contains(key) && app.Resources[key] is string text && !string.IsNullOrEmpty(text))
+ {
+ value = text;
+ return true;
+ }
+
+ value = string.Empty;
+ return false;
+ }
+
+ private bool TryGetStringFromEnglishFallbackDictionary(string key, out string value)
+ {
+ value = string.Empty;
+ try
+ {
+ this.englishFallbackDictionary ??= new ResourceDictionary
+ {
+ Source = new Uri(EnUsDictionaryPath, UriKind.Relative),
+ };
+ return TryGetString(this.englishFallbackDictionary, key, out value);
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogDebug(ex, "Failed to load English fallback localization dictionary");
+ return false;
+ }
+ }
+ }
+}
diff --git a/Services/NotificationService.cs b/Services/NotificationService.cs
index 7ba56c6..3298302 100644
--- a/Services/NotificationService.cs
+++ b/Services/NotificationService.cs
@@ -35,6 +35,7 @@ public class NotificationService : INotificationService, IDisposable
private readonly ILogger logger;
private readonly IApplicationSettingsService settingsService;
private readonly ISystemTrayService systemTrayService;
+ private readonly ILocalizationService localizationService;
private readonly List notificationHistory;
private ApplicationSettingsModel settings;
private bool disposed = false;
@@ -50,11 +51,13 @@ public class NotificationService : INotificationService, IDisposable
public NotificationService(
ILogger logger,
IApplicationSettingsService settingsService,
- ISystemTrayService systemTrayService)
+ ISystemTrayService systemTrayService,
+ ILocalizationService localizationService)
{
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
this.systemTrayService = systemTrayService ?? throw new ArgumentNullException(nameof(systemTrayService));
+ this.localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService));
this.notificationHistory = new List();
this.settings = this.settingsService.Settings;
@@ -102,6 +105,9 @@ public async Task ShowNotificationAsync(NotificationModel notification)
try
{
+ notification.Title = this.TryGetLocalizedNotificationString(notification.Title);
+ notification.Message = this.TryGetLocalizedNotificationString(notification.Message);
+
// Check if notifications are enabled
if (!this.AreNotificationsEnabled(notification.Type))
{
@@ -176,11 +182,18 @@ public async Task ShowPowerPlanChangeNotificationAsync(string oldPlan, string ne
return;
}
+ var title = this.GetLocalizedString("Notification_PowerPlanChangedTitle");
var message = string.IsNullOrEmpty(processName)
- ? $"Power plan changed from '{oldPlan}' to '{newPlan}'"
- : $"Power plan changed to '{newPlan}' for process '{processName}'";
+ ? string.Format(
+ this.GetLocalizedString("Notification_PowerPlanChangedFormat"),
+ oldPlan,
+ newPlan)
+ : string.Format(
+ this.GetLocalizedString("Notification_PowerPlanChangedProcessFormat"),
+ newPlan,
+ processName);
- var notification = new NotificationModel("Power Plan Changed", message, NotificationType.PowerPlanChange)
+ var notification = new NotificationModel(title, message, NotificationType.PowerPlanChange)
{
Category = "PowerPlan",
SourceService = "PowerPlanService",
@@ -197,7 +210,9 @@ public async Task ShowProcessMonitoringNotificationAsync(string message, bool is
return;
}
- var title = isEnabled ? "Process Monitoring Enabled" : "Process Monitoring Disabled";
+ var title = isEnabled
+ ? this.GetLocalizedString("Notification_ProcessMonitoringEnabled")
+ : this.GetLocalizedString("Notification_ProcessMonitoringDisabled");
var type = isEnabled ? NotificationType.Success : NotificationType.Warning;
var notification = new NotificationModel(title, message, type)
@@ -212,9 +227,15 @@ public async Task ShowProcessMonitoringNotificationAsync(string message, bool is
public async Task ShowCpuAffinityNotificationAsync(string processName, string affinityInfo)
{
+ var title = this.GetLocalizedString("Notification_CpuAffinityAppliedTitle");
+ var message = string.Format(
+ this.GetLocalizedString("Notification_CpuAffinityAppliedFormat"),
+ processName,
+ affinityInfo);
+
var notification = new NotificationModel(
- "CPU Affinity Applied",
- $"CPU affinity set for '{processName}': {affinityInfo}",
+ title,
+ message,
NotificationType.CpuAffinity)
{
Category = "CpuAffinity",
@@ -396,6 +417,63 @@ private void OnSettingsChanged(object? sender, ApplicationSettingsChangedEventAr
this.UpdateSettings(e.NewSettings);
}
+ private string GetLocalizedString(string key)
+ {
+ var localized = this.localizationService.GetString(key);
+ return string.IsNullOrEmpty(localized) ? key : localized;
+ }
+
+ private string TryGetLocalizedNotificationString(string input)
+ {
+ if (string.IsNullOrEmpty(input))
+ {
+ return input;
+ }
+
+ var key = input switch
+ {
+ "Game Boost Activated" => "Notification_GameBoostActivatedTitle",
+ "Game Boost Deactivated" => "Notification_GameBoostDeactivatedTitle",
+ "Process Monitor Error" => "Notification_ProcessMonitorErrorTitle",
+ "Affinity blocked" => "Notification_AffinityBlockedTitle",
+ "Priority blocked" => "Notification_PriorityBlockedTitle",
+ _ => null,
+ };
+
+ if (key != null)
+ {
+ var localized = this.GetLocalizedString(key);
+ if (!string.Equals(localized, key, StringComparison.Ordinal))
+ {
+ return localized;
+ }
+ }
+
+ const string GameBoostActivatedPrefix = "Game Boost mode activated for ";
+ if (input.StartsWith(GameBoostActivatedPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ var processName = input[GameBoostActivatedPrefix.Length..];
+ var format = this.GetLocalizedString("Notification_GameBoostActivatedFormat");
+ if (!string.Equals(format, "Notification_GameBoostActivatedFormat", StringComparison.Ordinal))
+ {
+ return string.Format(format, processName);
+ }
+ }
+
+ const string GameBoostDeactivatedPrefix = "Game Boost mode deactivated after ";
+ if (input.StartsWith(GameBoostDeactivatedPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ var duration = input[GameBoostDeactivatedPrefix.Length..];
+ var format = this.GetLocalizedString("Notification_GameBoostDeactivatedFormat");
+ if (!string.Equals(format, "Notification_GameBoostDeactivatedFormat", StringComparison.Ordinal))
+ {
+ return string.Format(format, duration);
+ }
+ }
+
+ return input;
+ }
+
public void Dispose()
{
if (this.disposed)
@@ -416,4 +494,3 @@ public void Dispose()
}
}
}
-
diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs
index 1bf3426..23dc385 100644
--- a/Services/ServiceConfiguration.cs
+++ b/Services/ServiceConfiguration.cs
@@ -165,6 +165,7 @@ private static IServiceCollection ConfigureApplicationLevelServices(this IServic
// Application configuration and settings
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
// User interface services
diff --git a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs
index 1fd0f35..214ac48 100644
--- a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs
+++ b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs
@@ -13,6 +13,21 @@ public void Constructor_StartMinimizedDefaultsFalse_ForManualLaunchVisibility()
Assert.False(settings.StartMinimized);
Assert.True(settings.ApplyPersistentRulesOnProcessStart);
Assert.False(settings.HasSeenStartupMinimizedSuggestion);
+ Assert.Equal("en-US", settings.Language);
+ }
+
+ [Fact]
+ public void CopyFrom_CopiesLanguage()
+ {
+ var source = new ApplicationSettingsModel
+ {
+ Language = "zh-CN",
+ };
+ var target = new ApplicationSettingsModel();
+
+ target.CopyFrom(source);
+
+ Assert.Equal("zh-CN", target.Language);
}
[Fact]
diff --git a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs
index cd9589f..eb712c4 100644
--- a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs
+++ b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs
@@ -26,6 +26,7 @@ public async Task LoadSettingsAsync_CreatesDefaults_WhenFileIsMissing()
Assert.False(service.Settings.EnableSelfAffinityLimit);
Assert.True(service.Settings.AutostartWithWindows);
Assert.False(service.Settings.StartMinimized);
+ Assert.Equal("en-US", service.Settings.Language);
}
[Fact]
@@ -148,6 +149,42 @@ public async Task LoadSettingsAsync_PreservesStartupMinimizedSuggestionDismissal
Assert.True(service.Settings.HasSeenStartupMinimizedSuggestion);
}
+ [Fact]
+ public async Task LoadSettingsAsync_PreservesSupportedLanguage()
+ {
+ var storage = new FakeSettingsStorage();
+ storage.Files[TestPaths.SettingsFilePath] = """
+ {
+ "language": "zh-CN"
+ }
+ """;
+ var service = CreateService(storage);
+
+ await service.LoadSettingsAsync();
+
+ Assert.Equal("zh-CN", service.Settings.Language);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("fr-FR")]
+ [InlineData("zh")]
+ public async Task LoadSettingsAsync_FallsBackToEnglish_WhenLanguageIsInvalid(string language)
+ {
+ var storage = new FakeSettingsStorage();
+ storage.Files[TestPaths.SettingsFilePath] = $$"""
+ {
+ "language": "{{language}}"
+ }
+ """;
+ var service = CreateService(storage);
+
+ await service.LoadSettingsAsync();
+
+ Assert.Equal("en-US", service.Settings.Language);
+ }
+
[Fact]
public async Task ImportSettingsAsync_Throws_WhenFileIsMissing()
{
diff --git a/Tests/ThreadPilot.Core.Tests/LocalizationServiceTests.cs b/Tests/ThreadPilot.Core.Tests/LocalizationServiceTests.cs
new file mode 100644
index 0000000..4f1d0d5
--- /dev/null
+++ b/Tests/ThreadPilot.Core.Tests/LocalizationServiceTests.cs
@@ -0,0 +1,200 @@
+namespace ThreadPilot.Core.Tests
+{
+ using System.Reflection;
+ using System.Windows;
+ using Microsoft.Extensions.Logging.Abstractions;
+ using ThreadPilot.Services;
+
+ public sealed class LocalizationServiceTests
+ {
+ [Fact]
+ public void Constructor_DefaultsToEnglish()
+ {
+ var service = CreateService();
+
+ Assert.Equal("en-US", service.CurrentLanguage);
+ }
+
+ [Fact]
+ public void ApplyLanguage_AppliesChinese_WhenSupported()
+ {
+ var service = CreateService();
+
+ service.ApplyLanguage("zh-CN");
+
+ Assert.Equal("zh-CN", service.CurrentLanguage);
+ }
+
+ [Fact]
+ public void ApplyLanguage_FiresLanguageChangedWithNormalizedLanguage()
+ {
+ var service = CreateService();
+ var observedLanguages = new List();
+ service.LanguageChanged += (_, language) => observedLanguages.Add(language);
+
+ service.ApplyLanguage("zh-cn");
+ service.ApplyLanguage("unsupported");
+
+ Assert.Equal(new[] { "zh-CN", "en-US" }, observedLanguages);
+ }
+
+ [Fact]
+ public void ApplyLanguage_RemovesDuplicateAndOldLocaleDictionaries()
+ {
+ var resources = new ResourceDictionary();
+ var nonLocaleDictionary = CreateDictionaryWithSource("Themes/FluentDark.xaml");
+ var oldEnglishDictionary = CreateDictionaryWithSource("Locales/en-US.xaml");
+ var duplicateChineseDictionary = CreateDictionaryWithSource("Locales/zh-CN.xaml");
+ var matchingChineseDictionary = CreateDictionaryWithSource("Locales/zh-CN.xaml");
+ resources.MergedDictionaries.Add(nonLocaleDictionary);
+ resources.MergedDictionaries.Add(oldEnglishDictionary);
+ resources.MergedDictionaries.Add(duplicateChineseDictionary);
+ resources.MergedDictionaries.Add(matchingChineseDictionary);
+ var service = CreateService();
+
+ InvokeApplyLanguageDictionary(service, resources, new Uri("Locales/zh-CN.xaml", UriKind.Relative));
+
+ Assert.Equal(2, resources.MergedDictionaries.Count);
+ Assert.Same(matchingChineseDictionary, resources.MergedDictionaries[0]);
+ Assert.Same(nonLocaleDictionary, resources.MergedDictionaries[1]);
+ Assert.DoesNotContain(resources.MergedDictionaries, dictionary => ReferenceEquals(dictionary, oldEnglishDictionary));
+ Assert.DoesNotContain(resources.MergedDictionaries, dictionary => ReferenceEquals(dictionary, duplicateChineseDictionary));
+ }
+
+ [Fact]
+ public void GetString_UsesCurrentLanguageOverrideBeforeEnglishFallback()
+ {
+ var service = CreateService(
+ new Dictionary
+ {
+ ["Shared_Key"] = "English",
+ },
+ new Dictionary
+ {
+ ["Shared_Key"] = "Chinese",
+ });
+ service.ApplyLanguage("zh-CN");
+
+ var result = service.GetString("Shared_Key");
+
+ Assert.Equal("Chinese", result);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("fr-FR")]
+ [InlineData("zh")]
+ public void ApplyLanguage_FallsBackToEnglish_WhenLanguageIsInvalid(string? language)
+ {
+ var service = CreateService();
+ service.ApplyLanguage("zh-CN");
+
+ service.ApplyLanguage(language);
+
+ Assert.Equal("en-US", service.CurrentLanguage);
+ }
+
+ [Fact]
+ public void GetString_UsesEnglishFallback_WhenActiveLanguageMissesKey()
+ {
+ var service = CreateService(
+ new Dictionary
+ {
+ ["Shared_Key"] = "English fallback",
+ },
+ new Dictionary());
+ service.ApplyLanguage("zh-CN");
+
+ var result = service.GetString("Shared_Key");
+
+ Assert.Equal("English fallback", result);
+ }
+
+ [Fact]
+ public void GetString_ReturnsKey_WhenNoTranslationExists()
+ {
+ var service = CreateService();
+
+ var result = service.GetString("Missing_Key");
+
+ Assert.Equal("Missing_Key", result);
+ }
+
+ [Fact]
+ public void GetString_ReturnsEmpty_WhenKeyIsBlank()
+ {
+ var service = CreateService();
+
+ Assert.Equal(string.Empty, service.GetString(string.Empty));
+ Assert.Equal(string.Empty, service.GetString(" "));
+ }
+
+ [Fact]
+ public void LocaleFiles_DefineEnglishDefaultAndOptionalChineseLanguageLabels()
+ {
+ var root = FindRepositoryRoot();
+ var english = File.ReadAllText(Path.Combine(root, "Locales", "en-US.xaml"));
+ var chinese = File.ReadAllText(Path.Combine(root, "Locales", "zh-CN.xaml"));
+ var appXaml = File.ReadAllText(Path.Combine(root, "App.xaml"));
+
+ Assert.Contains("Source=\"Locales/en-US.xaml\"", appXaml, StringComparison.Ordinal);
+ Assert.DoesNotContain("Source=\"Locales/zh-CN.xaml\"", appXaml, StringComparison.Ordinal);
+ Assert.Contains("x:Key=\"SettingsView_LanguageEnUs\"", english, StringComparison.Ordinal);
+ Assert.Contains("x:Key=\"SettingsView_LanguageZhCn\"", english, StringComparison.Ordinal);
+ Assert.Contains("x:Key=\"SettingsView_LanguageEnUs\"", chinese, StringComparison.Ordinal);
+ Assert.Contains("x:Key=\"SettingsView_LanguageZhCn\"", chinese, StringComparison.Ordinal);
+ }
+
+ private static LocalizationService CreateService(
+ IReadOnlyDictionary? englishStrings = null,
+ IReadOnlyDictionary? chineseStrings = null)
+ {
+ return new LocalizationService(
+ NullLogger.Instance,
+ englishStrings,
+ chineseStrings);
+ }
+
+ private static string FindRepositoryRoot()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory != null && !File.Exists(Path.Combine(directory.FullName, "ThreadPilot_1.sln")))
+ {
+ directory = directory.Parent;
+ }
+
+ return directory?.FullName ?? throw new InvalidOperationException("Repository root was not found.");
+ }
+
+ private static ResourceDictionary CreateDictionaryWithSource(string source)
+ {
+ var dictionary = new ResourceDictionary();
+ var sourceField = typeof(ResourceDictionary).GetField("_source", BindingFlags.Instance | BindingFlags.NonPublic);
+ if (sourceField == null)
+ {
+ throw new InvalidOperationException("ResourceDictionary source field was not found.");
+ }
+
+ sourceField.SetValue(dictionary, new Uri(source, UriKind.Relative));
+ return dictionary;
+ }
+
+ private static void InvokeApplyLanguageDictionary(
+ LocalizationService service,
+ ResourceDictionary resources,
+ Uri targetUri)
+ {
+ var method = typeof(LocalizationService).GetMethod(
+ "ApplyLanguageDictionary",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ if (method == null)
+ {
+ throw new InvalidOperationException("ApplyLanguageDictionary method was not found.");
+ }
+
+ method.Invoke(service, new object[] { resources, targetUri });
+ }
+ }
+}
diff --git a/Tests/ThreadPilot.Core.Tests/NotificationServiceLocalizationTests.cs b/Tests/ThreadPilot.Core.Tests/NotificationServiceLocalizationTests.cs
new file mode 100644
index 0000000..0383448
--- /dev/null
+++ b/Tests/ThreadPilot.Core.Tests/NotificationServiceLocalizationTests.cs
@@ -0,0 +1,161 @@
+namespace ThreadPilot.Core.Tests
+{
+ using Microsoft.Extensions.Logging.Abstractions;
+ using Moq;
+ using ThreadPilot.Models;
+ using ThreadPilot.Services;
+
+ public sealed class NotificationServiceLocalizationTests
+ {
+ [Fact]
+ public async Task ShowPowerPlanChangeNotificationAsync_UsesLocalizedTitleAndFormat()
+ {
+ var harness = new Harness(new Dictionary
+ {
+ ["Notification_PowerPlanChangedTitle"] = "Localized power title",
+ ["Notification_PowerPlanChangedFormat"] = "Changed {0} -> {1}",
+ });
+ var service = harness.CreateService();
+
+ await service.ShowPowerPlanChangeNotificationAsync("Balanced", "Performance");
+
+ var notification = Assert.Single(service.NotificationHistory);
+ Assert.Equal("Localized power title", notification.Title);
+ Assert.Equal("Changed Balanced -> Performance", notification.Message);
+ harness.Tray.Verify(
+ tray => tray.ShowTrayNotification(
+ "Localized power title",
+ "Changed Balanced -> Performance",
+ NotificationType.PowerPlanChange,
+ It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task ShowPowerPlanChangeNotificationAsync_UsesLocalizedProcessFormat()
+ {
+ var harness = new Harness(new Dictionary
+ {
+ ["Notification_PowerPlanChangedTitle"] = "Power",
+ ["Notification_PowerPlanChangedProcessFormat"] = "{1}: {0}",
+ });
+ var service = harness.CreateService();
+
+ await service.ShowPowerPlanChangeNotificationAsync("Balanced", "Performance", "game.exe");
+
+ var notification = Assert.Single(service.NotificationHistory);
+ Assert.Equal("Power", notification.Title);
+ Assert.Equal("game.exe: Performance", notification.Message);
+ }
+
+ [Theory]
+ [InlineData(true, "Enabled localized", NotificationType.Success)]
+ [InlineData(false, "Disabled localized", NotificationType.Warning)]
+ public async Task ShowProcessMonitoringNotificationAsync_UsesLocalizedTitle(bool isEnabled, string expectedTitle, NotificationType expectedType)
+ {
+ var harness = new Harness(new Dictionary
+ {
+ ["Notification_ProcessMonitoringEnabled"] = "Enabled localized",
+ ["Notification_ProcessMonitoringDisabled"] = "Disabled localized",
+ });
+ var service = harness.CreateService();
+
+ await service.ShowProcessMonitoringNotificationAsync("Monitoring changed", isEnabled);
+
+ var notification = Assert.Single(service.NotificationHistory);
+ Assert.Equal(expectedTitle, notification.Title);
+ Assert.Equal("Monitoring changed", notification.Message);
+ Assert.Equal(expectedType, notification.Type);
+ }
+
+ [Fact]
+ public async Task ShowCpuAffinityNotificationAsync_UsesLocalizedTitleAndFormat()
+ {
+ var harness = new Harness(new Dictionary
+ {
+ ["Notification_CpuAffinityAppliedTitle"] = "Affinity localized",
+ ["Notification_CpuAffinityAppliedFormat"] = "{0} uses {1}",
+ });
+ var service = harness.CreateService();
+
+ await service.ShowCpuAffinityNotificationAsync("game.exe", "CPU 0, 1");
+
+ var notification = Assert.Single(service.NotificationHistory);
+ Assert.Equal("Affinity localized", notification.Title);
+ Assert.Equal("game.exe uses CPU 0, 1", notification.Message);
+ }
+
+ [Fact]
+ public async Task ShowNotificationAsync_LocalizesKnownAndDynamicGameBoostStrings()
+ {
+ var harness = new Harness(new Dictionary
+ {
+ ["Notification_GameBoostActivatedTitle"] = "Boost title",
+ ["Notification_GameBoostActivatedFormat"] = "Boosted {0}",
+ });
+ var service = harness.CreateService();
+
+ await service.ShowNotificationAsync(
+ "Game Boost Activated",
+ "Game Boost mode activated for game.exe",
+ NotificationType.Information);
+
+ var notification = Assert.Single(service.NotificationHistory);
+ Assert.Equal("Boost title", notification.Title);
+ Assert.Equal("Boosted game.exe", notification.Message);
+ }
+
+ [Fact]
+ public async Task ShowNotificationAsync_KeepsOriginalText_WhenLocalizationKeyIsMissing()
+ {
+ var harness = new Harness(new Dictionary());
+ var service = harness.CreateService();
+
+ await service.ShowNotificationAsync(
+ "Affinity blocked",
+ "Unmapped notification message",
+ NotificationType.Warning);
+
+ var notification = Assert.Single(service.NotificationHistory);
+ Assert.Equal("Affinity blocked", notification.Title);
+ Assert.Equal("Unmapped notification message", notification.Message);
+ }
+
+ private sealed class Harness
+ {
+ private readonly IReadOnlyDictionary localizedStrings;
+
+ public Mock Settings { get; } = new(MockBehavior.Loose);
+
+ public Mock Tray { get; } = new(MockBehavior.Loose);
+
+ public Mock Localization { get; } = new(MockBehavior.Loose);
+
+ public Harness(IReadOnlyDictionary localizedStrings)
+ {
+ this.localizedStrings = localizedStrings;
+ this.Settings.SetupGet(service => service.Settings).Returns(new ApplicationSettingsModel
+ {
+ EnableToastNotifications = false,
+ });
+ this.Localization
+ .Setup(service => service.GetString(It.IsAny()))
+ .Returns(this.GetLocalizedString);
+ }
+
+ public NotificationService CreateService()
+ {
+ return new NotificationService(
+ NullLogger.Instance,
+ this.Settings.Object,
+ this.Tray.Object,
+ this.Localization.Object);
+ }
+
+ private string GetLocalizedString(string key)
+ {
+ return this.localizedStrings.TryGetValue(key, out var value) ? value : key;
+ }
+ }
+ }
+}
diff --git a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs
index 499b7cd..f03e1cd 100644
--- a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs
+++ b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs
@@ -1,6 +1,7 @@
namespace ThreadPilot.Core.Tests
{
using System.Collections.ObjectModel;
+ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ThreadPilot.Models;
@@ -80,6 +81,45 @@ public async Task ChangingApplyPersistentRulesOnProcessStart_UpdatesSettingAndLo
Assert.Contains(entries, entry => entry.Message == "[Settings] Apply saved rules at process start enabled.");
}
+ [Fact]
+ public async Task ChangingLanguage_AppliesLanguageAndLogsVisibleActivityEntry()
+ {
+ var harness = new Harness();
+ var viewModel = harness.CreateViewModel();
+
+ viewModel.Settings.Language = "zh-CN";
+
+ harness.Localization.Verify(service => service.ApplyLanguage("zh-CN"), Times.Once);
+ harness.Logging.Verify(
+ service => service.LogUserActionAsync(
+ "LanguageChanged",
+ "Language changed to Simplified Chinese",
+ null),
+ Times.Once);
+ var entry = Assert.Single(await harness.Audit.GetEntriesAsync());
+ Assert.Equal("Language changed to Simplified Chinese", entry.Message);
+ Assert.Equal("Language changed to Simplified Chinese.", viewModel.StatusMessage);
+ }
+
+ [Fact]
+ public async Task SaveSettingsCommand_PersistsSelectedLanguage()
+ {
+ var harness = new Harness();
+ ApplicationSettingsModel? savedSettings = null;
+ harness.SettingsService
+ .Setup(service => service.UpdateSettingsAsync(It.IsAny()))
+ .Callback(settings => savedSettings = (ApplicationSettingsModel)settings.Clone())
+ .Returns(Task.CompletedTask);
+ var viewModel = harness.CreateViewModel();
+ viewModel.Settings.Language = "zh-CN";
+
+ await ((IAsyncRelayCommand)viewModel.SaveSettingsCommand).ExecuteAsync(null);
+
+ Assert.NotNull(savedSettings);
+ Assert.Equal("zh-CN", savedSettings.Language);
+ Assert.False(viewModel.HasUnsavedChanges);
+ }
+
[Fact]
public void SettingsView_ExposesPersistentRuleAutoApplyToggle()
{
@@ -94,11 +134,30 @@ public void SettingsView_ExposesPersistentRuleAutoApplyToggle()
"SettingsView.xaml");
var serialized = File.ReadAllText(settingsViewPath);
- Assert.Contains("Text=\"Rules & automation\" Style=\"{StaticResource SectionHeaderStyle}\"", serialized, StringComparison.Ordinal);
- Assert.Contains("Text=\"Apply saved rules when matching processes start\"", serialized, StringComparison.Ordinal);
+ Assert.Contains("Text=\"{DynamicResource SettingsView_RulesAutomation}\" Style=\"{StaticResource SectionHeaderStyle}\"", serialized, StringComparison.Ordinal);
+ Assert.Contains("Text=\"{DynamicResource SettingsView_ApplyOnStart}\"", serialized, StringComparison.Ordinal);
Assert.Contains("TextWrapping=\"Wrap\"", serialized, StringComparison.Ordinal);
Assert.Contains("IsChecked=\"{Binding Settings.ApplyPersistentRulesOnProcessStart}\"", serialized, StringComparison.Ordinal);
- Assert.Contains("This does not install a Windows Service and does not use registry/IFEO persistence.", serialized, StringComparison.Ordinal);
+ Assert.Contains("Text=\"{DynamicResource SettingsView_ApplyOnStartDescription}\"", serialized, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void SettingsView_ExposesOptionalChineseLanguageSelection()
+ {
+ var settingsViewPath = Path.Combine(
+ AppContext.BaseDirectory,
+ "..",
+ "..",
+ "..",
+ "..",
+ "..",
+ "Views",
+ "SettingsView.xaml");
+ var serialized = File.ReadAllText(settingsViewPath);
+
+ Assert.Contains("SelectedValue=\"{Binding Settings.Language}\"", serialized, StringComparison.Ordinal);
+ Assert.Contains("Tag=\"en-US\"", serialized, StringComparison.Ordinal);
+ Assert.Contains("Tag=\"zh-CN\"", serialized, StringComparison.Ordinal);
}
private sealed class Harness
@@ -121,6 +180,8 @@ private sealed class Harness
public Mock Tray { get; } = new(MockBehavior.Loose);
+ public Mock Localization { get; } = new(MockBehavior.Loose);
+
public Mock Logging { get; } = new(MockBehavior.Loose);
public ActivityAuditService Audit { get; } = new(NullLogger.Instance);
@@ -133,6 +194,9 @@ public Harness(bool initialDarkTheme = false)
HasUserThemePreference = initialDarkTheme,
};
this.SettingsService.SetupGet(service => service.Settings).Returns(this.settings);
+ this.Autostart
+ .Setup(service => service.CheckAutostartStatusAsync())
+ .ReturnsAsync(true);
this.PowerPlans
.Setup(service => service.GetPowerPlansAsync())
.ReturnsAsync(new ObservableCollection());
@@ -159,6 +223,7 @@ public SettingsViewModel CreateViewModel() =>
this.Theme.Object,
this.Tray.Object,
new GitHubUpdateChecker(new Mock().Object),
+ this.Localization.Object,
this.Logging.Object,
this.Audit);
}
diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs
index 55c81a1..51d2ef3 100644
--- a/ViewModels/SettingsViewModel.cs
+++ b/ViewModels/SettingsViewModel.cs
@@ -49,6 +49,7 @@ public partial class SettingsViewModel : BaseViewModel
private readonly IThemeService themeService;
private readonly ISystemTrayService systemTrayService;
private readonly GitHubUpdateChecker gitHubUpdateChecker;
+ private readonly ILocalizationService localizationService;
private ApplicationSettingsModel savedSettingsSnapshot;
private bool isSyncingFromService = false;
private bool? appliedThemePreference;
@@ -106,6 +107,7 @@ public SettingsViewModel(
IThemeService themeService,
ISystemTrayService systemTrayService,
GitHubUpdateChecker gitHubUpdateChecker,
+ ILocalizationService localizationService,
IEnhancedLoggingService? enhancedLoggingService = null,
IActivityAuditService? activityAuditService = null)
: base(logger, enhancedLoggingService, activityAuditService)
@@ -119,6 +121,7 @@ public SettingsViewModel(
this.themeService = themeService ?? throw new ArgumentNullException(nameof(themeService));
this.systemTrayService = systemTrayService ?? throw new ArgumentNullException(nameof(systemTrayService));
this.gitHubUpdateChecker = gitHubUpdateChecker ?? throw new ArgumentNullException(nameof(gitHubUpdateChecker));
+ this.localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService));
// Get version and strip the git commit hash (everything after '+')
var rawVersion = typeof(App).Assembly
@@ -179,6 +182,13 @@ private void OnSettingsPropertyChanged(object? sender, System.ComponentModel.Pro
return;
}
+ if (string.Equals(e.PropertyName, nameof(ApplicationSettingsModel.Language), StringComparison.Ordinal))
+ {
+ this.UpdatePendingChangesState();
+ this.ApplyLanguagePreference(this.Settings.Language, logUserAction: true);
+ return;
+ }
+
if (string.Equals(e.PropertyName, nameof(ApplicationSettingsModel.ApplyPersistentRulesOnProcessStart), StringComparison.Ordinal))
{
this.UpdatePendingChangesState();
@@ -220,6 +230,31 @@ private void ApplyThemePreference(bool useDarkTheme, bool logUserAction)
}
}
+ private void ApplyLanguagePreference(string language, bool logUserAction)
+ {
+ var normalizedLanguage = LocalizationService.NormalizeLanguage(language);
+ try
+ {
+ this.localizationService.ApplyLanguage(normalizedLanguage);
+ this.Settings.Language = normalizedLanguage;
+ var languageName = normalizedLanguage == LocalizationService.SimplifiedChineseLanguage
+ ? "Simplified Chinese"
+ : "English";
+ this.StatusMessage = $"Language changed to {languageName}.";
+
+ if (logUserAction)
+ {
+ _ = this.LogUserActionAsync("LanguageChanged", $"Language changed to {languageName}");
+ }
+ }
+ catch (Exception ex)
+ {
+ this.StatusMessage = "Failed to change language.";
+ this.Logger.LogError(ex, "Failed to apply language preference {Language}", normalizedLanguage);
+ _ = this.LogUserActionAsync("LanguageChangeFailed", $"Failed to change language to {normalizedLanguage}: {ex.Message}");
+ }
+ }
+
partial void OnHasUnsavedChangesChanged(bool value)
{
OnPropertyChanged(nameof(CanSaveSettings));
@@ -279,6 +314,7 @@ private async Task SaveSettingsAsync()
this.Settings.UseDarkTheme = useDarkTheme;
this.isSyncingFromService = false;
this.ApplyThemePreference(useDarkTheme, logUserAction: false);
+ this.ApplyLanguagePreference(this.Settings.Language, logUserAction: false);
// Update monitoring services with new settings
this.processMonitorManagerService.UpdateSettings();
@@ -549,6 +585,7 @@ public async Task RefreshSettingsAsync()
this.Settings.UseDarkTheme = useDarkTheme;
this.isSyncingFromService = false;
this.ApplyThemePreference(useDarkTheme, logUserAction: false);
+ this.ApplyLanguagePreference(this.Settings.Language, logUserAction: false);
this.SetSavedSettingsSnapshot(this.Settings);
this.StatusMessage = "Settings loaded";
@@ -590,6 +627,7 @@ private void OnSettingsServiceSettingsChanged(object? sender, ApplicationSetting
this.Settings.DefaultPowerPlanName = this.cachedDefaultPowerPlanName;
}
this.SetSavedSettingsSnapshot(this.Settings);
+ this.ApplyLanguagePreference(this.Settings.Language, logUserAction: false);
this.StatusMessage = "Settings synchronized";
}
finally
diff --git a/Views/SettingsView.xaml b/Views/SettingsView.xaml
index f2b3ba5..c72bd35 100644
--- a/Views/SettingsView.xaml
+++ b/Views/SettingsView.xaml
@@ -178,49 +178,60 @@
-
+
-
-
+
-
-
-
-
-
+
-
+
+
+
+
+
+
+
+
+
-
+
-
+
-
-