From 7a22c520a230cf4e63867001c0bd70b8f2e2370d Mon Sep 17 00:00:00 2001
From: Prime Build <162145141+PrimeBuild-pc@users.noreply.github.com>
Date: Sat, 23 May 2026 16:13:17 +0200
Subject: [PATCH 1/7] Add latest release downloads badge
---
README.md | 325 +++++++++++++++++++++++++++---------------------------
1 file changed, 163 insertions(+), 162 deletions(-)
diff --git a/README.md b/README.md
index 3fc121f..3fdb304 100644
--- a/README.md
+++ b/README.md
@@ -1,33 +1,34 @@
-
-
-# ThreadPilot ✈️
-
+
+
+# ThreadPilot ✈️
+
**A free and open-source Windows process control center for deterministic CPU, priority, memory, and power-plan workflows.**
-
+
[](https://github.com/PrimeBuild-pc/ThreadPilot/actions/workflows/ci-devsecops.yml)
[](https://codecov.io/gh/PrimeBuild-pc/ThreadPilot)
[](https://github.com/PrimeBuild-pc/ThreadPilot/releases)
-[](https://github.com/microsoft/winget-pkgs/tree/master/manifests/p/PrimeBuild/ThreadPilot)
-[](https://www.microsoft.com/windows)
-[](https://dotnet.microsoft.com/)
-[](LICENSE)
-[](https://github.com/PrimeBuild-pc/ThreadPilot/issues)
-[](https://github.com/PrimeBuild-pc/ThreadPilot/discussions)
-
-[Install](#-install) • [Features](#-features) • [Screenshots](#-screenshots) • [Build](#-build-from-source) • [Support](#-support-the-project)
-
-
-
-
-
-## What is ThreadPilot?
-
+[](https://github.com/PrimeBuild-pc/ThreadPilot/releases/latest)
+[](https://github.com/microsoft/winget-pkgs/tree/master/manifests/p/PrimeBuild/ThreadPilot)
+[](https://www.microsoft.com/windows)
+[](https://dotnet.microsoft.com/)
+[](LICENSE)
+[](https://github.com/PrimeBuild-pc/ThreadPilot/issues)
+[](https://github.com/PrimeBuild-pc/ThreadPilot/discussions)
+
+[Install](#-install) • [Features](#-features) • [Screenshots](#-screenshots) • [Build](#-build-from-source) • [Support](#-support-the-project)
+
+
+
+
+
+## What is ThreadPilot?
+
ThreadPilot is a modern Windows process control center for users who want predictable control over process behavior, CPU affinity, CPU Sets, priority, memory priority, power plans, and saved process rules.
It is designed as an open-source alternative for power users who need Process Lasso-style capabilities, automation support, system tray controls, and a Windows 11-first experience. ThreadPilot is not a performance overlay: its primary job is applying explicit process controls safely and making the result visible.
-
-## ✨ Features
-
+
+## ✨ Features
+
- Live process management with refresh, filtering, context-menu actions, and a selected-process summary.
- Topology-aware CPU affinity through `CpuSelection`, including CPU Sets support, processor groups, and safe handling for systems with more than 64 logical processors.
- Safer CPU indexing in new affinity paths: CPU64 no longer aliases CPU0.
@@ -43,57 +44,57 @@ It is designed as an open-source alternative for power users who need Process La
- Administrator-aware Windows desktop workflow.
- CI-backed build validation and package-manager distribution.
- Windows 11 native visual refresh with neutral Fluent surfaces and refined card-based layouts.
-
-## 📦 Install
-
-### Install with WinGet
-
-ThreadPilot is available on WinGet as `PrimeBuild.ThreadPilot`.
-
-From **Command Prompt** or **PowerShell**:
-
-```cmd
-winget install --id PrimeBuild.ThreadPilot -e
-```
-
-To refresh your local WinGet source first:
-
-```cmd
-winget source update
-winget search ThreadPilot
-```
-
-### Download from GitHub Releases
-
-[](https://github.com/PrimeBuild-pc/ThreadPilot/releases/latest)
-[](https://github.com/PrimeBuild-pc/ThreadPilot/releases/latest)
-
-| Package | Recommended use |
-|---|---|
-| `ThreadPilot_v_Setup.exe` | Standard Windows installer for most users |
-| `ThreadPilot_v_Portable.zip` | Portable/no-install deployment |
-
-Optional checksum verification:
-
-```powershell
-Get-FileHash .\ThreadPilot_v_Setup.exe -Algorithm SHA256
-Get-FileHash .\ThreadPilot_v_Portable.zip -Algorithm SHA256
-```
-
-Compare the result with `SHA256SUMS.txt` from the same release.
-
-## 🖼️ Screenshots
-
-
-
-## ⚙️ Requirements
-
-- Windows 11, build 22000 or newer.
-- Administrator privileges to launch and manage system-level process settings.
-- .NET 8 SDK only if you want to build from source.
-
-## 🚀 Usage Notes
-
+
+## 📦 Install
+
+### Install with WinGet
+
+ThreadPilot is available on WinGet as `PrimeBuild.ThreadPilot`.
+
+From **Command Prompt** or **PowerShell**:
+
+```cmd
+winget install --id PrimeBuild.ThreadPilot -e
+```
+
+To refresh your local WinGet source first:
+
+```cmd
+winget source update
+winget search ThreadPilot
+```
+
+### Download from GitHub Releases
+
+[](https://github.com/PrimeBuild-pc/ThreadPilot/releases/latest)
+[](https://github.com/PrimeBuild-pc/ThreadPilot/releases/latest)
+
+| Package | Recommended use |
+|---|---|
+| `ThreadPilot_v_Setup.exe` | Standard Windows installer for most users |
+| `ThreadPilot_v_Portable.zip` | Portable/no-install deployment |
+
+Optional checksum verification:
+
+```powershell
+Get-FileHash .\ThreadPilot_v_Setup.exe -Algorithm SHA256
+Get-FileHash .\ThreadPilot_v_Portable.zip -Algorithm SHA256
+```
+
+Compare the result with `SHA256SUMS.txt` from the same release.
+
+## 🖼️ Screenshots
+
+
+
+## ⚙️ Requirements
+
+- Windows 11, build 22000 or newer.
+- Administrator privileges to launch and manage system-level process settings.
+- .NET 8 SDK only if you want to build from source.
+
+## 🚀 Usage Notes
+
ThreadPilot uses an administrator-required manifest and requests elevation at startup. If UAC elevation is declined, the application exits instead of continuing in a limited mode.
Persistent process rules are runtime-based. Apply at process start works only while ThreadPilot is running; it does not install a Windows Service, write registry or IFEO persistence, or use installer privilege tricks.
@@ -101,91 +102,91 @@ Persistent process rules are runtime-based. Apply at process start works only wh
ThreadPilot does not bypass anti-cheat or protected-process restrictions. Running as administrator may help with normal access-denied cases, but it does not override protected-process or anti-cheat enforcement.
Useful startup arguments:
-
-```text
---start-minimized
---autostart
---test
---smoke-test
-```
-
-In `Power Plans > Custom Power Plans`, use `Add .pow File` to import custom power plans directly from the app.
-
-## 🧱 Build from Source
-
-```powershell
-git clone https://github.com/PrimeBuild-pc/ThreadPilot.git
-cd ThreadPilot
-dotnet restore ThreadPilot_1.sln
-dotnet build ThreadPilot_1.sln --configuration Release
-dotnet run --project ThreadPilot.csproj --configuration Release
-```
-
-Run integrated runtime tests:
-
-```powershell
-dotnet run --project ThreadPilot.csproj --configuration Release -- --test
-```
-
-Publish a self-contained Windows build:
-
-```powershell
-dotnet publish ThreadPilot.csproj --configuration Release --runtime win-x64 --self-contained true
-```
-
-Build release artifacts with the project script:
-
-```powershell
-./build/build-release.ps1
-```
-
-## 🔐 Quality and Security
-
-- CI validates build, formatting, analyzers, vulnerability checks, and secret scanning.
-- Security disclosures are handled through private GitHub advisories. See [`docs/SECURITY.md`](docs/SECURITY.md).
-- Change history is tracked in [`docs/CHANGELOG.md`](docs/CHANGELOG.md).
-- Coverage focuses on business/application code and excludes generated build artifacts.
-
-## 🧭 Project Documentation
-
-- [`docs/README.md`](docs/README.md)
-- [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md)
-- [`docs/CODE_OF_CONDUCT.md`](docs/CODE_OF_CONDUCT.md)
-- [`docs/RELEASE_SIGNING.md`](docs/RELEASE_SIGNING.md)
-- [`docs/release/PACKAGING.md`](docs/release/PACKAGING.md)
-- [`docs/reference/ARCHITECTURE_GUIDE.md`](docs/reference/ARCHITECTURE_GUIDE.md)
-- [`docs/reference/DEVELOPER_GUIDE.md`](docs/reference/DEVELOPER_GUIDE.md)
-- [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md)
-- [`docs/reference/PROJECT_STRUCTURE.md`](docs/reference/PROJECT_STRUCTURE.md)
-- [`docs/reference/UI_STYLE_GUIDE.md`](docs/reference/UI_STYLE_GUIDE.md)
-
-## 🗺️ Roadmap
-
-- Expand unit and integration coverage for core services.
-- Continue async reliability refactoring for long-running monitoring paths.
-- Improve accessibility and localization readiness across major views.
-- Formalize release signing and distribution hardening.
-
-## 🤝 Contributing
-
-Contributions are welcome. Before opening a pull request, please read [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) and [`docs/CODE_OF_CONDUCT.md`](docs/CODE_OF_CONDUCT.md).
-
-For bugs, feature requests, or packaging issues, open a GitHub issue with reproduction steps and your Windows version.
-
-## 📄 License
-
-ThreadPilot is licensed under the **GNU Affero General Public License v3.0**. See [`LICENSE`](LICENSE).
-
-## 💬 Support the Project
-
-
-
-**Built with care for Windows power users.**
-
-[](https://github.com/PrimeBuild-pc/ThreadPilot/issues)
-[](https://github.com/PrimeBuild-pc/ThreadPilot/discussions)
-[](https://paypal.me/PrimeBuildOfficial?country.x=IT&locale.x=it_IT)
-
-If ThreadPilot is useful to you, consider starring the repository, opening thoughtful issues, sharing feedback, or supporting development with a small donation.
-
-
+
+```text
+--start-minimized
+--autostart
+--test
+--smoke-test
+```
+
+In `Power Plans > Custom Power Plans`, use `Add .pow File` to import custom power plans directly from the app.
+
+## 🧱 Build from Source
+
+```powershell
+git clone https://github.com/PrimeBuild-pc/ThreadPilot.git
+cd ThreadPilot
+dotnet restore ThreadPilot_1.sln
+dotnet build ThreadPilot_1.sln --configuration Release
+dotnet run --project ThreadPilot.csproj --configuration Release
+```
+
+Run integrated runtime tests:
+
+```powershell
+dotnet run --project ThreadPilot.csproj --configuration Release -- --test
+```
+
+Publish a self-contained Windows build:
+
+```powershell
+dotnet publish ThreadPilot.csproj --configuration Release --runtime win-x64 --self-contained true
+```
+
+Build release artifacts with the project script:
+
+```powershell
+./build/build-release.ps1
+```
+
+## 🔐 Quality and Security
+
+- CI validates build, formatting, analyzers, vulnerability checks, and secret scanning.
+- Security disclosures are handled through private GitHub advisories. See [`docs/SECURITY.md`](docs/SECURITY.md).
+- Change history is tracked in [`docs/CHANGELOG.md`](docs/CHANGELOG.md).
+- Coverage focuses on business/application code and excludes generated build artifacts.
+
+## 🧭 Project Documentation
+
+- [`docs/README.md`](docs/README.md)
+- [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md)
+- [`docs/CODE_OF_CONDUCT.md`](docs/CODE_OF_CONDUCT.md)
+- [`docs/RELEASE_SIGNING.md`](docs/RELEASE_SIGNING.md)
+- [`docs/release/PACKAGING.md`](docs/release/PACKAGING.md)
+- [`docs/reference/ARCHITECTURE_GUIDE.md`](docs/reference/ARCHITECTURE_GUIDE.md)
+- [`docs/reference/DEVELOPER_GUIDE.md`](docs/reference/DEVELOPER_GUIDE.md)
+- [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md)
+- [`docs/reference/PROJECT_STRUCTURE.md`](docs/reference/PROJECT_STRUCTURE.md)
+- [`docs/reference/UI_STYLE_GUIDE.md`](docs/reference/UI_STYLE_GUIDE.md)
+
+## 🗺️ Roadmap
+
+- Expand unit and integration coverage for core services.
+- Continue async reliability refactoring for long-running monitoring paths.
+- Improve accessibility and localization readiness across major views.
+- Formalize release signing and distribution hardening.
+
+## 🤝 Contributing
+
+Contributions are welcome. Before opening a pull request, please read [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) and [`docs/CODE_OF_CONDUCT.md`](docs/CODE_OF_CONDUCT.md).
+
+For bugs, feature requests, or packaging issues, open a GitHub issue with reproduction steps and your Windows version.
+
+## 📄 License
+
+ThreadPilot is licensed under the **GNU Affero General Public License v3.0**. See [`LICENSE`](LICENSE).
+
+## 💬 Support the Project
+
+
+
+**Built with care for Windows power users.**
+
+[](https://github.com/PrimeBuild-pc/ThreadPilot/issues)
+[](https://github.com/PrimeBuild-pc/ThreadPilot/discussions)
+[](https://paypal.me/PrimeBuildOfficial?country.x=IT&locale.x=it_IT)
+
+If ThreadPilot is useful to you, consider starring the repository, opening thoughtful issues, sharing feedback, or supporting development with a small donation.
+
+
\ No newline at end of file
From 340610dfd3fd1552a637661c7efefd36f8668cc1 Mon Sep 17 00:00:00 2001
From: Prime Build <162145141+PrimeBuild-pc@users.noreply.github.com>
Date: Sat, 6 Jun 2026 18:04:12 +0200
Subject: [PATCH 2/7] 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.
---
App.xaml | 1 +
App.xaml.cs | 2 +
Locales/en-US.xaml | 529 ++++++++++++++++++
Locales/zh-CN.xaml | 529 ++++++++++++++++++
Models/ApplicationSettingsModel.cs | 4 +
Services/ApplicationSettingsService.cs | 20 +-
Services/ILocalizationService.cs | 44 ++
Services/LocalizationService.cs | 269 +++++++++
Services/NotificationService.cs | 93 ++-
Services/ServiceConfiguration.cs | 1 +
.../ApplicationSettingsModelTests.cs | 15 +
.../ApplicationSettingsServiceTests.cs | 37 ++
.../LocalizationServiceTests.cs | 200 +++++++
.../NotificationServiceLocalizationTests.cs | 161 ++++++
.../SettingsViewModelThemeTests.cs | 71 ++-
ViewModels/SettingsViewModel.cs | 38 ++
Views/SettingsView.xaml | 37 +-
17 files changed, 2018 insertions(+), 33 deletions(-)
create mode 100644 Locales/en-US.xaml
create mode 100644 Locales/zh-CN.xaml
create mode 100644 Services/ILocalizationService.cs
create mode 100644 Services/LocalizationService.cs
create mode 100644 Tests/ThreadPilot.Core.Tests/LocalizationServiceTests.cs
create mode 100644 Tests/ThreadPilot.Core.Tests/NotificationServiceLocalizationTests.cs
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 @@
-
+
-
-
+
-
-
-
-
-
+
-
+
+
+
+
+
+
+
+
+
-
+
-
+
-
-
From edd6808c7dda2bd8eda882a94b38c065ad51eb9e Mon Sep 17 00:00:00 2001
From: Prime Build <162145141+PrimeBuild-pc@users.noreply.github.com>
Date: Sat, 6 Jun 2026 18:25:45 +0200
Subject: [PATCH 3/7] Prepare v1.3.0 release (#25)
---
Installer/Installer.iss | 2 +-
Installer/ThreadPilot.wxs | 2 +-
Installer/setup.iss | 2 +-
ThreadPilot.csproj | 8 +++---
app.manifest | 2 +-
build/build-installer.ps1 | 2 +-
build/build-release.ps1 | 2 +-
build/package-release-zips.ps1 | 2 +-
chocolatey/threadpilot.nuspec | 4 +--
docs/CHANGELOG.md | 22 +++++++++++++++
docs/release/PACKAGING.md | 19 +++++++------
docs/release/RELEASE_NOTES.md | 49 ++++++++++++++--------------------
docs/releases/v1.3.0.md | 48 +++++++++++++++++++++++++++++++++
sonar-project.properties | 2 +-
14 files changed, 113 insertions(+), 53 deletions(-)
create mode 100644 docs/releases/v1.3.0.md
diff --git a/Installer/Installer.iss b/Installer/Installer.iss
index 8beead0..622de70 100644
--- a/Installer/Installer.iss
+++ b/Installer/Installer.iss
@@ -5,7 +5,7 @@
#define MyAppPublisher "ThreadPilot"
#define MyAppURL "https://github.com/"
#define MyAppExeName "ThreadPilot.exe"
-#define MyAppVersion "1.2.0"
+#define MyAppVersion "1.3.0"
#ifndef MyWizardStyle
#define MyWizardStyle "modern dynamic windows11"
diff --git a/Installer/ThreadPilot.wxs b/Installer/ThreadPilot.wxs
index c924f7c..b72c9fe 100644
--- a/Installer/ThreadPilot.wxs
+++ b/Installer/ThreadPilot.wxs
@@ -7,7 +7,7 @@
diff --git a/Installer/setup.iss b/Installer/setup.iss
index 9cfc206..25a2870 100644
--- a/Installer/setup.iss
+++ b/Installer/setup.iss
@@ -11,7 +11,7 @@
#endif
#ifndef MyAppVersion
- #define MyAppVersion "1.2.0"
+ #define MyAppVersion "1.3.0"
#endif
#ifndef MyAppSourceDir
diff --git a/ThreadPilot.csproj b/ThreadPilot.csproj
index d660017..85e6592 100644
--- a/ThreadPilot.csproj
+++ b/ThreadPilot.csproj
@@ -16,10 +16,10 @@
link
true
CS1998;CS0067;CS0414;WFAC010;IL3000;MVVMTK0034
- 1.2.0
- 1.2.0.0
- 1.2.0.0
- 1.2.0
+ 1.3.0
+ 1.3.0.0
+ 1.3.0.0
+ 1.3.0
diff --git a/app.manifest b/app.manifest
index 970b893..5816daa 100644
--- a/app.manifest
+++ b/app.manifest
@@ -1,6 +1,6 @@
-
+
diff --git a/build/build-installer.ps1 b/build/build-installer.ps1
index 5ef8e30..1fbdb8e 100644
--- a/build/build-installer.ps1
+++ b/build/build-installer.ps1
@@ -1,5 +1,5 @@
param(
- [string]$Version = "1.2.0",
+ [string]$Version = "1.3.0",
[string]$Configuration = "Release",
[switch]$SkipPublish
)
diff --git a/build/build-release.ps1 b/build/build-release.ps1
index 681c789..ec4dd78 100644
--- a/build/build-release.ps1
+++ b/build/build-release.ps1
@@ -1,5 +1,5 @@
param(
- [string]$Version = "1.2.0",
+ [string]$Version = "1.3.0",
[string]$Configuration = "Release",
[string]$Runtime = "win-x64"
)
diff --git a/build/package-release-zips.ps1 b/build/package-release-zips.ps1
index 8ff92ec..54c8d59 100644
--- a/build/package-release-zips.ps1
+++ b/build/package-release-zips.ps1
@@ -1,5 +1,5 @@
param(
- [string]$Version = "1.2.0"
+ [string]$Version = "1.3.0"
)
$ErrorActionPreference = "Stop"
diff --git a/chocolatey/threadpilot.nuspec b/chocolatey/threadpilot.nuspec
index 2cbcdc1..e51f187 100644
--- a/chocolatey/threadpilot.nuspec
+++ b/chocolatey/threadpilot.nuspec
@@ -2,7 +2,7 @@
threadpilot
- 1.2.0
+ 1.3.0
Codestin Search App
Prime Build
https://github.com/PrimeBuild-pc/ThreadPilot
@@ -15,7 +15,7 @@
false
Advanced Windows process and power plan manager with rules automation and performance controls.
ThreadPilot process and power plan manager.
- https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.2.0
+ https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.3.0
threadpilot process powerplan performance windows
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index ce8218b..490dda5 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -2,6 +2,28 @@
All notable changes to this project are documented in this file.
+## v1.3.0 - Localization support
+
+### Added
+
+- Added localization infrastructure.
+- Added English and Simplified Chinese resource dictionaries.
+- Added language selector in Settings.
+- Added localized notification support for selected user-facing messages.
+- Added tests for localization fallback, language persistence, Settings language selection, and localized notifications.
+
+### Changed
+
+- English remains the default application language.
+- Simplified Chinese is available as an optional display language.
+- Project version updated to 1.3.0.
+
+### Safety
+
+- Unsupported or invalid language settings now fall back to English.
+- Missing translation keys fall back safely to English or the key.
+- No changes to elevation, system tweaks, affinity, or privileged operation behavior.
+
## v1.2.0 - CPU topology, persistent rules, and process control update
### Added
diff --git a/docs/release/PACKAGING.md b/docs/release/PACKAGING.md
index 127faf1..5df81e1 100644
--- a/docs/release/PACKAGING.md
+++ b/docs/release/PACKAGING.md
@@ -178,9 +178,9 @@ Generate manifests locally with:
```powershell
./build/generate-winget-manifests.ps1 `
- -Version "1.2.0" `
- -Tag "v1.2.0" `
- -InstallerUrl "https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.2.0/ThreadPilot_v1.2.0_Setup.exe" `
+ -Version "1.3.0" `
+ -Tag "v1.3.0" `
+ -InstallerUrl "https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.3.0/ThreadPilot_v1.3.0_Setup.exe" `
-InstallerSha256 "" `
-OutputRoot "winget-manifests"
```
@@ -224,9 +224,9 @@ Local packaging-only validation:
```powershell
./build/publish-chocolatey.ps1 `
- -Version "1.2.0" `
- -Tag "v1.2.0" `
- -InstallerPath ".\artifacts\release\installer\ThreadPilot_v1.2.0_Setup.exe" `
+ -Version "1.3.0" `
+ -Tag "v1.3.0" `
+ -InstallerPath ".\artifacts\release\installer\ThreadPilot_v1.3.0_Setup.exe" `
-DryRun `
-PackageOutputDirectory ".\artifacts\choco-dryrun" `
-MetadataOutputPath ".\artifacts\choco-dryrun\chocolatey-package-metadata.json"
@@ -250,9 +250,9 @@ Public publish path:
```powershell
./build/publish-chocolatey.ps1 `
- -Version "1.2.0" `
- -Tag "v1.2.0" `
- -InstallerPath ".\artifacts\release\installer\ThreadPilot_v1.2.0_Setup.exe" `
+ -Version "1.3.0" `
+ -Tag "v1.3.0" `
+ -InstallerPath ".\artifacts\release\installer\ThreadPilot_v1.3.0_Setup.exe" `
-ApiKey ""
```
@@ -278,4 +278,3 @@ The release workflow (`.github/workflows/release.yml`) now builds:
- ReadyToRun build output for internal CI validation only
- SHA-256 checksum manifest for public assets
- Optional signing of EXE artifacts when signing secrets are configured
-
diff --git a/docs/release/RELEASE_NOTES.md b/docs/release/RELEASE_NOTES.md
index f434581..193ee1d 100644
--- a/docs/release/RELEASE_NOTES.md
+++ b/docs/release/RELEASE_NOTES.md
@@ -1,48 +1,39 @@
-# ThreadPilot v1.2.0 Release Notes Draft
+# ThreadPilot v1.3.0 Release Notes Draft
## Highlights
-- CPU topology v2 with topology-aware `CpuSelection`, CPU Sets, processor groups, and safer handling above 64 logical processors.
-- New safe affinity paths where CPU64 no longer aliases CPU0.
-- Intel hybrid handling through topology and `EfficiencyClass`, plus AMD CCD/L3-aware preset generation.
-- Memory priority support and persistent process rules.
-- Apply saved rules automatically when matching processes start while ThreadPilot is running.
-- Process tab context menu actions, Save as rule, Apply now, and selected-process summary.
+- Added localization infrastructure with English and Simplified Chinese resources.
+- English remains the default language.
+- Simplified Chinese is available as an optional display language through Settings.
+- Selected user-facing notification messages now use localized resources.
+- Localization fallback behavior is covered by expanded tests.
## Added
-- CPU topology v2 and `CpuSelection` for topology-aware affinity.
-- Group-aware CPU Sets support and processor group safety.
-- Memory priority controls.
-- Persistent process rules with runtime apply-at-process-start support.
-- Process tab context menu actions and selected-process summary.
-- Optional Diagnostics view hidden by default.
+- Added localization infrastructure.
+- Added English and Simplified Chinese resource dictionaries.
+- Added language selector in Settings.
+- Added localized notification support for selected user-facing messages.
+- Added tests for localization fallback, language persistence, Settings language selection, and localized notifications.
## Changed
-- Default presets are gaming-oriented and generated from topology rather than hardcoded CPU SKU lists.
-- Intel hybrid behavior uses Windows topology and `EfficiencyClass`.
-- AMD behavior uses CCD/L3-aware preset generation.
-- Project version updated to 1.2.0.
-
-## Fixed
-
-- Startup no longer fails from a read-only selected-process summary binding.
-- CPU64 no longer aliases CPU0 in new safe affinity paths.
-- Persistent rule auto-apply cancellation does not log shutdown/future cancellation as a warning.
+- English remains the default application language.
+- Simplified Chinese is available as an optional display language.
+- Project version updated to 1.3.0.
## Safety
-- High CPU priority shows a warning and Realtime priority remains blocked.
-- ThreadPilot does not bypass anti-cheat or protected-process restrictions.
-- Administrator rights may help ordinary access-denied cases but do not bypass protected processes.
+- Unsupported or invalid language settings now fall back to English.
+- Missing translation keys fall back safely to English or the key.
+- No changes to elevation, system tweaks, affinity, or privileged operation behavior.
## Compatibility and Upgrade Notes
- Requires Windows 11 build 22000 or newer.
-- Existing legacy affinity profiles continue to load.
-- New saved rules prefer topology-aware `CpuSelection` when safe topology mapping is available.
-- Apply at process start works only while ThreadPilot is running.
+- Existing settings continue to load.
+- Existing installations without a language setting default to English.
+- Existing process, affinity, power plan, and automation behavior is unchanged.
## Known Non-Goals
diff --git a/docs/releases/v1.3.0.md b/docs/releases/v1.3.0.md
new file mode 100644
index 0000000..193ee1d
--- /dev/null
+++ b/docs/releases/v1.3.0.md
@@ -0,0 +1,48 @@
+# ThreadPilot v1.3.0 Release Notes Draft
+
+## Highlights
+
+- Added localization infrastructure with English and Simplified Chinese resources.
+- English remains the default language.
+- Simplified Chinese is available as an optional display language through Settings.
+- Selected user-facing notification messages now use localized resources.
+- Localization fallback behavior is covered by expanded tests.
+
+## Added
+
+- Added localization infrastructure.
+- Added English and Simplified Chinese resource dictionaries.
+- Added language selector in Settings.
+- Added localized notification support for selected user-facing messages.
+- Added tests for localization fallback, language persistence, Settings language selection, and localized notifications.
+
+## Changed
+
+- English remains the default application language.
+- Simplified Chinese is available as an optional display language.
+- Project version updated to 1.3.0.
+
+## Safety
+
+- Unsupported or invalid language settings now fall back to English.
+- Missing translation keys fall back safely to English or the key.
+- No changes to elevation, system tweaks, affinity, or privileged operation behavior.
+
+## Compatibility and Upgrade Notes
+
+- Requires Windows 11 build 22000 or newer.
+- Existing settings continue to load.
+- Existing installations without a language setting default to English.
+- Existing process, affinity, power plan, and automation behavior is unchanged.
+
+## Known Non-Goals
+
+- No anti-cheat bypass.
+- No Windows Service.
+- No registry or IFEO persistence.
+- No generated release artifacts yet.
+- No GitHub release or tag yet.
+
+## Release Artifact Status
+
+- Installer, portable ZIP, checksums, package metadata verification, and release upload remain pending manual validation.
diff --git a/sonar-project.properties b/sonar-project.properties
index c9fb7ea..6425be6 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -1,6 +1,6 @@
sonar.projectKey=threadpilot
sonar.projectName=ThreadPilot
-sonar.projectVersion=1.2.0
+sonar.projectVersion=1.3.0
sonar.sourceEncoding=UTF-8
sonar.sources=.
From c058fef7d55b050dddbd92d94b0bfb79d39692ff Mon Sep 17 00:00:00 2001
From: Prime Build <162145141+PrimeBuild-pc@users.noreply.github.com>
Date: Sun, 7 Jun 2026 04:33:51 +0200
Subject: [PATCH 4/7] Fix v1.3.1 localization and installer cleanup (#26)
## Summary
Fixes the incomplete Simplified Chinese localization shipped in v1.3.0 and prepares v1.3.1.
## Changes
- Completed zh-CN localization coverage across the main UI.
- Added/updated localization keys for primary views, tray/status text, notifications, and settings.
- Kept en-US as the default language.
- Updated version metadata to 1.3.1.
- Changed installer display name to ThreadPilot while keeping version metadata separate.
- Added guarded cleanup for obsolete ThreadPilot 0.1.0-beta uninstall entry.
- Improved uninstall cleanup for ThreadPilot AppData, startup task, and HKCU Run entry.
- Added localization and packaging metadata tests.
## Validation
- dotnet build ThreadPilot_1.sln: passed, 0 warnings
- dotnet test ThreadPilot_1.sln: passed, 524 tests
## Safety
- No change to elevation behavior.
- No change to affinity/process control behavior.
- No change to power plan or system tweak behavior.
- Normal install/update preserves user settings.
- Full uninstall removes ThreadPilot-owned AppData for the uninstalling user.
---
.github/workflows/release.yml | 17 +-
Installer/Installer.iss | 14 +-
Installer/ThreadPilot.wxs | 2 +-
Installer/setup.iss | 50 +++-
Locales/en-US.xaml | 113 ++++++++-
Locales/zh-CN.xaml | 111 +++++++++
MainWindow.xaml | 80 +++----
Services/NotificationService.cs | 38 ++++
Services/SystemTrayService.cs | 143 +++++++++---
Services/SystemTrayStatusUpdater.cs | 20 +-
.../LocalizationServiceTests.cs | 104 ++++++++-
.../MasksViewModelTests.cs | 24 +-
.../PackagingMetadataTests.cs | 106 +++++++++
.../PowerPlanViewXamlTests.cs | 6 +-
.../ProcessViewXamlBindingTests.cs | 48 ++--
.../SettingsViewModelThemeTests.cs | 19 ++
ThreadPilot.csproj | 8 +-
ViewModels/ProcessViewModel.cs | 6 +-
ViewModels/SelectedProcessSummaryViewModel.cs | 64 ++++--
ViewModels/SettingsViewModel.cs | 95 ++++----
Views/LogViewerView.xaml | 74 +++---
Views/MasksView.xaml | 40 ++--
Views/PerformanceView.xaml | 94 ++++----
Views/PowerPlanView.xaml | 36 +--
Views/ProcessPowerPlanAssociationView.xaml | 122 +++++-----
Views/ProcessView.xaml | 214 +++++++++---------
Views/SettingsView.xaml | 98 ++++----
Views/SettingsWindow.xaml | 14 +-
Views/SystemTweaksView.xaml | 12 +-
app.manifest | 2 +-
build/build-installer.ps1 | 2 +-
build/build-release.ps1 | 19 +-
build/package-release-zips.ps1 | 19 +-
chocolatey/threadpilot.nuspec | 4 +-
docs/CHANGELOG.md | 18 ++
docs/release/RELEASE_NOTES.md | 51 ++---
docs/releases/v1.3.1.md | 41 ++++
sonar-project.properties | 2 +-
38 files changed, 1332 insertions(+), 598 deletions(-)
create mode 100644 Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs
create mode 100644 docs/releases/v1.3.1.md
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2e973aa..d60d726 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -161,18 +161,13 @@ jobs:
schtasks /Delete /TN "ThreadPilot_Startup" /F >nul 2>&1
reg delete "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v "ThreadPilot" /f >nul 2>&1
- echo [3/4] Optional user data cleanup...
- set "REMOVE_DATA=N"
- set /p REMOVE_DATA=Do you want to remove user settings at "%APPDATA%\ThreadPilot"? [y/N]:
- if /I "%REMOVE_DATA%"=="Y" (
- if exist "%APPDATA%\ThreadPilot" (
- rd /s /q "%APPDATA%\ThreadPilot"
- echo User settings removed.
- ) else (
- echo No user settings folder found.
- )
+ echo [3/4] Removing ThreadPilot user data for this Windows account...
+ rem Full uninstall removes only ThreadPilot-owned per-user AppData. Normal install/update paths never run this script.
+ if exist "%APPDATA%\ThreadPilot" (
+ rd /s /q "%APPDATA%\ThreadPilot"
+ echo ThreadPilot user data removed.
) else (
- echo User settings were kept.
+ echo No ThreadPilot user data folder found.
)
echo [4/4] Scheduling app folder removal...
diff --git a/Installer/Installer.iss b/Installer/Installer.iss
index 622de70..9505e78 100644
--- a/Installer/Installer.iss
+++ b/Installer/Installer.iss
@@ -5,7 +5,7 @@
#define MyAppPublisher "ThreadPilot"
#define MyAppURL "https://github.com/"
#define MyAppExeName "ThreadPilot.exe"
-#define MyAppVersion "1.3.0"
+#define MyAppVersion "1.3.1"
#ifndef MyWizardStyle
#define MyWizardStyle "modern dynamic windows11"
@@ -20,7 +20,7 @@
AppId={{A2A4C8B5-4A9A-4B1B-93F4-5F8B1C7E8C2A}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
-AppVerName={#MyAppName} {#MyAppVersion}
+AppVerName={#MyAppName}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
@@ -54,7 +54,15 @@ Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: de
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
[UninstallRun]
-Filename: taskkill.exe; Parameters: "/IM '{#MyAppExeName}' /F"; Flags: runhidden waituntilterminated; RunOnceId: UninstallKill
+Filename: "taskkill.exe"; Parameters: "/IM ""{#MyAppExeName}"" /F"; Flags: runhidden waituntilterminated; RunOnceId: "UninstallKill"
+Filename: "schtasks.exe"; Parameters: "/Delete /TN ""ThreadPilot_Startup"" /F"; Flags: runhidden waituntilterminated; RunOnceId: "UninstallRemoveThreadPilotStartupTask"
+Filename: "reg.exe"; Parameters: "delete ""HKCU\Software\Microsoft\Windows\CurrentVersion\Run"" /v ""ThreadPilot"" /f"; Flags: runhidden waituntilterminated; RunOnceId: "UninstallRemoveThreadPilotRunEntry"
+
+; ThreadPilot user data is preserved during install/update and removed only when
+; the generated uninstaller runs. Per-user AppData cleanup is limited to the
+; account context used by uninstall.
+[UninstallDelete]
+Type: filesandordirs; Name: "{userappdata}\ThreadPilot"
[Code]
diff --git a/Installer/ThreadPilot.wxs b/Installer/ThreadPilot.wxs
index b72c9fe..3306343 100644
--- a/Installer/ThreadPilot.wxs
+++ b/Installer/ThreadPilot.wxs
@@ -7,7 +7,7 @@
diff --git a/Installer/setup.iss b/Installer/setup.iss
index 25a2870..48135e4 100644
--- a/Installer/setup.iss
+++ b/Installer/setup.iss
@@ -11,7 +11,7 @@
#endif
#ifndef MyAppVersion
- #define MyAppVersion "1.3.0"
+ #define MyAppVersion "1.3.1"
#endif
#ifndef MyAppSourceDir
@@ -22,7 +22,7 @@
AppId={{E8F7A3B2-5C4D-4E6F-8A9B-1C2D3E4F5A6B}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
-AppVerName={#MyAppName} {#MyAppVersion}
+AppVerName={#MyAppName}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}/issues
@@ -62,3 +62,49 @@ Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
; Intentionally do not auto-launch after setup to keep package-manager installs unattended.
+; ThreadPilot user data is preserved during install/update. Inno removes installed
+; files and shortcuts automatically only when the generated uninstaller runs.
+; Per-user AppData cleanup is limited to the account context used by uninstall.
+[UninstallRun]
+Filename: "taskkill.exe"; Parameters: "/IM ""{#MyAppExeName}"" /F"; Flags: runhidden waituntilterminated; RunOnceId: "UninstallKillThreadPilot"
+Filename: "schtasks.exe"; Parameters: "/Delete /TN ""ThreadPilot_Startup"" /F"; Flags: runhidden waituntilterminated; RunOnceId: "UninstallRemoveThreadPilotStartupTask"
+Filename: "reg.exe"; Parameters: "delete ""HKCU\Software\Microsoft\Windows\CurrentVersion\Run"" /v ""ThreadPilot"" /f"; Flags: runhidden waituntilterminated; RunOnceId: "UninstallRemoveThreadPilotRunEntry"
+
+[UninstallDelete]
+Type: filesandordirs; Name: "{userappdata}\ThreadPilot"
+
+[Code]
+const
+ LegacyBetaUninstallKey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{A2A4C8B5-4A9A-4B1B-93F4-5F8B1C7E8C2A}_is1';
+ LegacyBetaDisplayName = 'ThreadPilot 0.1.0-beta';
+
+function IsLegacyThreadPilotInstallPath(InstallLocation: string): Boolean;
+var
+ NormalizedLocation: string;
+ ExpectedInstallRoot: string;
+begin
+ NormalizedLocation := Lowercase(RemoveBackslashUnlessRoot(RemoveQuotes(InstallLocation)));
+ ExpectedInstallRoot := Lowercase(RemoveBackslashUnlessRoot(ExpandConstant('{autopf}\ThreadPilot')));
+ Result := (NormalizedLocation = ExpectedInstallRoot);
+end;
+
+procedure DeleteLegacyBetaUninstallEntry(RootKey: Integer);
+var
+ DisplayName: string;
+ InstallLocation: string;
+begin
+ if RegQueryStringValue(RootKey, LegacyBetaUninstallKey, 'DisplayName', DisplayName) and
+ RegQueryStringValue(RootKey, LegacyBetaUninstallKey, 'InstallLocation', InstallLocation) and
+ (DisplayName = LegacyBetaDisplayName) and
+ IsLegacyThreadPilotInstallPath(InstallLocation) then
+ begin
+ RegDeleteKeyIncludingSubkeys(RootKey, LegacyBetaUninstallKey);
+ end;
+end;
+
+function InitializeSetup(): Boolean;
+begin
+ DeleteLegacyBetaUninstallEntry(HKLM);
+ DeleteLegacyBetaUninstallEntry(HKCU);
+ Result := True;
+end;
diff --git a/Locales/en-US.xaml b/Locales/en-US.xaml
index 41967ab..b09572b 100644
--- a/Locales/en-US.xaml
+++ b/Locales/en-US.xaml
@@ -30,6 +30,12 @@
Recommended
Don't show again
Open Settings
+ Primary navigation
+ Elevation warning overlay
+ Performance introduction overlay
+ Startup minimized suggestion
+ Unsaved settings dialog
+ Application startup loading overlay
ThreadPilot Activity
@@ -94,10 +100,13 @@
Delete selected mask
CPUs
CPU
+ Mask Options
Optional Diagnostics
Diagnostics are optional and intended for troubleshooting. For in-game overlays and detailed performance graphs, use dedicated tools.
+ 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.
@@ -128,6 +137,12 @@
Refresh the current dashboard snapshot
Global Power Plan
+ Process Hotspots
+ Filter
+ Memory
+ Name
+ Priority
+ Window
Memory Used
CPU %
Mem %
@@ -180,6 +195,7 @@
Rule CPU Mask:
Optional: Select a CPU mask to apply when this process starts
Rule Priority
+ Update
Optional: Select the process priority to apply when this process starts
Association Priority:
Higher priority associations take precedence when multiple match
@@ -206,6 +222,7 @@
Save Configuration
No automation rules yet
Status:
+ Automation Monitoring
Start Automation Monitoring
Stop Automation Monitoring
@@ -226,6 +243,7 @@
Process Management
Search, filter, and control active process configurations
Search processes by name
+ Process search
Hide Windows system processes
Hide System Processes
Hide processes with very low CPU usage
@@ -276,7 +294,11 @@
Refresh the process list
Load More
Load more processes
-
+ Refresh processes
+ Load more processes
+ Running process list
+ Virtualized process table with sorting and selection
+
Process Name
Window Title
CPU Usage
@@ -294,6 +316,32 @@
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.
+ Current process status
+ Advanced affinity picker
+ Select the pending mask for row context-menu affinity actions
+ Selected power plan
+ Save as Rule
+ No visible window title
+ Batch
+ total)
+ Priority
+ CPU: unavailable
+ Memory: unavailable
+ CPU priority: unavailable
+ Memory priority unavailable
+ Affinity: unavailable
+ No saved rule
+ No recent ThreadPilot action
+ Selected process: {0} (PID {1})
+ Current process status: protected or access denied
+ Current process status: selected
+ CPU: {0:N1}%
+ Memory: {0}
+ CPU priority: {0}
+ Affinity: legacy mask 0x{0:X}
+ Memory priority: {0}
+ saved rule
+ Saved rule exists: {0}
Above Normal
Below Normal
@@ -395,6 +443,46 @@
Import Configuration
Save Settings
ThreadPilot
+ Theme changed to {0}.
+ Failed to change theme to {0}.
+ Language changed to {0}.
+ Failed to change language.
+ Saving settings...
+ Settings saved with warnings: {0}
+ Settings saved and applied successfully.
+ Error saving settings: {0}
+ Resetting to defaults...
+ Settings reset to defaults (not saved yet)
+ Error resetting settings: {0}
+ Exporting configuration bundle...
+ Export canceled
+ Configuration exported to: {0}
+ Error exporting settings: {0}
+ Importing configuration...
+ Import canceled
+ Configuration bundle imported and applied
+ Legacy settings imported (rules unchanged)
+ Error importing settings: {0}
+ Test notification sent
+ Error sending test notification: {0}
+ Loading settings...
+ Settings loaded
+ Error loading settings: {0}
+ Settings synchronized
+ Checking for updates...
+ Unable to determine the latest version.
+ New version available: {0}
+ Application is up to date. Installed version: {0}
+ Error while checking updates: {0}
+ Settings have been modified
+ Settings match the saved configuration
+ Simplified Chinese
+ English
+ Dark
+ Light
+ Export ThreadPilot Configuration
+ Import ThreadPilot Configuration
+ Failed to update Windows autostart. Keeping previous autostart state.
ThreadPilot Settings
@@ -509,7 +597,30 @@
Game Boost mode deactivated after {0}
Process Monitor Error
Affinity blocked
+ Affinity applied
+ Affinity adjusted
+ Affinity failed
+ Affinity error
Priority blocked
+ Priority warning
+ Priority applied
+ Priority adjusted
+ Priority error
+ Keyboard Shortcut
+ Toggle monitoring shortcut activated
+ High Performance power plan shortcut activated
+ Refresh process list shortcut activated
+ ThreadPilot Started
+ Process monitoring and power plan management is now active
+ Startup Error
+ Failed to start process monitoring manager
+ Automation Monitoring Error
+ Settings Saved
+ Application settings have been saved successfully
+ Settings Saved with Warnings
+ Settings Error
+ Failed to save settings
+ This is a test notification to verify your settings are working correctly.
ThreadPilot requires administrator privileges to manage process affinity and power plans.
Would you like to restart the application with administrator privileges?
diff --git a/Locales/zh-CN.xaml b/Locales/zh-CN.xaml
index 3a9ce20..8f4bcfc 100644
--- a/Locales/zh-CN.xaml
+++ b/Locales/zh-CN.xaml
@@ -30,6 +30,12 @@
推荐
不再显示
打开设置
+ 主导航
+ 权限提升警告覆盖层
+ 性能诊断介绍覆盖层
+ 启动时最小化建议
+ 未保存设置对话框
+ 应用启动加载覆盖层
ThreadPilot 活动日志
@@ -94,10 +100,13 @@
删除选定的掩码
CPU 核心
CPU
+ 掩码选项
可选性能诊断
诊断是可选的,仅用于故障排除。对于游戏内覆盖层和详细的性能图表,请使用专用工具。
+ 诊断是可选的,仅用于故障排除。
+ 对于游戏内覆盖层和详细的性能图表,请使用专用工具。
快速提示:
1. 仅在需要针对性的故障排除快照时才打开诊断。
2. 在创建自动化规则之前,仅将热点查看作为提示。
@@ -128,6 +137,12 @@
刷新当前的仪表板快照
全局电源计划
+ 进程热点
+ 筛选
+ 内存
+ 名称
+ 优先级
+ 窗口
已用内存
CPU %
内存 %
@@ -180,6 +195,7 @@
规则 CPU 掩码:
可选: 选择在该进程启动时要应用的 CPU 掩码
规则优先级
+ 更新
可选: 选择在该进程启动时要应用的进程优先级
关联优先级:
当有多个匹配时,优先级较高的关联优先使用
@@ -206,6 +222,7 @@
保存配置
尚无自动化规则
状态:
+ 自动化监控
启动自动化监控
停止自动化监控
@@ -226,6 +243,7 @@
系统进程管理
搜索、过滤和控制活动的进程配置
按名称搜索进程
+ 进程搜索
隐藏 Windows 系统进程
隐藏系统进程
隐藏 CPU 使用率极低的进程
@@ -276,6 +294,10 @@
刷新进程列表
加载更多
加载更多进程
+ 刷新进程
+ 加载更多进程
+ 运行中进程列表
+ 支持排序和选择的虚拟化进程表
进程名称
窗口标题
@@ -294,6 +316,32 @@
应用待处理设置
将待处理的关联性和选定的电源计划应用于选定的进程
ThreadPilot 仅在配置后才应用规则和更改。
+ 当前进程状态
+ 高级关联性选择器
+ 为行上下文菜单关联性操作选择待处理掩码
+ 所选电源计划
+ 保存为规则
+ 无可见窗口标题
+ 批次
+ 总计)
+ 优先级
+ CPU: 不可用
+ 内存: 不可用
+ CPU 优先级: 不可用
+ 内存优先级不可用
+ 关联性: 不可用
+ 无已保存规则
+ 暂无最近的 ThreadPilot 操作
+ 选定进程: {0} (PID {1})
+ 当前进程状态: 受保护或访问被拒绝
+ 当前进程状态: 已选择
+ CPU: {0:N1}%
+ 内存: {0}
+ CPU 优先级: {0}
+ 关联性: 旧版掩码 0x{0:X}
+ 内存优先级: {0}
+ 已保存规则
+ 存在已保存规则: {0}
高于正常
低于正常
@@ -395,6 +443,46 @@
导入配置包
保存并应用设置
ThreadPilot
+ 主题已切换为{0}。
+ 无法切换主题为{0}。
+ 语言已切换为{0}。
+ 无法切换语言。
+ 正在保存设置...
+ 设置已保存但有警告: {0}
+ 设置已成功保存并应用。
+ 保存设置时出错: {0}
+ 正在重置为默认值...
+ 设置已重置为默认值(尚未保存)
+ 重置设置时出错: {0}
+ 正在导出配置包...
+ 已取消导出
+ 配置已导出到: {0}
+ 导出设置时出错: {0}
+ 正在导入配置...
+ 已取消导入
+ 配置包已导入并应用
+ 旧版设置已导入(规则未更改)
+ 导入设置时出错: {0}
+ 测试通知已发送
+ 发送测试通知时出错: {0}
+ 正在加载设置...
+ 设置已加载
+ 加载设置时出错: {0}
+ 设置已同步
+ 正在检查更新...
+ 无法确定最新版本。
+ 发现新版本: {0}
+ 应用已是最新版本。已安装版本: {0}
+ 检查更新时出错: {0}
+ 设置已被修改
+ 设置与已保存的配置一致
+ 简体中文
+ 英文
+ 深色
+ 浅色
+ 导出 ThreadPilot 配置
+ 导入 ThreadPilot 配置
+ 无法更新 Windows 自启动。将保留之前的自启动状态。
ThreadPilot 设置
@@ -509,7 +597,30 @@
游戏加速模式已关闭,持续时间: {0}
进程监控器错误
关联性应用被阻止
+ 关联性已应用
+ 关联性已调整
+ 关联性应用失败
+ 关联性错误
优先级应用被阻止
+ 优先级警告
+ 优先级已应用
+ 优先级已调整
+ 优先级错误
+ 键盘快捷键
+ 切换监控快捷键已触发
+ 高性能电源计划快捷键已触发
+ 刷新进程列表快捷键已触发
+ ThreadPilot 已启动
+ 进程监控和电源计划管理现已激活
+ 启动错误
+ 无法启动进程监控管理器
+ 自动化监控错误
+ 设置已保存
+ 应用设置已成功保存
+ 设置已保存但有警告
+ 设置错误
+ 无法保存设置
+ 这是一条用于验证您的设置是否正常工作的测试通知。
ThreadPilot 需要管理员权限来管理进程关联性和电源计划。
您想以管理员权限重新启动应用程序吗?
diff --git a/MainWindow.xaml b/MainWindow.xaml
index 7a203ed..c991768 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -10,7 +10,7 @@
mc:Ignorable="d"
ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica"
- Title="ThreadPilot - Process & Power Plan Manager"
+ Title="{DynamicResource MainWindow_Title}"
Height="864"
Width="1280"
WindowStartupLocation="CenterScreen"
@@ -86,7 +86,7 @@
IsPaneOpen="False"
OpenPaneLength="180"
CompactPaneLength="52"
- AutomationProperties.Name="Primary navigation">
+ AutomationProperties.Name="{DynamicResource MainWindow_PrimaryNavigation}">
-
-
-
+
@@ -290,22 +290,22 @@
-
+
-
+
-
+
-
+
-
-
+
-
-
+
-
+
@@ -168,9 +168,9 @@
-
+
-
-
+
@@ -219,20 +219,20 @@
-
+
-
-
+ ToolTip="{DynamicResource MasksView_DefaultPresetTip}"/>
-
+ ToolTip="{DynamicResource MasksView_DisableTip}"/>
diff --git a/Views/PerformanceView.xaml b/Views/PerformanceView.xaml
index 2279773..a7aa5ff 100644
--- a/Views/PerformanceView.xaml
+++ b/Views/PerformanceView.xaml
@@ -75,33 +75,33 @@
-
-
-
+
+
+
-
-
+
-
+
-
+
+ ToolTip="{DynamicResource PerformanceView_ClearHistoryTooltip}"/>
@@ -124,7 +124,7 @@
-
+
@@ -132,7 +132,7 @@
-
+
@@ -140,7 +140,7 @@
-
+
@@ -169,20 +169,20 @@
-
-
+
+
-
-
+
-
@@ -198,7 +198,7 @@
@@ -215,18 +215,18 @@
Width="110"
Margin="0,0,8,0">
-
-
+
+
@@ -250,12 +250,12 @@
MinHeight="220"
MaxHeight="380">
-
-
-
-
-
-
+
+
+
+
+
+
@@ -303,8 +303,8 @@
-
-
+
+
@@ -320,8 +320,8 @@
-
-
+
+
-
+
-
+
-
-
-
-
+
+
+
+
-
+
-
-
-
-
-
+
+
+
+
+
diff --git a/Views/PowerPlanView.xaml b/Views/PowerPlanView.xaml
index d095797..c8d4bfd 100644
--- a/Views/PowerPlanView.xaml
+++ b/Views/PowerPlanView.xaml
@@ -16,8 +16,8 @@
-
-
+
+
@@ -29,7 +29,7 @@
@@ -43,21 +43,21 @@
+ ToolTip="{DynamicResource PowerPlanView_SelectActiveTip}"/>
+ ToolTip="{DynamicResource PowerPlanView_SetActiveTooltip}"/>
+ ToolTip="{DynamicResource PowerPlanView_RefreshTooltip}"/>
@@ -73,7 +73,7 @@
-
@@ -120,7 +120,7 @@
Padding="8,2"
VerticalAlignment="Top"
Visibility="{Binding IsActive, Converter={StaticResource BoolToVisibilityConverter}}">
-
@@ -135,7 +135,7 @@
@@ -149,21 +149,21 @@
+ ToolTip="{DynamicResource PowerPlanView_LocalPlansTip}"/>
+ ToolTip="{DynamicResource PowerPlanView_ImportSelectedTooltip}"/>
+ ToolTip="{DynamicResource PowerPlanView_AddFileTooltip}"/>
diff --git a/Views/ProcessPowerPlanAssociationView.xaml b/Views/ProcessPowerPlanAssociationView.xaml
index 7f05b0a..47de0e7 100644
--- a/Views/ProcessPowerPlanAssociationView.xaml
+++ b/Views/ProcessPowerPlanAssociationView.xaml
@@ -21,8 +21,8 @@
-
-
+
@@ -62,7 +62,7 @@
-
+
@@ -71,7 +71,7 @@
-
+
@@ -87,28 +87,28 @@
HeadersVisibility="Column"
BorderThickness="1">
-
-
-
-
-
-
-
-
@@ -130,11 +130,11 @@
-
-
-
-
+
+ ToolTip="{DynamicResource ProcessPowerPlanAssociationView_RemoveTooltip}"/>
-
+
-
-
+
-
+ ToolTip="{DynamicResource ProcessPowerPlanAssociationView_BrowseTooltip}"/>
-
+
-
+ ToolTip="{DynamicResource ProcessPowerPlanAssociationView_ClearSelectionTooltip}"/>
-
+ ToolTip="{DynamicResource ProcessPowerPlanAssociationView_MatchByPathTooltip}"/>
-
-
+
-
-
+
+ ToolTip="{DynamicResource ProcessPowerPlanAssociationView_RuleCpuMaskHelp}"/>
-
-
+
+ ToolTip="{DynamicResource ProcessPowerPlanAssociationView_RulePriorityHelp}"/>
-
+
+ ToolTip="{DynamicResource ProcessPowerPlanAssociationView_AssociationPriorityHelp}"/>
-
+
-
-
-
+ ToolTip="{DynamicResource ProcessPowerPlanAssociationView_UseRunningProcessTooltip}"/>
-
+ ToolTip="{DynamicResource ProcessPowerPlanAssociationView_UseSelectedProcessTooltip}"/>
@@ -275,29 +275,29 @@
-
+
-
+
-
-
+
-
-
@@ -306,9 +306,9 @@
-
+
-
-
-
+
-
-
-
-
-
-
diff --git a/Views/ProcessView.xaml b/Views/ProcessView.xaml
index e194618..6a734ff 100644
--- a/Views/ProcessView.xaml
+++ b/Views/ProcessView.xaml
@@ -156,8 +156,8 @@
-
-
+
+
@@ -201,53 +201,53 @@
+ AutomationProperties.Name="{DynamicResource ProcessView_SearchAutomationName}"/>
+ ToolTip="{DynamicResource ProcessView_VisibleWindowsOnly}"/>
+ Content="{DynamicResource ProcessView_Refresh}"
+ ToolTip="{DynamicResource ProcessView_RefreshTooltip}"
+ AutomationProperties.Name="{DynamicResource ProcessView_RefreshAutomationName}"/>
+ ToolTip="{DynamicResource ProcessView_LoadMoreTooltip}"
+ AutomationProperties.Name="{DynamicResource ProcessView_LoadMoreAutomationName}"/>
-
-
+
-
+
-
+ ToolTip="{DynamicResource ProcessView_LockListTooltip}"/>
+
-
-
-
-
+ ToolTip="{DynamicResource ProcessView_SortByTooltip}">
+
+
+
+
@@ -269,8 +269,8 @@
EnableRowVirtualization="True"
EnableColumnVirtualization="True"
ScrollViewer.CanContentScroll="True"
- AutomationProperties.Name="Running process list"
- AutomationProperties.HelpText="Virtualized process table with sorting and selection">
+ AutomationProperties.Name="{DynamicResource ProcessView_RunningListAutomationName}"
+ AutomationProperties.HelpText="{DynamicResource ProcessView_RunningListAutomationHelp}">
-
-
-
-
-
-
+
+
+
+
+
-
+
@@ -415,13 +415,13 @@
Padding="8,5"
CornerRadius="{DynamicResource StandardCardCornerRadius}">
-
+
-
+
@@ -434,11 +434,11 @@
Padding="10"
Margin="0,12,0,0">
-
-
-
+
-
@@ -499,7 +499,7 @@
@@ -508,14 +508,14 @@
SelectedItem="{Binding SelectedCoreMask, Mode=TwoWay}"
DisplayMemberPath="Name"
IsEnabled="{Binding SelectedProcess, Converter={StaticResource NullToBoolConverter}}"
- ToolTip="Select the pending mask for row context-menu affinity actions"/>
+ ToolTip="{DynamicResource ProcessView_RowMaskTooltip}"/>
+ ToolTip="{DynamicResource ProcessView_OpenMasksTabTooltip}">
-
+
@@ -583,7 +583,7 @@
CornerRadius="8">
-
@@ -595,7 +595,7 @@
Background="{DynamicResource QuietRowBackgroundBrush}"
Margin="0,0,0,12">
-
@@ -610,7 +610,7 @@
-
-
+
-
+
+ ToolTip="{DynamicResource ProcessView_AffinityCurrentTooltip}"/>
+ ToolTip="{DynamicResource ProcessView_AffinityStagedTooltip}"/>
-
+
+ ToolTip="{DynamicResource ProcessView_StageMaskTooltip}">
@@ -700,7 +700,7 @@
FontSize="{StaticResource ProcessFontSmall}"
Margin="0,2,0,6"
Foreground="{Binding IsHyperThreadingActive, Converter={StaticResource BoolToColorConverter}}"
- ToolTip="Shows whether Hyper-Threading (Intel) or SMT (AMD) is present and active on this system"/>
+ ToolTip="{DynamicResource ProcessView_HyperThreadingTooltip}"/>
+ ToolTip="{DynamicResource ProcessView_CpuSelectionPreviewTooltip}"/>
@@ -736,59 +736,59 @@
+ ToolTip="{DynamicResource ProcessView_ApplyAffinityTooltip}"/>
+ ToolTip="{DynamicResource ProcessView_OpenMasksTabTooltip}">
-
+
-
+
-
-
-
-
-
-
+
+
+
+
+
+
-
+
-
+
+ ToolTip="{DynamicResource ProcessView_DisableIdleServerTooltip}"/>
-
+
-
@@ -796,7 +796,7 @@
SelectedItem="{Binding SelectedPowerPlan, Mode=TwoWay}"
DisplayMemberPath="Name"
Margin="0,0,0,8"
- ToolTip="Choose the power plan to activate manually or include with quick apply"/>
+ ToolTip="{DynamicResource ProcessView_PowerPlanTooltip}"/>
@@ -805,24 +805,24 @@
+ ToolTip="{DynamicResource ProcessView_SetPowerPlanTooltip}"/>
+ ToolTip="{DynamicResource ProcessView_ApplyPendingSettingsTooltip}"/>
-
+
+ ToolTip="{DynamicResource ProcessView_ProfileNamePlaceholder}"/>
@@ -831,21 +831,21 @@
+ ToolTip="{DynamicResource ProcessView_SaveProfileTooltip}"/>
+ ToolTip="{DynamicResource ProcessView_LoadProfileTooltip}"/>
+ ToolTip="{DynamicResource ProcessView_ApplyAffinitySaveRuleTooltip}"/>
diff --git a/Views/SettingsView.xaml b/Views/SettingsView.xaml
index c72bd35..c98d358 100644
--- a/Views/SettingsView.xaml
+++ b/Views/SettingsView.xaml
@@ -40,8 +40,8 @@
-
-
+
@@ -51,54 +51,54 @@
-
+
-
-
+
-
-
-
+
+
+
-
-
-
+
-
-
-
-
@@ -112,7 +112,7 @@
-
+
-
-
+
-
-
-
-
-
-
-
@@ -237,17 +237,17 @@
-
-
-
-
@@ -260,7 +260,7 @@
-
+
-
+
-
+
-
@@ -310,7 +310,7 @@
-
+
-
-
-
-
@@ -343,23 +343,23 @@
-
+
-
-
+
+
-
-
+
+
-
-
@@ -412,22 +412,22 @@
-
-
-
-
diff --git a/Views/SettingsWindow.xaml b/Views/SettingsWindow.xaml
index 84a7dc3..4c7cb60 100644
--- a/Views/SettingsWindow.xaml
+++ b/Views/SettingsWindow.xaml
@@ -5,7 +5,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:ThreadPilot.Views"
mc:Ignorable="d"
- Title="ThreadPilot Settings"
+ Title="{DynamicResource SettingsWindow_Title}"
Height="700" Width="900"
MinHeight="600" MinWidth="800"
WindowStartupLocation="CenterOwner"
@@ -25,7 +25,7 @@
Visibility="Collapsed"
Background="{DynamicResource OverlayBrush}"
Panel.ZIndex="10"
- AutomationProperties.Name="Unsaved settings dialog">
+ AutomationProperties.Name="{DynamicResource MainWindow_UnsavedSettingsDialog}">
-
-
-
-
-
-
-
@@ -127,7 +127,7 @@
@@ -227,7 +227,7 @@
VerticalAlignment="Center"
HorizontalAlignment="Right"
Margin="10,0,0,0"
- ToolTip="Toggle this Windows tweak"/>
+ ToolTip="{DynamicResource SystemTweaksView_TweakTooltip}"/>
-
+
diff --git a/build/build-installer.ps1 b/build/build-installer.ps1
index 1fbdb8e..c5ad8c8 100644
--- a/build/build-installer.ps1
+++ b/build/build-installer.ps1
@@ -1,5 +1,5 @@
param(
- [string]$Version = "1.3.0",
+ [string]$Version = "1.3.1",
[string]$Configuration = "Release",
[switch]$SkipPublish
)
diff --git a/build/build-release.ps1 b/build/build-release.ps1
index ec4dd78..cd6db20 100644
--- a/build/build-release.ps1
+++ b/build/build-release.ps1
@@ -1,5 +1,5 @@
param(
- [string]$Version = "1.3.0",
+ [string]$Version = "1.3.1",
[string]$Configuration = "Release",
[string]$Runtime = "win-x64"
)
@@ -65,18 +65,13 @@ echo [2/4] Removing startup task and startup registry entry...
schtasks /Delete /TN "ThreadPilot_Startup" /F >nul 2>&1
reg delete "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v "ThreadPilot" /f >nul 2>&1
-echo [3/4] Optional user data cleanup...
-set "REMOVE_DATA=N"
-set /p REMOVE_DATA=Do you want to remove user settings at "%APPDATA%\ThreadPilot"? [y/N]:
-if /I "%REMOVE_DATA%"=="Y" (
- if exist "%APPDATA%\ThreadPilot" (
- rd /s /q "%APPDATA%\ThreadPilot"
- echo User settings removed.
- ) else (
- echo No user settings folder found.
- )
+echo [3/4] Removing ThreadPilot user data for this Windows account...
+rem Full uninstall removes only ThreadPilot-owned per-user AppData. Normal install/update paths never run this script.
+if exist "%APPDATA%\ThreadPilot" (
+ rd /s /q "%APPDATA%\ThreadPilot"
+ echo ThreadPilot user data removed.
) else (
- echo User settings were kept.
+ echo No ThreadPilot user data folder found.
)
echo [4/4] Scheduling app folder removal...
diff --git a/build/package-release-zips.ps1 b/build/package-release-zips.ps1
index 54c8d59..85125a0 100644
--- a/build/package-release-zips.ps1
+++ b/build/package-release-zips.ps1
@@ -1,5 +1,5 @@
param(
- [string]$Version = "1.3.0"
+ [string]$Version = "1.3.1"
)
$ErrorActionPreference = "Stop"
@@ -38,18 +38,13 @@ echo [2/4] Removing startup task and startup registry entry...
schtasks /Delete /TN "ThreadPilot_Startup" /F >nul 2>&1
reg delete "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v "ThreadPilot" /f >nul 2>&1
-echo [3/4] Optional user data cleanup...
-set "REMOVE_DATA=N"
-set /p REMOVE_DATA=Do you want to remove user settings at "%APPDATA%\ThreadPilot"? [y/N]:
-if /I "%REMOVE_DATA%"=="Y" (
- if exist "%APPDATA%\ThreadPilot" (
- rd /s /q "%APPDATA%\ThreadPilot"
- echo User settings removed.
- ) else (
- echo No user settings folder found.
- )
+echo [3/4] Removing ThreadPilot user data for this Windows account...
+rem Full uninstall removes only ThreadPilot-owned per-user AppData. Normal install/update paths never run this script.
+if exist "%APPDATA%\ThreadPilot" (
+ rd /s /q "%APPDATA%\ThreadPilot"
+ echo ThreadPilot user data removed.
) else (
- echo User settings were kept.
+ echo No ThreadPilot user data folder found.
)
echo [4/4] Scheduling app folder removal...
diff --git a/chocolatey/threadpilot.nuspec b/chocolatey/threadpilot.nuspec
index e51f187..7048d8d 100644
--- a/chocolatey/threadpilot.nuspec
+++ b/chocolatey/threadpilot.nuspec
@@ -2,7 +2,7 @@
threadpilot
- 1.3.0
+ 1.3.1
Codestin Search App
Prime Build
https://github.com/PrimeBuild-pc/ThreadPilot
@@ -15,7 +15,7 @@
false
Advanced Windows process and power plan manager with rules automation and performance controls.
ThreadPilot process and power plan manager.
- https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.3.0
+ https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.3.1
threadpilot process powerplan performance windows
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 490dda5..dd4979b 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -2,6 +2,24 @@
All notable changes to this project are documented in this file.
+## v1.3.1 - Localization and installer metadata hotfix
+
+### Fixed
+
+- Completed Simplified Chinese localization coverage for primary WPF views, dialogs, context menus, tooltips, tray menu text, status text, and user-facing service messages.
+- Changed Inno Setup display metadata so installed apps list ThreadPilot as `ThreadPilot` while keeping `1.3.1` in version metadata.
+- Added guarded cleanup for obsolete `ThreadPilot 0.1.0-beta` uninstall registry metadata only when it clearly matches the old ThreadPilot display name and Program Files install path.
+
+### Changed
+
+- Project, package, installer, Chocolatey, Sonar, and app manifest metadata updated to 1.3.1.
+- Full uninstall removes ThreadPilot-owned AppData/settings for the uninstalling user account and removes ThreadPilot startup entries; normal install/update preserves user data.
+
+### Safety
+
+- No automatic in-app updating was added.
+- No elevation, affinity, process control, power plan, or system tweak behavior was changed.
+
## v1.3.0 - Localization support
### Added
diff --git a/docs/release/RELEASE_NOTES.md b/docs/release/RELEASE_NOTES.md
index 193ee1d..c186a41 100644
--- a/docs/release/RELEASE_NOTES.md
+++ b/docs/release/RELEASE_NOTES.md
@@ -1,48 +1,41 @@
-# ThreadPilot v1.3.0 Release Notes Draft
+# ThreadPilot v1.3.1 Release Notes Draft
## Highlights
-- Added localization infrastructure with English and Simplified Chinese resources.
-- English remains the default language.
-- Simplified Chinese is available as an optional display language through Settings.
-- Selected user-facing notification messages now use localized resources.
-- Localization fallback behavior is covered by expanded tests.
+- Completed the Simplified Chinese localization pass for the main WPF views, dialogs, context menus, tray menu, tooltips, accessibility text, status text, and user-facing service notifications.
+- Kept English as the default startup language and Simplified Chinese as an optional Settings choice.
+- Changed installer display metadata so installed apps list ThreadPilot as `ThreadPilot` while preserving version metadata separately as `1.3.1`.
+- Improved uninstall cleanup for ThreadPilot-owned application files, shortcuts, startup entries, and per-user AppData/settings when the uninstaller runs.
-## Added
+## Fixed
-- Added localization infrastructure.
-- Added English and Simplified Chinese resource dictionaries.
-- Added language selector in Settings.
-- Added localized notification support for selected user-facing messages.
-- Added tests for localization fallback, language persistence, Settings language selection, and localized notifications.
+- Fixed incomplete zh-CN coverage that left many v1.3.0 UI surfaces in English.
+- Fixed Inno Setup `AppVerName` so uninstall tools do not show versioned names such as `ThreadPilot 1.3.1`.
+- Added guarded cleanup for the obsolete `ThreadPilot 0.1.0-beta` uninstall registry entry only when it exactly matches the legacy ThreadPilot display name and Program Files install location.
-## Changed
+## Safety
-- English remains the default application language.
-- Simplified Chinese is available as an optional display language.
-- Project version updated to 1.3.0.
+- No automatic in-app updater was added.
+- No elevation, affinity, process control, power plan, or system tweak behavior was changed.
+- Normal install/update preserves existing user settings and data.
+- Full uninstall removes only ThreadPilot-owned AppData for the uninstalling user account; elevated uninstall contexts may not be able to clean another Windows user's per-user AppData.
-## Safety
+## Testing
-- Unsupported or invalid language settings now fall back to English.
-- Missing translation keys fall back safely to English or the key.
-- No changes to elevation, system tweaks, affinity, or privileged operation behavior.
+- Added resource key parity coverage for `Locales/en-US.xaml` and `Locales/zh-CN.xaml`.
+- Added hardcoded-English checks for important WPF views with brand and technical terms whitelisted.
+- Added release metadata tests for installer display name, uninstall cleanup scope, legacy beta uninstall-entry cleanup, and v1.3.1 version surfaces.
## Compatibility and Upgrade Notes
- Requires Windows 11 build 22000 or newer.
-- Existing settings continue to load.
-- Existing installations without a language setting default to English.
-- Existing process, affinity, power plan, and automation behavior is unchanged.
+- Existing settings, profiles, masks, persistent rules, imported power plans, and logs continue to load after update.
+- Existing process, affinity, power plan, automation, and system tweak behavior is unchanged.
## Known Non-Goals
+- No GitHub release or tag is created by this hotfix branch.
+- No automatic in-app updating.
- No anti-cheat bypass.
- No Windows Service.
- No registry or IFEO persistence.
-- No generated release artifacts yet.
-- No GitHub release or tag yet.
-
-## Release Artifact Status
-
-- Installer, portable ZIP, checksums, package metadata verification, and release upload remain pending manual validation.
diff --git a/docs/releases/v1.3.1.md b/docs/releases/v1.3.1.md
new file mode 100644
index 0000000..c186a41
--- /dev/null
+++ b/docs/releases/v1.3.1.md
@@ -0,0 +1,41 @@
+# ThreadPilot v1.3.1 Release Notes Draft
+
+## Highlights
+
+- Completed the Simplified Chinese localization pass for the main WPF views, dialogs, context menus, tray menu, tooltips, accessibility text, status text, and user-facing service notifications.
+- Kept English as the default startup language and Simplified Chinese as an optional Settings choice.
+- Changed installer display metadata so installed apps list ThreadPilot as `ThreadPilot` while preserving version metadata separately as `1.3.1`.
+- Improved uninstall cleanup for ThreadPilot-owned application files, shortcuts, startup entries, and per-user AppData/settings when the uninstaller runs.
+
+## Fixed
+
+- Fixed incomplete zh-CN coverage that left many v1.3.0 UI surfaces in English.
+- Fixed Inno Setup `AppVerName` so uninstall tools do not show versioned names such as `ThreadPilot 1.3.1`.
+- Added guarded cleanup for the obsolete `ThreadPilot 0.1.0-beta` uninstall registry entry only when it exactly matches the legacy ThreadPilot display name and Program Files install location.
+
+## Safety
+
+- No automatic in-app updater was added.
+- No elevation, affinity, process control, power plan, or system tweak behavior was changed.
+- Normal install/update preserves existing user settings and data.
+- Full uninstall removes only ThreadPilot-owned AppData for the uninstalling user account; elevated uninstall contexts may not be able to clean another Windows user's per-user AppData.
+
+## Testing
+
+- Added resource key parity coverage for `Locales/en-US.xaml` and `Locales/zh-CN.xaml`.
+- Added hardcoded-English checks for important WPF views with brand and technical terms whitelisted.
+- Added release metadata tests for installer display name, uninstall cleanup scope, legacy beta uninstall-entry cleanup, and v1.3.1 version surfaces.
+
+## Compatibility and Upgrade Notes
+
+- Requires Windows 11 build 22000 or newer.
+- Existing settings, profiles, masks, persistent rules, imported power plans, and logs continue to load after update.
+- Existing process, affinity, power plan, automation, and system tweak behavior is unchanged.
+
+## Known Non-Goals
+
+- No GitHub release or tag is created by this hotfix branch.
+- No automatic in-app updating.
+- No anti-cheat bypass.
+- No Windows Service.
+- No registry or IFEO persistence.
diff --git a/sonar-project.properties b/sonar-project.properties
index 6425be6..fb3dab0 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -1,6 +1,6 @@
sonar.projectKey=threadpilot
sonar.projectName=ThreadPilot
-sonar.projectVersion=1.3.0
+sonar.projectVersion=1.3.1
sonar.sourceEncoding=UTF-8
sonar.sources=.
From b0b65e8c36010e73255bab544abeff1fade56b70 Mon Sep 17 00:00:00 2001
From: Prime Build <162145141+PrimeBuild-pc@users.noreply.github.com>
Date: Sun, 7 Jun 2026 05:16:49 +0200
Subject: [PATCH 5/7] Fix release smoke-test exit (#27)
## Summary
Fixes the release workflow smoke-test hang by handling `--smoke-test` early during app startup.
## Changes
- Detects `--smoke-test` before elevation, mutex, WPF UI startup, tray, or background services.
- Performs lightweight startup/package validation only.
- Exits explicitly with code 0 on success, 1 on validation failure, 2 on timeout.
- Adds regression tests for early smoke-test behavior.
## Validation
- `dotnet build ThreadPilot_1.sln`: passed
- `dotnet test ThreadPilot_1.sln`: passed, 527 tests
## Safety
- No normal startup behavior changes.
- No localization changes.
- No installer/uninstaller changes.
- No elevation/process/power plan behavior changes.
---
App.xaml.cs | 174 ++++++++++--------
.../AppSmokeTestStartupTests.cs | 78 ++++++++
2 files changed, 178 insertions(+), 74 deletions(-)
create mode 100644 Tests/ThreadPilot.Core.Tests/AppSmokeTestStartupTests.cs
diff --git a/App.xaml.cs b/App.xaml.cs
index 4916d2a..06dc022 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -63,50 +63,22 @@ public App()
protected override void OnStartup(StartupEventArgs e)
{
// Parse command line arguments early so special startup modes can short-circuit normal flow.
- bool startMinimized = false;
- bool isAutostart = false;
- bool isSmokeTest = false;
- bool registerLaunchTask = false;
- bool launchedViaTask = false;
-#if DEBUG
- bool isTestMode = false;
-#endif
+ var startupMode = StartupMode.Parse(e.Args);
bool effectiveStartMinimized = false;
ApplicationSettingsModel? loadedSettings = null;
- foreach (var arg in e.Args)
+ effectiveStartMinimized = startupMode.StartMinimized;
+
+ if (startupMode.IsSmokeTest)
{
- switch (arg.ToLowerInvariant())
- {
-#if DEBUG
- case "--test":
- isTestMode = true;
- break;
-#endif
- case "--smoke-test":
- isSmokeTest = true;
- break;
- case "--start-minimized":
- startMinimized = true;
- break;
- case "--autostart":
- isAutostart = true;
- break;
- case "--startup": // Alternative startup argument
- isAutostart = true;
- startMinimized = true;
- break;
- case RegisterLaunchTaskArgument:
- registerLaunchTask = true;
- break;
- case LaunchedViaTaskArgument:
- launchedViaTask = true;
- break;
- }
+ var smokeLogger = this.ServiceProvider.GetRequiredService>();
+ var smokeTestResult = this.RunSmokeTestWithTimeout(smokeLogger, TimeSpan.FromSeconds(10));
+ Environment.ExitCode = smokeTestResult;
+ this.Shutdown(smokeTestResult);
+ Environment.Exit(smokeTestResult);
+ return;
}
- effectiveStartMinimized = startMinimized;
-
// Set up global exception handlers first
AppDomain.CurrentDomain.UnhandledException += this.OnUnhandledException;
this.DispatcherUnhandledException += this.OnDispatcherUnhandledException;
@@ -130,14 +102,14 @@ protected override void OnStartup(StartupEventArgs e)
}
else
{
- if (launchedViaTask)
+ if (startupMode.LaunchedViaTask)
{
logger.LogError("Application was launched via managed task marker but is still not elevated.");
}
#if DEBUG
- else if (!isSmokeTest && !isTestMode)
+ else if (!startupMode.IsTestMode)
#else
- else if (!isSmokeTest)
+ else
#endif
{
var launchedElevatedInstance = Task.Run(async () => await elevatedTaskService.TryRunLaunchTaskAsync()).GetAwaiter().GetResult();
@@ -148,7 +120,7 @@ protected override void OnStartup(StartupEventArgs e)
return;
}
- if (!registerLaunchTask)
+ if (!startupMode.RegisterLaunchTask)
{
logger.LogInformation("Managed elevated launch task is unavailable. Requesting one-time elevation to bootstrap persistent launch.");
var restartInitiated = Task.Run(async () => await elevationService.RestartWithElevation(new[] { RegisterLaunchTaskArgument })).GetAwaiter().GetResult();
@@ -160,9 +132,9 @@ protected override void OnStartup(StartupEventArgs e)
}
#if DEBUG
- if (!isSmokeTest && !isTestMode)
+ if (!startupMode.IsTestMode)
#else
- if (!isSmokeTest)
+ if (true)
#endif
{
logger.LogError("ThreadPilot requires administrator privileges and cannot continue without elevation.");
@@ -170,33 +142,28 @@ protected override void OnStartup(StartupEventArgs e)
this.Shutdown(1);
return;
}
-
- logger.LogWarning("Application is running without administrator privileges in smoke test mode.");
}
// Enforce single-instance after elevation bootstrap logic to avoid mutex races during handoff.
- if (!isSmokeTest)
+ bool createdNew;
+ this.singleInstanceMutex = new Mutex(initiallyOwned: true, name: "Global\\ThreadPilot_SingleInstance", createdNew: out createdNew);
+ if (!createdNew)
{
- bool createdNew;
- this.singleInstanceMutex = new Mutex(initiallyOwned: true, name: "Global\\ThreadPilot_SingleInstance", createdNew: out createdNew);
- if (!createdNew)
- {
- System.Windows.MessageBox.Show(
- "ThreadPilot is already running.",
- "Instance already open",
- MessageBoxButton.OK,
- MessageBoxImage.Information);
+ System.Windows.MessageBox.Show(
+ "ThreadPilot is already running.",
+ "Instance already open",
+ MessageBoxButton.OK,
+ MessageBoxImage.Information);
- this.Shutdown();
- return;
- }
+ this.Shutdown();
+ return;
}
base.OnStartup(e);
// Check for test mode
#if DEBUG
- if (isTestMode)
+ if (startupMode.IsTestMode)
{
// Run in console test mode
AllocConsole();
@@ -209,13 +176,6 @@ protected override void OnStartup(StartupEventArgs e)
}
#endif
- if (isSmokeTest)
- {
- var smokeTestResult = Task.Run(async () => await this.RunSmokeTestAsync(logger)).GetAwaiter().GetResult();
- this.Shutdown(smokeTestResult);
- return;
- }
-
try
{
var settingsService = this.ServiceProvider.GetRequiredService();
@@ -226,7 +186,7 @@ protected override void OnStartup(StartupEventArgs e)
var settings = settingsService.Settings;
loadedSettings = settings;
localizationService.ApplyLanguage(settings.Language);
- effectiveStartMinimized = startMinimized || settings.StartMinimized;
+ effectiveStartMinimized = startupMode.StartMinimized || settings.StartMinimized;
var useDarkTheme = settings.HasUserThemePreference
? settings.UseDarkTheme
: themeService.GetSystemUsesDarkTheme();
@@ -258,7 +218,7 @@ protected override void OnStartup(StartupEventArgs e)
throw new InvalidOperationException("MainWindow could not be created");
}
- var startupWindowBehavior = StartupWindowBehavior.Resolve(isAutostart, effectiveStartMinimized);
+ var startupWindowBehavior = StartupWindowBehavior.Resolve(startupMode.IsAutostart, effectiveStartMinimized);
var showStartupSuggestion = loadedSettings != null
&& StartupMinimizedSuggestionPolicy.ShouldShow(loadedSettings, startupWindowBehavior);
mainWindow.ConfigureStartupMode(
@@ -301,16 +261,33 @@ protected override void OnStartup(StartupEventArgs e)
}
}
- private async Task RunSmokeTestAsync(ILogger logger)
+ private int RunSmokeTestWithTimeout(ILogger logger, TimeSpan timeout)
+ {
+ var smokeTestTask = Task.Run(() => this.RunSmokeTest(logger));
+ if (smokeTestTask.Wait(timeout))
+ {
+ return smokeTestTask.GetAwaiter().GetResult();
+ }
+
+ logger.LogError("ThreadPilot smoke test timed out after {TimeoutSeconds} seconds", timeout.TotalSeconds);
+ return 2;
+ }
+
+ private int RunSmokeTest(ILogger logger)
{
try
{
logger.LogInformation("Starting ThreadPilot smoke test");
- var settingsService = this.ServiceProvider.GetRequiredService();
- await settingsService.LoadSettingsAsync().ConfigureAwait(false);
- _ = this.ServiceProvider.GetRequiredService();
- _ = this.ServiceProvider.GetRequiredService();
+ _ = this.ServiceProvider.GetRequiredService();
+ _ = this.ServiceProvider.GetRequiredService();
+ _ = this.ServiceProvider.GetRequiredService();
+ _ = this.ServiceProvider.GetRequiredService();
+
+ if (!System.IO.Directory.Exists(AppContext.BaseDirectory))
+ {
+ throw new InvalidOperationException("Application base directory was not found.");
+ }
logger.LogInformation("ThreadPilot smoke test completed successfully");
return 0;
@@ -322,6 +299,55 @@ private async Task RunSmokeTestAsync(ILogger logger)
}
}
+ private readonly struct StartupMode
+ {
+ public bool StartMinimized { get; init; }
+
+ public bool IsAutostart { get; init; }
+
+ public bool IsSmokeTest { get; init; }
+
+ public bool RegisterLaunchTask { get; init; }
+
+ public bool LaunchedViaTask { get; init; }
+
+ public bool IsTestMode { get; init; }
+
+ public static StartupMode Parse(IEnumerable args)
+ {
+ var mode = default(StartupMode);
+ foreach (var arg in args)
+ {
+ switch (arg.ToLowerInvariant())
+ {
+ case "--test":
+ mode = mode with { IsTestMode = true };
+ break;
+ case "--smoke-test":
+ mode = mode with { IsSmokeTest = true };
+ break;
+ case "--start-minimized":
+ mode = mode with { StartMinimized = true };
+ break;
+ case "--autostart":
+ mode = mode with { IsAutostart = true };
+ break;
+ case "--startup":
+ mode = mode with { IsAutostart = true, StartMinimized = true };
+ break;
+ case RegisterLaunchTaskArgument:
+ mode = mode with { RegisterLaunchTask = true };
+ break;
+ case LaunchedViaTaskArgument:
+ mode = mode with { LaunchedViaTask = true };
+ break;
+ }
+ }
+
+ return mode;
+ }
+ }
+
protected override void OnExit(ExitEventArgs e)
{
AppDomain.CurrentDomain.UnhandledException -= this.OnUnhandledException;
diff --git a/Tests/ThreadPilot.Core.Tests/AppSmokeTestStartupTests.cs b/Tests/ThreadPilot.Core.Tests/AppSmokeTestStartupTests.cs
new file mode 100644
index 0000000..882acdd
--- /dev/null
+++ b/Tests/ThreadPilot.Core.Tests/AppSmokeTestStartupTests.cs
@@ -0,0 +1,78 @@
+namespace ThreadPilot.Core.Tests
+{
+ public sealed class AppSmokeTestStartupTests
+ {
+ [Fact]
+ public void OnStartup_HandlesSmokeTestBeforeElevationSingleInstanceAndWindowStartup()
+ {
+ var source = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "App.xaml.cs"));
+
+ var smokeTestBranchIndex = source.IndexOf("if (startupMode.IsSmokeTest)", StringComparison.Ordinal);
+ var elevationIndex = source.IndexOf("GetRequiredService", StringComparison.Ordinal);
+ var mutexIndex = source.IndexOf("Global\\\\ThreadPilot_SingleInstance", StringComparison.Ordinal);
+ var baseStartupIndex = source.IndexOf("base.OnStartup(e);", StringComparison.Ordinal);
+ var mainWindowIndex = source.IndexOf("GetRequiredService", StringComparison.Ordinal);
+
+ Assert.NotEqual(-1, smokeTestBranchIndex);
+ Assert.True(smokeTestBranchIndex < elevationIndex);
+ Assert.True(smokeTestBranchIndex < mutexIndex);
+ Assert.True(smokeTestBranchIndex < baseStartupIndex);
+ Assert.True(smokeTestBranchIndex < mainWindowIndex);
+ }
+
+ [Fact]
+ public void SmokeTestMode_ExitsTheProcessAfterShutdownToAvoidDispatcherOrTimerHangs()
+ {
+ var source = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "App.xaml.cs"));
+
+ var smokeTestBranch = ExtractSection(
+ source,
+ "if (startupMode.IsSmokeTest)",
+ " // Set up global exception handlers first");
+
+ Assert.Contains("this.Shutdown(smokeTestResult);", smokeTestBranch, StringComparison.Ordinal);
+ Assert.Contains("Environment.Exit(smokeTestResult);", smokeTestBranch, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RunSmokeTest_DoesNotResolveUiViewModelsOrMainWindow()
+ {
+ var source = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "App.xaml.cs"));
+ var smokeTestMethod = ExtractSection(
+ source,
+ "private int RunSmokeTest",
+ "protected override void OnExit");
+
+ Assert.DoesNotContain("ProcessViewModel", smokeTestMethod, StringComparison.Ordinal);
+ Assert.DoesNotContain("PowerPlanViewModel", smokeTestMethod, StringComparison.Ordinal);
+ Assert.DoesNotContain("MainWindow", smokeTestMethod, StringComparison.Ordinal);
+ }
+
+ private static string ExtractSection(string source, string startMarker, string endMarker)
+ {
+ var startIndex = source.IndexOf(startMarker, StringComparison.Ordinal);
+ Assert.NotEqual(-1, startIndex);
+
+ var endIndex = source.IndexOf(endMarker, startIndex, StringComparison.Ordinal);
+ Assert.NotEqual(-1, endIndex);
+
+ return source[startIndex..endIndex];
+ }
+
+ private static string GetRepositoryRoot()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory != null && !File.Exists(Path.Combine(directory.FullName, "ThreadPilot.csproj")))
+ {
+ directory = directory.Parent;
+ }
+
+ if (directory == null)
+ {
+ throw new InvalidOperationException("Repository root was not found.");
+ }
+
+ return directory.FullName;
+ }
+ }
+}
From 024673dcbe4ca53e81443ae0083333dc5c47d3e4 Mon Sep 17 00:00:00 2001
From: Prime Build <162145141+PrimeBuild-pc@users.noreply.github.com>
Date: Sun, 7 Jun 2026 06:06:53 +0200
Subject: [PATCH 6/7] Add safe in-app updater (#28)
## Summary
Adds a safe in-app updater for ThreadPilot. This PR does not bump the app version and does not prepare a release; version and release metadata will be handled separately for v1.4.0.
## Type of Change
- [x] Feature
- [x] Security hardening
- [x] Documentation
- [x] Added/updated tests
## Update flow
- Checks for updates on demand from Settings.
- Performs background startup checks only when enabled and the last check is older than the configured interval.
- Defaults to a 7-day interval.
- Excludes prereleases by default.
- Notifies users when an update is available.
- Requires explicit user action before download/install.
## Security and safety
- Uses release metadata from the official `PrimeBuild-pc/ThreadPilot` repository only.
- Selects safe ThreadPilot installer assets from GitHub HTTPS release assets.
- Downloads into a ThreadPilot-owned temp update directory.
- Verifies SHA256 checksums when `SHA256SUMS.txt` is available.
- Rejects checksum mismatches.
- Performs best-effort Authenticode verification and rejects explicitly invalid signatures.
- Starts the installer elevated without shell command construction.
- Prevents concurrent install attempts.
## User data preservation
- Update flow does not delete AppData, settings, profiles, CPU masks, rules, custom/imported power plans, or logs.
- Only updater temp files are cleaned.
- Full uninstall behavior remains separate.
## Validation
- `dotnet restore ThreadPilot_1.sln`: passed
- `dotnet build ThreadPilot_1.sln`: passed, 0 warnings, 0 errors
- `dotnet test ThreadPilot_1.sln`: passed, 537 tests
## Release note
This PR intentionally leaves `ThreadPilot.csproj` at `1.3.1`. The `1.4.0` version bump, changelog, packaging metadata, and tag should happen in a separate release-prep branch after this PR is merged.
---
Locales/en-US.xaml | 15 +
Locales/zh-CN.xaml | 15 +
MainWindow.Behaviors.partial.cs | 17 +-
Models/ApplicationSettingsModel.cs | 21 ++
Services/Abstractions/IGitHubReleaseClient.cs | 2 +
Services/ApplicationSettingsService.cs | 32 +-
Services/ApplicationVersionProvider.cs | 36 ++
Services/AuthenticodeSignatureVerifier.cs | 46 +++
Services/GitHubReleaseClient.cs | 6 +
Services/GitHubUpdateChecker.cs | 69 +++-
Services/HttpUpdateDownloadClient.cs | 44 +++
Services/SemanticVersion.cs | 104 ++++++
Services/ServiceConfiguration.cs | 10 +
Services/SystemUpdateClock.cs | 12 +
Services/UpdateAssetSelector.cs | 81 +++++
Services/UpdateChecksumVerifier.cs | 73 ++++
Services/UpdateDownloadService.cs | 103 ++++++
Services/UpdateInstallerService.cs | 64 ++++
Services/UpdateModels.cs | 126 +++++++
Services/UpdateService.cs | 158 +++++++++
Services/UpdateTempDirectoryProvider.cs | 68 ++++
Services/WpfApplicationShutdownService.cs | 21 ++
.../ApplicationSettingsModelTests.cs | 4 +
.../GitHubUpdateCheckerTests.cs | 159 ++++-----
.../ProcessViewXamlBindingTests.cs | 3 +-
.../SettingsViewModelThemeTests.cs | 10 +-
.../UpdateServiceTests.cs | 323 ++++++++++++++++++
ViewModels/SettingsViewModel.cs | 153 ++++++---
Views/SettingsView.xaml | 38 +++
docs/UPDATES.md | 50 +++
30 files changed, 1717 insertions(+), 146 deletions(-)
create mode 100644 Services/ApplicationVersionProvider.cs
create mode 100644 Services/AuthenticodeSignatureVerifier.cs
create mode 100644 Services/HttpUpdateDownloadClient.cs
create mode 100644 Services/SemanticVersion.cs
create mode 100644 Services/SystemUpdateClock.cs
create mode 100644 Services/UpdateAssetSelector.cs
create mode 100644 Services/UpdateChecksumVerifier.cs
create mode 100644 Services/UpdateDownloadService.cs
create mode 100644 Services/UpdateInstallerService.cs
create mode 100644 Services/UpdateModels.cs
create mode 100644 Services/UpdateService.cs
create mode 100644 Services/UpdateTempDirectoryProvider.cs
create mode 100644 Services/WpfApplicationShutdownService.cs
create mode 100644 Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs
create mode 100644 docs/UPDATES.md
diff --git a/Locales/en-US.xaml b/Locales/en-US.xaml
index b09572b..e2bc6fd 100644
--- a/Locales/en-US.xaml
+++ b/Locales/en-US.xaml
@@ -436,6 +436,12 @@
Translator: Ylimhs
License: AGPLv3
Check for updates
+ Download and install update
+ Check automatically on startup
+ Check interval (days):
+ Include prerelease updates
+ Latest version:
+ Last checked:
Checks on demand using the official GitHub Releases.
Reset to Defaults
@@ -474,6 +480,15 @@
New version available: {0}
Application is up to date. Installed version: {0}
Error while checking updates: {0}
+ Unknown
+ Not checked
+ Never
+ Install ThreadPilot update
+ ThreadPilot will download and verify version {0}, then ask Windows for permission to run the installer. Continue?
+ Update canceled.
+ Downloading and verifying update...
+ Update installer started.
+ Update install failed: {0}
Settings have been modified
Settings match the saved configuration
Simplified Chinese
diff --git a/Locales/zh-CN.xaml b/Locales/zh-CN.xaml
index 8f4bcfc..d3cc1fa 100644
--- a/Locales/zh-CN.xaml
+++ b/Locales/zh-CN.xaml
@@ -436,6 +436,12 @@
翻译人员:Ylimhs
软件授权: AGPLv3
检查新版本
+ 下载并安装更新
+ 启动时自动检查更新
+ 检查间隔(天):
+ 包含预发布版本
+ 最新版本:
+ 上次检查:
按需通过官方 GitHub Releases API 检索最新稳定版本。
重置为默认值
@@ -474,6 +480,15 @@
发现新版本: {0}
应用已是最新版本。已安装版本: {0}
检查更新时出错: {0}
+ 未知
+ 尚未检查
+ 从未检查
+ 安装 ThreadPilot 更新
+ ThreadPilot 将下载并验证版本 {0},然后请求 Windows 权限运行安装程序。是否继续?
+ 更新已取消。
+ 正在下载并验证更新...
+ 更新安装程序已启动。
+ 更新安装失败: {0}
设置已被修改
设置与已保存的配置一致
简体中文
diff --git a/MainWindow.Behaviors.partial.cs b/MainWindow.Behaviors.partial.cs
index a4da578..1c857ee 100644
--- a/MainWindow.Behaviors.partial.cs
+++ b/MainWindow.Behaviors.partial.cs
@@ -157,27 +157,26 @@ private async Task CheckForUpdatesAtStartupAsync()
try
{
this.LogDebug("Startup update check started");
- var checker = this.serviceProvider.GetRequiredService();
- var currentVersion = GetCurrentApplicationVersion();
- var (latest, _) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot");
+ var updateService = this.serviceProvider.GetRequiredService();
+ var result = await updateService.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup));
- if (latest == null)
+ if (result.Status == UpdateCheckStatus.Skipped)
{
- this.LogDebug("Startup update check completed without release information");
+ this.LogDebug($"Startup update check skipped: {result.Message}");
return;
}
- if (latest <= currentVersion)
+ if (!result.IsUpdateAvailable || result.Release == null)
{
- this.LogDebug($"Startup update check complete: installed {currentVersion}, latest {latest}");
+ this.LogDebug($"Startup update check complete: {result.Message}");
return;
}
await this.notificationService.ShowNotificationAsync(
"Update available",
- $"ThreadPilot {latest} is available from GitHub releases.",
+ $"ThreadPilot {result.Release.Version} is available. Open Settings to download and install it.",
NotificationType.Information);
- this.LogDebug($"Startup update check found update: installed {currentVersion}, latest {latest}");
+ this.LogDebug($"Startup update check found update: installed {result.CurrentVersion}, latest {result.Release.Version}");
}
catch (Exception ex)
{
diff --git a/Models/ApplicationSettingsModel.cs b/Models/ApplicationSettingsModel.cs
index 40e6c78..3219d8a 100644
--- a/Models/ApplicationSettingsModel.cs
+++ b/Models/ApplicationSettingsModel.cs
@@ -154,6 +154,18 @@ public partial class ApplicationSettingsModel : ObservableObject, IModel
[ObservableProperty]
private string language = LocalizationService.DefaultLanguage;
+ [ObservableProperty]
+ private bool enableAutomaticUpdateChecks = true;
+
+ [ObservableProperty]
+ private DateTimeOffset? lastUpdateCheckUtc = null;
+
+ [ObservableProperty]
+ private int updateCheckIntervalDays = 7;
+
+ [ObservableProperty]
+ private bool includePrereleaseUpdates = false;
+
// Monitoring Settings
[ObservableProperty]
private int pollingIntervalMs = 5000;
@@ -253,6 +265,10 @@ public void CopyFrom(ApplicationSettingsModel other)
this.UseDarkTheme = other.UseDarkTheme;
this.HasUserThemePreference = other.HasUserThemePreference;
this.Language = LocalizationService.NormalizeLanguage(other.Language);
+ this.EnableAutomaticUpdateChecks = other.EnableAutomaticUpdateChecks;
+ this.LastUpdateCheckUtc = other.LastUpdateCheckUtc;
+ this.UpdateCheckIntervalDays = other.UpdateCheckIntervalDays;
+ this.IncludePrereleaseUpdates = other.IncludePrereleaseUpdates;
// Monitoring Settings
this.PollingIntervalMs = other.PollingIntervalMs;
@@ -298,6 +314,11 @@ public ValidationResult Validate()
errors.Add("Fallback polling interval must be between 1 and 60 seconds");
}
+ if (this.UpdateCheckIntervalDays < 1 || this.UpdateCheckIntervalDays > 365)
+ {
+ errors.Add("Update check interval must be between 1 and 365 days");
+ }
+
return errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors.ToArray());
}
diff --git a/Services/Abstractions/IGitHubReleaseClient.cs b/Services/Abstractions/IGitHubReleaseClient.cs
index bf062d6..15f0c2e 100644
--- a/Services/Abstractions/IGitHubReleaseClient.cs
+++ b/Services/Abstractions/IGitHubReleaseClient.cs
@@ -9,5 +9,7 @@ namespace ThreadPilot.Services.Abstractions
public interface IGitHubReleaseClient
{
Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default);
+
+ Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default);
}
}
diff --git a/Services/ApplicationSettingsService.cs b/Services/ApplicationSettingsService.cs
index 02d52b7..d7ae681 100644
--- a/Services/ApplicationSettingsService.cs
+++ b/Services/ApplicationSettingsService.cs
@@ -246,18 +246,28 @@ 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.Language = LocalizationService.NormalizeLanguage(this.settings.Language);
- }
+ this.settings.UseCustomTrayIcon = false;
+ }
+ }
+
+ this.settings.Language = LocalizationService.NormalizeLanguage(this.settings.Language);
+
+ if (this.settings.UpdateCheckIntervalDays < 1)
+ {
+ this.settings.UpdateCheckIntervalDays = 1;
+ }
+
+ if (this.settings.UpdateCheckIntervalDays > 365)
+ {
+ this.settings.UpdateCheckIntervalDays = 365;
+ }
+ }
public async Task ExportSettingsAsync(string filePath)
{
diff --git a/Services/ApplicationVersionProvider.cs b/Services/ApplicationVersionProvider.cs
new file mode 100644
index 0000000..b62b44a
--- /dev/null
+++ b/Services/ApplicationVersionProvider.cs
@@ -0,0 +1,36 @@
+/*
+ * ThreadPilot - application version provider for update checks.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.Linq;
+ using System.Reflection;
+
+ public sealed class ApplicationVersionProvider : IApplicationVersionProvider
+ {
+ public SemanticVersion CurrentVersion
+ {
+ get
+ {
+ var rawVersion = GetRawVersion();
+ return SemanticVersion.TryParse(rawVersion, out var version)
+ ? version
+ : new SemanticVersion(0, 0, 0);
+ }
+ }
+
+ public string DisplayVersion => $"v{this.CurrentVersion}";
+
+ private static string GetRawVersion()
+ {
+ return typeof(App).Assembly
+ .GetCustomAttributes(typeof(AssemblyInformationalVersionAttribute), false)
+ .OfType()
+ .FirstOrDefault()?
+ .InformationalVersion
+ ?? typeof(App).Assembly.GetName().Version?.ToString()
+ ?? "0.0.0";
+ }
+ }
+}
diff --git a/Services/AuthenticodeSignatureVerifier.cs b/Services/AuthenticodeSignatureVerifier.cs
new file mode 100644
index 0000000..63b1735
--- /dev/null
+++ b/Services/AuthenticodeSignatureVerifier.cs
@@ -0,0 +1,46 @@
+/*
+ * ThreadPilot - best-effort Authenticode signature detection.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.IO;
+ using System.Security.Cryptography;
+ using System.Security.Cryptography.X509Certificates;
+
+ public sealed class AuthenticodeSignatureVerifier : IUpdateSignatureVerifier
+ {
+ public UpdateSignatureStatus Verify(string installerPath)
+ {
+ if (!File.Exists(installerPath))
+ {
+ return UpdateSignatureStatus.Invalid;
+ }
+
+ try
+ {
+ using var certificate = new X509Certificate2(X509Certificate.CreateFromSignedFile(installerPath));
+ using var chain = new X509Chain
+ {
+ ChainPolicy =
+ {
+ RevocationMode = X509RevocationMode.Online,
+ RevocationFlag = X509RevocationFlag.ExcludeRoot,
+ },
+ };
+
+ return chain.Build(certificate)
+ ? UpdateSignatureStatus.Valid
+ : UpdateSignatureStatus.Unknown;
+ }
+ catch (CryptographicException)
+ {
+ return UpdateSignatureStatus.Unknown;
+ }
+ catch (PlatformNotSupportedException)
+ {
+ return UpdateSignatureStatus.Unknown;
+ }
+ }
+ }
+}
diff --git a/Services/GitHubReleaseClient.cs b/Services/GitHubReleaseClient.cs
index ac4a542..655ce50 100644
--- a/Services/GitHubReleaseClient.cs
+++ b/Services/GitHubReleaseClient.cs
@@ -23,5 +23,11 @@ public Task GetLatestReleaseJsonAsync(string owner, string repo, Cancell
var url = $"https://api.github.com/repos/{owner}/{repo}/releases/latest";
return this.httpClient.GetStringAsync(url, cancellationToken);
}
+
+ public Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default)
+ {
+ var url = $"https://api.github.com/repos/{owner}/{repo}/releases";
+ return this.httpClient.GetStringAsync(url, cancellationToken);
+ }
}
}
diff --git a/Services/GitHubUpdateChecker.cs b/Services/GitHubUpdateChecker.cs
index 027753c..621b31e 100644
--- a/Services/GitHubUpdateChecker.cs
+++ b/Services/GitHubUpdateChecker.cs
@@ -17,6 +17,8 @@
namespace ThreadPilot.Services
{
using System;
+ using System.Collections.Generic;
+ using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -26,7 +28,14 @@ public sealed class GitHubUpdateChecker
{
private readonly IGitHubReleaseClient gitHubReleaseClient;
- private record LatestRelease(string Tag_name, bool Prerelease, bool Draft, string Html_url);
+ private record LatestRelease(
+ string Tag_name,
+ bool Prerelease,
+ bool Draft,
+ string Html_url,
+ IReadOnlyList? Assets);
+
+ private record LatestReleaseAsset(string Name, string Browser_download_url, long Size);
public GitHubUpdateChecker(IGitHubReleaseClient gitHubReleaseClient)
{
@@ -71,6 +80,64 @@ public GitHubUpdateChecker(IGitHubReleaseClient gitHubReleaseClient)
? (version, release.Html_url)
: (null, release.Html_url);
}
+
+ public async Task GetLatestReleaseInfoAsync(
+ string owner,
+ string repo,
+ bool includePrereleases = false,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(owner))
+ {
+ throw new ArgumentException("Owner is required", nameof(owner));
+ }
+
+ if (string.IsNullOrWhiteSpace(repo))
+ {
+ throw new ArgumentException("Repository is required", nameof(repo));
+ }
+
+ var json = await this.gitHubReleaseClient.GetReleasesJsonAsync(owner, repo, cancellationToken).ConfigureAwait(false);
+ var releases = JsonSerializer.Deserialize>(json, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true,
+ });
+
+ if (releases == null || releases.Count == 0)
+ {
+ return null;
+ }
+
+ return releases
+ .Where(release => !release.Draft)
+ .Where(release => includePrereleases || !release.Prerelease)
+ .Select(TryMapRelease)
+ .Where(release => release != null)
+ .Cast()
+ .OrderByDescending(release => release.Version)
+ .FirstOrDefault();
+ }
+
+ private static UpdateReleaseInfo? TryMapRelease(LatestRelease release)
+ {
+ if (!SemanticVersion.TryParse(release.Tag_name, out var version) ||
+ string.IsNullOrWhiteSpace(release.Html_url) ||
+ !Uri.TryCreate(release.Html_url, UriKind.Absolute, out var releasePageUrl))
+ {
+ return null;
+ }
+
+ var assets = (release.Assets ?? Array.Empty())
+ .Where(asset => !string.IsNullOrWhiteSpace(asset.Name))
+ .Where(asset => Uri.TryCreate(asset.Browser_download_url, UriKind.Absolute, out _))
+ .Select(asset => new UpdateAsset(
+ asset.Name,
+ new Uri(asset.Browser_download_url, UriKind.Absolute),
+ asset.Size))
+ .ToArray();
+
+ return new UpdateReleaseInfo(version, release.Tag_name, releasePageUrl, release.Prerelease, assets);
+ }
}
}
diff --git a/Services/HttpUpdateDownloadClient.cs b/Services/HttpUpdateDownloadClient.cs
new file mode 100644
index 0000000..a98aba1
--- /dev/null
+++ b/Services/HttpUpdateDownloadClient.cs
@@ -0,0 +1,44 @@
+/*
+ * ThreadPilot - HTTP downloads for update assets.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.IO;
+ using System.Net.Http;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ public sealed class HttpUpdateDownloadClient : IUpdateDownloadClient
+ {
+ private readonly HttpClient httpClient;
+
+ public HttpUpdateDownloadClient(HttpClient httpClient)
+ {
+ this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ }
+
+ public async Task DownloadFileAsync(Uri uri, string destinationPath, CancellationToken cancellationToken = default)
+ {
+ using var response = await this.httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
+ .ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ await using var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using var destination = File.Create(destinationPath);
+ await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task TryDownloadStringAsync(Uri uri, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ return await this.httpClient.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
+ }
+ catch (HttpRequestException)
+ {
+ return null;
+ }
+ }
+ }
+}
diff --git a/Services/SemanticVersion.cs b/Services/SemanticVersion.cs
new file mode 100644
index 0000000..cf8dd64
--- /dev/null
+++ b/Services/SemanticVersion.cs
@@ -0,0 +1,104 @@
+/*
+ * ThreadPilot - semantic version parsing for updater decisions.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.Globalization;
+
+ public readonly record struct SemanticVersion(int Major, int Minor, int Patch, string? Prerelease = null)
+ : IComparable
+ {
+ public bool IsPrerelease => !string.IsNullOrWhiteSpace(this.Prerelease);
+
+ public static bool TryParse(string? value, out SemanticVersion version)
+ {
+ version = default;
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return false;
+ }
+
+ var sanitized = value.Trim();
+ if (sanitized.StartsWith("v", StringComparison.OrdinalIgnoreCase))
+ {
+ sanitized = sanitized[1..];
+ }
+
+ sanitized = sanitized.Split('+')[0];
+ var versionAndPrerelease = sanitized.Split('-', 2);
+ var parts = versionAndPrerelease[0].Split('.');
+ if (parts.Length < 2 || parts.Length > 3)
+ {
+ return false;
+ }
+
+ if (!int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major) ||
+ !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor))
+ {
+ return false;
+ }
+
+ var patch = 0;
+ if (parts.Length == 3 &&
+ !int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out patch))
+ {
+ return false;
+ }
+
+ version = new SemanticVersion(
+ major,
+ minor,
+ patch,
+ versionAndPrerelease.Length == 2 ? versionAndPrerelease[1] : null);
+ return true;
+ }
+
+ public int CompareTo(SemanticVersion other)
+ {
+ var major = this.Major.CompareTo(other.Major);
+ if (major != 0)
+ {
+ return major;
+ }
+
+ var minor = this.Minor.CompareTo(other.Minor);
+ if (minor != 0)
+ {
+ return minor;
+ }
+
+ var patch = this.Patch.CompareTo(other.Patch);
+ if (patch != 0)
+ {
+ return patch;
+ }
+
+ if (!this.IsPrerelease && other.IsPrerelease)
+ {
+ return 1;
+ }
+
+ if (this.IsPrerelease && !other.IsPrerelease)
+ {
+ return -1;
+ }
+
+ return string.Compare(this.Prerelease, other.Prerelease, StringComparison.OrdinalIgnoreCase);
+ }
+
+ public override string ToString()
+ {
+ var version = $"{this.Major}.{this.Minor}.{this.Patch}";
+ return this.IsPrerelease ? $"{version}-{this.Prerelease}" : version;
+ }
+
+ public static bool operator >(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) > 0;
+
+ public static bool operator <(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) < 0;
+
+ public static bool operator >=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) >= 0;
+
+ public static bool operator <=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) <= 0;
+ }
+}
diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs
index 23dc385..45a0a46 100644
--- a/Services/ServiceConfiguration.cs
+++ b/Services/ServiceConfiguration.cs
@@ -78,6 +78,16 @@ private static IServiceCollection ConfigureServiceInfrastructure(this IServiceCo
});
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
// Memory caching for performance - PERFORMANCE IMPROVEMENT
services.AddMemoryCache();
diff --git a/Services/SystemUpdateClock.cs b/Services/SystemUpdateClock.cs
new file mode 100644
index 0000000..35f8351
--- /dev/null
+++ b/Services/SystemUpdateClock.cs
@@ -0,0 +1,12 @@
+/*
+ * ThreadPilot - updater clock abstraction.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+
+ public sealed class SystemUpdateClock : IUpdateClock
+ {
+ public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
+ }
+}
diff --git a/Services/UpdateAssetSelector.cs b/Services/UpdateAssetSelector.cs
new file mode 100644
index 0000000..10819f0
--- /dev/null
+++ b/Services/UpdateAssetSelector.cs
@@ -0,0 +1,81 @@
+/*
+ * ThreadPilot - release asset selection for safe installer updates.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.IO;
+ using System.Linq;
+
+ public static class UpdateAssetSelector
+ {
+ public static bool TrySelectInstaller(UpdateReleaseInfo release, out UpdateAsset asset)
+ {
+ var selected = release.Assets
+ .Where(IsInstallerAsset)
+ .OrderByDescending(candidate => candidate.Name.Contains("setup", StringComparison.OrdinalIgnoreCase))
+ .FirstOrDefault();
+
+ asset = selected!;
+ return selected != null;
+ }
+
+ public static UpdateAsset? SelectChecksumAsset(UpdateReleaseInfo release)
+ {
+ return release.Assets.FirstOrDefault(asset =>
+ string.Equals(asset.Name, "SHA256SUMS.txt", StringComparison.OrdinalIgnoreCase));
+ }
+
+ public static bool IsSafeGitHubAssetUrl(Uri uri)
+ {
+ if (uri.Scheme != Uri.UriSchemeHttps)
+ {
+ return false;
+ }
+
+ return string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(uri.Host, "objects.githubusercontent.com", StringComparison.OrdinalIgnoreCase);
+ }
+
+ public static bool IsSafeAssetFileName(string assetName)
+ {
+ if (string.IsNullOrWhiteSpace(assetName))
+ {
+ return false;
+ }
+
+ if (!string.Equals(Path.GetFileName(assetName), assetName, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ return assetName.IndexOfAny(Path.GetInvalidFileNameChars()) < 0;
+ }
+
+ private static bool IsInstallerAsset(UpdateAsset asset)
+ {
+ if (!IsSafeGitHubAssetUrl(asset.DownloadUrl) || !IsSafeAssetFileName(asset.Name))
+ {
+ return false;
+ }
+
+ if (!asset.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ if (!asset.Name.StartsWith("ThreadPilot", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ if (asset.Name.Contains("portable", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return asset.Name.Contains("setup", StringComparison.OrdinalIgnoreCase) ||
+ asset.Name.Contains("installer", StringComparison.OrdinalIgnoreCase);
+ }
+ }
+}
diff --git a/Services/UpdateChecksumVerifier.cs b/Services/UpdateChecksumVerifier.cs
new file mode 100644
index 0000000..82ff53e
--- /dev/null
+++ b/Services/UpdateChecksumVerifier.cs
@@ -0,0 +1,73 @@
+/*
+ * ThreadPilot - SHA256SUMS parsing and verification.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.Globalization;
+ using System.IO;
+ using System.Linq;
+ using System.Security.Cryptography;
+
+ public static class UpdateChecksumVerifier
+ {
+ public static bool TryFindExpectedHash(string checksumsText, string fileName, out string expectedHash)
+ {
+ expectedHash = string.Empty;
+ foreach (var rawLine in checksumsText.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ var line = rawLine.Trim();
+ if (line.Length == 0 || line.StartsWith('#'))
+ {
+ continue;
+ }
+
+ if (line.StartsWith("SHA256(", StringComparison.OrdinalIgnoreCase))
+ {
+ var close = line.IndexOf(')');
+ var equals = line.IndexOf('=', StringComparison.Ordinal);
+ if (close > 7 && equals > close)
+ {
+ var listedName = line[7..close];
+ var hash = line[(equals + 1)..].Trim();
+ if (IsHash(hash) && string.Equals(listedName, fileName, StringComparison.OrdinalIgnoreCase))
+ {
+ expectedHash = hash.ToUpperInvariant();
+ return true;
+ }
+ }
+ }
+
+ var parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length >= 2 && IsHash(parts[0]))
+ {
+ var listedName = parts[^1].TrimStart('*');
+ if (string.Equals(listedName, fileName, StringComparison.OrdinalIgnoreCase))
+ {
+ expectedHash = parts[0].ToUpperInvariant();
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ public static string ComputeSha256(string filePath)
+ {
+ using var stream = File.OpenRead(filePath);
+ var hash = SHA256.HashData(stream);
+ return string.Concat(hash.Select(b => b.ToString("X2", CultureInfo.InvariantCulture)));
+ }
+
+ public static bool Verify(string filePath, string expectedHash)
+ {
+ return string.Equals(ComputeSha256(filePath), expectedHash, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsHash(string value)
+ {
+ return value.Length == 64 && value.All(Uri.IsHexDigit);
+ }
+ }
+}
diff --git a/Services/UpdateDownloadService.cs b/Services/UpdateDownloadService.cs
new file mode 100644
index 0000000..e14c343
--- /dev/null
+++ b/Services/UpdateDownloadService.cs
@@ -0,0 +1,103 @@
+/*
+ * ThreadPilot - secure update installer download and verification.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.IO;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Extensions.Logging;
+
+ public sealed class UpdateDownloadService : IUpdateDownloadService
+ {
+ private readonly IUpdateDownloadClient downloadClient;
+ private readonly IUpdateTempDirectoryProvider tempDirectoryProvider;
+ private readonly IUpdateSignatureVerifier signatureVerifier;
+ private readonly ILogger logger;
+
+ public UpdateDownloadService(
+ IUpdateDownloadClient downloadClient,
+ IUpdateTempDirectoryProvider tempDirectoryProvider,
+ IUpdateSignatureVerifier signatureVerifier,
+ ILogger logger)
+ {
+ this.downloadClient = downloadClient ?? throw new ArgumentNullException(nameof(downloadClient));
+ this.tempDirectoryProvider = tempDirectoryProvider ?? throw new ArgumentNullException(nameof(tempDirectoryProvider));
+ this.signatureVerifier = signatureVerifier ?? throw new ArgumentNullException(nameof(signatureVerifier));
+ this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task DownloadInstallerAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default)
+ {
+ if (!UpdateAssetSelector.TrySelectInstaller(release, out var installerAsset))
+ {
+ throw new InvalidOperationException("Release does not contain a ThreadPilot installer asset.");
+ }
+
+ var tempDirectory = this.tempDirectoryProvider.CreateUpdateTempDirectory(release.Version);
+ try
+ {
+ var installerPath = Path.Combine(tempDirectory, installerAsset.Name);
+ await this.downloadClient.DownloadFileAsync(installerAsset.DownloadUrl, installerPath, cancellationToken)
+ .ConfigureAwait(false);
+
+ var checksumVerified = false;
+ var checksumAsset = UpdateAssetSelector.SelectChecksumAsset(release);
+ if (checksumAsset != null)
+ {
+ var checksumText = await this.downloadClient.TryDownloadStringAsync(checksumAsset.DownloadUrl, cancellationToken)
+ .ConfigureAwait(false);
+ if (string.IsNullOrWhiteSpace(checksumText) ||
+ !UpdateChecksumVerifier.TryFindExpectedHash(checksumText, installerAsset.Name, out var expectedHash))
+ {
+ throw new InvalidOperationException("SHA256SUMS.txt did not contain the installer checksum.");
+ }
+
+ if (!UpdateChecksumVerifier.Verify(installerPath, expectedHash))
+ {
+ throw new InvalidOperationException("Installer SHA256 checksum did not match SHA256SUMS.txt.");
+ }
+
+ checksumVerified = true;
+ }
+
+ var signatureStatus = this.signatureVerifier.Verify(installerPath);
+ if (signatureStatus == UpdateSignatureStatus.Invalid)
+ {
+ throw new InvalidOperationException("Installer Authenticode signature is invalid.");
+ }
+
+ this.logger.LogInformation(
+ "Downloaded ThreadPilot update installer {InstallerName}; checksum verified: {ChecksumVerified}; signature: {SignatureStatus}",
+ installerAsset.Name,
+ checksumVerified,
+ signatureStatus);
+
+ return new UpdateDownloadResult(
+ installerPath,
+ tempDirectory,
+ checksumVerified,
+ signatureStatus,
+ checksumVerified ? "Installer checksum verified." : "No SHA256SUMS.txt asset was available.");
+ }
+ catch
+ {
+ this.TryCleanup(tempDirectory);
+ throw;
+ }
+ }
+
+ private void TryCleanup(string tempDirectory)
+ {
+ try
+ {
+ this.tempDirectoryProvider.Cleanup(tempDirectory);
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogWarning(ex, "Failed to clean update temp directory {TempDirectory}", tempDirectory);
+ }
+ }
+ }
+}
diff --git a/Services/UpdateInstallerService.cs b/Services/UpdateInstallerService.cs
new file mode 100644
index 0000000..d710d65
--- /dev/null
+++ b/Services/UpdateInstallerService.cs
@@ -0,0 +1,64 @@
+/*
+ * ThreadPilot - elevated update installer launch.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Diagnostics;
+ using System.IO;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ public sealed class UpdateInstallerService : IUpdateInstallerService
+ {
+ private readonly IUpdateTempDirectoryProvider tempDirectoryProvider;
+ private readonly IUpdateProcessLauncher processLauncher;
+
+ public UpdateInstallerService(
+ IUpdateTempDirectoryProvider tempDirectoryProvider,
+ IUpdateProcessLauncher processLauncher)
+ {
+ this.tempDirectoryProvider = tempDirectoryProvider ?? throw new ArgumentNullException(nameof(tempDirectoryProvider));
+ this.processLauncher = processLauncher ?? throw new ArgumentNullException(nameof(processLauncher));
+ }
+
+ public Task LaunchInstallerElevatedAsync(string installerPath, CancellationToken cancellationToken = default)
+ {
+ if (!File.Exists(installerPath))
+ {
+ throw new FileNotFoundException("Update installer was not found.", installerPath);
+ }
+
+ if (!string.Equals(Path.GetExtension(installerPath), ".exe", StringComparison.OrdinalIgnoreCase) ||
+ !this.tempDirectoryProvider.IsSafeUpdateTempPath(installerPath))
+ {
+ throw new InvalidOperationException("Update installer path is not trusted.");
+ }
+
+ return this.processLauncher.LaunchElevatedAsync(installerPath, Array.Empty(), cancellationToken);
+ }
+ }
+
+ public sealed class ShellUpdateProcessLauncher : IUpdateProcessLauncher
+ {
+ public Task LaunchElevatedAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken = default)
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = fileName,
+ UseShellExecute = true,
+ Verb = "runas",
+ WorkingDirectory = Path.GetDirectoryName(fileName) ?? Environment.CurrentDirectory,
+ };
+
+ foreach (var argument in arguments)
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+
+ Process.Start(startInfo);
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/Services/UpdateModels.cs b/Services/UpdateModels.cs
new file mode 100644
index 0000000..85cfe3e
--- /dev/null
+++ b/Services/UpdateModels.cs
@@ -0,0 +1,126 @@
+/*
+ * ThreadPilot - updater models and abstractions.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ public enum UpdateCheckTrigger
+ {
+ Startup,
+ Manual,
+ }
+
+ public enum UpdateCheckStatus
+ {
+ Skipped,
+ UpToDate,
+ UpdateAvailable,
+ Failed,
+ }
+
+ public enum UpdateInstallStatus
+ {
+ Started,
+ Failed,
+ }
+
+ public enum UpdateSignatureStatus
+ {
+ Valid,
+ Invalid,
+ Unknown,
+ }
+
+ public sealed record UpdateCheckRequest(UpdateCheckTrigger Trigger);
+
+ public sealed record UpdateAsset(string Name, Uri DownloadUrl, long Size);
+
+ public sealed record UpdateReleaseInfo(
+ SemanticVersion Version,
+ string TagName,
+ Uri ReleasePageUrl,
+ bool IsPrerelease,
+ IReadOnlyList Assets);
+
+ public sealed record UpdateCheckResult(
+ UpdateCheckStatus Status,
+ SemanticVersion CurrentVersion,
+ UpdateReleaseInfo? Release,
+ string Message)
+ {
+ public bool IsUpdateAvailable => this.Status == UpdateCheckStatus.UpdateAvailable && this.Release != null;
+ }
+
+ public sealed record UpdateDownloadResult(
+ string InstallerPath,
+ string TempDirectory,
+ bool ChecksumVerified,
+ UpdateSignatureStatus SignatureStatus,
+ string Message);
+
+ public sealed record UpdateInstallResult(UpdateInstallStatus Status, string Message);
+
+ public interface IApplicationVersionProvider
+ {
+ SemanticVersion CurrentVersion { get; }
+
+ string DisplayVersion { get; }
+ }
+
+ public interface IUpdateClock
+ {
+ DateTimeOffset UtcNow { get; }
+ }
+
+ public interface IUpdateService
+ {
+ Task CheckForUpdatesAsync(UpdateCheckRequest request, CancellationToken cancellationToken = default);
+
+ Task DownloadAndInstallAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default);
+ }
+
+ public interface IUpdateDownloadService
+ {
+ Task DownloadInstallerAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default);
+ }
+
+ public interface IUpdateInstallerService
+ {
+ Task LaunchInstallerElevatedAsync(string installerPath, CancellationToken cancellationToken = default);
+ }
+
+ public interface IUpdateDownloadClient
+ {
+ Task DownloadFileAsync(Uri uri, string destinationPath, CancellationToken cancellationToken = default);
+
+ Task TryDownloadStringAsync(Uri uri, CancellationToken cancellationToken = default);
+ }
+
+ public interface IUpdateTempDirectoryProvider
+ {
+ string CreateUpdateTempDirectory(SemanticVersion version);
+
+ bool IsSafeUpdateTempPath(string path);
+
+ void Cleanup(string path);
+ }
+
+ public interface IUpdateSignatureVerifier
+ {
+ UpdateSignatureStatus Verify(string installerPath);
+ }
+
+ public interface IUpdateProcessLauncher
+ {
+ Task LaunchElevatedAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken = default);
+ }
+
+ public interface IApplicationShutdownService
+ {
+ void RequestShutdownForUpdate();
+ }
+}
diff --git a/Services/UpdateService.cs b/Services/UpdateService.cs
new file mode 100644
index 0000000..5cc3256
--- /dev/null
+++ b/Services/UpdateService.cs
@@ -0,0 +1,158 @@
+/*
+ * ThreadPilot - safe in-app update orchestration.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Extensions.Logging;
+
+ public sealed class UpdateService : IUpdateService
+ {
+ private const string OfficialOwner = "PrimeBuild-pc";
+ private const string OfficialRepository = "ThreadPilot";
+
+ private readonly GitHubUpdateChecker updateChecker;
+ private readonly IApplicationSettingsService settingsService;
+ private readonly IApplicationVersionProvider versionProvider;
+ private readonly IUpdateDownloadService downloadService;
+ private readonly IUpdateInstallerService installerService;
+ private readonly IUpdateTempDirectoryProvider tempDirectoryProvider;
+ private readonly IApplicationShutdownService shutdownService;
+ private readonly IUpdateClock clock;
+ private readonly ILogger logger;
+ private readonly SemaphoreSlim checkGate = new(1, 1);
+ private readonly SemaphoreSlim installGate = new(1, 1);
+
+ public UpdateService(
+ GitHubUpdateChecker updateChecker,
+ IApplicationSettingsService settingsService,
+ IApplicationVersionProvider versionProvider,
+ IUpdateDownloadService downloadService,
+ IUpdateInstallerService installerService,
+ IUpdateTempDirectoryProvider tempDirectoryProvider,
+ IApplicationShutdownService shutdownService,
+ IUpdateClock clock,
+ ILogger logger)
+ {
+ this.updateChecker = updateChecker ?? throw new ArgumentNullException(nameof(updateChecker));
+ this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
+ this.versionProvider = versionProvider ?? throw new ArgumentNullException(nameof(versionProvider));
+ this.downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
+ this.installerService = installerService ?? throw new ArgumentNullException(nameof(installerService));
+ this.tempDirectoryProvider = tempDirectoryProvider ?? throw new ArgumentNullException(nameof(tempDirectoryProvider));
+ this.shutdownService = shutdownService ?? throw new ArgumentNullException(nameof(shutdownService));
+ this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
+ this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task CheckForUpdatesAsync(UpdateCheckRequest request, CancellationToken cancellationToken = default)
+ {
+ var currentVersion = this.versionProvider.CurrentVersion;
+ var settings = this.settingsService.Settings;
+
+ if (request.Trigger == UpdateCheckTrigger.Startup)
+ {
+ if (!settings.EnableAutomaticUpdateChecks)
+ {
+ return new UpdateCheckResult(UpdateCheckStatus.Skipped, currentVersion, null, "Automatic update checks are disabled.");
+ }
+
+ var intervalDays = Math.Max(1, settings.UpdateCheckIntervalDays);
+ if (settings.LastUpdateCheckUtc.HasValue &&
+ this.clock.UtcNow - settings.LastUpdateCheckUtc.Value < TimeSpan.FromDays(intervalDays))
+ {
+ return new UpdateCheckResult(UpdateCheckStatus.Skipped, currentVersion, null, "Startup update check throttled.");
+ }
+ }
+
+ await this.checkGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await this.MarkUpdateCheckAttemptAsync(cancellationToken).ConfigureAwait(false);
+
+ var release = await this.updateChecker.GetLatestReleaseInfoAsync(
+ OfficialOwner,
+ OfficialRepository,
+ settings.IncludePrereleaseUpdates,
+ cancellationToken).ConfigureAwait(false);
+
+ if (release == null)
+ {
+ return new UpdateCheckResult(UpdateCheckStatus.Failed, currentVersion, null, "Unable to determine the latest ThreadPilot release.");
+ }
+
+ if (release.Version > currentVersion)
+ {
+ this.logger.LogInformation(
+ "ThreadPilot update available: current {CurrentVersion}, latest {LatestVersion}",
+ currentVersion,
+ release.Version);
+ return new UpdateCheckResult(UpdateCheckStatus.UpdateAvailable, currentVersion, release, "A newer ThreadPilot version is available.");
+ }
+
+ return new UpdateCheckResult(UpdateCheckStatus.UpToDate, currentVersion, release, "ThreadPilot is up to date.");
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ this.logger.LogWarning(ex, "ThreadPilot update check failed");
+ return new UpdateCheckResult(UpdateCheckStatus.Failed, currentVersion, null, ex.Message);
+ }
+ finally
+ {
+ this.checkGate.Release();
+ }
+ }
+
+ public async Task DownloadAndInstallAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default)
+ {
+ if (!await this.installGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
+ {
+ return new UpdateInstallResult(UpdateInstallStatus.Failed, "Another update is already in progress.");
+ }
+
+ UpdateDownloadResult? download = null;
+ try
+ {
+ download = await this.downloadService.DownloadInstallerAsync(release, cancellationToken).ConfigureAwait(false);
+ await this.installerService.LaunchInstallerElevatedAsync(download.InstallerPath, cancellationToken).ConfigureAwait(false);
+ this.shutdownService.RequestShutdownForUpdate();
+ return new UpdateInstallResult(UpdateInstallStatus.Started, "Update installer started.");
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ this.logger.LogWarning(ex, "ThreadPilot update install failed");
+ return new UpdateInstallResult(UpdateInstallStatus.Failed, ex.Message);
+ }
+ finally
+ {
+ if (download != null)
+ {
+ this.TryCleanup(download.TempDirectory);
+ }
+
+ this.installGate.Release();
+ }
+ }
+
+ private async Task MarkUpdateCheckAttemptAsync(CancellationToken cancellationToken)
+ {
+ var settings = this.settingsService.Settings;
+ settings.LastUpdateCheckUtc = this.clock.UtcNow;
+ await this.settingsService.UpdateSettingsAsync(settings).ConfigureAwait(false);
+ }
+
+ private void TryCleanup(string tempDirectory)
+ {
+ try
+ {
+ this.tempDirectoryProvider.Cleanup(tempDirectory);
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogWarning(ex, "Failed to clean update temp directory {TempDirectory}", tempDirectory);
+ }
+ }
+ }
+}
diff --git a/Services/UpdateTempDirectoryProvider.cs b/Services/UpdateTempDirectoryProvider.cs
new file mode 100644
index 0000000..d2ac116
--- /dev/null
+++ b/Services/UpdateTempDirectoryProvider.cs
@@ -0,0 +1,68 @@
+/*
+ * ThreadPilot - safe temporary directory management for update downloads.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.IO;
+
+ public sealed class UpdateTempDirectoryProvider : IUpdateTempDirectoryProvider
+ {
+ private readonly string rootDirectory;
+
+ public UpdateTempDirectoryProvider()
+ : this(Path.Combine(Path.GetTempPath(), "ThreadPilot", "Updates"))
+ {
+ }
+
+ public UpdateTempDirectoryProvider(string rootDirectory)
+ {
+ this.rootDirectory = Path.GetFullPath(rootDirectory ?? throw new ArgumentNullException(nameof(rootDirectory)));
+ }
+
+ public string CreateUpdateTempDirectory(SemanticVersion version)
+ {
+ var directory = Path.Combine(this.rootDirectory, version.ToString(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+ return directory;
+ }
+
+ public bool IsSafeUpdateTempPath(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return false;
+ }
+
+ var fullPath = Path.GetFullPath(path);
+ var rootWithSeparator = this.rootDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ + Path.DirectorySeparatorChar;
+ return fullPath.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase);
+ }
+
+ public void Cleanup(string path)
+ {
+ if (!this.IsSafeUpdateTempPath(path) || !Directory.Exists(path))
+ {
+ return;
+ }
+
+ Directory.Delete(path, recursive: true);
+ this.DeleteEmptyParentsUntilRoot(Path.GetDirectoryName(Path.GetFullPath(path)));
+ }
+
+ private void DeleteEmptyParentsUntilRoot(string? directory)
+ {
+ while (!string.IsNullOrWhiteSpace(directory) && this.IsSafeUpdateTempPath(directory))
+ {
+ if (Directory.GetFileSystemEntries(directory).Length > 0)
+ {
+ return;
+ }
+
+ Directory.Delete(directory);
+ directory = Path.GetDirectoryName(directory);
+ }
+ }
+ }
+}
diff --git a/Services/WpfApplicationShutdownService.cs b/Services/WpfApplicationShutdownService.cs
new file mode 100644
index 0000000..2ae900d
--- /dev/null
+++ b/Services/WpfApplicationShutdownService.cs
@@ -0,0 +1,21 @@
+/*
+ * ThreadPilot - graceful shutdown hook after updater launch.
+ */
+namespace ThreadPilot.Services
+{
+ using System.Windows;
+
+ public sealed class WpfApplicationShutdownService : IApplicationShutdownService
+ {
+ public void RequestShutdownForUpdate()
+ {
+ var application = Application.Current;
+ if (application == null)
+ {
+ return;
+ }
+
+ application.Dispatcher.InvokeAsync(application.Shutdown);
+ }
+ }
+}
diff --git a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs
index 214ac48..6eed3cb 100644
--- a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs
+++ b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs
@@ -14,6 +14,10 @@ public void Constructor_StartMinimizedDefaultsFalse_ForManualLaunchVisibility()
Assert.True(settings.ApplyPersistentRulesOnProcessStart);
Assert.False(settings.HasSeenStartupMinimizedSuggestion);
Assert.Equal("en-US", settings.Language);
+ Assert.True(settings.EnableAutomaticUpdateChecks);
+ Assert.Equal(7, settings.UpdateCheckIntervalDays);
+ Assert.False(settings.IncludePrereleaseUpdates);
+ Assert.Null(settings.LastUpdateCheckUtc);
}
[Fact]
diff --git a/Tests/ThreadPilot.Core.Tests/GitHubUpdateCheckerTests.cs b/Tests/ThreadPilot.Core.Tests/GitHubUpdateCheckerTests.cs
index 4af15aa..51143bc 100644
--- a/Tests/ThreadPilot.Core.Tests/GitHubUpdateCheckerTests.cs
+++ b/Tests/ThreadPilot.Core.Tests/GitHubUpdateCheckerTests.cs
@@ -1,84 +1,89 @@
-namespace ThreadPilot.Core.Tests
-{
- using System.Threading;
- using System.Threading.Tasks;
- using ThreadPilot.Services;
- using ThreadPilot.Services.Abstractions;
-
- public sealed class GitHubUpdateCheckerTests
- {
- [Fact]
- public async Task GetLatestVersionAsync_ReturnsStableRelease_WhenTagIsSemver()
- {
- var client = new FakeGitHubReleaseClient(
- """
- {
- "tag_name": "v1.2.3",
- "prerelease": false,
- "draft": false,
- "html_url": "https://example.test/releases/v1.2.3"
- }
- """);
- var checker = new GitHubUpdateChecker(client);
-
- var (latest, releaseUrl) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot");
-
- Assert.Equal(new System.Version(1, 2, 3), latest);
- Assert.Equal("https://example.test/releases/v1.2.3", releaseUrl);
- }
-
- [Fact]
- public async Task GetLatestVersionAsync_IgnoresDraftOrPrerelease()
- {
- var client = new FakeGitHubReleaseClient(
- """
- {
- "tag_name": "v1.2.3-beta1",
- "prerelease": true,
- "draft": false,
- "html_url": "https://example.test/releases/v1.2.3-beta1"
- }
- """);
- var checker = new GitHubUpdateChecker(client);
-
- var (latest, releaseUrl) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot");
-
- Assert.Null(latest);
- Assert.Null(releaseUrl);
- }
-
- [Fact]
- public async Task GetLatestVersionAsync_ReturnsNull_WhenTagCannotBeParsed()
- {
- var client = new FakeGitHubReleaseClient(
- """
- {
- "tag_name": "release-main",
- "prerelease": false,
- "draft": false,
- "html_url": "https://example.test/releases/release-main"
- }
- """);
- var checker = new GitHubUpdateChecker(client);
-
- var (latest, releaseUrl) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot");
-
- Assert.Null(latest);
- Assert.Equal("https://example.test/releases/release-main", releaseUrl);
- }
-
- private sealed class FakeGitHubReleaseClient : IGitHubReleaseClient
- {
- private readonly string responseJson;
-
- public FakeGitHubReleaseClient(string responseJson)
+namespace ThreadPilot.Core.Tests
+{
+ using System.Threading;
+ using System.Threading.Tasks;
+ using ThreadPilot.Services;
+ using ThreadPilot.Services.Abstractions;
+
+ public sealed class GitHubUpdateCheckerTests
+ {
+ [Fact]
+ public async Task GetLatestVersionAsync_ReturnsStableRelease_WhenTagIsSemver()
+ {
+ var client = new FakeGitHubReleaseClient(
+ """
+ {
+ "tag_name": "v1.2.3",
+ "prerelease": false,
+ "draft": false,
+ "html_url": "https://example.test/releases/v1.2.3"
+ }
+ """);
+ var checker = new GitHubUpdateChecker(client);
+
+ var (latest, releaseUrl) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot");
+
+ Assert.Equal(new System.Version(1, 2, 3), latest);
+ Assert.Equal("https://example.test/releases/v1.2.3", releaseUrl);
+ }
+
+ [Fact]
+ public async Task GetLatestVersionAsync_IgnoresDraftOrPrerelease()
+ {
+ var client = new FakeGitHubReleaseClient(
+ """
+ {
+ "tag_name": "v1.2.3-beta1",
+ "prerelease": true,
+ "draft": false,
+ "html_url": "https://example.test/releases/v1.2.3-beta1"
+ }
+ """);
+ var checker = new GitHubUpdateChecker(client);
+
+ var (latest, releaseUrl) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot");
+
+ Assert.Null(latest);
+ Assert.Null(releaseUrl);
+ }
+
+ [Fact]
+ public async Task GetLatestVersionAsync_ReturnsNull_WhenTagCannotBeParsed()
+ {
+ var client = new FakeGitHubReleaseClient(
+ """
+ {
+ "tag_name": "release-main",
+ "prerelease": false,
+ "draft": false,
+ "html_url": "https://example.test/releases/release-main"
+ }
+ """);
+ var checker = new GitHubUpdateChecker(client);
+
+ var (latest, releaseUrl) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot");
+
+ Assert.Null(latest);
+ Assert.Equal("https://example.test/releases/release-main", releaseUrl);
+ }
+
+ private sealed class FakeGitHubReleaseClient : IGitHubReleaseClient
+ {
+ private readonly string responseJson;
+
+ public FakeGitHubReleaseClient(string responseJson)
+ {
+ this.responseJson = responseJson;
+ }
+
+ public Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default)
{
- this.responseJson = responseJson;
+ return Task.FromResult(this.responseJson);
}
- public Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default)
+ public Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default)
{
- return Task.FromResult(this.responseJson);
+ return Task.FromResult($"[{this.responseJson}]");
}
}
}
diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs
index 4b344fe..9348f80 100644
--- a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs
+++ b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs
@@ -254,7 +254,8 @@ public void MainWindow_QueuesStartupUpdateCheckOnceWithoutBlockingStartup()
Assert.Contains("QueueStartupUpdateCheck();", source, StringComparison.Ordinal);
Assert.Contains("Interlocked.Exchange(ref this.startupUpdateCheckStarted, 1)", updateCheckSection, StringComparison.Ordinal);
Assert.Contains("TaskSafety.FireAndForget(this.CheckForUpdatesAtStartupAsync()", updateCheckSection, StringComparison.Ordinal);
- Assert.Contains("GetLatestVersionAsync(\"PrimeBuild-pc\", \"ThreadPilot\")", updateCheckSection, StringComparison.Ordinal);
+ Assert.Contains("GetRequiredService()", updateCheckSection, StringComparison.Ordinal);
+ Assert.Contains("CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup))", updateCheckSection, StringComparison.Ordinal);
Assert.Contains("Startup update check ignored failure", updateCheckSection, StringComparison.Ordinal);
Assert.DoesNotContain("System.Windows.MessageBox.Show", updateCheckSection, StringComparison.Ordinal);
}
diff --git a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs
index 2d69ee3..4afd6e0 100644
--- a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs
+++ b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs
@@ -6,7 +6,6 @@ namespace ThreadPilot.Core.Tests
using Moq;
using ThreadPilot.Models;
using ThreadPilot.Services;
- using ThreadPilot.Services.Abstractions;
using ThreadPilot.ViewModels;
public sealed class SettingsViewModelThemeTests
@@ -201,6 +200,10 @@ private sealed class Harness
public Mock Localization { get; } = new(MockBehavior.Loose);
+ public Mock Updates { get; } = new(MockBehavior.Loose);
+
+ public Mock VersionProvider { get; } = new(MockBehavior.Loose);
+
public Mock Logging { get; } = new(MockBehavior.Loose);
public ActivityAuditService Audit { get; } = new(NullLogger.Instance);
@@ -228,6 +231,8 @@ public Harness(bool initialDarkTheme = false)
this.Associations
.Setup(service => service.GetDefaultPowerPlanAsync())
.ReturnsAsync((string.Empty, string.Empty));
+ this.VersionProvider.SetupGet(service => service.DisplayVersion).Returns("v1.3.1");
+ this.VersionProvider.SetupGet(service => service.CurrentVersion).Returns(new SemanticVersion(1, 3, 1));
}
public SettingsViewModel CreateViewModel() =>
@@ -241,7 +246,8 @@ public SettingsViewModel CreateViewModel() =>
this.ProcessMonitorManager.Object,
this.Theme.Object,
this.Tray.Object,
- new GitHubUpdateChecker(new Mock().Object),
+ this.Updates.Object,
+ this.VersionProvider.Object,
this.Localization.Object,
this.Logging.Object,
this.Audit);
diff --git a/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs b/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs
new file mode 100644
index 0000000..2f9729e
--- /dev/null
+++ b/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs
@@ -0,0 +1,323 @@
+namespace ThreadPilot.Core.Tests
+{
+ using System;
+ using System.Collections.Generic;
+ using System.IO;
+ using System.Text;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Extensions.Logging.Abstractions;
+ using Moq;
+ using ThreadPilot.Models;
+ using ThreadPilot.Services;
+ using ThreadPilot.Services.Abstractions;
+
+ public sealed class UpdateServiceTests
+ {
+ [Fact]
+ public void SemanticVersion_OrdersStableAbovePrerelease()
+ {
+ Assert.True(SemanticVersion.TryParse("v1.4.0-beta.1", out var prerelease));
+ Assert.True(SemanticVersion.TryParse("1.4.0", out var stable));
+
+ Assert.True(stable > prerelease);
+ }
+
+ [Fact]
+ public async Task GitHubUpdateChecker_ExcludesPrereleasesByDefault()
+ {
+ var checker = new GitHubUpdateChecker(new FakeGitHubReleaseClient(
+ """
+ [
+ { "tag_name": "v1.5.0-beta.1", "prerelease": true, "draft": false, "html_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.5.0-beta.1", "assets": [] },
+ { "tag_name": "v1.4.0", "prerelease": false, "draft": false, "html_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.4.0", "assets": [] }
+ ]
+ """));
+
+ var release = await checker.GetLatestReleaseInfoAsync("PrimeBuild-pc", "ThreadPilot");
+
+ Assert.NotNull(release);
+ Assert.Equal("1.4.0", release.Version.ToString());
+ }
+
+ [Fact]
+ public async Task CheckForUpdatesAsync_StartupSkipsWhenLastCheckInsideInterval()
+ {
+ var harness = new Harness();
+ harness.Settings.LastUpdateCheckUtc = harness.Clock.UtcNow.AddDays(-2);
+
+ var result = await harness.Service.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup));
+
+ Assert.Equal(UpdateCheckStatus.Skipped, result.Status);
+ Assert.False(harness.ReleaseClient.RequestedReleases);
+ }
+
+ [Fact]
+ public async Task CheckForUpdatesAsync_ManualFindsNewerStableRelease()
+ {
+ var harness = new Harness();
+
+ var result = await harness.Service.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Manual));
+
+ Assert.True(result.IsUpdateAvailable);
+ Assert.Equal("1.4.0", result.Release?.Version.ToString());
+ Assert.Equal(harness.Clock.UtcNow, harness.SavedSettings?.LastUpdateCheckUtc);
+ }
+
+ [Fact]
+ public void UpdateAssetSelector_SelectsInstallerAndRejectsPortable()
+ {
+ var release = CreateRelease(
+ new UpdateAsset("ThreadPilot_v1.4.0_Portable.zip", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Portable.zip"), 1),
+ new UpdateAsset("ThreadPilot_v1.4.0_Setup.exe", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Setup.exe"), 1));
+
+ var selected = UpdateAssetSelector.TrySelectInstaller(release, out var asset);
+
+ Assert.True(selected);
+ Assert.Equal("ThreadPilot_v1.4.0_Setup.exe", asset.Name);
+ }
+
+ [Fact]
+ public async Task DownloadInstallerAsync_VerifiesChecksum()
+ {
+ using var tempRoot = new TempDirectory();
+ var installerBytes = Encoding.UTF8.GetBytes("installer-content");
+ var expectedHash = ComputeSha256(installerBytes);
+ var client = new FakeUpdateDownloadClient(installerBytes, $"{expectedHash} ThreadPilot_v1.4.0_Setup.exe");
+ var service = CreateDownloadService(tempRoot.Path, client);
+
+ var result = await service.DownloadInstallerAsync(CreateReleaseWithInstallerAndChecksum());
+
+ Assert.True(result.ChecksumVerified);
+ Assert.True(File.Exists(result.InstallerPath));
+ }
+
+ [Fact]
+ public async Task DownloadInstallerAsync_RejectsInvalidChecksumAndCleansTemp()
+ {
+ using var tempRoot = new TempDirectory();
+ var client = new FakeUpdateDownloadClient(Encoding.UTF8.GetBytes("installer-content"), $"{new string('0', 64)} ThreadPilot_v1.4.0_Setup.exe");
+ var service = CreateDownloadService(tempRoot.Path, client);
+
+ await Assert.ThrowsAsync(() => service.DownloadInstallerAsync(CreateReleaseWithInstallerAndChecksum()));
+ Assert.Empty(Directory.GetDirectories(tempRoot.Path));
+ }
+
+ [Fact]
+ public void UpdateTempDirectoryProvider_DoesNotDeleteOutsideUpdateRoot()
+ {
+ using var tempRoot = new TempDirectory();
+ using var outside = new TempDirectory();
+ File.WriteAllText(Path.Combine(outside.Path, "settings.json"), "{}");
+ var provider = new UpdateTempDirectoryProvider(tempRoot.Path);
+
+ provider.Cleanup(outside.Path);
+
+ Assert.True(File.Exists(Path.Combine(outside.Path, "settings.json")));
+ }
+
+ [Fact]
+ public async Task StartupCheck_DoesNotDownloadOrInstallWithoutUserConsent()
+ {
+ var harness = new Harness();
+
+ var result = await harness.Service.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup));
+
+ Assert.True(result.IsUpdateAvailable);
+ harness.Download.Verify(service => service.DownloadInstallerAsync(It.IsAny(), It.IsAny()), Times.Never);
+ harness.Installer.Verify(service => service.LaunchInstallerElevatedAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task DownloadAndInstallAsync_StartsInstallerAndRequestsShutdown()
+ {
+ var harness = new Harness();
+ harness.Download
+ .Setup(service => service.DownloadInstallerAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new UpdateDownloadResult(
+ Path.Combine(harness.TempDirectory, "ThreadPilot_v1.4.0_Setup.exe"),
+ harness.TempDirectory,
+ true,
+ UpdateSignatureStatus.Unknown,
+ "ok"));
+ File.WriteAllText(Path.Combine(harness.TempDirectory, "ThreadPilot_v1.4.0_Setup.exe"), "installer");
+
+ var result = await harness.Service.DownloadAndInstallAsync(CreateReleaseWithInstallerAndChecksum());
+
+ Assert.Equal(UpdateInstallStatus.Started, result.Status);
+ harness.Installer.Verify(service => service.LaunchInstallerElevatedAsync(It.IsAny(), It.IsAny()), Times.Once);
+ harness.Shutdown.Verify(service => service.RequestShutdownForUpdate(), Times.Once);
+ }
+
+ private static UpdateDownloadService CreateDownloadService(string tempRoot, IUpdateDownloadClient client)
+ {
+ var signature = new Mock();
+ signature.Setup(verifier => verifier.Verify(It.IsAny())).Returns(UpdateSignatureStatus.Unknown);
+ return new UpdateDownloadService(
+ client,
+ new UpdateTempDirectoryProvider(tempRoot),
+ signature.Object,
+ NullLogger.Instance);
+ }
+
+ private static UpdateReleaseInfo CreateReleaseWithInstallerAndChecksum()
+ {
+ return CreateRelease(
+ new UpdateAsset("ThreadPilot_v1.4.0_Setup.exe", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Setup.exe"), 10),
+ new UpdateAsset("SHA256SUMS.txt", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/SHA256SUMS.txt"), 10));
+ }
+
+ private static UpdateReleaseInfo CreateRelease(params UpdateAsset[] assets)
+ {
+ return new UpdateReleaseInfo(
+ new SemanticVersion(1, 4, 0),
+ "v1.4.0",
+ new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.4.0"),
+ false,
+ assets);
+ }
+
+ private static string ComputeSha256(byte[] bytes)
+ {
+ var hash = System.Security.Cryptography.SHA256.HashData(bytes);
+ return Convert.ToHexString(hash);
+ }
+
+ private sealed class Harness
+ {
+ public ApplicationSettingsModel Settings { get; } = new();
+
+ public ApplicationSettingsModel? SavedSettings { get; private set; }
+
+ public FakeClock Clock { get; } = new();
+
+ public FakeGitHubReleaseClient ReleaseClient { get; } = new(
+ """
+ [
+ {
+ "tag_name": "v1.4.0",
+ "prerelease": false,
+ "draft": false,
+ "html_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.4.0",
+ "assets": [
+ { "name": "ThreadPilot_v1.4.0_Setup.exe", "browser_download_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Setup.exe", "size": 100 },
+ { "name": "SHA256SUMS.txt", "browser_download_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/SHA256SUMS.txt", "size": 100 }
+ ]
+ }
+ ]
+ """);
+
+ public Mock Download { get; } = new(MockBehavior.Strict);
+
+ public Mock Installer { get; } = new(MockBehavior.Strict);
+
+ public Mock Shutdown { get; } = new(MockBehavior.Strict);
+
+ public string TempDirectory { get; }
+
+ public UpdateService Service { get; }
+
+ public Harness()
+ {
+ this.TempDirectory = Directory.CreateTempSubdirectory("ThreadPilotUpdateTest").FullName;
+ var settingsService = new Mock();
+ settingsService.SetupGet(service => service.Settings).Returns(() => (ApplicationSettingsModel)this.Settings.Clone());
+ settingsService
+ .Setup(service => service.UpdateSettingsAsync(It.IsAny()))
+ .Callback(settings =>
+ {
+ this.SavedSettings = (ApplicationSettingsModel)settings.Clone();
+ this.Settings.CopyFrom(settings);
+ })
+ .Returns(Task.CompletedTask);
+
+ var versionProvider = new Mock();
+ versionProvider.SetupGet(provider => provider.CurrentVersion).Returns(new SemanticVersion(1, 3, 1));
+ versionProvider.SetupGet(provider => provider.DisplayVersion).Returns("v1.3.1");
+
+ var tempProvider = new Mock();
+ tempProvider.Setup(provider => provider.Cleanup(It.IsAny()));
+
+ this.Shutdown.Setup(service => service.RequestShutdownForUpdate());
+ this.Installer
+ .Setup(service => service.LaunchInstallerElevatedAsync(It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ this.Service = new UpdateService(
+ new GitHubUpdateChecker(this.ReleaseClient),
+ settingsService.Object,
+ versionProvider.Object,
+ this.Download.Object,
+ this.Installer.Object,
+ tempProvider.Object,
+ this.Shutdown.Object,
+ this.Clock,
+ NullLogger.Instance);
+ }
+ }
+
+ private sealed class FakeClock : IUpdateClock
+ {
+ public DateTimeOffset UtcNow { get; } = new(2026, 6, 7, 12, 0, 0, TimeSpan.Zero);
+ }
+
+ private sealed class FakeGitHubReleaseClient : IGitHubReleaseClient
+ {
+ private readonly string releasesJson;
+
+ public bool RequestedReleases { get; private set; }
+
+ public FakeGitHubReleaseClient(string releasesJson)
+ {
+ this.releasesJson = releasesJson;
+ }
+
+ public Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default)
+ {
+ throw new NotSupportedException();
+ }
+
+ public Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default)
+ {
+ this.RequestedReleases = true;
+ return Task.FromResult(this.releasesJson);
+ }
+ }
+
+ private sealed class FakeUpdateDownloadClient : IUpdateDownloadClient
+ {
+ private readonly byte[] fileBytes;
+ private readonly string? checksumsText;
+
+ public FakeUpdateDownloadClient(byte[] fileBytes, string? checksumsText)
+ {
+ this.fileBytes = fileBytes;
+ this.checksumsText = checksumsText;
+ }
+
+ public Task DownloadFileAsync(Uri uri, string destinationPath, CancellationToken cancellationToken = default)
+ {
+ File.WriteAllBytes(destinationPath, this.fileBytes);
+ return Task.CompletedTask;
+ }
+
+ public Task TryDownloadStringAsync(Uri uri, CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(this.checksumsText);
+ }
+ }
+
+ private sealed class TempDirectory : IDisposable
+ {
+ public string Path { get; } = Directory.CreateTempSubdirectory("ThreadPilotUpdateTest").FullName;
+
+ public void Dispose()
+ {
+ if (Directory.Exists(this.Path))
+ {
+ Directory.Delete(this.Path, recursive: true);
+ }
+ }
+ }
+ }
+}
diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs
index c764aad..fdbcde9 100644
--- a/ViewModels/SettingsViewModel.cs
+++ b/ViewModels/SettingsViewModel.cs
@@ -19,7 +19,6 @@ namespace ThreadPilot.ViewModels
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
- using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
@@ -48,13 +47,15 @@ public partial class SettingsViewModel : BaseViewModel
private readonly IProcessMonitorManagerService processMonitorManagerService;
private readonly IThemeService themeService;
private readonly ISystemTrayService systemTrayService;
- private readonly GitHubUpdateChecker gitHubUpdateChecker;
+ private readonly IUpdateService updateService;
+ private readonly IApplicationVersionProvider versionProvider;
private readonly ILocalizationService localizationService;
private ApplicationSettingsModel savedSettingsSnapshot;
private bool isSyncingFromService = false;
private bool? appliedThemePreference;
private string cachedDefaultPowerPlanGuid = string.Empty;
private string cachedDefaultPowerPlanName = string.Empty;
+ private UpdateReleaseInfo? availableUpdate;
private static readonly JsonSerializerOptions ImportExportJsonOptions = new()
{
WriteIndented = true,
@@ -96,6 +97,19 @@ public partial class SettingsViewModel : BaseViewModel
public ICommand CheckUpdatesCommand { get; }
+ public IAsyncRelayCommand DownloadAndInstallUpdateCommand { get; }
+
+ [ObservableProperty]
+ private string latestUpdateVersion = string.Empty;
+
+ [ObservableProperty]
+ private string lastUpdateCheckText = string.Empty;
+
+ [ObservableProperty]
+ private bool isUpdateAvailable = false;
+
+ public bool CanDownloadAndInstallUpdate => this.IsUpdateAvailable && !this.IsLoading;
+
public SettingsViewModel(
ILogger logger,
IApplicationSettingsService settingsService,
@@ -106,7 +120,8 @@ public SettingsViewModel(
IProcessMonitorManagerService processMonitorManagerService,
IThemeService themeService,
ISystemTrayService systemTrayService,
- GitHubUpdateChecker gitHubUpdateChecker,
+ IUpdateService updateService,
+ IApplicationVersionProvider versionProvider,
ILocalizationService localizationService,
IEnhancedLoggingService? enhancedLoggingService = null,
IActivityAuditService? activityAuditService = null)
@@ -120,24 +135,18 @@ public SettingsViewModel(
this.processMonitorManagerService = processMonitorManagerService ?? throw new ArgumentNullException(nameof(processMonitorManagerService));
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.updateService = updateService ?? throw new ArgumentNullException(nameof(updateService));
+ this.versionProvider = versionProvider ?? throw new ArgumentNullException(nameof(versionProvider));
this.localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService));
- // Get version and strip the git commit hash (everything after '+')
- var rawVersion = typeof(App).Assembly
- .GetCustomAttribute()?
- .InformationalVersion
- ?? typeof(App).Assembly.GetName().Version?.ToString()
- ?? "0.0.0";
-
- // Remove commit hash suffix and add 'v' prefix
- var cleanVersion = rawVersion.Split('+')[0];
- this.ApplicationVersion = $"v{cleanVersion}";
+ this.ApplicationVersion = this.versionProvider.DisplayVersion;
// Initialize with current settings
this.settings = (ApplicationSettingsModel)this.settingsService.Settings.Clone();
this.savedSettingsSnapshot = (ApplicationSettingsModel)this.settings.Clone();
this.appliedThemePreference = this.settings.UseDarkTheme;
+ this.LatestUpdateVersion = this.GetLocalizedString("Settings_UpdateNotChecked", "Not checked");
+ this.UpdateLastCheckedText();
// Initialize commands
this.SaveSettingsCommand = new AsyncRelayCommand(this.SaveSettingsAsync);
@@ -147,6 +156,9 @@ public SettingsViewModel(
this.TestNotificationCommand = new AsyncRelayCommand(this.TestNotificationAsync);
this.RefreshPowerPlansCommand = new AsyncRelayCommand(this.RefreshPowerPlansAsync);
this.CheckUpdatesCommand = new AsyncRelayCommand(this.CheckUpdatesAsync);
+ this.DownloadAndInstallUpdateCommand = new AsyncRelayCommand(
+ this.DownloadAndInstallUpdateAsync,
+ () => this.CanDownloadAndInstallUpdate);
// Subscribe to property changes to track unsaved changes
this.Settings.PropertyChanged += this.OnSettingsPropertyChanged;
@@ -265,6 +277,14 @@ partial void OnHasUnsavedChangesChanged(bool value)
partial void OnIsLoadingChanged(bool value)
{
OnPropertyChanged(nameof(CanSaveSettings));
+ OnPropertyChanged(nameof(CanDownloadAndInstallUpdate));
+ this.DownloadAndInstallUpdateCommand.NotifyCanExecuteChanged();
+ }
+
+ partial void OnIsUpdateAvailableChanged(bool value)
+ {
+ OnPropertyChanged(nameof(CanDownloadAndInstallUpdate));
+ this.DownloadAndInstallUpdateCommand.NotifyCanExecuteChanged();
}
private async Task SaveSettingsAsync()
@@ -671,40 +691,34 @@ private async Task CheckUpdatesAsync()
this.IsLoading = true;
this.StatusMessage = this.GetLocalizedString("Settings_StatusCheckingUpdates", "Checking for updates...");
- var currentVersion = ParseVersion(this.ApplicationVersion);
- var (latest, releaseUrl) = await this.gitHubUpdateChecker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot");
+ var result = await this.updateService.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Manual));
+ this.UpdateLastCheckedText();
- if (latest is null)
+ if (result.Status == UpdateCheckStatus.Failed)
{
this.StatusMessage = this.GetLocalizedString("Settings_StatusLatestUnknown", "Unable to determine the latest version.");
await this.notificationService.ShowErrorNotificationAsync(
"Update Check",
- "Unable to retrieve latest release information.");
+ result.Message);
return;
}
- if (latest > currentVersion)
+ if (result.IsUpdateAvailable && result.Release != null)
{
- this.StatusMessage = this.GetLocalizedString("Settings_StatusNewVersionFormat", "New version available: {0}", latest);
-
- var result = System.Windows.MessageBox.Show(
- $"Update available\nInstalled version: {this.ApplicationVersion}\nNew version: {latest}\n\nDo you want to open the download page?",
+ this.availableUpdate = result.Release;
+ this.LatestUpdateVersion = $"v{result.Release.Version}";
+ this.IsUpdateAvailable = true;
+ this.StatusMessage = this.GetLocalizedString("Settings_StatusNewVersionFormat", "New version available: {0}", result.Release.Version);
+ await this.notificationService.ShowNotificationAsync(
"Update available",
- MessageBoxButton.YesNo,
- MessageBoxImage.Information);
-
- if (result == MessageBoxResult.Yes)
- {
- var url = releaseUrl ?? "https://github.com/PrimeBuild-pc/ThreadPilot/releases/latest";
- Process.Start(new ProcessStartInfo
- {
- FileName = url,
- UseShellExecute = true,
- });
- }
+ $"ThreadPilot {result.Release.Version} is available.",
+ NotificationType.Information);
}
else
{
+ this.availableUpdate = null;
+ this.LatestUpdateVersion = result.Release != null ? $"v{result.Release.Version}" : this.GetLocalizedString("Settings_UpdateLatestUnknown", "Unknown");
+ this.IsUpdateAvailable = false;
this.StatusMessage = this.GetLocalizedString("Settings_StatusUpToDateFormat", "Application is up to date. Installed version: {0}", this.ApplicationVersion);
await this.notificationService.ShowSuccessNotificationAsync(
"Application up to date",
@@ -727,24 +741,73 @@ await this.notificationService.ShowErrorNotificationAsync(
}
}
- private static Version ParseVersion(string versionString)
+ private async Task DownloadAndInstallUpdateAsync()
{
- if (string.IsNullOrWhiteSpace(versionString))
+ if (this.availableUpdate == null)
{
- return new Version(0, 0, 0);
+ this.StatusMessage = this.GetLocalizedString("Settings_StatusLatestUnknown", "Unable to determine the latest version.");
+ return;
}
- var sanitized = versionString.Trim();
- if (sanitized.StartsWith("v", StringComparison.OrdinalIgnoreCase))
+ var message = this.GetLocalizedString(
+ "Settings_UpdateConfirmMessageFormat",
+ "ThreadPilot will download and verify version {0}, then ask Windows for permission to run the installer. Continue?",
+ this.availableUpdate.Version);
+ var confirmation = System.Windows.MessageBox.Show(
+ message,
+ this.GetLocalizedString("Settings_UpdateConfirmTitle", "Install ThreadPilot update"),
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Information);
+
+ if (confirmation != MessageBoxResult.Yes)
{
- sanitized = sanitized[1..];
+ this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateCanceled", "Update canceled.");
+ return;
}
- sanitized = sanitized.Split('-', '+')[0];
+ try
+ {
+ this.IsLoading = true;
+ this.StatusMessage = this.GetLocalizedString("Settings_StatusDownloadingUpdate", "Downloading and verifying update...");
- return Version.TryParse(sanitized, out var parsed)
- ? parsed
- : new Version(0, 0, 0);
+ var result = await this.updateService.DownloadAndInstallAsync(this.availableUpdate);
+ if (result.Status == UpdateInstallStatus.Started)
+ {
+ this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateInstallerStarted", "Update installer started.");
+ await this.notificationService.ShowNotificationAsync(
+ "Update installer started",
+ "ThreadPilot will close while the installer runs.",
+ NotificationType.Information);
+ }
+ else
+ {
+ this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateInstallFailedFormat", "Update install failed: {0}", result.Message);
+ await this.notificationService.ShowErrorNotificationAsync(
+ "Update install failed",
+ result.Message);
+ }
+ }
+ catch (Exception ex)
+ {
+ this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateInstallFailedFormat", "Update install failed: {0}", ex.Message);
+ this.Logger.LogError(ex, "Error downloading or installing update");
+ await this.notificationService.ShowErrorNotificationAsync(
+ "Update install failed",
+ "Unable to download or start the update installer",
+ ex);
+ }
+ finally
+ {
+ this.IsLoading = false;
+ }
+ }
+
+ private void UpdateLastCheckedText()
+ {
+ var lastCheck = this.settingsService.Settings.LastUpdateCheckUtc;
+ this.LastUpdateCheckText = lastCheck.HasValue
+ ? lastCheck.Value.LocalDateTime.ToString("g", System.Globalization.CultureInfo.CurrentCulture)
+ : this.GetLocalizedString("Settings_UpdateLastCheckedNever", "Never");
}
private string GetLocalizedString(string key, string fallback, params object[] args)
diff --git a/Views/SettingsView.xaml b/Views/SettingsView.xaml
index c98d358..bb09a94 100644
--- a/Views/SettingsView.xaml
+++ b/Views/SettingsView.xaml
@@ -350,6 +350,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -359,6 +391,12 @@
Command="{Binding CheckUpdatesCommand}"
Padding="15,6"
HorizontalAlignment="Left"/>
+
diff --git a/docs/UPDATES.md b/docs/UPDATES.md
new file mode 100644
index 0000000..3dd20c3
--- /dev/null
+++ b/docs/UPDATES.md
@@ -0,0 +1,50 @@
+# ThreadPilot In-App Updates
+
+ThreadPilot checks the official `PrimeBuild-pc/ThreadPilot` GitHub Releases feed for stable releases.
+
+## Check Behavior
+
+- Manual checks run from Settings.
+- Startup checks run in the background only when automatic checks are enabled and the last check was more than the configured interval ago.
+- The default interval is 7 days.
+- Prereleases are excluded by default.
+- Startup checks do not block app startup and never install updates.
+
+## Install Flow
+
+1. ThreadPilot reads release metadata from `PrimeBuild-pc/ThreadPilot`.
+2. The updater selects a ThreadPilot installer asset from that release.
+3. The installer downloads into a ThreadPilot-owned temp update directory.
+4. If `SHA256SUMS.txt` is present in the release, ThreadPilot verifies the installer hash before launch.
+5. ThreadPilot performs best-effort Authenticode verification. Invalid signatures are rejected; unsigned or unverifiable files are reported as unknown.
+6. The user must confirm the update from Settings before download/install starts.
+7. Windows shows the normal UAC prompt when the installer is launched elevated.
+8. ThreadPilot requests shutdown after the installer starts so the installer can replace app files.
+
+## Data Preservation
+
+Updates do not delete ThreadPilot user data. The updater only cleans its own temporary download directory.
+
+The following user-owned ThreadPilot data is preserved during update:
+
+- settings;
+- profiles;
+- CPU masks;
+- rules;
+- imported or custom power plans;
+- logs, subject to the app's normal log retention policy.
+
+Full uninstall remains the path that removes ThreadPilot-owned AppData/settings. Update code must not call uninstall cleanup or remove `%AppData%\ThreadPilot`.
+
+## Security Notes
+
+- Update metadata is fetched only for the official `PrimeBuild-pc/ThreadPilot` repository.
+- Asset URLs must be HTTPS GitHub release asset URLs.
+- Installer file names must be safe file names and must match ThreadPilot installer naming.
+- The updater does not invoke a shell command line to download files.
+- The installer is launched with `ProcessStartInfo.ArgumentList` and `UseShellExecute=true` for UAC elevation.
+- Concurrent update installation attempts are rejected.
+
+## Known Limitation
+
+If Windows keeps the elevated installer file locked after launch, immediate temp cleanup can fail. ThreadPilot logs the cleanup failure and leaves only the ThreadPilot update temp directory behind.
From 12feeafb77911e44e5659a11d305a304a9889597 Mon Sep 17 00:00:00 2001
From: Prime Build <162145141+PrimeBuild-pc@users.noreply.github.com>
Date: Sun, 7 Jun 2026 12:27:19 +0200
Subject: [PATCH 7/7] Prepare v1.4.0 release (#29)
## Summary
Prepares the v1.4.0 release after the safe in-app updater feature merge.
## Changes
- Bumps project/package/installer metadata from 1.3.1 to 1.4.0.
- Updates changelog and release notes.
- Adds v1.4.0 release notes.
- Updates packaging examples where needed.
## Release highlights
- Safe in-app updater.
- Manual update checks from Settings.
- Optional background update checks with a default 7-day interval.
- Explicit user confirmation before installing updates.
- SHA256 verification when release checksums are available.
- Best-effort Authenticode verification.
- User data preservation during update.
## Validation
- `dotnet build ThreadPilot_1.sln`: passed
- `dotnet test ThreadPilot_1.sln`: passed
---
Installer/Installer.iss | 2 +-
Installer/ThreadPilot.wxs | 2 +-
Installer/setup.iss | 2 +-
.../PackagingMetadataTests.cs | 36 ++++++------
ThreadPilot.csproj | 8 +--
app.manifest | 2 +-
build/build-installer.ps1 | 2 +-
build/build-release.ps1 | 2 +-
build/package-release-zips.ps1 | 2 +-
chocolatey/threadpilot.nuspec | 4 +-
docs/CHANGELOG.md | 38 ++++++++++++
docs/release/PACKAGING.md | 18 +++---
docs/release/RELEASE_NOTES.md | 58 +++++++++----------
docs/releases/v1.4.0.md | 37 ++++++++++++
sonar-project.properties | 2 +-
15 files changed, 143 insertions(+), 72 deletions(-)
create mode 100644 docs/releases/v1.4.0.md
diff --git a/Installer/Installer.iss b/Installer/Installer.iss
index 9505e78..a17973c 100644
--- a/Installer/Installer.iss
+++ b/Installer/Installer.iss
@@ -5,7 +5,7 @@
#define MyAppPublisher "ThreadPilot"
#define MyAppURL "https://github.com/"
#define MyAppExeName "ThreadPilot.exe"
-#define MyAppVersion "1.3.1"
+#define MyAppVersion "1.4.0"
#ifndef MyWizardStyle
#define MyWizardStyle "modern dynamic windows11"
diff --git a/Installer/ThreadPilot.wxs b/Installer/ThreadPilot.wxs
index 3306343..8bcf6f1 100644
--- a/Installer/ThreadPilot.wxs
+++ b/Installer/ThreadPilot.wxs
@@ -7,7 +7,7 @@
diff --git a/Installer/setup.iss b/Installer/setup.iss
index 48135e4..7fbed61 100644
--- a/Installer/setup.iss
+++ b/Installer/setup.iss
@@ -11,7 +11,7 @@
#endif
#ifndef MyAppVersion
- #define MyAppVersion "1.3.1"
+ #define MyAppVersion "1.4.0"
#endif
#ifndef MyAppSourceDir
diff --git a/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs b/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs
index 5d72a1a..d28b98f 100644
--- a/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs
+++ b/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs
@@ -4,8 +4,8 @@ namespace ThreadPilot.Core.Tests
public sealed partial class PackagingMetadataTests
{
- private const string HotfixVersion = "1.3.1";
- private const string HotfixAssemblyVersion = "1.3.1.0";
+ private const string ReleaseVersion = "1.4.0";
+ private const string ReleaseAssemblyVersion = "1.4.0.0";
[Fact]
public void InnoInstallers_UseStableDisplayNameAndSeparateVersionMetadata()
@@ -57,24 +57,24 @@ public void PrimaryInstaller_CleansOnlyRecognizedLegacyBetaUninstallRegistryEntr
}
[Fact]
- public void VersionMetadata_IsBumpedToHotfixVersion()
+ public void VersionMetadata_IsBumpedToReleaseVersion()
{
var root = FindRepositoryRoot();
- AssertFileContains(Path.Combine(root, "ThreadPilot.csproj"), $"{HotfixVersion} ");
- AssertFileContains(Path.Combine(root, "ThreadPilot.csproj"), $"{HotfixAssemblyVersion} ");
- AssertFileContains(Path.Combine(root, "ThreadPilot.csproj"), $"{HotfixAssemblyVersion} ");
- AssertFileContains(Path.Combine(root, "ThreadPilot.csproj"), $"{HotfixVersion} ");
- AssertFileContains(Path.Combine(root, "app.manifest"), $"version=\"{HotfixAssemblyVersion}\"");
- AssertFileContains(Path.Combine(root, "Installer", "ThreadPilot.wxs"), $"Version=\"{HotfixAssemblyVersion}\"");
- AssertFileContains(Path.Combine(root, "chocolatey", "threadpilot.nuspec"), $"{HotfixVersion} ");
- AssertFileContains(Path.Combine(root, "chocolatey", "threadpilot.nuspec"), $"releases/tag/v{HotfixVersion}");
- AssertFileContains(Path.Combine(root, "sonar-project.properties"), $"sonar.projectVersion={HotfixVersion}");
- AssertFileContains(Path.Combine(root, "build", "build-release.ps1"), $"[string]$Version = \"{HotfixVersion}\"");
- AssertFileContains(Path.Combine(root, "build", "build-installer.ps1"), $"[string]$Version = \"{HotfixVersion}\"");
- AssertFileContains(Path.Combine(root, "build", "package-release-zips.ps1"), $"[string]$Version = \"{HotfixVersion}\"");
- Assert.True(File.Exists(Path.Combine(root, "docs", "releases", $"v{HotfixVersion}.md")));
- AssertFileContains(Path.Combine(root, "docs", "release", "RELEASE_NOTES.md"), $"v{HotfixVersion}");
+ AssertFileContains(Path.Combine(root, "ThreadPilot.csproj"), $"{ReleaseVersion} ");
+ AssertFileContains(Path.Combine(root, "ThreadPilot.csproj"), $"{ReleaseAssemblyVersion} ");
+ AssertFileContains(Path.Combine(root, "ThreadPilot.csproj"), $"{ReleaseAssemblyVersion} ");
+ AssertFileContains(Path.Combine(root, "ThreadPilot.csproj"), $"{ReleaseVersion} ");
+ AssertFileContains(Path.Combine(root, "app.manifest"), $"version=\"{ReleaseAssemblyVersion}\"");
+ AssertFileContains(Path.Combine(root, "Installer", "ThreadPilot.wxs"), $"Version=\"{ReleaseAssemblyVersion}\"");
+ AssertFileContains(Path.Combine(root, "chocolatey", "threadpilot.nuspec"), $"{ReleaseVersion} ");
+ AssertFileContains(Path.Combine(root, "chocolatey", "threadpilot.nuspec"), $"releases/tag/v{ReleaseVersion}");
+ AssertFileContains(Path.Combine(root, "sonar-project.properties"), $"sonar.projectVersion={ReleaseVersion}");
+ AssertFileContains(Path.Combine(root, "build", "build-release.ps1"), $"[string]$Version = \"{ReleaseVersion}\"");
+ AssertFileContains(Path.Combine(root, "build", "build-installer.ps1"), $"[string]$Version = \"{ReleaseVersion}\"");
+ AssertFileContains(Path.Combine(root, "build", "package-release-zips.ps1"), $"[string]$Version = \"{ReleaseVersion}\"");
+ Assert.True(File.Exists(Path.Combine(root, "docs", "releases", $"v{ReleaseVersion}.md")));
+ AssertFileContains(Path.Combine(root, "docs", "release", "RELEASE_NOTES.md"), $"v{ReleaseVersion}");
}
private static void AssertFileContains(string path, string expected)
@@ -100,7 +100,7 @@ private static string FindRepositoryRoot()
throw new InvalidOperationException("Repository root could not be located.");
}
- [GeneratedRegex("#define MyAppVersion \"1\\.3\\.1\"", RegexOptions.CultureInvariant)]
+ [GeneratedRegex("#define MyAppVersion \"1\\.4\\.0\"", RegexOptions.CultureInvariant)]
private static partial Regex MyAppVersionRegex();
}
}
diff --git a/ThreadPilot.csproj b/ThreadPilot.csproj
index 72fc3e8..b244865 100644
--- a/ThreadPilot.csproj
+++ b/ThreadPilot.csproj
@@ -16,10 +16,10 @@
link
true
CS1998;CS0067;CS0414;WFAC010;IL3000;MVVMTK0034
- 1.3.1
- 1.3.1.0
- 1.3.1.0
- 1.3.1
+ 1.4.0
+ 1.4.0.0
+ 1.4.0.0
+ 1.4.0
diff --git a/app.manifest b/app.manifest
index 22b403e..59561ee 100644
--- a/app.manifest
+++ b/app.manifest
@@ -1,6 +1,6 @@
-
+
diff --git a/build/build-installer.ps1 b/build/build-installer.ps1
index c5ad8c8..68818be 100644
--- a/build/build-installer.ps1
+++ b/build/build-installer.ps1
@@ -1,5 +1,5 @@
param(
- [string]$Version = "1.3.1",
+ [string]$Version = "1.4.0",
[string]$Configuration = "Release",
[switch]$SkipPublish
)
diff --git a/build/build-release.ps1 b/build/build-release.ps1
index cd6db20..35430d8 100644
--- a/build/build-release.ps1
+++ b/build/build-release.ps1
@@ -1,5 +1,5 @@
param(
- [string]$Version = "1.3.1",
+ [string]$Version = "1.4.0",
[string]$Configuration = "Release",
[string]$Runtime = "win-x64"
)
diff --git a/build/package-release-zips.ps1 b/build/package-release-zips.ps1
index 85125a0..214aec3 100644
--- a/build/package-release-zips.ps1
+++ b/build/package-release-zips.ps1
@@ -1,5 +1,5 @@
param(
- [string]$Version = "1.3.1"
+ [string]$Version = "1.4.0"
)
$ErrorActionPreference = "Stop"
diff --git a/chocolatey/threadpilot.nuspec b/chocolatey/threadpilot.nuspec
index 7048d8d..8ca6856 100644
--- a/chocolatey/threadpilot.nuspec
+++ b/chocolatey/threadpilot.nuspec
@@ -2,7 +2,7 @@
threadpilot
- 1.3.1
+ 1.4.0
Codestin Search App
Prime Build
https://github.com/PrimeBuild-pc/ThreadPilot
@@ -15,7 +15,7 @@
false
Advanced Windows process and power plan manager with rules automation and performance controls.
ThreadPilot process and power plan manager.
- https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.3.1
+ https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.4.0
threadpilot process powerplan performance windows
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index dd4979b..587ad60 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -2,6 +2,44 @@
All notable changes to this project are documented in this file.
+## v1.4.0 - Safe in-app updater
+
+### Added
+
+- Added safe in-app updater support.
+- Added manual update checks from Settings.
+- Added optional background update checks with a default 7-day interval.
+- Added latest/current version display in Settings.
+- Added update download and install flow with explicit user confirmation.
+- Added updater documentation.
+
+### Security
+
+- Update metadata is fetched only from the official PrimeBuild-pc/ThreadPilot GitHub repository.
+- Prereleases are excluded by default.
+- Installer assets are selected from GitHub HTTPS release assets.
+- SHA256 checksums are verified when SHA256SUMS.txt is available.
+- Checksum mismatches are rejected.
+- Authenticode signature verification is performed on a best-effort basis, rejecting explicitly invalid signatures.
+- Installer launch uses ProcessStartInfo without shell command construction.
+- Concurrent update attempts are prevented.
+
+### User data preservation
+
+- Updates preserve AppData, settings, profiles, CPU masks, rules, custom/imported power plans, and logs.
+- Only updater temporary files are cleaned by the update flow.
+- Full uninstall behavior remains separate.
+
+### Changed
+
+- Project version updated to 1.4.0.
+- Installer, packaging, Chocolatey, and release metadata updated to v1.4.0.
+
+### Verification
+
+- Build passed.
+- Automated tests passed.
+
## v1.3.1 - Localization and installer metadata hotfix
### Fixed
diff --git a/docs/release/PACKAGING.md b/docs/release/PACKAGING.md
index 5df81e1..a61fe1e 100644
--- a/docs/release/PACKAGING.md
+++ b/docs/release/PACKAGING.md
@@ -178,9 +178,9 @@ Generate manifests locally with:
```powershell
./build/generate-winget-manifests.ps1 `
- -Version "1.3.0" `
- -Tag "v1.3.0" `
- -InstallerUrl "https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.3.0/ThreadPilot_v1.3.0_Setup.exe" `
+ -Version "1.4.0" `
+ -Tag "v1.4.0" `
+ -InstallerUrl "https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Setup.exe" `
-InstallerSha256 "" `
-OutputRoot "winget-manifests"
```
@@ -224,9 +224,9 @@ Local packaging-only validation:
```powershell
./build/publish-chocolatey.ps1 `
- -Version "1.3.0" `
- -Tag "v1.3.0" `
- -InstallerPath ".\artifacts\release\installer\ThreadPilot_v1.3.0_Setup.exe" `
+ -Version "1.4.0" `
+ -Tag "v1.4.0" `
+ -InstallerPath ".\artifacts\release\installer\ThreadPilot_v1.4.0_Setup.exe" `
-DryRun `
-PackageOutputDirectory ".\artifacts\choco-dryrun" `
-MetadataOutputPath ".\artifacts\choco-dryrun\chocolatey-package-metadata.json"
@@ -250,9 +250,9 @@ Public publish path:
```powershell
./build/publish-chocolatey.ps1 `
- -Version "1.3.0" `
- -Tag "v1.3.0" `
- -InstallerPath ".\artifacts\release\installer\ThreadPilot_v1.3.0_Setup.exe" `
+ -Version "1.4.0" `
+ -Tag "v1.4.0" `
+ -InstallerPath ".\artifacts\release\installer\ThreadPilot_v1.4.0_Setup.exe" `
-ApiKey ""
```
diff --git a/docs/release/RELEASE_NOTES.md b/docs/release/RELEASE_NOTES.md
index c186a41..358b482 100644
--- a/docs/release/RELEASE_NOTES.md
+++ b/docs/release/RELEASE_NOTES.md
@@ -1,41 +1,37 @@
-# ThreadPilot v1.3.1 Release Notes Draft
+## ThreadPilot v1.4.0
-## Highlights
+### Added
-- Completed the Simplified Chinese localization pass for the main WPF views, dialogs, context menus, tray menu, tooltips, accessibility text, status text, and user-facing service notifications.
-- Kept English as the default startup language and Simplified Chinese as an optional Settings choice.
-- Changed installer display metadata so installed apps list ThreadPilot as `ThreadPilot` while preserving version metadata separately as `1.3.1`.
-- Improved uninstall cleanup for ThreadPilot-owned application files, shortcuts, startup entries, and per-user AppData/settings when the uninstaller runs.
+- Added safe in-app updater support.
+- Added manual update checks from Settings.
+- Added optional background update checks with a default 7-day interval.
+- Added latest/current version display in Settings.
+- Added update download and install flow with explicit user confirmation.
+- Added updater documentation.
-## Fixed
+### Security
-- Fixed incomplete zh-CN coverage that left many v1.3.0 UI surfaces in English.
-- Fixed Inno Setup `AppVerName` so uninstall tools do not show versioned names such as `ThreadPilot 1.3.1`.
-- Added guarded cleanup for the obsolete `ThreadPilot 0.1.0-beta` uninstall registry entry only when it exactly matches the legacy ThreadPilot display name and Program Files install location.
+- Update metadata is fetched only from the official PrimeBuild-pc/ThreadPilot GitHub repository.
+- Prereleases are excluded by default.
+- Installer assets are selected from GitHub HTTPS release assets.
+- SHA256 checksums are verified when SHA256SUMS.txt is available.
+- Checksum mismatches are rejected.
+- Authenticode signature verification is performed on a best-effort basis, rejecting explicitly invalid signatures.
+- Installer launch uses ProcessStartInfo without shell command construction.
+- Concurrent update attempts are prevented.
-## Safety
+### User data preservation
-- No automatic in-app updater was added.
-- No elevation, affinity, process control, power plan, or system tweak behavior was changed.
-- Normal install/update preserves existing user settings and data.
-- Full uninstall removes only ThreadPilot-owned AppData for the uninstalling user account; elevated uninstall contexts may not be able to clean another Windows user's per-user AppData.
+- Updates preserve AppData, settings, profiles, CPU masks, rules, custom/imported power plans, and logs.
+- Only updater temporary files are cleaned by the update flow.
+- Full uninstall behavior remains separate.
-## Testing
+### Changed
-- Added resource key parity coverage for `Locales/en-US.xaml` and `Locales/zh-CN.xaml`.
-- Added hardcoded-English checks for important WPF views with brand and technical terms whitelisted.
-- Added release metadata tests for installer display name, uninstall cleanup scope, legacy beta uninstall-entry cleanup, and v1.3.1 version surfaces.
+- Project version updated to 1.4.0.
+- Installer, packaging, Chocolatey, and release metadata updated to v1.4.0.
-## Compatibility and Upgrade Notes
+### Verification
-- Requires Windows 11 build 22000 or newer.
-- Existing settings, profiles, masks, persistent rules, imported power plans, and logs continue to load after update.
-- Existing process, affinity, power plan, automation, and system tweak behavior is unchanged.
-
-## Known Non-Goals
-
-- No GitHub release or tag is created by this hotfix branch.
-- No automatic in-app updating.
-- No anti-cheat bypass.
-- No Windows Service.
-- No registry or IFEO persistence.
+- Build passed.
+- Automated tests passed.
diff --git a/docs/releases/v1.4.0.md b/docs/releases/v1.4.0.md
new file mode 100644
index 0000000..358b482
--- /dev/null
+++ b/docs/releases/v1.4.0.md
@@ -0,0 +1,37 @@
+## ThreadPilot v1.4.0
+
+### Added
+
+- Added safe in-app updater support.
+- Added manual update checks from Settings.
+- Added optional background update checks with a default 7-day interval.
+- Added latest/current version display in Settings.
+- Added update download and install flow with explicit user confirmation.
+- Added updater documentation.
+
+### Security
+
+- Update metadata is fetched only from the official PrimeBuild-pc/ThreadPilot GitHub repository.
+- Prereleases are excluded by default.
+- Installer assets are selected from GitHub HTTPS release assets.
+- SHA256 checksums are verified when SHA256SUMS.txt is available.
+- Checksum mismatches are rejected.
+- Authenticode signature verification is performed on a best-effort basis, rejecting explicitly invalid signatures.
+- Installer launch uses ProcessStartInfo without shell command construction.
+- Concurrent update attempts are prevented.
+
+### User data preservation
+
+- Updates preserve AppData, settings, profiles, CPU masks, rules, custom/imported power plans, and logs.
+- Only updater temporary files are cleaned by the update flow.
+- Full uninstall behavior remains separate.
+
+### Changed
+
+- Project version updated to 1.4.0.
+- Installer, packaging, Chocolatey, and release metadata updated to v1.4.0.
+
+### Verification
+
+- Build passed.
+- Automated tests passed.
diff --git a/sonar-project.properties b/sonar-project.properties
index fb3dab0..3294c87 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -1,6 +1,6 @@
sonar.projectKey=threadpilot
sonar.projectName=ThreadPilot
-sonar.projectVersion=1.3.1
+sonar.projectVersion=1.4.0
sonar.sourceEncoding=UTF-8
sonar.sources=.