From 1430e4420f95eaaa9ad7187b18bb5c7d3ca865f8 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Tue, 20 Oct 2026 13:38:20 +0200 Subject: [PATCH 01/34] feat: Game Boost scheduler hints and dynamic CPU Set release Game Boost now gives Windows hints as well as constraints, and adapts the game's CPU Set to its load instead of applying it once: - the game gets high QoS (power throttling off), on every CPU, so Windows never throttles it while it is not in the foreground; - in Isolation the listed background apps get EcoQoS, also on CPUs where CPU Sets are not applied; - a game that keeps its confined CPUs saturated has its CPU Set released so Windows can use every core, and confined again after a minute of low load (hysteresis, at most two releases per session). Same ownership rules as CPU Sets: only values Windows still manages are changed, user rules and the app's own QoS win, everything is journaled and restored on exit, toggle and after a crash. Efficiency mode writes now keep the process's other power-throttling flags: Windows replaces the whole state on set, so the old write wiped a timer-resolution opt-out set by the process itself. --- Locales/de-DE.xaml | 6 +- Locales/en-US.xaml | 6 +- Locales/es-ES.xaml | 6 +- Locales/fr-FR.xaml | 6 +- Locales/it-IT.xaml | 6 +- Locales/ru-RU.xaml | 6 +- Locales/zh-CN.xaml | 6 +- Services/GameBoostPolicy.cs | 67 ++++ Services/GameSessionService.cs | 328 +++++++++++++++--- Services/ProcessMemoryPriorityService.cs | 18 +- .../GameSessionNativeIntegrationTests.cs | 12 + .../GameSessionServiceTests.cs | 189 +++++++++- .../ProcessMemoryPriorityServiceTests.cs | 20 +- 13 files changed, 600 insertions(+), 76 deletions(-) diff --git a/Locales/de-DE.xaml b/Locales/de-DE.xaml index 88f5d72..ce948f7 100644 --- a/Locales/de-DE.xaml +++ b/Locales/de-DE.xaml @@ -559,11 +559,11 @@ Ein Aus Game Boost - Während ein Spiel läuft, setzt Game Boost die Standard-CPU-Sets des Spiels und ausgewählter Hintergrund-Apps, damit sie auf getrennten Kernen laufen. Windows plant weiterhin jeden Thread darin, und beim Beenden des Spiels wird alles wiederhergestellt. + Während ein Spiel läuft, setzt Game Boost die Standard-CPU-Sets des Spiels und ausgewählter Hintergrund-Apps, damit sie auf getrennten Kernen laufen, und bittet Windows, das Spiel nicht energiesparend zu drosseln. Windows plant weiterhin jeden Thread: Hält das Spiel alle seine Kerne ausgelastet, darf es alle Kerne nutzen, bis die Last sinkt. Beim Beenden des Spiels wird alles wiederhergestellt. Modus Automatisch Isolation - Isolation verschiebt zusätzlich weitere Neben-Apps (Launcher-Helfer, Mediaplayer, Office-Apps) und hält das Spiel auf CPUs mit zwei gleichen CCDs auf CCD0. + Isolation verschiebt zusätzlich weitere Neben-Apps (Launcher-Helfer, Mediaplayer, Office-Apps), versetzt die ausgewählten Hintergrund-Apps in den Effizienzmodus (EcoQoS) und hält das Spiel auf CPUs mit zwei gleichen CCDs auf CCD0. Spielpriorität auf Höher als normal setzen Experimentell: in deinen Spielen messen. Niemals Hoch oder Echtzeit. Spiele und Apps @@ -576,7 +576,7 @@ Spiel auf CCD0 ({0} CPUs), Hintergrund-Apps auf dem anderen CCD ({1} CPUs). Windows platziert das Spiel; ausgewählte Hintergrund-Apps verlassen CCD0 ({0} CPUs). Isolation hält zusätzlich das Spiel auf CCD0. Spiel auf den P-Kernen ({0} CPUs), ausgewählte Hintergrund-Apps auf den E-Kernen ({1} CPUs). - Auf dieser CPU entscheidet Windows über die Kerne; Game Boost setzt nur die optionale Priorität. + Auf dieser CPU entscheidet Windows über die Kerne; Game Boost bittet Windows, das Spiel nicht zu drosseln, setzt die optionale Priorität und in Isolation den Effizienzmodus für Hintergrund-Apps. Von Windows verwaltet Windows-Einstellungen konnten nicht geöffnet werden Einstellungen für {0} geöffnet diff --git a/Locales/en-US.xaml b/Locales/en-US.xaml index 8fca21c..dba33f0 100644 --- a/Locales/en-US.xaml +++ b/Locales/en-US.xaml @@ -559,11 +559,11 @@ On Off Game Boost - While a game runs, sets the default CPU Sets of the game and of selected background apps so they run on separate cores. Windows still schedules every thread inside them, and everything is restored when the game closes. + While a game runs, sets the default CPU Sets of the game and of selected background apps so they run on separate cores, and asks Windows not to power-throttle the game. Windows still schedules every thread: if the game keeps all its cores busy, Game Boost lets it use every core until the load drops. Everything is restored when the game closes. Mode Automatic Isolation - Isolation also moves more secondary apps (launcher helpers, media players, office apps) and, on CPUs with two equal CCDs, keeps the game on CCD0. + Isolation also moves more secondary apps (launcher helpers, media players, office apps), puts the selected background apps in efficiency mode (EcoQoS) and, on CPUs with two equal CCDs, keeps the game on CCD0. Raise game priority to Above Normal Experimental: measure it on your games. Never High or Realtime. Games and apps @@ -576,7 +576,7 @@ Game on CCD0 ({0} CPUs), background apps on the other CCD ({1} CPUs). Windows places the game; selected background apps move off CCD0 ({0} CPUs). Isolation also keeps the game on CCD0. Game on the P-cores ({0} CPUs), selected background apps on the E-cores ({1} CPUs). - On this CPU Windows decides the cores; Game Boost only applies the optional priority. + On this CPU Windows decides the cores; Game Boost asks Windows not to throttle the game, plus the optional priority and, in Isolation, efficiency mode for background apps. Managed by Windows Could not open Windows settings Opened settings for {0} diff --git a/Locales/es-ES.xaml b/Locales/es-ES.xaml index 1087a59..f67ad40 100644 --- a/Locales/es-ES.xaml +++ b/Locales/es-ES.xaml @@ -559,11 +559,11 @@ Activado Desactivado Game Boost - Mientras se ejecuta un juego, establece los CPU Sets predeterminados del juego y de las aplicaciones en segundo plano seleccionadas para que usen núcleos separados. Windows sigue planificando cada hilo dentro de ellos y todo se restaura al cerrar el juego. + Mientras se ejecuta un juego, establece los CPU Sets predeterminados del juego y de las aplicaciones en segundo plano seleccionadas para que usen núcleos separados, y pide a Windows que no limite el juego para ahorrar energía. Windows sigue planificando cada hilo: si el juego mantiene ocupados todos sus núcleos, Game Boost le deja usar todos los núcleos hasta que baje la carga. Todo se restaura al cerrar el juego. Modo Automático Aislamiento - Aislamiento también mueve otras aplicaciones secundarias (ayudantes de lanzadores, reproductores multimedia, aplicaciones de oficina) y, en CPU con dos CCD iguales, mantiene el juego en el CCD0. + Aislamiento también mueve otras aplicaciones secundarias (ayudantes de lanzadores, reproductores multimedia, aplicaciones de oficina), pone las aplicaciones en segundo plano seleccionadas en modo de eficiencia (EcoQoS) y, en CPU con dos CCD iguales, mantiene el juego en el CCD0. Subir la prioridad del juego a Por encima de lo normal Experimental: mídelo en tus juegos. Nunca Alta ni Tiempo real. Juegos y aplicaciones @@ -576,7 +576,7 @@ Juego en el CCD0 ({0} CPU), aplicaciones en segundo plano en el otro CCD ({1} CPU). Windows coloca el juego; las aplicaciones en segundo plano seleccionadas salen del CCD0 ({0} CPU). Aislamiento también mantiene el juego en el CCD0. Juego en los núcleos P ({0} CPU), aplicaciones en segundo plano seleccionadas en los núcleos E ({1} CPU). - En esta CPU Windows decide los núcleos; Game Boost solo aplica la prioridad opcional. + En esta CPU Windows decide los núcleos; Game Boost pide a Windows que no limite el juego, además de la prioridad opcional y, en Aislamiento, el modo de eficiencia para las aplicaciones en segundo plano. Administrado por Windows No se pudo abrir la configuración de Windows Configuración abierta para {0} diff --git a/Locales/fr-FR.xaml b/Locales/fr-FR.xaml index 6bc06a7..76399bb 100644 --- a/Locales/fr-FR.xaml +++ b/Locales/fr-FR.xaml @@ -559,11 +559,11 @@ Activé Désactivé Game Boost - Pendant qu'un jeu tourne, définit les CPU Sets par défaut du jeu et des applications d'arrière-plan sélectionnées pour qu'ils s'exécutent sur des cœurs séparés. Windows planifie toujours chaque thread à l'intérieur, et tout est restauré à la fermeture du jeu. + Pendant qu'un jeu tourne, définit les CPU Sets par défaut du jeu et des applications d'arrière-plan sélectionnées pour qu'ils s'exécutent sur des cœurs séparés, et demande à Windows de ne pas brider le jeu pour économiser l'énergie. Windows planifie toujours chaque thread : si le jeu occupe tous ses cœurs, Game Boost le laisse utiliser tous les cœurs jusqu'à ce que la charge baisse. Tout est restauré à la fermeture du jeu. Mode Automatique Isolation - L'isolation déplace aussi d'autres applications secondaires (assistants de lanceurs, lecteurs multimédias, applications bureautiques) et, sur les CPU à deux CCD identiques, garde le jeu sur le CCD0. + L'isolation déplace aussi d'autres applications secondaires (assistants de lanceurs, lecteurs multimédias, applications bureautiques), met les applications d'arrière-plan sélectionnées en mode efficacité (EcoQoS) et, sur les CPU à deux CCD identiques, garde le jeu sur le CCD0. Passer la priorité du jeu à Supérieure à la normale Expérimental : mesurez-le sur vos jeux. Jamais Haute ni Temps réel. Jeux et applications @@ -576,7 +576,7 @@ Jeu sur le CCD0 ({0} CPU), applications d'arrière-plan sur l'autre CCD ({1} CPU). Windows place le jeu ; les applications d'arrière-plan sélectionnées quittent le CCD0 ({0} CPU). L'isolation garde aussi le jeu sur le CCD0. Jeu sur les cœurs P ({0} CPU), applications d'arrière-plan sélectionnées sur les cœurs E ({1} CPU). - Sur ce CPU, Windows choisit les cœurs ; Game Boost applique seulement la priorité facultative. + Sur ce CPU, Windows choisit les cœurs ; Game Boost demande à Windows de ne pas brider le jeu, applique la priorité facultative et, en isolation, le mode efficacité des applications d'arrière-plan. Géré par Windows Impossible d'ouvrir les paramètres Windows Paramètres ouverts pour {0} diff --git a/Locales/it-IT.xaml b/Locales/it-IT.xaml index edd0868..a8a98c5 100644 --- a/Locales/it-IT.xaml +++ b/Locales/it-IT.xaml @@ -559,11 +559,11 @@ Attivato Disattivato Game Boost - Mentre un gioco è in esecuzione, imposta i CPU Set predefiniti del gioco e delle app in background selezionate, così girano su core separati. Windows continua a gestire ogni thread al loro interno, e tutto viene ripristinato quando il gioco si chiude. + Mentre un gioco è in esecuzione, imposta i CPU Set predefiniti del gioco e delle app in background selezionate, così girano su core separati, e chiede a Windows di non limitare il gioco per risparmio energetico. Windows continua a gestire ogni thread: se il gioco tiene occupati tutti i suoi core, Game Boost gli lascia usare tutti i core finché il carico non scende. Tutto viene ripristinato quando il gioco si chiude. Modalità Automatico Isolamento - Isolamento sposta anche altre app secondarie (helper dei launcher, lettori multimediali, app per ufficio) e, sulle CPU con due CCD uguali, tiene il gioco sul CCD0. + Isolamento sposta anche altre app secondarie (helper dei launcher, lettori multimediali, app per ufficio), mette le app in background selezionate in modalità efficienza (EcoQoS) e, sulle CPU con due CCD uguali, tiene il gioco sul CCD0. Alza la priorità del gioco a Superiore al normale Sperimentale: misuralo sui tuoi giochi. Mai Alta o Tempo reale. Giochi e app @@ -576,7 +576,7 @@ Gioco sul CCD0 ({0} CPU), app in background sull'altro CCD ({1} CPU). Il gioco lo posiziona Windows; le app in background selezionate lasciano il CCD0 ({0} CPU). Isolamento tiene anche il gioco sul CCD0. Gioco sui P-core ({0} CPU), app in background selezionate sugli E-core ({1} CPU). - Su questa CPU i core li decide Windows; Game Boost applica solo la priorità facoltativa. + Su questa CPU i core li decide Windows; Game Boost chiede a Windows di non limitare il gioco, più la priorità facoltativa e, in Isolamento, la modalità efficienza per le app in background. Gestito da Windows Impossibile aprire le impostazioni di Windows Impostazioni aperte per {0} diff --git a/Locales/ru-RU.xaml b/Locales/ru-RU.xaml index e9f4bbd..80bddb3 100644 --- a/Locales/ru-RU.xaml +++ b/Locales/ru-RU.xaml @@ -559,11 +559,11 @@ Вкл. Откл. Game Boost - Пока запущена игра, задаёт наборы ЦП (CPU Sets) по умолчанию для игры и выбранных фоновых приложений, чтобы они работали на разных ядрах. Windows по-прежнему распределяет каждый поток внутри них, а после закрытия игры всё восстанавливается. + Пока запущена игра, задаёт наборы ЦП (CPU Sets) по умолчанию для игры и выбранных фоновых приложений, чтобы они работали на разных ядрах, и просит Windows не ограничивать игру ради энергосбережения. Windows по-прежнему распределяет каждый поток: если игра загружает все свои ядра, Game Boost разрешает ей использовать все ядра, пока нагрузка не снизится. После закрытия игры всё восстанавливается. Режим Автоматически Изоляция - Изоляция также переносит другие второстепенные приложения (помощники лаунчеров, медиаплееры, офисные приложения), а на ЦП с двумя одинаковыми CCD оставляет игру на CCD0. + Изоляция также переносит другие второстепенные приложения (помощники лаунчеров, медиаплееры, офисные приложения), включает для выбранных фоновых приложений режим эффективности (EcoQoS), а на ЦП с двумя одинаковыми CCD оставляет игру на CCD0. Повысить приоритет игры до «Выше среднего» Экспериментально: проверьте на своих играх. Никогда «Высокий» или «Реального времени». Игры и приложения @@ -576,7 +576,7 @@ Игра на CCD0 ({0} ЦП), фоновые приложения на другом CCD ({1} ЦП). Игру размещает Windows; выбранные фоновые приложения уходят с CCD0 ({0} ЦП). Изоляция также оставляет игру на CCD0. Игра на P-ядрах ({0} ЦП), выбранные фоновые приложения на E-ядрах ({1} ЦП). - На этом ЦП ядра выбирает Windows; Game Boost применяет только необязательный приоритет. + На этом ЦП ядра выбирает Windows; Game Boost просит Windows не ограничивать игру, применяет необязательный приоритет, а в режиме изоляции — режим эффективности для фоновых приложений. Управляется Windows Не удалось открыть параметры Windows Открыты параметры для {0} diff --git a/Locales/zh-CN.xaml b/Locales/zh-CN.xaml index 91d8719..410dfc0 100644 --- a/Locales/zh-CN.xaml +++ b/Locales/zh-CN.xaml @@ -559,11 +559,11 @@ 开 关 游戏加速 - 游戏运行时,为游戏和选定的后台应用设置默认 CPU 集,使它们运行在不同的核心上。Windows 仍在其中调度每个线程,游戏关闭后一切都会恢复。 + 游戏运行时,为游戏和选定的后台应用设置默认 CPU 集,使它们运行在不同的核心上,并请求 Windows 不对游戏进行节能限速。Windows 仍调度每个线程:如果游戏持续占满它的所有核心,游戏加速会让它使用全部核心,直到负载下降。游戏关闭后一切都会恢复。 模式 自动 隔离 - 隔离模式还会移动更多次要应用(启动器辅助进程、媒体播放器、办公应用),并在两个 CCD 相同的 CPU 上让游戏保持在 CCD0。 + 隔离模式还会移动更多次要应用(启动器辅助进程、媒体播放器、办公应用),让选定的后台应用进入效率模式(EcoQoS),并在两个 CCD 相同的 CPU 上让游戏保持在 CCD0。 将游戏优先级提高到“高于正常” 实验性功能:请在你的游戏中实测。绝不使用“高”或“实时”。 游戏和应用 @@ -576,7 +576,7 @@ 游戏在 CCD0 上({0} 个 CPU),后台应用在另一个 CCD 上({1} 个 CPU)。 游戏由 Windows 放置;选定的后台应用移出 CCD0({0} 个 CPU)。隔离模式还会让游戏保持在 CCD0。 游戏在 P 核上({0} 个 CPU),选定的后台应用在 E 核上({1} 个 CPU)。 - 在此 CPU 上由 Windows 决定核心;游戏加速只应用可选的优先级。 + 在此 CPU 上由 Windows 决定核心;游戏加速会请求 Windows 不对游戏限速,并应用可选的优先级,在隔离模式下还会让后台应用进入效率模式。 由 Windows 管理 无法打开 Windows 设置 已打开 {0} 的设置 diff --git a/Services/GameBoostPolicy.cs b/Services/GameBoostPolicy.cs index 562ee35..76e95b8 100644 --- a/Services/GameBoostPolicy.cs +++ b/Services/GameBoostPolicy.cs @@ -181,4 +181,71 @@ private static int CountPhysicalCores(CpuTopologySnapshot topology, IEnumerable< .Distinct() .Count(); } + + public enum GameLoadAction + { + None = 0, + + /// The game keeps its CPU Set busy: drop it so Windows can use every core. + Release, + + /// The load has been low for a while: put the CPU Set back. + Confine, + } + + /// + /// Decides when a confined game's CPU Set gets in the way. Fed one CPU-time sample per tick, it releases + /// the CPU Set after a sustained high load and restores it after a longer low one. The two thresholds and + /// the different lengths are the hysteresis; after the game stays on all cores. + /// + public sealed class GameLoadTracker + { + // shortcut: thresholds are first guesses, not measured; tune them with the beta testers' frame-time data. + public const double ReleaseLoad = 0.8; + public const double ConfineLoad = 0.5; + public const int ReleaseSamples = 4; + public const int ConfineSamples = 20; + public const int MaxReleases = 2; + + private readonly int gameCpuCount; + private int streak; + + public GameLoadTracker(int gameCpuCount) + { + ArgumentOutOfRangeException.ThrowIfLessThan(gameCpuCount, 1); + this.gameCpuCount = gameCpuCount; + } + + public bool Released { get; private set; } + + public int Releases { get; private set; } + + /// Adds one load sample and says whether the game's CPU Set should change now. + /// CPU time the game used over the sample divided by its length (2.0 = two CPUs busy). + public GameLoadAction Observe(double cpusUsed) + { + if (this.Released && this.Releases >= MaxReleases) + { + return GameLoadAction.None; + } + + var load = cpusUsed / this.gameCpuCount; + var pressing = this.Released ? load <= ConfineLoad : load >= ReleaseLoad; + this.streak = pressing ? this.streak + 1 : 0; + if (this.streak < (this.Released ? ConfineSamples : ReleaseSamples)) + { + return GameLoadAction.None; + } + + this.streak = 0; + this.Released = !this.Released; + if (this.Released) + { + this.Releases++; + return GameLoadAction.Release; + } + + return GameLoadAction.Confine; + } + } } diff --git a/Services/GameSessionService.cs b/Services/GameSessionService.cs index 8ad8cbd..f6540f4 100644 --- a/Services/GameSessionService.cs +++ b/Services/GameSessionService.cs @@ -16,6 +16,7 @@ public enum GameBoostSetting { CpuSets = 0, Priority = 1, + EfficiencyMode = 2, } /// @@ -38,6 +39,8 @@ public sealed record GameBoostJournalEntry public ProcessPriorityClass? AppliedPriority { get; init; } + public ProcessEfficiencyMode? AppliedEfficiencyMode { get; init; } + public bool Confirmed { get; init; } } @@ -45,6 +48,9 @@ public interface IGameBoostProcessProbe { /// Start time and Windows session of a running process; false when it is gone or unreadable. bool TryGetIdentity(int processId, out long startTimeUtcTicks, out int sessionId); + + /// Total CPU time a running process has used; false when it is gone or unreadable. + bool TryGetCpuTime(int processId, out TimeSpan totalProcessorTime); } public sealed class GameBoostProcessProbe : IGameBoostProcessProbe @@ -65,6 +71,21 @@ public bool TryGetIdentity(int processId, out long startTimeUtcTicks, out int se return false; } } + + public bool TryGetCpuTime(int processId, out TimeSpan totalProcessorTime) + { + try + { + using var process = Process.GetProcessById(processId); + totalProcessorTime = process.TotalProcessorTime; + return true; + } + catch + { + totalProcessorTime = TimeSpan.Zero; + return false; + } + } } public interface IGameSessionService : IDisposable @@ -82,9 +103,11 @@ public interface IGameSessionService : IDisposable } /// - /// Applies the Game Boost policy for one game session at a time: the game's and the listed background - /// processes' default CPU Sets (and optionally Above Normal priority for the game), each applied once and - /// restored when the game exits, Game Boost is turned off or ThreadPilot closes. User rules always win. + /// Applies the Game Boost policy for one game session at a time. Constraints: the game's and the listed + /// background processes' default CPU Sets, released while the game keeps its own cores saturated. Hints: + /// high QoS for the game, EcoQoS for the listed background in Isolation, optionally Above Normal priority. + /// Each change is journaled and restored when the game exits, Game Boost is turned off or ThreadPilot + /// closes. User rules and selections made by anyone else always win. /// public sealed class GameSessionService : IGameSessionService { @@ -106,11 +129,13 @@ public sealed class GameSessionService : IGameSessionService private readonly ProcessFilterService processFilterService; private readonly ProcessPolicyApplyGate applyGate; private readonly IGameBoostProcessProbe processProbe; + private readonly IProcessMemoryPriorityService efficiencyService; + private readonly TimeProvider timeProvider; private readonly ILogger logger; private readonly IActivityAuditService? activityAuditService; private readonly string journalPath; private readonly SemaphoreSlim stateLock = new(1, 1); - private readonly HashSet<(int ProcessId, long StartTimeUtcTicks)> attempted = new(); + private readonly HashSet<(int ProcessId, long StartTimeUtcTicks, GameBoostSetting Setting)> attempted = new(); private List journal = new(); private Timer? timer; @@ -118,6 +143,8 @@ public sealed class GameSessionService : IGameSessionService private GameBoostDecision? cachedDecision; private GameBoostMode cachedDecisionMode; private int tickCount; + private GameLoadTracker? loadTracker; + private (TimeSpan CpuTime, long Timestamp)? lastLoadSample; private bool disposed; public GameSessionService( @@ -132,9 +159,11 @@ public GameSessionService( ProcessFilterService processFilterService, ProcessPolicyApplyGate applyGate, IGameBoostProcessProbe processProbe, + IProcessMemoryPriorityService efficiencyService, ILogger logger, string? journalPath = null, - IActivityAuditService? activityAuditService = null) + IActivityAuditService? activityAuditService = null, + TimeProvider? timeProvider = null) { this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); this.topologyProvider = topologyProvider ?? throw new ArgumentNullException(nameof(topologyProvider)); @@ -147,6 +176,8 @@ public GameSessionService( this.processFilterService = processFilterService ?? throw new ArgumentNullException(nameof(processFilterService)); this.applyGate = applyGate ?? throw new ArgumentNullException(nameof(applyGate)); this.processProbe = processProbe ?? throw new ArgumentNullException(nameof(processProbe)); + this.efficiencyService = efficiencyService ?? throw new ArgumentNullException(nameof(efficiencyService)); + this.timeProvider = timeProvider ?? TimeProvider.System; this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.journalPath = journalPath ?? Path.Combine(StoragePaths.AppDataRoot, "game_boost_journal.json"); this.activityAuditService = activityAuditService; @@ -268,6 +299,8 @@ internal async Task TickAsync() return; } + await this.AdaptGameCpuSetsAsync().ConfigureAwait(false); + if (++this.tickCount % BackgroundRescanTicks == 0) { await this.ApplyToSessionAsync(includeGame: false).ConfigureAwait(false); @@ -296,7 +329,7 @@ private async Task TryBeginSessionAsync() } this.session = new GameSession(game, startTicks, sessionId, Path.GetDirectoryName(game.ExecutablePath)); - this.tickCount = 0; + this.ResetSessionState(); this.logger.LogInformation("Game Boost session started for {Game} (PID {ProcessId})", game.Name, game.ProcessId); await this.AuditSessionStartAsync(game).ConfigureAwait(false); await this.ApplyToSessionAsync(includeGame: true).ConfigureAwait(false); @@ -316,6 +349,11 @@ private async Task AuditSessionStartAsync(ProcessModel game) GameBoostTopologyKind.IntelHybrid => $"Intel hybrid: game on {gameCpus} P-core CPUs, listed background on {background} E-core CPUs", _ => "CPU Sets unchanged: this CPU topology is not supported, Windows decides the cores", }; + if (this.Settings.GameBoostMode == GameBoostMode.Isolation) + { + policy += "; listed background in efficiency mode (EcoQoS)"; + } + await this.AuditAsync(ActivityAuditSeverity.Info, $"Session started: {game.Name} ({mode})", policy).ConfigureAwait(false); } @@ -336,24 +374,35 @@ private async Task ApplyToSessionAsync(bool includeGame) if (includeGame) { var game = currentSession.Game; + var startTicks = currentSession.GameStartTimeUtcTicks; if (decision.GameSelection != null) { - var (outcome, reason) = await this.TryApplyCpuSetsAsync(game, currentSession.GameStartTimeUtcTicks, decision.GameSelection, rules).ConfigureAwait(false); + var (outcome, reason) = await this.TryApplyCpuSetsAsync(game, startTicks, decision.GameSelection, rules).ConfigureAwait(false); await this.AuditGameCpuSetsAsync(game.Name, outcome, reason, decision.GameSelection.GlobalLogicalProcessorIndexes.Count).ConfigureAwait(false); + if (outcome == ApplyOutcome.Applied) + { + this.loadTracker = new GameLoadTracker(decision.GameSelection.GlobalLogicalProcessorIndexes.Count); + } } + // A hint, not a constraint: Windows never power-throttles the game, even while it is not in the foreground. + var (qosOutcome, qosReason) = await this.TryApplyEfficiencyModeAsync(game, startTicks, ProcessEfficiencyMode.Off, rules).ConfigureAwait(false); + await this.AuditGameQosAsync(game.Name, qosOutcome, qosReason).ConfigureAwait(false); + if (this.Settings.GameBoostAboveNormalPriority) { - await this.TryApplyAboveNormalAsync(game, currentSession.GameStartTimeUtcTicks, rules).ConfigureAwait(false); + await this.TryApplyAboveNormalAsync(game, startTicks, rules).ConfigureAwait(false); } } - if (decision.BackgroundSelection == null) + var useEcoQos = this.Settings.GameBoostMode == GameBoostMode.Isolation; + if (decision.BackgroundSelection == null && !useEcoQos) { return; } var moved = new List(); + var eco = new List(); var notMoved = new List(); var processes = await this.processService.GetProcessesWithPathsAsync().ConfigureAwait(false); foreach (var process in processes) @@ -366,22 +415,21 @@ private async Task ApplyToSessionAsync(bool includeGame) continue; } - var (outcome, reason) = await this.TryApplyCpuSetsAsync(process, startTicks, decision.BackgroundSelection, rules).ConfigureAwait(false); - switch (outcome) + if (decision.BackgroundSelection != null) { - case CpuSetOutcome.Applied: - moved.Add(process.Name); - break; - case CpuSetOutcome.UserRule: - notMoved.Add($"{process.Name} (your rule)"); - break; - case CpuSetOutcome.ExistingSelection: - notMoved.Add($"{process.Name} (already has CPU Sets)"); - break; - case CpuSetOutcome.Unreadable: - case CpuSetOutcome.Failed: - notMoved.Add($"{process.Name} ({reason})"); - break; + var (outcome, reason) = await this.TryApplyCpuSetsAsync(process, startTicks, decision.BackgroundSelection, rules).ConfigureAwait(false); + Collect(outcome, reason, "CPU Sets", process.Name, moved, notMoved); + } + + if (useEcoQos) + { + var (outcome, reason) = await this.TryApplyEfficiencyModeAsync(process, startTicks, ProcessEfficiencyMode.On, rules).ConfigureAwait(false); + + // Browsers set their own QoS on new processes all the time: expected, so not logged on every rescan. + if (outcome != ApplyOutcome.ExistingSelection) + { + Collect(outcome, reason, "efficiency mode", process.Name, eco, notMoved); + } } } @@ -390,7 +438,12 @@ private async Task ApplyToSessionAsync(bool includeGame) { await this.AuditAsync( ActivityAuditSeverity.Success, - $"Background moved to {decision.BackgroundSelection.GlobalLogicalProcessorIndexes.Count} CPUs: {Summarize(moved)}").ConfigureAwait(false); + $"Background moved to {decision.BackgroundSelection!.GlobalLogicalProcessorIndexes.Count} CPUs: {Summarize(moved)}").ConfigureAwait(false); + } + + if (eco.Count > 0) + { + await this.AuditAsync(ActivityAuditSeverity.Success, $"Background in efficiency mode: {Summarize(eco)}").ConfigureAwait(false); } if (notMoved.Count > 0) @@ -399,12 +452,134 @@ await this.AuditAsync( } } - private Task AuditGameCpuSetsAsync(string game, CpuSetOutcome outcome, string reason, int cpuCount) => outcome switch + private static void Collect(ApplyOutcome outcome, string reason, string what, string name, List applied, List notApplied) + { + switch (outcome) + { + case ApplyOutcome.Applied: + applied.Add(name); + break; + case ApplyOutcome.UserRule: + notApplied.Add($"{name} (your rule)"); + break; + case ApplyOutcome.ExistingSelection: + notApplied.Add($"{name} ({what} already set)"); + break; + case ApplyOutcome.Unreadable: + case ApplyOutcome.Failed: + notApplied.Add($"{name} ({reason})"); + break; + } + } + + private void ResetSessionState() + { + this.tickCount = 0; + this.loadTracker = null; + this.lastLoadSample = null; + } + + // The dynamic part: a confined game that keeps its cores saturated gets every core back from Windows, + // and is confined again once its load has stayed low for a while (see GameLoadTracker). + private async Task AdaptGameCpuSetsAsync() + { + var currentSession = this.session; + if (currentSession == null || this.loadTracker == null) + { + return; + } + + var game = currentSession.Game; + var now = this.timeProvider.GetTimestamp(); + if (!this.processProbe.TryGetCpuTime(game.ProcessId, out var cpuTime)) + { + return; + } + + var previous = this.lastLoadSample; + this.lastLoadSample = (cpuTime, now); + if (previous is not var (previousCpu, previousStamp)) + { + return; + } + + var elapsed = this.timeProvider.GetElapsedTime(previousStamp, now); + if (elapsed <= TimeSpan.Zero) + { + return; + } + + var action = this.loadTracker.Observe((cpuTime - previousCpu) / elapsed); + if (action == GameLoadAction.None) + { + return; + } + + var decision = await this.GetDecisionAsync().ConfigureAwait(false); + var cpuCount = decision.GameSelection?.GlobalLogicalProcessorIndexes.Count ?? 0; + var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false); + using var gate = await this.applyGate.EnterAsync().ConfigureAwait(false); + + if (action == GameLoadAction.Release) + { + var entry = this.journal.FirstOrDefault(e => e.ProcessId == game.ProcessId + && e.StartTimeUtcTicks == currentSession.GameStartTimeUtcTicks + && e.Setting == GameBoostSetting.CpuSets); + var outcome = entry == null ? RestoreOutcome.LeftAlone : await this.RestoreAsync(entry, rules).ConfigureAwait(false); + if (outcome != RestoreOutcome.Failed && entry != null) + { + this.journal.Remove(entry); + await this.SaveJournalAsync().ConfigureAwait(false); + } + + if (outcome != RestoreOutcome.Restored) + { + // Someone else owns the cores now, or the call failed: stop adapting for this session. + this.loadTracker = null; + return; + } + + var final = this.loadTracker.Releases >= GameLoadTracker.MaxReleases; + await this.AuditAsync( + ActivityAuditSeverity.Info, + $"{game.Name}: kept its {cpuCount} CPUs busy, CPU Sets released so Windows can use every core", + final ? "It stays on all cores for the rest of the session" : "They are set again once its load stays low for about a minute").ConfigureAwait(false); + return; + } + + if (decision.GameSelection == null) + { + this.loadTracker = null; + return; + } + + this.attempted.Remove((game.ProcessId, currentSession.GameStartTimeUtcTicks, GameBoostSetting.CpuSets)); + var (applied, reason) = await this.TryApplyCpuSetsAsync(game, currentSession.GameStartTimeUtcTicks, decision.GameSelection, rules).ConfigureAwait(false); + if (applied != ApplyOutcome.Applied) + { + this.loadTracker = null; + await this.AuditGameCpuSetsAsync(game.Name, applied, reason, cpuCount).ConfigureAwait(false); + return; + } + + await this.AuditAsync(ActivityAuditSeverity.Info, $"{game.Name}: load is low again, CPU Sets set back to {cpuCount} CPUs").ConfigureAwait(false); + } + + private Task AuditGameCpuSetsAsync(string game, ApplyOutcome outcome, string reason, int cpuCount) => outcome switch + { + ApplyOutcome.Applied => this.AuditAsync(ActivityAuditSeverity.Success, $"{game}: CPU Sets set to {cpuCount} CPUs"), + ApplyOutcome.UserRule => this.AuditAsync(ActivityAuditSeverity.Info, $"{game}: cores left to your rule"), + ApplyOutcome.ExistingSelection => this.AuditAsync(ActivityAuditSeverity.Info, $"{game}: already has CPU Sets, left unchanged"), + ApplyOutcome.Unreadable or ApplyOutcome.Failed => this.AuditAsync(ActivityAuditSeverity.Warning, $"{game}: CPU Sets not changed", reason), + _ => Task.CompletedTask, + }; + + private Task AuditGameQosAsync(string game, ApplyOutcome outcome, string reason) => outcome switch { - CpuSetOutcome.Applied => this.AuditAsync(ActivityAuditSeverity.Success, $"{game}: CPU Sets set to {cpuCount} CPUs"), - CpuSetOutcome.UserRule => this.AuditAsync(ActivityAuditSeverity.Info, $"{game}: cores left to your rule"), - CpuSetOutcome.ExistingSelection => this.AuditAsync(ActivityAuditSeverity.Info, $"{game}: already has CPU Sets, left unchanged"), - CpuSetOutcome.Unreadable or CpuSetOutcome.Failed => this.AuditAsync(ActivityAuditSeverity.Warning, $"{game}: CPU Sets not changed", reason), + ApplyOutcome.Applied => this.AuditAsync(ActivityAuditSeverity.Success, $"{game}: high QoS, Windows will not power-throttle it"), + ApplyOutcome.UserRule => this.AuditAsync(ActivityAuditSeverity.Info, $"{game}: efficiency mode left to your rule"), + ApplyOutcome.ExistingSelection => this.AuditAsync(ActivityAuditSeverity.Info, $"{game}: efficiency mode already set by the game or you, left unchanged"), + ApplyOutcome.Unreadable or ApplyOutcome.Failed => this.AuditAsync(ActivityAuditSeverity.Warning, $"{game}: QoS not changed", reason), _ => Task.CompletedTask, }; @@ -415,33 +590,33 @@ private bool IsBackgroundCandidate(ProcessModel process, GameSession currentSess && !this.processFilterService.IsSystemProcess(process) && !IsUnderDirectory(process.ExecutablePath, currentSession.GameDirectory); - private async Task<(CpuSetOutcome Outcome, string Reason)> TryApplyCpuSetsAsync( + private async Task<(ApplyOutcome Outcome, string Reason)> TryApplyCpuSetsAsync( ProcessModel process, long startTicks, CpuSelection selection, IReadOnlyList rules) { // Applied at most once per process: a later manual change is the user's and is never undone or redone. - if (!this.attempted.Add((process.ProcessId, startTicks))) + if (!this.attempted.Add((process.ProcessId, startTicks, GameBoostSetting.CpuSets))) { - return (CpuSetOutcome.AlreadyAttempted, string.Empty); + return (ApplyOutcome.AlreadyAttempted, string.Empty); } if (this.HasUserRule(rules, process, GameBoostSetting.CpuSets)) { - return (CpuSetOutcome.UserRule, string.Empty); + return (ApplyOutcome.UserRule, string.Empty); } // null: unreadable, so unknown; non-empty: someone else's selection. Only an empty set is ours to change. var current = await this.processService.GetCpuAssignmentLogicalProcessorIndexesAsync(process, CpuAssignmentMode.CpuSets).ConfigureAwait(false); if (current == null) { - return (CpuSetOutcome.Unreadable, "CPU Sets unreadable: access denied or protected process"); + return (ApplyOutcome.Unreadable, "CPU Sets unreadable: access denied or protected process"); } if (current.Count > 0) { - return (CpuSetOutcome.ExistingSelection, string.Empty); + return (ApplyOutcome.ExistingSelection, string.Empty); } var entry = new GameBoostJournalEntry @@ -463,7 +638,7 @@ private bool IsBackgroundCandidate(ProcessModel process, GameSession currentSess process.Name, process.ProcessId, entry.AppliedCpuIndexes.Count); - return (CpuSetOutcome.Applied, string.Empty); + return (ApplyOutcome.Applied, string.Empty); } else { @@ -473,10 +648,60 @@ private bool IsBackgroundCandidate(ProcessModel process, GameSession currentSess process.Name, process.ProcessId, result.TechnicalMessage); - return (CpuSetOutcome.Failed, string.IsNullOrWhiteSpace(result.UserMessage) ? "not applied" : result.UserMessage.TrimEnd('.')); + return (ApplyOutcome.Failed, string.IsNullOrWhiteSpace(result.UserMessage) ? "not applied" : result.UserMessage.TrimEnd('.')); } } + // Same ownership rules as CPU Sets: only a process Windows still manages (no explicit QoS from the app, + // the user or a rule) is changed, once, and restored only if the value is still Game Boost's. + private async Task<(ApplyOutcome Outcome, string Reason)> TryApplyEfficiencyModeAsync( + ProcessModel process, + long startTicks, + ProcessEfficiencyMode mode, + IReadOnlyList rules) + { + if (!this.attempted.Add((process.ProcessId, startTicks, GameBoostSetting.EfficiencyMode))) + { + return (ApplyOutcome.AlreadyAttempted, string.Empty); + } + + if (this.HasUserRule(rules, process, GameBoostSetting.EfficiencyMode)) + { + return (ApplyOutcome.UserRule, string.Empty); + } + + var current = await this.efficiencyService.GetEfficiencyModeAsync(process).ConfigureAwait(false); + if (current == null) + { + return (ApplyOutcome.Unreadable, "efficiency mode unreadable: access denied or protected process"); + } + + if (current != ProcessEfficiencyMode.SystemManaged) + { + return (ApplyOutcome.ExistingSelection, string.Empty); + } + + var entry = new GameBoostJournalEntry + { + ProcessId = process.ProcessId, + StartTimeUtcTicks = startTicks, + ProcessName = process.Name, + Setting = GameBoostSetting.EfficiencyMode, + AppliedEfficiencyMode = mode, + }; + await this.AddJournalEntryAsync(entry).ConfigureAwait(false); + + var result = await this.efficiencyService.SetEfficiencyModeAsync(process, mode).ConfigureAwait(false); + if (!result.Success) + { + this.logger.LogInformation("Game Boost could not set efficiency mode {Mode} on {ProcessName} (PID {ProcessId})", mode, process.Name, process.ProcessId); + return (ApplyOutcome.Failed, "efficiency mode not applied"); + } + + await this.ConfirmJournalEntryAsync(entry).ConfigureAwait(false); + return (ApplyOutcome.Applied, string.Empty); + } + private async Task TryApplyAboveNormalAsync(ProcessModel game, long startTicks, IReadOnlyList rules) { if (this.journal.Any(e => e.ProcessId == game.ProcessId && e.StartTimeUtcTicks == startTicks && e.Setting == GameBoostSetting.Priority)) @@ -581,7 +806,7 @@ await this.AuditAsync( left++; break; default: - failed.Add($"{entry.ProcessName} {(entry.Setting == GameBoostSetting.CpuSets ? "CPU Sets" : "priority")}"); + failed.Add($"{entry.ProcessName} {entry.Setting switch { GameBoostSetting.CpuSets => "CPU Sets", GameBoostSetting.EfficiencyMode => "efficiency mode", _ => "priority" }}"); break; } @@ -624,6 +849,18 @@ private async Task RestoreAsync(GameBoostJournalEntry entry, IRe return RestoreOutcome.Restored; } + if (entry.Setting == GameBoostSetting.EfficiencyMode) + { + if (entry.AppliedEfficiencyMode == null + || await this.efficiencyService.GetEfficiencyModeAsync(process).ConfigureAwait(false) != entry.AppliedEfficiencyMode) + { + return RestoreOutcome.LeftAlone; + } + + var result = await this.efficiencyService.SetEfficiencyModeAsync(process, ProcessEfficiencyMode.SystemManaged).ConfigureAwait(false); + return result.Success ? RestoreOutcome.Restored : RestoreOutcome.Failed; + } + if (entry.OriginalPriority is not ProcessPriorityClass original || process.Priority != entry.AppliedPriority) { return RestoreOutcome.LeftAlone; @@ -669,9 +906,12 @@ private Task AuditAsync(ActivityAuditSeverity severity, string message, string? } private bool HasUserRule(IReadOnlyList rules, ProcessModel process, GameBoostSetting setting) => - rules.Any(rule => this.ruleMatcher.IsMatch(rule, process) && (setting == GameBoostSetting.CpuSets - ? rule.CpuSelection != null || rule.LegacyAffinityMask.HasValue - : rule.Priority.HasValue)); + rules.Any(rule => this.ruleMatcher.IsMatch(rule, process) && setting switch + { + GameBoostSetting.CpuSets => rule.CpuSelection != null || rule.LegacyAffinityMask.HasValue, + GameBoostSetting.EfficiencyMode => rule.EfficiencyMode.HasValue, + _ => rule.Priority.HasValue, + }); private bool IsSameProcessAlive(int processId, long startTicks) => this.processProbe.TryGetIdentity(processId, out var currentStartTicks, out _) && currentStartTicks == startTicks; @@ -714,7 +954,7 @@ internal async Task ReapplyAsync() } this.session = previous; - this.tickCount = 0; + this.ResetSessionState(); await this.AuditSessionStartAsync(previous.Game).ConfigureAwait(false); await this.ApplyToSessionAsync(includeGame: true).ConfigureAwait(false); } @@ -793,7 +1033,7 @@ private static void LogAmdVCacheDriver(ILogger logger) } } - private enum CpuSetOutcome + private enum ApplyOutcome { AlreadyAttempted, Applied, diff --git a/Services/ProcessMemoryPriorityService.cs b/Services/ProcessMemoryPriorityService.cs index 5a922ba..38371ce 100644 --- a/Services/ProcessMemoryPriorityService.cs +++ b/Services/ProcessMemoryPriorityService.cs @@ -364,7 +364,7 @@ public Task SetEfficiencyModeAsync(ProcessModel process, try { using var handle = this.nativeApi.OpenProcess( - ProcessAccessFlags.PROCESS_SET_INFORMATION, + ProcessAccessFlags.PROCESS_SET_INFORMATION | ProcessAccessFlags.PROCESS_QUERY_LIMITED_INFORMATION, inheritHandle: false, (uint)process.ProcessId); if (handle.IsInvalid) @@ -372,12 +372,18 @@ public Task SetEfficiencyModeAsync(ProcessModel process, return Task.FromResult(this.FromLastError("OpenProcess failed before SetProcessInformation(ProcessPowerThrottling).", FailureMessage)); } - var state = new PowerThrottlingState + // Windows replaces the whole state on set: keep the process's other throttling flags + // (e.g. its timer-resolution opt-out) and change only the execution-speed bit. + var state = new PowerThrottlingState { Version = PowerThrottlingState.CurrentVersion }; + if (!this.nativeApi.GetPowerThrottling(handle, ref state)) { - Version = PowerThrottlingState.CurrentVersion, - ControlMask = mode == ProcessEfficiencyMode.SystemManaged ? 0 : PowerThrottlingState.ExecutionSpeed, - StateMask = mode == ProcessEfficiencyMode.On ? PowerThrottlingState.ExecutionSpeed : 0, - }; + state = default; + } + + const uint speed = PowerThrottlingState.ExecutionSpeed; + state.Version = PowerThrottlingState.CurrentVersion; + state.ControlMask = mode == ProcessEfficiencyMode.SystemManaged ? state.ControlMask & ~speed : state.ControlMask | speed; + state.StateMask = mode == ProcessEfficiencyMode.On ? state.StateMask | speed : state.StateMask & ~speed; if (!this.nativeApi.SetPowerThrottling(handle, ref state)) { return Task.FromResult(this.FromLastError("SetProcessInformation(ProcessPowerThrottling) failed.", FailureMessage)); diff --git a/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs b/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs index 4b405f5..99ac98d 100644 --- a/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs +++ b/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs @@ -6,6 +6,7 @@ namespace ThreadPilot.Core.Tests using Microsoft.Win32.SafeHandles; using Moq; using ThreadPilot.Models; + using ThreadPilot.Platforms.Windows; using ThreadPilot.Services; using Xunit.Abstractions; @@ -20,6 +21,7 @@ public sealed class GameSessionNativeIntegrationTests(ITestOutputHelper output) private readonly string root = Path.Combine(Path.GetTempPath(), $"tp-gb-{Guid.NewGuid():N}"); private readonly List children = new(); + private readonly ProcessMemoryPriorityService efficiency = new(ProcessMemoryPriorityNativeApi.Instance, NullLogger.Instance); public void Dispose() { @@ -81,6 +83,7 @@ public async Task RealProcesses_ApplyThenRestore_AndRecoverAfterCrash() { GameBoostEnabled = true, GameBoostAboveNormalPriority = true, + GameBoostMode = GameBoostMode.Isolation, GameBoostBackgroundProcesses = [browserPath], }; @@ -90,6 +93,8 @@ public async Task RealProcesses_ApplyThenRestore_AndRecoverAfterCrash() Assert.Equal(gameCpus, ReadDefaultCpuSetIndexes(game, topology)); Assert.Equal(backgroundCpus, ReadDefaultCpuSetIndexes(browser, topology)); Assert.Equal(ProcessPriorityClass.AboveNormal, ReadPriority(game)); + Assert.Equal(ProcessEfficiencyMode.Off, await this.ReadEfficiencyAsync(game)); + Assert.Equal(ProcessEfficiencyMode.On, await this.ReadEfficiencyAsync(browser)); Assert.True(File.Exists(journal)); // 2. Turned off: everything restored without a restart. @@ -98,6 +103,8 @@ public async Task RealProcesses_ApplyThenRestore_AndRecoverAfterCrash() Assert.Empty(ReadDefaultCpuSetIndexes(game, topology)); Assert.Empty(ReadDefaultCpuSetIndexes(browser, topology)); Assert.Equal(ProcessPriorityClass.Normal, ReadPriority(game)); + Assert.Equal(ProcessEfficiencyMode.SystemManaged, await this.ReadEfficiencyAsync(game)); + Assert.Equal(ProcessEfficiencyMode.SystemManaged, await this.ReadEfficiencyAsync(browser)); Assert.False(File.Exists(journal)); // 3. On again, then a "crash": the service is dropped without StopAsync. @@ -112,6 +119,7 @@ public async Task RealProcesses_ApplyThenRestore_AndRecoverAfterCrash() Assert.Empty(ReadDefaultCpuSetIndexes(game, topology)); Assert.Empty(ReadDefaultCpuSetIndexes(browser, topology)); Assert.Equal(ProcessPriorityClass.Normal, ReadPriority(game)); + Assert.Equal(ProcessEfficiencyMode.SystemManaged, await this.ReadEfficiencyAsync(browser)); Assert.False(File.Exists(journal)); await second.StopAsync(); } @@ -147,6 +155,7 @@ private GameSessionService CreateService(CpuTopologySnapshot topology, Applicati new ProcessFilterService(), new ProcessPolicyApplyGate(), new GameBoostProcessProbe(), + this.efficiency, NullLogger.Instance, journal); } @@ -166,6 +175,9 @@ private Process StartCopyOfPing(string directory, string fileName) return process; } + private Task ReadEfficiencyAsync(Process process) => + this.efficiency.GetEfficiencyModeAsync(new ProcessModel { ProcessId = process.Id, Name = process.ProcessName }); + private static ProcessPriorityClass ReadPriority(Process process) { process.Refresh(); diff --git a/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs b/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs index 8eded2a..1d760c9 100644 --- a/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs @@ -22,6 +22,7 @@ public sealed class GameSessionServiceTests : IDisposable private readonly FakeProcesses processes = new(); private readonly List rules = new(); private readonly ActivityAuditService audit = new(NullLogger.Instance); + private readonly FakeClock clock = new(); private int? foregroundPid = GamePid; public GameSessionServiceTests() @@ -285,14 +286,15 @@ public async Task Activity_ShowsSessionChangesAndRestore_WithoutRepeatingOnResca [ "Session started: Game (Automatic)", "Game: CPU Sets set to 16 CPUs", + "Game: high QoS, Windows will not power-throttle it", "Background moved to 16 CPUs: chrome x2", "Background left unchanged", "Session ended for Game: the game exited", ], entries.Select(entry => entry.Message)); Assert.Contains("AMD 3D V-Cache", entries[0].Details, StringComparison.Ordinal); - Assert.Equal("steam (your rule)", entries[3].Details); - Assert.Equal("Restored 2 change(s); 1 left as they are (changed by you or a rule since, or the process exited)", entries[4].Details); + Assert.Equal("steam (your rule)", entries[4].Details); + Assert.Equal("Restored 2 change(s); 2 left as they are (changed by you or a rule since, or the process exited)", entries[5].Details); } [Fact] @@ -311,7 +313,150 @@ public async Task Activity_CrashRecoveryAndSkippedPriority() var messages = (await this.audit.GetEntriesAsync()).Select(entry => entry.Message).ToList(); Assert.Contains("Game: priority left at BelowNormal", messages); - Assert.Contains("Restored 2 change(s) left by an unexpected close", messages); + Assert.Contains("Restored 3 change(s) left by an unexpected close", messages); + } + + [Fact] + public async Task Game_GetsHighQos_RestoredWhenTheSessionEnds() + { + using var service = this.CreateService(); + await service.TickAsync(); + + Assert.Equal(ProcessEfficiencyMode.Off, this.processes.Efficiency[GamePid]); + Assert.Equal(ProcessEfficiencyMode.SystemManaged, this.processes.Efficiency[ChromePid]); // Automatic: no EcoQoS + + this.settings.GameBoostEnabled = false; + await service.TickAsync(); + + Assert.Equal(ProcessEfficiencyMode.SystemManaged, this.processes.Efficiency[GamePid]); + } + + [Fact] + public async Task Game_HighQosEvenWhenTheCpuIsNotSupported() + { + using var service = this.CreateService(TopologyFixtures.Amd([8], [96 * MB])); + + await service.TickAsync(); + + Assert.Empty(this.processes.CpuSets[GamePid]); + Assert.Equal(ProcessEfficiencyMode.Off, this.processes.Efficiency[GamePid]); + } + + [Fact] + public async Task ExplicitQos_FromTheAppOrARule_NotTouched() + { + this.processes.Efficiency[GamePid] = ProcessEfficiencyMode.On; + this.settings.GameBoostMode = GameBoostMode.Isolation; + this.rules.Add(new PersistentProcessRule { IsEnabled = true, ProcessName = "chrome", EfficiencyMode = ProcessEfficiencyMode.Off }); + this.processes.Efficiency[ChromePid] = ProcessEfficiencyMode.Off; + using var service = this.CreateService(); + + await service.TickAsync(); + this.processes.Exit(GamePid); + await service.TickAsync(); + + Assert.Equal(ProcessEfficiencyMode.Off, this.processes.Efficiency[ChromePid]); + Assert.Equal(ProcessEfficiencyMode.On, this.processes.Efficiency[GamePid]); + } + + [Fact] + public async Task Isolation_ListedBackgroundGetsEcoQos_EvenWithoutCpuSets_AndIsRestored() + { + this.settings.GameBoostMode = GameBoostMode.Isolation; + using var service = this.CreateService(TopologyFixtures.Amd([8], [96 * MB])); + + await service.TickAsync(); + + Assert.Equal(ProcessEfficiencyMode.On, this.processes.Efficiency[ChromePid]); + Assert.Equal(ProcessEfficiencyMode.SystemManaged, this.processes.Efficiency[DiscordPid]); + Assert.Empty(this.processes.CpuSets[ChromePid]); + + this.processes.Efficiency[ChromePid] = ProcessEfficiencyMode.Off; // the app changed it itself + this.processes.Exit(GamePid); + await service.TickAsync(); + + Assert.Equal(ProcessEfficiencyMode.Off, this.processes.Efficiency[ChromePid]); + Assert.False(File.Exists(this.journalPath)); + } + + [Fact] + public async Task SaturatedGame_CpuSetsReleased_ThenConfinedAgainWhenLoadDrops() + { + using var service = this.CreateService(); + await service.TickAsync(); + Assert.Equal(VCacheCpus, this.processes.CpuSets[GamePid]); + + await this.RunLoadAsync(service, cpusBusy: 15, ticks: GameLoadTracker.ReleaseSamples); // 15 of 16 CPUs + Assert.Equal(VCacheCpus, this.processes.CpuSets[GamePid]); // the first tick only sets the baseline + + await this.RunLoadAsync(service, cpusBusy: 15, ticks: 1); + Assert.Empty(this.processes.CpuSets[GamePid]); + Assert.Equal(OtherCcdCpus, this.processes.CpuSets[ChromePid]); // background stays moved + + await this.RunLoadAsync(service, cpusBusy: 4, ticks: GameLoadTracker.ConfineSamples); + Assert.Equal(VCacheCpus, this.processes.CpuSets[GamePid]); + + this.processes.Exit(GamePid); + await service.TickAsync(); + Assert.False(File.Exists(this.journalPath)); + var messages = (await this.audit.GetEntriesAsync()).Select(entry => entry.Message).ToList(); + Assert.Contains("Game: kept its 16 CPUs busy, CPU Sets released so Windows can use every core", messages); + Assert.Contains("Game: load is low again, CPU Sets set back to 16 CPUs", messages); + } + + [Fact] + public async Task SaturatedGame_AfterTheSecondRelease_StaysOnAllCores() + { + using var service = this.CreateService(); + await service.TickAsync(); + + await this.RunLoadAsync(service, cpusBusy: 16, ticks: GameLoadTracker.ReleaseSamples + 1); + await this.RunLoadAsync(service, cpusBusy: 2, ticks: GameLoadTracker.ConfineSamples); + await this.RunLoadAsync(service, cpusBusy: 16, ticks: GameLoadTracker.ReleaseSamples); + Assert.Empty(this.processes.CpuSets[GamePid]); + + await this.RunLoadAsync(service, cpusBusy: 1, ticks: GameLoadTracker.ConfineSamples * 2); + Assert.Empty(this.processes.CpuSets[GamePid]); + } + + [Fact] + public async Task SaturatedGame_UserChangedItsCores_NotReleasedNorConfinedAgain() + { + using var service = this.CreateService(); + await service.TickAsync(); + this.processes.CpuSets[GamePid] = [0, 1, 2, 3]; + + await this.RunLoadAsync(service, cpusBusy: 16, ticks: GameLoadTracker.ReleaseSamples + 1); + await this.RunLoadAsync(service, cpusBusy: 1, ticks: GameLoadTracker.ConfineSamples + 1); + + Assert.Equal([0, 1, 2, 3], this.processes.CpuSets[GamePid]); + } + + [Fact] + public async Task ShortSpikes_DoNotReleaseTheGame() + { + using var service = this.CreateService(); + await service.TickAsync(); + + for (var i = 0; i < 10; i++) + { + await this.RunLoadAsync(service, cpusBusy: 16, ticks: GameLoadTracker.ReleaseSamples - 1); + await this.RunLoadAsync(service, cpusBusy: 3, ticks: 1); + } + + Assert.Equal(VCacheCpus, this.processes.CpuSets[GamePid]); + } + + // One tick every 3 s; over each one the game uses as much CPU time as cpusBusy fully busy CPUs. + private async Task RunLoadAsync(GameSessionService service, double cpusBusy, int ticks) + { + for (var i = 0; i < ticks; i++) + { + var interval = TimeSpan.FromSeconds(3); + this.clock.Advance(interval); + this.processes.CpuTime[GamePid] += interval * cpusBusy; + await service.TickAsync(); + } } private GameSessionService CreateService(CpuTopologySnapshot? topology = null) @@ -338,9 +483,23 @@ private GameSessionService CreateService(CpuTopologySnapshot? topology = null) new ProcessFilterService(), new ProcessPolicyApplyGate(), this.processes, + this.processes.EfficiencyService, NullLogger.Instance, this.journalPath, - this.audit); + this.audit, + this.clock); + } + + /// Timestamps in TimeSpan ticks, moved by hand. + private sealed class FakeClock : TimeProvider + { + public long Now { get; private set; } + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override long GetTimestamp() => this.Now; + + public void Advance(TimeSpan by) => this.Now += by.Ticks; } /// In-memory processes: CPU Sets, priorities and identities the service reads and writes. @@ -380,8 +539,25 @@ public FakeProcesses() : AffinityApplyResult.SucceededWithCpuSets("ok"); }); this.AffinityService = affinityService.Object; + + var efficiencyService = new Mock(); + efficiencyService.Setup(s => s.GetEfficiencyModeAsync(It.IsAny())) + .ReturnsAsync((ProcessModel p) => this.Unreadable.Contains(p.ProcessId) ? null : this.Efficiency[p.ProcessId]); + efficiencyService.Setup(s => s.SetEfficiencyModeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((ProcessModel p, ProcessEfficiencyMode mode) => + { + this.Efficiency[p.ProcessId] = mode; + return ProcessOperationResult.Succeeded("ok", "ok"); + }); + this.EfficiencyService = efficiencyService.Object; } + public IProcessMemoryPriorityService EfficiencyService { get; } + + public Dictionary Efficiency { get; } = new(); + + public Dictionary CpuTime { get; } = new(); + public IProcessService ProcessService { get; } public IAffinityApplyService AffinityService { get; } @@ -402,6 +578,8 @@ public void Add(int pid, string name, string path) this.startTimes[pid] = 1000 + pid; this.CpuSets[pid] = []; this.Priorities[pid] = ProcessPriorityClass.Normal; + this.Efficiency[pid] = ProcessEfficiencyMode.SystemManaged; + this.CpuTime[pid] = TimeSpan.Zero; } public void Exit(int pid) => this.startTimes.Remove(pid); @@ -414,6 +592,9 @@ public bool TryGetIdentity(int processId, out long startTimeUtcTicks, out int se return this.startTimes.TryGetValue(processId, out startTimeUtcTicks); } + public bool TryGetCpuTime(int processId, out TimeSpan totalProcessorTime) => + this.CpuTime.TryGetValue(processId, out totalProcessorTime) && this.startTimes.ContainsKey(processId); + private ProcessModel Snapshot(int pid) { var model = this.models[pid]; diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs index 8df9c63..2b37043 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs @@ -38,7 +38,7 @@ public async Task SetEfficiencyModeAsync_MapsEachModeToItsThrottlingMasks(Proces var result = await CreateService(nativeApi).SetEfficiencyModeAsync(CreateProcess(), mode); Assert.True(result.Success); - Assert.Equal(ProcessAccessFlags.PROCESS_SET_INFORMATION, nativeApi.LastOpenAccess); + Assert.Equal(ProcessAccessFlags.PROCESS_SET_INFORMATION | ProcessAccessFlags.PROCESS_QUERY_LIMITED_INFORMATION, nativeApi.LastOpenAccess); Assert.Equal(PowerThrottlingState.CurrentVersion, nativeApi.LastSetThrottling!.Value.Version); Assert.Equal(control, nativeApi.LastSetThrottling.Value.ControlMask); Assert.Equal(state, nativeApi.LastSetThrottling.Value.StateMask); @@ -48,6 +48,24 @@ public async Task SetEfficiencyModeAsync_MapsEachModeToItsThrottlingMasks(Proces Assert.Equal(mode, readBack); } + [Theory] + [InlineData(ProcessEfficiencyMode.On, 5u, 5u)] + [InlineData(ProcessEfficiencyMode.Off, 5u, 4u)] + [InlineData(ProcessEfficiencyMode.SystemManaged, 4u, 4u)] + public async Task SetEfficiencyModeAsync_KeepsTheProcessesOtherThrottlingFlags(ProcessEfficiencyMode mode, uint control, uint state) + { + // 0x4 = PROCESS_POWER_THROTTLING_IGNORE_TIMER_RESOLUTION, set by the process itself. + var nativeApi = new FakeProcessMemoryPriorityNativeApi + { + ThrottlingToReturn = new PowerThrottlingState { Version = 1, ControlMask = 0x5, StateMask = 0x5 }, + }; + + await CreateService(nativeApi).SetEfficiencyModeAsync(CreateProcess(), mode); + + Assert.Equal(control, nativeApi.LastSetThrottling!.Value.ControlMask); + Assert.Equal(state, nativeApi.LastSetThrottling.Value.StateMask); + } + [Fact] public async Task SetEfficiencyModeAsync_WhenWindowsDenies_ReportsAccessDenied() { From 52d4b3e10912d389e8ef5ff4c337c8b5094e1762 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Tue, 20 Oct 2026 23:26:14 +0200 Subject: [PATCH 02/34] perf: stop the startup spinner animating forever after the overlay hides The indeterminate ProgressRing kept WPF rendering every frame even when collapsed (~25% of a core with the window open, ~2% in the tray). It now animates only while visible. The SpinnerAnimation storyboard targeted a missing element, so its Begin() threw and the 15 s timeout guard was never created; both are removed. --- MainWindow.Behaviors.partial.cs | 36 ------------------- MainWindow.xaml | 10 ++---- MainWindow.xaml.cs | 6 ---- .../IdleAnimationXamlTests.cs | 29 +++++++++++++++ 4 files changed, 31 insertions(+), 50 deletions(-) create mode 100644 Tests/ThreadPilot.Core.Tests/IdleAnimationXamlTests.cs diff --git a/MainWindow.Behaviors.partial.cs b/MainWindow.Behaviors.partial.cs index 793bd56..1a47225 100644 --- a/MainWindow.Behaviors.partial.cs +++ b/MainWindow.Behaviors.partial.cs @@ -60,21 +60,6 @@ private void InitializeLoadingOverlay() { this.ApplyUIContentBlur(15); } - - // Start spinner animation if available - var spinnerAnimation = this.FindResource("SpinnerAnimation") as Storyboard; - if (!this.isSilentStartupMode) - { - spinnerAnimation?.Begin(); - } - - // Set a timeout guard for initialization - this.initializationTimeoutTimer = new System.Timers.Timer(15000) - { - AutoReset = false, - }; - this.initializationTimeoutTimer.Elapsed += this.OnInitializationTimeout; - this.initializationTimeoutTimer.Start(); } catch (Exception ex) { @@ -245,8 +230,6 @@ private void HideLoadingOverlay() { System.Diagnostics.Debug.WriteLine("=== Starting HideLoadingOverlay ==="); this.isInitializationComplete = true; - this.initializationTimeoutTimer?.Stop(); - this.initializationTimeoutTimer?.Dispose(); if (this.isSilentStartupMode) { @@ -262,11 +245,6 @@ private void HideLoadingOverlay() return; } - // Stop spinner animation - var spinnerAnimation = this.FindResource("SpinnerAnimation") as Storyboard; - spinnerAnimation?.Stop(); - System.Diagnostics.Debug.WriteLine("Spinner animation stopped"); - // Start fade-out animation var fadeOutAnimation = this.FindResource("FadeOutAnimation") as Storyboard; if (fadeOutAnimation != null) @@ -356,17 +334,6 @@ private void ClearUIContentBlur() this.UIContent.Effect = null; } - private void OnInitializationTimeout(object? sender, ElapsedEventArgs e) - { - this.Dispatcher.InvokeAsync(() => - { - if (!this.isInitializationComplete) - { - this.ShowInitializationError(new TimeoutException("Application initialization timed out after 15 seconds")); - } - }); - } - private void ShowInitializationError(Exception ex) { try @@ -2271,9 +2238,6 @@ protected override void OnClosed(EventArgs e) this.systemTrayUpdateTimer?.Stop(); this.systemTrayUpdateTimer?.Dispose(); - this.initializationTimeoutTimer?.Stop(); - this.initializationTimeoutTimer?.Dispose(); - this.performanceViewModel?.Dispose(); this.settingsViewModel.Dispose(); this.powerPlanViewModel.Dispose(); diff --git a/MainWindow.xaml b/MainWindow.xaml index f9cda54..27b68fb 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -20,13 +20,6 @@ KeyboardNavigation.ControlTabNavigation="Cycle"> - - - - - - + + initializationTasks = new(); private readonly object initializationLock = new(); - private System.Timers.Timer? initializationTimeoutTimer; private readonly string debugLogPath = Path.Combine(Path.GetTempPath(), "ThreadPilot_Debug.log"); // Flag to skip process monitoring during startup if it causes issues @@ -170,11 +169,6 @@ public void ConfigureStartupMode(bool isSilentStartupMode, bool showStartupMinim this.LoadingOverlay.Visibility = Visibility.Collapsed; this.ClearUIContentBlur(); - if (this.FindResource("SpinnerAnimation") is Storyboard spinnerAnimation) - { - spinnerAnimation.Stop(); - } - this.isSystemTrayUpdatesSuspended = true; this.lastAppliedRefreshState = AppActivityState.TrayHidden; this.processViewModel.SetProcessViewActive(false); diff --git a/Tests/ThreadPilot.Core.Tests/IdleAnimationXamlTests.cs b/Tests/ThreadPilot.Core.Tests/IdleAnimationXamlTests.cs new file mode 100644 index 0000000..a8cc6a3 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/IdleAnimationXamlTests.cs @@ -0,0 +1,29 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Xml.Linq; + + public sealed class IdleAnimationXamlTests + { + private static readonly string RepoRoot = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, "..", "..", "..", "..", "..")); + + // A hard-coded indeterminate progress control animates forever, even collapsed: WPF then renders + // every frame (measured: ~25% of a core with the window open, ~2% in the tray). + [Fact] + public void NoProgressControl_IsIndeterminateForever() + { + var xamlFiles = Directory.GetFiles(Path.Combine(RepoRoot, "Views"), "*.xaml") + .Append(Path.Combine(RepoRoot, "MainWindow.xaml")); + + var offenders = xamlFiles + .SelectMany(file => XDocument.Load(file).Descendants() + .Where(element => element.Name.LocalName is "ProgressRing" or "ProgressBar") + .Where(element => string.Equals( + (string?)element.Attribute("IsIndeterminate"), "True", StringComparison.OrdinalIgnoreCase)) + .Select(_ => Path.GetFileName(file))) + .ToList(); + + Assert.Empty(offenders); + } + } +} From f40c6911276c5449fdb36746475517715f8e073f Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Tue, 20 Oct 2026 23:27:28 +0200 Subject: [PATCH 03/34] security: ship WPF native DLLs next to the exe instead of extracting them to %TEMP% With IncludeNativeLibrariesForSelfExtract the elevated app loaded wpfgfx_cor3, PresentationNative_cor3 and D3DCompiler_47_cor3 from %TEMP%\.net\ThreadPilot, which the non-elevated user can modify. The installer already copies *.dll and the portable zip and smoke test take the whole publish folder. --- Directory.Publish.props | 28 ++++++++++--------- .../PublishProfiles/WinX64-SingleFile.pubxml | 28 +++++++++---------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/Directory.Publish.props b/Directory.Publish.props index 6f68263..fb2e421 100644 --- a/Directory.Publish.props +++ b/Directory.Publish.props @@ -1,13 +1,15 @@ - - - true - embedded - false - true - false - - - - true - - + + + true + embedded + false + + false + false + + + + true + + diff --git a/Properties/PublishProfiles/WinX64-SingleFile.pubxml b/Properties/PublishProfiles/WinX64-SingleFile.pubxml index d21b406..927baea 100644 --- a/Properties/PublishProfiles/WinX64-SingleFile.pubxml +++ b/Properties/PublishProfiles/WinX64-SingleFile.pubxml @@ -1,14 +1,14 @@ - - - FileSystem - Release - net8.0-windows10.0.22000.0 - win-x64 - true - true - false - false - true - artifacts\release\singlefile\ - - + + + FileSystem + Release + net8.0-windows10.0.22000.0 + win-x64 + true + true + false + false + false + artifacts\release\singlefile\ + + From 782ebac70bd371600ac52b67e9a7e0f13f2b50c6 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Tue, 20 Oct 2026 23:31:51 +0200 Subject: [PATCH 04/34] security: register the no-UAC elevated tasks only for a Program Files install ThreadPilot_Launch and ThreadPilot_Startup run with highest privileges and no prompt, yet were bound to whichever copy last ran elevated, including portable and dev folders any non-admin program can write to. Each elevated start now deletes ThreadPilot tasks that point outside Program Files and registers them only for an exe under Program Files, skipping the re-registration when the task already points at this exe. Enabling autostart from a portable copy shows why it is unavailable instead of a generic failure. --- Locales/de-DE.xaml | 1 + Locales/en-US.xaml | 1 + Locales/es-ES.xaml | 1 + Locales/fr-FR.xaml | 1 + Locales/it-IT.xaml | 1 + Locales/ru-RU.xaml | 1 + Locales/zh-CN.xaml | 1 + Services/AutostartService.cs | 2 + Services/ElevatedTaskService.cs | 95 ++++++++++++++- Services/IAutostartService.cs | 3 + .../ElevatedTaskServiceTests.cs | 109 +++++++++++++++++- ViewModels/SettingsViewModel.cs | 9 +- 12 files changed, 219 insertions(+), 6 deletions(-) diff --git a/Locales/de-DE.xaml b/Locales/de-DE.xaml index ce948f7..66ab31c 100644 --- a/Locales/de-DE.xaml +++ b/Locales/de-DE.xaml @@ -500,6 +500,7 @@ ThreadPilot-Konfiguration exportieren ThreadPilot-Konfiguration importieren Der Autostart von Windows konnte nicht aktualisiert werden. Den vorherigen Autostart-Status beibehalten. + Der Autostart erfordert eine Installation von ThreadPilot unter „Programme“: Eine portable Kopie könnte von jedem Programm ersetzt und dann als Administrator gestartet werden. ThreadPilot Einstellungen diff --git a/Locales/en-US.xaml b/Locales/en-US.xaml index dba33f0..6319147 100644 --- a/Locales/en-US.xaml +++ b/Locales/en-US.xaml @@ -500,6 +500,7 @@ Export ThreadPilot Configuration Import ThreadPilot Configuration Failed to update Windows autostart. Keeping previous autostart state. + Autostart needs ThreadPilot installed in Program Files: a portable copy could be replaced by any program and then started as administrator. ThreadPilot Settings diff --git a/Locales/es-ES.xaml b/Locales/es-ES.xaml index f67ad40..2a8c29e 100644 --- a/Locales/es-ES.xaml +++ b/Locales/es-ES.xaml @@ -500,6 +500,7 @@ Exportar configuración ThreadPilot Importar configuración ThreadPilot No se pudo actualizar el inicio automático Windows. Manteniendo el estado de inicio automático anterior. + El inicio automático requiere ThreadPilot instalado en Archivos de programa: cualquier programa podría reemplazar una copia portable y ejecutarla como administrador. ThreadPilot Configuración diff --git a/Locales/fr-FR.xaml b/Locales/fr-FR.xaml index 76399bb..571e602 100644 --- a/Locales/fr-FR.xaml +++ b/Locales/fr-FR.xaml @@ -500,6 +500,7 @@ Exporter la configuration ThreadPilot Importer la configuration ThreadPilot Échec de la mise à jour du démarrage automatique Windows. Conserver l'état de démarrage automatique précédent. + Le démarrage automatique nécessite ThreadPilot installé dans Program Files : n'importe quel programme pourrait remplacer une copie portable, qui serait ensuite lancée en administrateur. ThreadPilot Paramètres diff --git a/Locales/it-IT.xaml b/Locales/it-IT.xaml index a8a98c5..d1cd34e 100644 --- a/Locales/it-IT.xaml +++ b/Locales/it-IT.xaml @@ -500,6 +500,7 @@ Esporta ThreadPilot Configurazione Importa ThreadPilot Configurazione Impossibile aggiornare l'avvio automatico Windows. Mantenimento dello stato di avvio automatico precedente. + L'avvio automatico richiede ThreadPilot installato in Programmi: una copia portable potrebbe essere sostituita da qualsiasi programma e poi avviata come amministratore. ThreadPilot Impostazioni diff --git a/Locales/ru-RU.xaml b/Locales/ru-RU.xaml index 80bddb3..8cdc0ab 100644 --- a/Locales/ru-RU.xaml +++ b/Locales/ru-RU.xaml @@ -500,6 +500,7 @@ Экспорт конфигурации ThreadPilot Импорт конфигурации ThreadPilot Не удалось обновить автозапуск Windows. Сохранение предыдущего состояния автозапуска. + Для автозапуска ThreadPilot должен быть установлен в Program Files: портативную копию может подменить любая программа, и она запустится от имени администратора. ThreadPilot Настройки diff --git a/Locales/zh-CN.xaml b/Locales/zh-CN.xaml index 410dfc0..8d85cd2 100644 --- a/Locales/zh-CN.xaml +++ b/Locales/zh-CN.xaml @@ -500,6 +500,7 @@ 导出 ThreadPilot 配置 导入 ThreadPilot 配置 无法更新 Windows 自启动。将保留之前的自启动状态。 + 自启动需要将 ThreadPilot 安装在 Program Files 中:便携版可能被任何程序替换,并以管理员身份启动。 ThreadPilot 设置 diff --git a/Services/AutostartService.cs b/Services/AutostartService.cs index db205ce..caa1e05 100644 --- a/Services/AutostartService.cs +++ b/Services/AutostartService.cs @@ -36,6 +36,8 @@ public AutostartService(ILogger logger, IElevationService elev }); } + public bool IsAutostartSupported => ElevatedTaskService.IsProtectedInstallPath(this.GetExecutablePath()); + public async Task EnableAutostartAsync(bool startMinimized = true) { try diff --git a/Services/ElevatedTaskService.cs b/Services/ElevatedTaskService.cs index fe9ad83..df982ef 100644 --- a/Services/ElevatedTaskService.cs +++ b/Services/ElevatedTaskService.cs @@ -4,10 +4,13 @@ namespace ThreadPilot.Services using System.Collections.Generic; using System.Diagnostics; using System.IO; + using System.Linq; + using System.Net; using System.Reflection; using System.Security; using System.Security.Principal; using System.Text; + using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using ThreadPilot.Services.Abstractions; @@ -21,6 +24,7 @@ public partial class ElevatedTaskService : IElevatedTaskService private readonly IProcessRunner processRunner; private readonly Func? executablePathProvider; private readonly Func? currentUserProvider; + private readonly Func isProtectedInstallPath; public ElevatedTaskService(ILogger logger, IProcessRunner processRunner) : this(logger, processRunner, null, null) @@ -31,29 +35,72 @@ public ElevatedTaskService( ILogger logger, IProcessRunner processRunner, Func? executablePathProvider, - Func? currentUserProvider) + Func? currentUserProvider, + Func? isProtectedInstallPath = null) { this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.processRunner = processRunner ?? throw new ArgumentNullException(nameof(processRunner)); this.executablePathProvider = executablePathProvider; this.currentUserProvider = currentUserProvider; + this.isProtectedInstallPath = isProtectedInstallPath ?? IsProtectedInstallPath; } public string LaunchTaskName => "ThreadPilot_Launch"; public string AutostartTaskName => "ThreadPilot_Startup"; + /// + /// True when the path is inside Program Files. The tasks run with highest privileges and no UAC prompt, + /// so they may only point at an exe that a non-admin user cannot replace. + /// + public static bool IsProtectedInstallPath(string? executablePath) + { + if (string.IsNullOrWhiteSpace(executablePath) || !Path.IsPathFullyQualified(executablePath)) + { + return false; + } + + string fullPath; + try + { + fullPath = Path.GetFullPath(executablePath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return false; + } + + return new[] { Environment.SpecialFolder.ProgramFiles, Environment.SpecialFolder.ProgramFilesX86 } + .Select(Environment.GetFolderPath) + .Where(root => !string.IsNullOrWhiteSpace(root)) + .Select(root => Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar) + .Any(root => fullPath.StartsWith(root, StringComparison.OrdinalIgnoreCase)); + } + public async Task EnsureLaunchTaskAsync() { try { var executablePath = this.executablePathProvider?.Invoke() ?? this.GetExecutablePath(); + await this.RemoveTasksOutsideProtectedLocationsAsync(); + if (!IsValidExecutablePath(executablePath)) { LogSkipEnsureLaunchTaskInvalidPath(this.logger, executablePath ?? "(null)"); return false; } + if (!this.isProtectedInstallPath(executablePath)) + { + LogLaunchTaskNotRegisteredUnprotectedPath(this.logger, executablePath!); + return false; + } + + if (string.Equals(await this.QueryTaskCommandAsync(this.LaunchTaskName), executablePath, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + var taskXmlPath = Path.Combine(Path.GetTempPath(), $"threadpilot-launch-task-{Guid.NewGuid():N}.xml"); try { @@ -127,6 +174,12 @@ public async Task EnsureAutostartTaskAsync(string executablePath, string a return false; } + if (!this.isProtectedInstallPath(executablePath)) + { + LogAutostartTaskNotRegisteredUnprotectedPath(this.logger, executablePath); + return false; + } + var taskRunCommand = BuildCommand(executablePath, arguments); var result = await this.RunSchTasksAsync(new List { @@ -210,6 +263,35 @@ public async Task IsAutostartTaskRegisteredAsync() } } + // Deletes ThreadPilot tasks left pointing at a user-writable exe (portable or dev copies, older versions). + private async Task RemoveTasksOutsideProtectedLocationsAsync() + { + foreach (var taskName in new[] { this.LaunchTaskName, this.AutostartTaskName }) + { + var command = await this.QueryTaskCommandAsync(taskName); + if (command == null || this.isProtectedInstallPath(command)) + { + continue; + } + + var result = await this.RunSchTasksAsync(new List { "/Delete", "/TN", taskName, "/F" }); + LogUnprotectedTaskRemoved(this.logger, taskName, command, result.ExitCode); + } + } + + // schtasks prints the XML in the console code page despite its UTF-16 header, so read it as text. + private async Task QueryTaskCommandAsync(string taskName) + { + var result = await this.RunSchTasksAsync(new List { "/Query", "/TN", taskName, "/XML" }); + if (result.ExitCode != 0) + { + return null; + } + + var match = Regex.Match(result.StandardOutput ?? string.Empty, "(.*?)", RegexOptions.Singleline); + return match.Success ? WebUtility.HtmlDecode(match.Groups[1].Value).Trim().Trim('"') : null; + } + private string? GetExecutablePath() { try @@ -252,7 +334,7 @@ private static string BuildCommand(string executablePath, string arguments) private static bool IsValidExecutablePath(string? executablePath) { - if (string.IsNullOrWhiteSpace(executablePath) || !Path.IsPathRooted(executablePath)) + if (string.IsNullOrWhiteSpace(executablePath) || !Path.IsPathFullyQualified(executablePath)) { return false; } @@ -410,5 +492,14 @@ private static void TryDeleteFile(string path, ILogger logger) [LoggerMessage(EventId = 4268, Level = LogLevel.Debug, Message = "Failed to delete temporary file '{Path}'")] private static partial void LogDeleteTemporaryFileFailed(ILogger logger, string path, Exception ex); + + [LoggerMessage(EventId = 4269, Level = LogLevel.Information, Message = "Elevated launch task not registered: '{ExecutablePath}' is outside Program Files, so a non-admin user could replace it")] + private static partial void LogLaunchTaskNotRegisteredUnprotectedPath(ILogger logger, string executablePath); + + [LoggerMessage(EventId = 4270, Level = LogLevel.Warning, Message = "Autostart task not registered: '{ExecutablePath}' is outside Program Files, so a non-admin user could replace it")] + private static partial void LogAutostartTaskNotRegisteredUnprotectedPath(ILogger logger, string executablePath); + + [LoggerMessage(EventId = 4271, Level = LogLevel.Warning, Message = "Removed task '{TaskName}' pointing at '{Command}' outside Program Files (schtasks exit code {ExitCode})")] + private static partial void LogUnprotectedTaskRemoved(ILogger logger, string taskName, string command, int exitCode); } } diff --git a/Services/IAutostartService.cs b/Services/IAutostartService.cs index 3aad691..ff3fdae 100644 --- a/Services/IAutostartService.cs +++ b/Services/IAutostartService.cs @@ -11,6 +11,9 @@ public interface IAutostartService string? AutostartPath { get; } + /// Gets a value indicating whether this copy can autostart: only an install in Program Files can. + bool IsAutostartSupported { get; } + Task EnableAutostartAsync(bool startMinimized = true); Task DisableAutostartAsync(); diff --git a/Tests/ThreadPilot.Core.Tests/ElevatedTaskServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ElevatedTaskServiceTests.cs index 89dfe0e..e999195 100644 --- a/Tests/ThreadPilot.Core.Tests/ElevatedTaskServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ElevatedTaskServiceTests.cs @@ -51,6 +51,11 @@ public async Task EnsureLaunchTaskAsync_WritesExpectedLaunchTaskDefinition() { ResultFactory = invocation => { + if (invocation.Arguments[0] != "/Create") + { + return new ProcessRunResult(1, string.Empty, "not found"); + } + var xmlIndex = invocation.Arguments.IndexOf("/XML"); Assert.True(xmlIndex >= 0); xmlPath = invocation.Arguments[xmlIndex + 1]; @@ -64,7 +69,8 @@ public async Task EnsureLaunchTaskAsync_WritesExpectedLaunchTaskDefinition() var service = CreateService( runner, executablePathProvider: () => executablePath, - currentUserProvider: () => @"TEST\User"); + currentUserProvider: () => @"TEST\User", + isProtectedInstallPath: _ => true); var result = await service.EnsureLaunchTaskAsync(); @@ -89,16 +95,113 @@ public async Task EnsureLaunchTaskAsync_WritesExpectedLaunchTaskDefinition() } } + [Theory] + [InlineData(@"{PF}\ThreadPilot\ThreadPilot.exe", true)] + [InlineData(@"{PF}\..\Users\Public\ThreadPilot.exe", false)] + [InlineData(@"{PF} Evil\ThreadPilot.exe", false)] + [InlineData(@"C:\Users\Someone\Downloads\ThreadPilot\ThreadPilot.exe", false)] + [InlineData(@"ThreadPilot.exe", false)] + [InlineData(@"\ThreadPilot.exe", false)] + [InlineData("", false)] + public void IsProtectedInstallPath_OnlyAcceptsProgramFiles(string path, bool expected) + { + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + + Assert.Equal(expected, ElevatedTaskService.IsProtectedInstallPath(path.Replace("{PF}", programFiles))); + } + + [Fact] + public async Task EnsureLaunchTaskAsync_FromAWritableFolder_DeletesStaleTasksAndRegistersNothing() + { + var executablePath = CreateTemporaryExecutablePath(); + var installed = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "ThreadPilot", "ThreadPilot.exe"); + var runner = new RecordingProcessRunner + { + ResultFactory = invocation => invocation.Arguments switch + { + ["/Query", "/TN", "ThreadPilot_Launch", "/XML"] => TaskXml(@"C:\Users\Someone\Documents\ThreadPilot.exe"), + ["/Query", "/TN", "ThreadPilot_Startup", "/XML"] => TaskXml($"\"{installed}\""), + _ => new ProcessRunResult(0, string.Empty, string.Empty), + }, + }; + + try + { + var service = CreateService(runner, executablePathProvider: () => executablePath); + + var result = await service.EnsureLaunchTaskAsync(); + + Assert.False(result); + Assert.Contains(runner.Invocations, invocation => invocation.Arguments.SequenceEqual(new[] { "/Delete", "/TN", "ThreadPilot_Launch", "/F" })); + Assert.DoesNotContain(runner.Invocations, invocation => invocation.Arguments.Contains("ThreadPilot_Startup") && invocation.Arguments[0] == "/Delete"); + Assert.DoesNotContain(runner.Invocations, invocation => invocation.Arguments[0] == "/Create"); + } + finally + { + File.Delete(executablePath); + } + } + + [Fact] + public async Task EnsureLaunchTaskAsync_TaskAlreadyPointsAtThisExe_NotRegisteredAgain() + { + var executablePath = CreateTemporaryExecutablePath(); + var runner = new RecordingProcessRunner + { + ResultFactory = invocation => invocation.Arguments[0] == "/Query" + ? TaskXml(executablePath.Replace("&", "&")) + : new ProcessRunResult(0, string.Empty, string.Empty), + }; + + try + { + var service = CreateService(runner, executablePathProvider: () => executablePath, isProtectedInstallPath: _ => true); + + Assert.True(await service.EnsureLaunchTaskAsync()); + Assert.All(runner.Invocations, invocation => Assert.Equal("/Query", invocation.Arguments[0])); + } + finally + { + File.Delete(executablePath); + } + } + + [Fact] + public async Task EnsureAutostartTaskAsync_FromAWritableFolder_RegistersNothing() + { + var executablePath = CreateTemporaryExecutablePath(); + var runner = new RecordingProcessRunner(); + + try + { + var service = CreateService(runner); + + Assert.False(await service.EnsureAutostartTaskAsync(executablePath, "--autostart")); + Assert.Empty(runner.Invocations); + } + finally + { + File.Delete(executablePath); + } + } + + private static ProcessRunResult TaskXml(string command) => new( + 0, + $"\r\r\n{command}", + string.Empty); + private static ElevatedTaskService CreateService( IProcessRunner runner, Func? executablePathProvider = null, - Func? currentUserProvider = null) + Func? currentUserProvider = null, + Func? isProtectedInstallPath = null) { return new ElevatedTaskService( NullLogger.Instance, runner, executablePathProvider, - currentUserProvider); + currentUserProvider, + isProtectedInstallPath); } private static string CreateTemporaryExecutablePath() diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs index 7c765e5..08edf3a 100644 --- a/ViewModels/SettingsViewModel.cs +++ b/ViewModels/SettingsViewModel.cs @@ -339,7 +339,14 @@ private async Task SaveSettingsAsync() autostartUpdated = await this.autostartService.DisableAutostartAsync(); } - if (!autostartUpdated) + if (!autostartUpdated && this.Settings.AutostartWithWindows && !this.autostartService.IsAutostartSupported) + { + warnings.Add(this.GetLocalizedString( + "Settings_WarningAutostartNeedsInstall", + "Autostart needs ThreadPilot installed in Program Files: a portable copy could be replaced by any program and then started as administrator.")); + this.Settings.AutostartWithWindows = currentAutostartState; + } + else if (!autostartUpdated) { warnings.Add(this.GetLocalizedString( "Settings_WarningAutostartFailed", From 2e681a972fd53eb26b809f83601871e4fc9022cf Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Tue, 20 Oct 2026 23:35:30 +0200 Subject: [PATCH 05/34] security: keep the task XML and update installer in admin-only folders The elevated app wrote the scheduled-task definition and downloaded the update installer into the user's %TEMP%, where a non-admin process can swap them (or plant DLLs beside the installer) between the check and the elevated use. Both now go into a fresh, unguessable folder under %SystemRoot%\Temp whose DACL grants only Administrators and SYSTEM; users there cannot rename or delete another account's folders. --- Services/AdminOnlyDirectory.cs | 54 +++++++++++++ Services/ElevatedTaskService.cs | 12 +-- Services/UpdateTempDirectoryProvider.cs | 32 +++----- .../AdminOnlyDirectoryTests.cs | 81 +++++++++++++++++++ 4 files changed, 152 insertions(+), 27 deletions(-) create mode 100644 Services/AdminOnlyDirectory.cs create mode 100644 Tests/ThreadPilot.Core.Tests/AdminOnlyDirectoryTests.cs diff --git a/Services/AdminOnlyDirectory.cs b/Services/AdminOnlyDirectory.cs new file mode 100644 index 0000000..09ee8ac --- /dev/null +++ b/Services/AdminOnlyDirectory.cs @@ -0,0 +1,54 @@ +/* + * ThreadPilot - directories only administrators and SYSTEM can write, for files the elevated app later runs or hands to Windows. + */ +namespace ThreadPilot.Services +{ + using System; + using System.IO; + using System.Security.AccessControl; + using System.Security.Principal; + + public static class AdminOnlyDirectory + { + /// + /// Gets the default parent: in %SystemRoot%\Temp users may create folders but cannot rename or delete + /// another account's, unlike their own %TEMP%, where they could swap a file between check and use. + /// + public static string DefaultRoot { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "Temp"); + + /// Creates (which must not exist) with an Administrators + SYSTEM only DACL. + public static string Create(string path) + { + var security = new DirectorySecurity(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + Allow(security, new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null)); + Allow(security, new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null)); + + // Not elevated (tests, dev runs): there is no privilege gap to protect, and without this the + // process could not write its own files. + using var identity = WindowsIdentity.GetCurrent(); + if (!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator) && identity.User != null) + { + Allow(security, identity.User); + } + + if (Directory.Exists(path) || File.Exists(path)) + { + throw new IOException($"Refusing to reuse existing path '{path}'."); + } + + security.CreateDirectory(path); + return path; + } + + private static void Allow(DirectorySecurity security, IdentityReference identity) + { + security.AddAccessRule(new FileSystemAccessRule( + identity, + FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, + AccessControlType.Allow)); + } + } +} diff --git a/Services/ElevatedTaskService.cs b/Services/ElevatedTaskService.cs index df982ef..31dc8fb 100644 --- a/Services/ElevatedTaskService.cs +++ b/Services/ElevatedTaskService.cs @@ -101,7 +101,9 @@ public async Task EnsureLaunchTaskAsync() return true; } - var taskXmlPath = Path.Combine(Path.GetTempPath(), $"threadpilot-launch-task-{Guid.NewGuid():N}.xml"); + // schtasks reads the definition from a file: keep it where only admins can change it before it does. + var taskXmlDirectory = AdminOnlyDirectory.Create(Path.Combine(AdminOnlyDirectory.DefaultRoot, $"ThreadPilot-task-{Guid.NewGuid():N}")); + var taskXmlPath = Path.Combine(taskXmlDirectory, "launch-task.xml"); try { WriteLaunchTaskDefinition( @@ -128,7 +130,7 @@ public async Task EnsureLaunchTaskAsync() } finally { - TryDeleteFile(taskXmlPath, this.logger); + TryDeleteDirectory(taskXmlDirectory, this.logger); } } catch (Exception ex) @@ -421,13 +423,13 @@ private Task RunSchTasksAsync(IReadOnlyList arguments) return this.processRunner.RunAsync(SchTasksExecutablePath, arguments, ScheduledTaskTimeout); } - private static void TryDeleteFile(string path, ILogger logger) + private static void TryDeleteDirectory(string path, ILogger logger) { try { - if (File.Exists(path)) + if (Directory.Exists(path)) { - File.Delete(path); + Directory.Delete(path, recursive: true); } } catch (Exception ex) diff --git a/Services/UpdateTempDirectoryProvider.cs b/Services/UpdateTempDirectoryProvider.cs index 4f9a8d0..b46047d 100644 --- a/Services/UpdateTempDirectoryProvider.cs +++ b/Services/UpdateTempDirectoryProvider.cs @@ -6,12 +6,18 @@ namespace ThreadPilot.Services using System; using System.IO; + /// + /// Each download gets its own admin-only folder directly under the root, with an unguessable name, so a + /// non-admin process cannot swap the verified installer (or plant DLLs beside it) before it runs elevated. + /// public sealed class UpdateTempDirectoryProvider : IUpdateTempDirectoryProvider { + private const string FolderPrefix = "ThreadPilot-update-"; + private readonly string rootDirectory; public UpdateTempDirectoryProvider() - : this(Path.Combine(Path.GetTempPath(), "ThreadPilot", "Updates")) + : this(AdminOnlyDirectory.DefaultRoot) { } @@ -22,9 +28,7 @@ public UpdateTempDirectoryProvider(string rootDirectory) public string CreateUpdateTempDirectory(SemanticVersion version) { - var directory = Path.Combine(this.rootDirectory, version.ToString(), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(directory); - return directory; + return AdminOnlyDirectory.Create(Path.Combine(this.rootDirectory, $"{FolderPrefix}{version}-{Guid.NewGuid():N}")); } public bool IsSafeUpdateTempPath(string path) @@ -35,9 +39,8 @@ public bool IsSafeUpdateTempPath(string path) } var fullPath = Path.GetFullPath(path); - var rootWithSeparator = this.rootDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) - + Path.DirectorySeparatorChar; - return fullPath.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase); + var prefix = Path.TrimEndingDirectorySeparator(this.rootDirectory) + Path.DirectorySeparatorChar + FolderPrefix; + return fullPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); } public void Cleanup(string path) @@ -48,21 +51,6 @@ public void Cleanup(string path) } 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/Tests/ThreadPilot.Core.Tests/AdminOnlyDirectoryTests.cs b/Tests/ThreadPilot.Core.Tests/AdminOnlyDirectoryTests.cs new file mode 100644 index 0000000..d5c41a5 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/AdminOnlyDirectoryTests.cs @@ -0,0 +1,81 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Security.AccessControl; + using System.Security.Principal; + using ThreadPilot.Services; + + public sealed class AdminOnlyDirectoryTests + { + [Fact] + public void Create_GrantsOnlyAdministratorsAndSystem_WithoutInheritingTheParentAcl() + { + var path = Path.Combine(Path.GetTempPath(), $"threadpilot-acl-{Guid.NewGuid():N}"); + try + { + AdminOnlyDirectory.Create(path); + + var security = new DirectoryInfo(path).GetAccessControl(); + Assert.True(security.AreAccessRulesProtected); + + var allowed = security.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier)) + .Cast() + .Select(rule => (SecurityIdentifier)rule.IdentityReference) + .ToHashSet(); + var expected = new HashSet + { + new(WellKnownSidType.LocalSystemSid, null), + new(WellKnownSidType.BuiltinAdministratorsSid, null), + }; + using var identity = WindowsIdentity.GetCurrent(); + if (!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator)) + { + expected.Add(identity.User!); + } + + Assert.Equal(expected, allowed); + } + finally + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + } + + [Fact] + public void Create_RefusesAnExistingFolder() + { + var path = Directory.CreateTempSubdirectory("threadpilot-acl-").FullName; + try + { + Assert.Throws(() => AdminOnlyDirectory.Create(path)); + } + finally + { + Directory.Delete(path, recursive: true); + } + } + + [Fact] + public void UpdateFolders_AreCreatedDirectlyUnderTheRoot_AndOnlyThoseAreTrusted() + { + var root = Directory.CreateTempSubdirectory("threadpilot-updates-").FullName; + try + { + var provider = new UpdateTempDirectoryProvider(root); + + var created = provider.CreateUpdateTempDirectory(new SemanticVersion(1, 8, 0)); + + Assert.Equal(root, Path.GetDirectoryName(created)); + Assert.True(provider.IsSafeUpdateTempPath(Path.Combine(created, "ThreadPilot_v1.8.0_Setup.exe"))); + Assert.False(provider.IsSafeUpdateTempPath(Path.Combine(root, "other", "ThreadPilot_v1.8.0_Setup.exe"))); + Assert.False(provider.IsSafeUpdateTempPath(Path.Combine(created, "..", "..", "Setup.exe"))); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + } +} From 02e8e93c38a8e8e526da6adf5e578e6569576cfd Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Tue, 20 Oct 2026 23:36:46 +0200 Subject: [PATCH 06/34] security: refuse an update whose release has no trusted SHA256SUMS.txt The installer runs elevated and silent, yet a release without the checksum file was installed unverified. The download now stops before fetching anything when the checksum asset is missing or not hosted on GitHub. --- Services/UpdateAssetSelector.cs | 3 +- Services/UpdateDownloadService.cs | 36 +++++++++---------- .../UpdateServiceTests.cs | 14 ++++++++ 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/Services/UpdateAssetSelector.cs b/Services/UpdateAssetSelector.cs index f3d619c..7468adf 100644 --- a/Services/UpdateAssetSelector.cs +++ b/Services/UpdateAssetSelector.cs @@ -23,7 +23,8 @@ public static bool TrySelectInstaller(UpdateReleaseInfo release, out UpdateAsset public static UpdateAsset? SelectChecksumAsset(UpdateReleaseInfo release) { return release.Assets.FirstOrDefault(asset => - string.Equals(asset.Name, "SHA256SUMS.txt", StringComparison.OrdinalIgnoreCase)); + string.Equals(asset.Name, "SHA256SUMS.txt", StringComparison.OrdinalIgnoreCase) && + IsSafeGitHubAssetUrl(asset.DownloadUrl)); } public static bool IsSafeGitHubAssetUrl(Uri uri) diff --git a/Services/UpdateDownloadService.cs b/Services/UpdateDownloadService.cs index 54c9181..26d948a 100644 --- a/Services/UpdateDownloadService.cs +++ b/Services/UpdateDownloadService.cs @@ -35,6 +35,10 @@ public async Task DownloadInstallerAsync(UpdateReleaseInfo throw new InvalidOperationException("Release does not contain a ThreadPilot installer asset."); } + // The installer runs elevated and silent: never without a checksum to check it against. + var checksumAsset = UpdateAssetSelector.SelectChecksumAsset(release) + ?? throw new InvalidOperationException("Release has no SHA256SUMS.txt, so the installer cannot be verified."); + var tempDirectory = this.tempDirectoryProvider.CreateUpdateTempDirectory(release.Version); try { @@ -42,24 +46,17 @@ public async Task DownloadInstallerAsync(UpdateReleaseInfo 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)) { - 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."); - } + throw new InvalidOperationException("SHA256SUMS.txt did not contain the installer checksum."); + } - checksumVerified = true; + if (!UpdateChecksumVerifier.Verify(installerPath, expectedHash)) + { + throw new InvalidOperationException("Installer SHA256 checksum did not match SHA256SUMS.txt."); } var signatureStatus = this.signatureVerifier.Verify(installerPath); @@ -69,17 +66,16 @@ await this.downloadClient.DownloadFileAsync(installerAsset.DownloadUrl, installe } this.logger.LogInformation( - "Downloaded ThreadPilot update installer {InstallerName}; checksum verified: {ChecksumVerified}; signature: {SignatureStatus}", + "Downloaded ThreadPilot update installer {InstallerName}; checksum verified; signature: {SignatureStatus}", installerAsset.Name, - checksumVerified, signatureStatus); return new UpdateDownloadResult( installerPath, tempDirectory, - checksumVerified, + true, signatureStatus, - checksumVerified ? "Installer checksum verified." : "No SHA256SUMS.txt asset was available."); + "Installer checksum verified."); } catch { diff --git a/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs b/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs index 461b181..a948af2 100644 --- a/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs @@ -103,6 +103,20 @@ public async Task DownloadInstallerAsync_RejectsInvalidChecksumAndCleansTemp() Assert.Empty(Directory.GetDirectories(tempRoot.Path)); } + [Fact] + public async Task DownloadInstallerAsync_WithoutChecksumAsset_RefusesBeforeDownloading() + { + using var tempRoot = new TempDirectory(); + var client = new FakeUpdateDownloadClient(Encoding.UTF8.GetBytes("installer-content"), null); + var service = CreateDownloadService(tempRoot.Path, client); + var release = 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://example.com/SHA256SUMS.txt"), 10)); + + await Assert.ThrowsAsync(() => service.DownloadInstallerAsync(release)); + Assert.Empty(Directory.GetFileSystemEntries(tempRoot.Path)); + } + [Fact] public void UpdateTempDirectoryProvider_DoesNotDeleteOutsideUpdateRoot() { From 119f6449a34fb50482638ef197ac9d655272a502 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Tue, 20 Oct 2026 23:39:39 +0200 Subject: [PATCH 07/34] security: saved rules and tweak restores no longer trust their user-writable files Rules load from %AppData%, which any program of the user can edit, but only the priority path checked for protected processes; CPU Sets, memory and I/O priority and EcoQoS went straight through. The rules engine now checks the live process (critical-process list and PPL) before applying anything, and the list gains a few more system processes. Driver tweak restores accept only the values the tweak itself writes, and their backup file is written atomically. --- Services/PersistentRulesEngine.cs | 38 ++++++++++++++++++- Services/SecurityService.cs | 6 +++ Services/SystemTweaksService.cs | 9 +++-- .../PersistentRulesEngineTests.cs | 25 ++++++++++++ 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/Services/PersistentRulesEngine.cs b/Services/PersistentRulesEngine.cs index f275ea3..228936e 100644 --- a/Services/PersistentRulesEngine.cs +++ b/Services/PersistentRulesEngine.cs @@ -26,6 +26,7 @@ public sealed class PersistentRulesEngine : IPersistentRulesEngine private const string PriorityNotObservedErrorCode = "PriorityNotObserved"; private const string PriorityVerificationUnavailableErrorCode = "PriorityVerificationUnavailable"; private const string RealtimePriorityBlockedErrorCode = "RealtimePriorityBlocked"; + private const string ProtectedProcessErrorCode = "PersistentRuleProtectedProcess"; private readonly IPersistentProcessRuleStore ruleStore; private readonly IPersistentProcessRuleMatcher matcher; @@ -34,6 +35,7 @@ public sealed class PersistentRulesEngine : IPersistentRulesEngine private readonly IProcessMemoryPriorityService memoryPriorityService; private readonly ProcessPowerRequestService? powerRequestService; private readonly ProcessPolicyApplyGate? applyGate; + private readonly ISecurityService? securityService; private readonly ILogger logger; public PersistentRulesEngine( @@ -44,7 +46,8 @@ public PersistentRulesEngine( IProcessMemoryPriorityService memoryPriorityService, ILogger logger, ProcessPowerRequestService? powerRequestService = null, - ProcessPolicyApplyGate? applyGate = null) + ProcessPolicyApplyGate? applyGate = null, + ISecurityService? securityService = null) { this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore)); this.matcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); @@ -54,6 +57,7 @@ public PersistentRulesEngine( this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.powerRequestService = powerRequestService; this.applyGate = applyGate; + this.securityService = securityService; } public async Task> ApplyMatchingRulesAsync( @@ -99,6 +103,18 @@ private async Task ApplyRuleAsync( "This saved rule has no actions to apply."); } + if (this.IsProtectedProcess(process)) + { + const string message = "Saved rules never change protected system processes."; + return result with + { + Success = false, + ErrorCode = ProtectedProcessErrorCode, + UserMessage = message, + TechnicalMessage = $"Persistent rule '{rule.Name}' ({rule.Id}) skipped for protected process {process.Name} ({process.ProcessId}).", + }; + } + if (rule.ApplyAffinityOnStart) { if (rule.CpuSelection == null && !rule.LegacyAffinityMask.HasValue) @@ -391,6 +407,26 @@ private Task ApplyAffinityAsync(PersistentProcessRule rule, return Task.FromResult(AffinityApplyResult.Succeeded(0, process.ProcessorAffinity)); } + // Rules come from a file any program of the user can edit, so the elevated engine checks the live + // process (critical-process list + PPL) itself instead of relying on each setter to do it. + private bool IsProtectedProcess(ProcessModel process) + { + if (this.securityService == null) + { + return false; + } + + try + { + using var liveProcess = Process.GetProcessById(process.ProcessId); + return this.securityService.IsProtected(liveProcess); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return false; // Exited: the apply calls below report it. + } + } + private static PersistentRuleApplyResult MarkRuleConfigurationFailure( PersistentRuleApplyResult result, PersistentProcessRule rule, diff --git a/Services/SecurityService.cs b/Services/SecurityService.cs index cd4f777..9985d99 100644 --- a/Services/SecurityService.cs +++ b/Services/SecurityService.cs @@ -47,6 +47,12 @@ public class SecurityService : ISecurityService "MsMpEng", "SecurityHealthService", "audiodg", + "Registry", + "MemCompression", + "Secure System", + "LsaIso", + "fontdrvhost", + "sihost", }; private const uint ProcessQueryLimitedInformation = 0x1000; diff --git a/Services/SystemTweaksService.cs b/Services/SystemTweaksService.cs index a616d21..6119170 100644 --- a/Services/SystemTweaksService.cs +++ b/Services/SystemTweaksService.cs @@ -830,15 +830,18 @@ private async Task SetRegistryTargetsAsync( if (changedBackups) { - this.SaveRegistryBackups(backups); + await SaveRegistryBackupsAsync(backups); } foreach (var target in targets) { var backupKey = target.BackupKey; + + // The backup file is user-writable: only restore a value this tweak itself could have written. var desiredValue = enabled ? enabledValue : restoreOriginalWhenDisabled && backups.TryGetValue(backupKey, out var backup) + && (backup.Value == enabledValue || backup.Value == disabledValue) ? backup.Value : disabledValue; using var key = Registry.LocalMachine.OpenSubKey(target.Path, writable: true); @@ -917,10 +920,10 @@ private Dictionary LoadRegistryBackups() } } - private void SaveRegistryBackups(Dictionary backups) + private static async Task SaveRegistryBackupsAsync(Dictionary backups) { StoragePaths.EnsureAppDataDirectories(); - File.WriteAllText( + await AtomicFileWriter.WriteAllTextAsync( StoragePaths.DeviceTweakBackupsFilePath, JsonSerializer.Serialize(backups, new JsonSerializerOptions { WriteIndented = true })); } diff --git a/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs index 774d9bd..88ee4d8 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs @@ -521,6 +521,31 @@ public async Task ApplyMatchingRulesAsync_WaitsForTheSharedApplyGate() affinity.Verify(s => s.ApplyAsync(process, 3), Times.Once); } + [Fact] + public async Task ApplyMatchingRulesAsync_ProtectedProcess_ChangesNothing() + { + var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true); + var affinity = new Mock(MockBehavior.Strict); + var security = new Mock(MockBehavior.Strict); + security.Setup(s => s.IsProtected(It.IsAny())).Returns(true); + var engine = new PersistentRulesEngine( + new FakePersistentProcessRuleStore([rule]), + new PersistentProcessRuleMatcher(), + affinity.Object, + new Mock(MockBehavior.Strict).Object, + new Mock(MockBehavior.Strict).Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, + securityService: security.Object); + var process = CreateProcess(); + process.ProcessId = Environment.ProcessId; + + var result = Assert.Single(await engine.ApplyMatchingRulesAsync(process)); + + Assert.False(result.Success); + Assert.Equal("PersistentRuleProtectedProcess", result.ErrorCode); + security.Verify(s => s.IsProtected(It.IsAny()), Times.Once); + } + private static PersistentRulesEngine CreateEngine( IReadOnlyList rules, IAffinityApplyService affinityApplyService, From 3280f69c08813473fdad5c26ccb11b5490ffd0f1 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Tue, 20 Oct 2026 23:42:00 +0200 Subject: [PATCH 08/34] ci: pin workflow actions to commit SHAs and narrow the release token A repointed third-party tag could swap the installer during the release job, which held a write token for every job. Actions are now pinned to commit SHAs (Dependabot keeps them current), only the job that publishes the release can write, workflow inputs reach pwsh through env instead of ${{ }} interpolation, Inno Setup and the SBOM tool are version-pinned, and the gitleaks download is checked against its published SHA256. --- .github/dependabot.yml | 8 +++ .github/workflows/ci-devsecops.yml | 25 +++++----- .github/workflows/codeql.yml | 8 +-- .github/workflows/dotnet-desktop.yml | 4 +- .github/workflows/publish-chocolatey.yml | 11 +++-- .github/workflows/release.yml | 62 ++++++++++++++---------- 6 files changed, 70 insertions(+), 48 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7f81ea4..16182f5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,3 +8,11 @@ updates: labels: - dependencies - security + # Keeps the SHA-pinned workflow actions current. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + labels: + - dependencies + - security diff --git a/.github/workflows/ci-devsecops.yml b/.github/workflows/ci-devsecops.yml index 4063708..b50e453 100644 --- a/.github/workflows/ci-devsecops.yml +++ b/.github/workflows/ci-devsecops.yml @@ -17,10 +17,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: "8.0.x" @@ -76,7 +76,7 @@ jobs: } - name: Upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 with: files: '**/coverage.cobertura.xml' fail_ci_if_error: false @@ -103,23 +103,22 @@ jobs: $version = "8.24.3" $baseUrl = "https://github.com/gitleaks/gitleaks/releases/download/v$version" $zipAsset = "gitleaks_${version}_windows_x64.zip" - $tarAsset = "gitleaks_${version}_windows_x64.tar.gz" $toolDir = Join-Path $env:RUNNER_TEMP "gitleaks-bin" if (Test-Path $toolDir) { Remove-Item -Path $toolDir -Recurse -Force } - try { - Invoke-WebRequest -Uri "$baseUrl/$zipAsset" -OutFile "gitleaks.zip" - Expand-Archive -Path "gitleaks.zip" -DestinationPath $toolDir -Force - } - catch { - Invoke-WebRequest -Uri "$baseUrl/$tarAsset" -OutFile "gitleaks.tar.gz" - New-Item -ItemType Directory -Force -Path $toolDir | Out-Null - tar -xzf "gitleaks.tar.gz" -C $toolDir + # Pinned hash from gitleaks_8.24.3_checksums.txt: refuse a swapped binary. + $expectedZipSha256 = "3f1a35578631dbfe633cc5b49e6c906e55ff14a4bfd7336a10fb27fe33b6dcd2" + Invoke-WebRequest -Uri "$baseUrl/$zipAsset" -OutFile "gitleaks.zip" + $actualZipSha256 = (Get-FileHash -Path "gitleaks.zip" -Algorithm SHA256).Hash + if ($actualZipSha256 -ne $expectedZipSha256) { + throw "Gitleaks archive checksum mismatch: $actualZipSha256" } + Expand-Archive -Path "gitleaks.zip" -DestinationPath $toolDir -Force + # Remove vendored tool docs from scan scope to avoid known upstream sample false positives. if (Test-Path ".\\gitleaks-bin") { Remove-Item -Path ".\\gitleaks-bin" -Recurse -Force @@ -130,7 +129,7 @@ jobs: - name: Upload security artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: security-reports path: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 518220a..0c5d2c7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -33,15 +33,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: "8.0.x" - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -53,6 +53,6 @@ jobs: run: dotnet build "ThreadPilot_1.sln" --configuration Release --no-restore - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/dotnet-desktop.yml b/.github/workflows/dotnet-desktop.yml index 8a7ed4a..33175d5 100644 --- a/.github/workflows/dotnet-desktop.yml +++ b/.github/workflows/dotnet-desktop.yml @@ -9,10 +9,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup .NET Core 8.0 - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: 8.0.x diff --git a/.github/workflows/publish-chocolatey.yml b/.github/workflows/publish-chocolatey.yml index ebf9450..5d91460 100644 --- a/.github/workflows/publish-chocolatey.yml +++ b/.github/workflows/publish-chocolatey.yml @@ -27,17 +27,20 @@ jobs: - name: Resolve release metadata id: release shell: pwsh + env: + TAG_INPUT: ${{ github.event.inputs.tag }} + PUBLISH_INPUT: ${{ github.event.inputs.publish }} run: | $ErrorActionPreference = "Stop" - $tag = "${{ github.event.inputs.tag }}".Trim() + $tag = "$env:TAG_INPUT".Trim() if ($tag -notmatch '^v\d+\.\d+\.\d+$') { throw "Tag must match vX.Y.Z. Received '$tag'." } $version = $tag.Substring(1) - $publish = "${{ github.event.inputs.publish }}" -eq "true" + $publish = $env:PUBLISH_INPUT -eq "true" $installerName = "ThreadPilot_$($tag)_Setup.exe" "tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append @@ -46,7 +49,7 @@ jobs: "installer_name=$installerName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Download installer from GitHub release shell: pwsh @@ -176,7 +179,7 @@ jobs: - name: Upload chocolatey package artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: chocolatey-package-artifacts path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b75da5..3e962ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,9 +10,14 @@ on: description: "Build and validate release without winget publication" required: false default: "false" + type: choice + options: + - "false" + - "true" +# Read-only by default: only the job that publishes the GitHub release gets write access. permissions: - contents: write + contents: read jobs: build: @@ -25,20 +30,22 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: "8.0.x" - name: Resolve version id: version shell: pwsh + env: + REF_NAME: ${{ github.ref_name }} run: | - $refName = "${{ github.ref_name }}" + $refName = $env:REF_NAME if ($refName -match '^v\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$') { $version = $refName.TrimStart('v') $tag = $refName @@ -75,7 +82,7 @@ jobs: - name: Install Inno Setup shell: pwsh - run: choco install innosetup --no-progress -y + run: choco install innosetup --version=6.7.1 --no-progress -y - name: Build Inno Setup installer shell: pwsh @@ -234,7 +241,7 @@ jobs: shell: pwsh run: | $ErrorActionPreference = "Stop" - dotnet tool install --global Microsoft.Sbom.DotNetTool + dotnet tool install --global Microsoft.Sbom.DotNetTool --version 4.1.13 $env:PATH += ";$env:USERPROFILE\.dotnet\tools" sbom-tool generate -b ./artifacts -bc . -pn ThreadPilot -pv "${{ steps.version.outputs.version }}" -ps PrimeBuild -nsb https://github.com/PrimeBuild-pc/ThreadPilot @@ -253,38 +260,38 @@ jobs: Copy-Item -LiteralPath $sbomManifest.FullName -Destination (Join-Path $sbomReleaseDir "manifest.spdx.json") -Force - name: Upload portable artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-portable path: artifacts/release/singlefile - name: Upload installer artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-installer path: artifacts/release/installer - name: Upload packages artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-packages path: artifacts/release/packages - name: Upload checksums artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-checksums path: artifacts/release/SHA256SUMS.txt - name: Upload winget manifests if: steps.version.outputs.prerelease != 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: winget-manifests path: winget-manifests - name: Upload SBOM - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: sbom path: artifacts/release/sbom/manifest.spdx.json @@ -295,7 +302,7 @@ jobs: needs: build steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: release-portable path: smoke @@ -320,43 +327,45 @@ jobs: runs-on: ubuntu-latest # Protected environment: waits for the maintainer's approval before it can write the release. environment: release + permissions: + contents: write needs: - build - smoke-test steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Download installer artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: release-installer path: release-assets/installer - name: Download packages artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: release-packages path: release-assets/packages - name: Download checksums artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: release-checksums path: release-assets - name: Download winget manifests if: needs.build.outputs.prerelease != 'true' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: winget-manifests path: winget-manifests - name: Download SBOM artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: sbom path: sbom @@ -395,7 +404,7 @@ jobs: fi - name: Install git-cliff - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 with: tool: git-cliff@2.10.0 @@ -426,7 +435,7 @@ jobs: Write-Host "CONTRIBUTORS.md is maintained manually in this workflow." - name: Publish GitHub release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: tag_name: ${{ needs.build.outputs.tag }} name: ThreadPilot ${{ needs.build.outputs.tag }} @@ -451,9 +460,12 @@ jobs: - name: Resolve winget publish policy id: policy shell: pwsh + env: + EVENT_NAME: ${{ github.event_name }} + DRY_RUN_INPUT: ${{ github.event.inputs.dry_run_publish }} run: | $ErrorActionPreference = "Stop" - $dryRun = "${{ github.event_name }}" -eq "workflow_dispatch" -and "${{ github.event.inputs.dry_run_publish }}" -eq "true" + $dryRun = $env:EVENT_NAME -eq "workflow_dispatch" -and $env:DRY_RUN_INPUT -eq "true" "dry_run=$($dryRun.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Check winget publish policy @@ -505,11 +517,11 @@ jobs: - name: Checkout if: steps.gate.outputs.enabled == 'true' - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Download winget manifests if: steps.gate.outputs.enabled == 'true' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: winget-manifests path: winget-manifests @@ -561,7 +573,7 @@ jobs: - name: Upload winget publish logs if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: winget-publish-logs path: release-channel-logs/winget-submit-log.txt From 328f405204d2fd6005cfcd3eca8b35d1522adb43 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Tue, 20 Oct 2026 23:50:03 +0200 Subject: [PATCH 09/34] perf: update the visible process list in place instead of replacing it Every 5 s refresh built a new FilteredProcesses collection, so the grid rebuilt all rows and dropped the sort picked from a column header. The filtered result is now synced into the existing collection with remove/move/insert, which keeps rows, selection and header sort (window-open idle CPU 3.0-3.5% -> 2.2% of a core). --- Services/ProcessListDeltaUpdater.cs | 51 +++++++++++++++++++ .../ProcessListDeltaUpdaterTests.cs | 33 ++++++++++++ .../ProcessViewModel.Behaviors.partial.cs | 2 +- 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/Services/ProcessListDeltaUpdater.cs b/Services/ProcessListDeltaUpdater.cs index 2523b2c..49bc3ff 100644 --- a/Services/ProcessListDeltaUpdater.cs +++ b/Services/ProcessListDeltaUpdater.cs @@ -64,6 +64,57 @@ public static ProcessListDeltaResult ApplyDelta( return new ProcessListDeltaResult(selectedProcess, selectedProcessTerminated); } + /// + /// Makes hold exactly , in order, by removing, moving + /// and inserting items instead of replacing the collection: the grid keeps its rows, selection and the + /// sort the user picked from a column header. + /// + public static void SyncInPlace(ObservableCollection target, IReadOnlyList desired) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(desired); + + var wanted = new HashSet(desired, ReferenceEqualityComparer.Instance); + for (var i = target.Count - 1; i >= 0; i--) + { + if (!wanted.Contains(target[i])) + { + target.RemoveAt(i); + } + } + + for (var i = 0; i < desired.Count; i++) + { + if (i < target.Count && ReferenceEquals(target[i], desired[i])) + { + continue; + } + + var current = IndexOf(target, desired[i], i + 1); + if (current >= 0) + { + target.Move(current, i); + } + else + { + target.Insert(i, desired[i]); + } + } + } + + private static int IndexOf(ObservableCollection items, ProcessModel item, int start) + { + for (var i = start; i < items.Count; i++) + { + if (ReferenceEquals(items[i], item)) + { + return i; + } + } + + return -1; + } + private static void CopyProcessState(ProcessModel source, ProcessModel target) { target.Name = source.Name; diff --git a/Tests/ThreadPilot.Core.Tests/ProcessListDeltaUpdaterTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessListDeltaUpdaterTests.cs index 17c37f1..008b6dc 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessListDeltaUpdaterTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessListDeltaUpdaterTests.cs @@ -7,6 +7,39 @@ namespace ThreadPilot.Core.Tests public sealed class ProcessListDeltaUpdaterTests { + [Theory] + [InlineData("ABCDE", "ABCDE")] + [InlineData("ABCDE", "EDCBA")] + [InlineData("ABCDE", "BXDAY")] + [InlineData("", "ABC")] + [InlineData("ABC", "")] + [InlineData("ABCD", "CAB")] + public void SyncInPlace_ReachesTheDesiredOrder_KeepingTheSameInstances(string before, string after) + { + var pool = "ABCDEXY".ToDictionary(letter => letter, letter => new ProcessModel { ProcessId = letter, Name = letter.ToString() }); + var target = new ObservableCollection(before.Select(letter => pool[letter])); + var resets = 0; + target.CollectionChanged += (_, e) => resets += e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Reset ? 1 : 0; + + ProcessListDeltaUpdater.SyncInPlace(target, after.Select(letter => pool[letter]).ToList()); + + Assert.Equal(after.Select(letter => pool[letter]), target); + Assert.Equal(0, resets); + } + + [Fact] + public void SyncInPlace_UnchangedOrder_RaisesNoChange() + { + var items = Enumerable.Range(1, 5).Select(pid => new ProcessModel { ProcessId = pid }).ToList(); + var target = new ObservableCollection(items); + var changes = 0; + target.CollectionChanged += (_, _) => changes++; + + ProcessListDeltaUpdater.SyncInPlace(target, items); + + Assert.Equal(0, changes); + } + [Fact] public void ApplyDelta_PreservesExistingInstancesAndUpdatesProperties() { diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index cb0d294..609c7b8 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -2222,7 +2222,7 @@ private void FilterProcesses() }; var filteredResults = this.processFilterService.FilterAndSort(this.Processes, criteria); - this.FilteredProcesses = new ObservableCollection(filteredResults); + ProcessListDeltaUpdater.SyncInPlace(this.FilteredProcesses, filteredResults); // The batch total is a snapshot from the first load; refreshes add processes, so the // counter showed "157 / 151". Once everything is loaded the list itself is the total. From 1e7c3a3de0a552c4cab3c248b5f99e81842cc45c Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Wed, 21 Oct 2026 00:08:22 +0200 Subject: [PATCH 10/34] perf: start up with less work and without blocking the first window - TieredCompilation off: the background tier-1 re-JIT cost ~4.5 s of CPU at every start for a tray app that gains little from it. Measured: startup CPU 8.8 s -> 5.5 s, idle after ~7 s instead of 9-18 s, same idle CPU, window +0.3 s. - The elevated launch task is ensured in the background: only the next launch needs it, and its schtasks calls held up the first window. - No second settings load in MainWindow (App already loaded them and applied the theme), no fixed 500 ms wait before hiding the overlay, and the cold WMI CPU-name query runs off the UI thread. --- App.xaml.cs | 15 ++++++++++----- MainWindow.Behaviors.partial.cs | 5 +---- Services/CpuTopologyService.cs | 20 ++++++++++++-------- ThreadPilot.csproj | 3 +++ 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/App.xaml.cs b/App.xaml.cs index 95c04b6..3daa19e 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -78,11 +78,16 @@ protected override void OnStartup(StartupEventArgs e) { logger.LogInformation("Application is running with administrator privileges"); - var launchTaskEnsured = Task.Run(async () => await elevatedTaskService.EnsureLaunchTaskAsync()).GetAwaiter().GetResult(); - if (!launchTaskEnsured) - { - logger.LogWarning("Failed to ensure managed elevated launch task during startup. Future launches may require one-time elevation again."); - } + // Only the next launch needs the task, so the schtasks calls no longer hold up the first window. + TaskSafety.FireAndForget( + Task.Run(async () => + { + if (!await elevatedTaskService.EnsureLaunchTaskAsync()) + { + logger.LogInformation("Elevated launch task not available; the next launch will ask for elevation."); + } + }), + ex => logger.LogWarning(ex, "Failed to ensure managed elevated launch task during startup")); } else { diff --git a/MainWindow.Behaviors.partial.cs b/MainWindow.Behaviors.partial.cs index 1a47225..d56ad6d 100644 --- a/MainWindow.Behaviors.partial.cs +++ b/MainWindow.Behaviors.partial.cs @@ -93,7 +93,6 @@ private async Task InitializeApplicationAsync() await this.Dispatcher.InvokeAsync(() => this.UpdateLoadingStatus("Finalizing startup...", "Applying final UI state and startup checks.")); this.LogDebug("Finalizing startup..."); - await Task.Delay(500); // Brief delay to show final status this.CompleteInitializationTask("Finalization"); // All initialization complete @@ -528,9 +527,7 @@ private async Task InitializeSettingsAsync() { try { - await this.settingsService.LoadSettingsAsync(); - - // Apply initial settings + // App.OnStartup already loaded the settings and applied the theme before creating this window. var settings = this.settingsService.Settings; var useDarkTheme = settings.HasUserThemePreference ? settings.UseDarkTheme diff --git a/Services/CpuTopologyService.cs b/Services/CpuTopologyService.cs index 2406135..5d80038 100644 --- a/Services/CpuTopologyService.cs +++ b/Services/CpuTopologyService.cs @@ -149,15 +149,19 @@ private async Task DetectBasicCpuInfoAsync(CpuTopologyModel topology) { try { - using var searcher = new ManagementObjectSearcher("SELECT * FROM Win32_Processor"); - using var collection = searcher.Get(); - - foreach (ManagementObject processor in collection) + // A cold WMI query takes hundreds of ms; this runs during startup, so keep it off the UI thread. + await Task.Run(() => { - topology.CpuBrand = processor["Name"]?.ToString() ?? "Unknown"; - topology.CpuArchitecture = processor["Architecture"]?.ToString() ?? "Unknown"; - break; // Take first processor for basic info - } + using var searcher = new ManagementObjectSearcher("SELECT Name, Architecture FROM Win32_Processor"); + using var collection = searcher.Get(); + + foreach (ManagementObject processor in collection) + { + topology.CpuBrand = processor["Name"]?.ToString() ?? "Unknown"; + topology.CpuArchitecture = processor["Architecture"]?.ToString() ?? "Unknown"; + break; // Take first processor for basic info + } + }).ConfigureAwait(false); } catch (Exception ex) { diff --git a/ThreadPilot.csproj b/ThreadPilot.csproj index 306335a..658b952 100644 --- a/ThreadPilot.csproj +++ b/ThreadPilot.csproj @@ -15,6 +15,9 @@ false link true + + false CS1998;CS0067;CS0414;WFAC010;IL3000;MVVMTK0034 1.7.8-beta.1 1.7.8.0 From a16fdab6931198a63d05c42778ac2478396ac809 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Wed, 21 Oct 2026 00:11:55 +0200 Subject: [PATCH 11/34] perf: Game Boost idles when off and power plan switches spawn fewer powercfg - The Game Boost timer runs only while Game Boost is on (off by default); turning it off still restores everything through ReapplyAsync. - A foreground process just rejected as not-a-game is not rebuilt into a full process model every 3 s; it is looked at again after 30 s or on a settings change. - Switching power plans reads one /list for the previous and target plans and reuses it after /setactive: 3 powercfg.exe starts instead of 5 (2 when duplicate prevention is off). --- Services/GameSessionService.cs | 61 ++++++++++++++++--- Services/PowerPlanService.cs | 11 +++- .../GameSessionServiceTests.cs | 18 ++++++ .../PowerPlanServiceTests.cs | 33 ++++++++++ 4 files changed, 112 insertions(+), 11 deletions(-) diff --git a/Services/GameSessionService.cs b/Services/GameSessionService.cs index f6540f4..652e838 100644 --- a/Services/GameSessionService.cs +++ b/Services/GameSessionService.cs @@ -112,6 +112,7 @@ public interface IGameSessionService : IDisposable public sealed class GameSessionService : IGameSessionService { private static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(3); + private static readonly TimeSpan RejectedForegroundMemory = TimeSpan.FromSeconds(30); // Background is rescanned every few ticks (about 15 s), not on every one. private const int BackgroundRescanTicks = 5; @@ -136,9 +137,12 @@ public sealed class GameSessionService : IGameSessionService private readonly string journalPath; private readonly SemaphoreSlim stateLock = new(1, 1); private readonly HashSet<(int ProcessId, long StartTimeUtcTicks, GameBoostSetting Setting)> attempted = new(); + private readonly object timerGate = new(); private List journal = new(); private Timer? timer; + private bool started; + private (int ProcessId, long Timestamp)? rejectedForeground; private GameSession? session; private GameBoostDecision? cachedDecision; private GameBoostMode cachedDecisionMode; @@ -236,7 +240,8 @@ await this.AuditAsync( DescribeRestore(restored, left, failed)).ConfigureAwait(false); } - this.timer ??= new Timer(_ => TaskSafety.FireAndForget(this.TickAsync(), ex => this.logger.LogWarning(ex, "Game Boost tick failed")), null, TickInterval, TickInterval); + this.started = true; + this.SyncTimer(); } finally { @@ -246,8 +251,13 @@ await this.AuditAsync( public async Task StopAsync() { - this.timer?.Dispose(); - this.timer = null; + lock (this.timerGate) + { + this.started = false; + this.timer?.Dispose(); + this.timer = null; + } + await this.stateLock.WaitAsync().ConfigureAwait(false); try { @@ -268,8 +278,27 @@ public void Dispose() this.disposed = true; this.settingsService.SettingsChanged -= this.OnSettingsChanged; - this.timer?.Dispose(); - this.timer = null; + lock (this.timerGate) + { + this.timer?.Dispose(); + this.timer = null; + } + } + + // Ticks only while Game Boost is on (it is off by default); turning it off restores through ReapplyAsync. + private void SyncTimer() + { + lock (this.timerGate) + { + if (this.started && !this.disposed && this.Settings.GameBoostEnabled) + { + this.timer ??= new Timer(_ => TaskSafety.FireAndForget(this.TickAsync(), ex => this.logger.LogWarning(ex, "Game Boost tick failed")), null, TickInterval, TickInterval); + return; + } + + this.timer?.Dispose(); + this.timer = null; + } } internal async Task TickAsync() @@ -320,10 +349,24 @@ private async Task TryBeginSessionAsync() return; } + // The browser or desktop usually stays in front for minutes: building its full process model every + // 3 s only to reject it again costs ~8 process queries and a window enumeration per tick. + if (this.rejectedForeground is { } rejected + && rejected.ProcessId == processId + && this.timeProvider.GetElapsedTime(rejected.Timestamp) < RejectedForegroundMemory) + { + return; + } + var game = await this.processService.GetProcessByIdAsync(processId).ConfigureAwait(false); - if (game == null - || !GameBoostProcessLists.IsGame(game.Name, game.ExecutablePath, this.Settings.GameBoostManualGames, this.Settings.GameBoostExcludedGames) - || !this.processProbe.TryGetIdentity(processId, out var startTicks, out var sessionId)) + if (game != null + && !GameBoostProcessLists.IsGame(game.Name, game.ExecutablePath, this.Settings.GameBoostManualGames, this.Settings.GameBoostExcludedGames)) + { + this.rejectedForeground = (processId, this.timeProvider.GetTimestamp()); + return; + } + + if (game == null || !this.processProbe.TryGetIdentity(processId, out var startTicks, out var sessionId)) { return; } @@ -933,6 +976,8 @@ private void OnSettingsChanged(object? sender, ApplicationSettingsChangedEventAr || !oldSettings.GameBoostExcludedGames.SequenceEqual(newSettings.GameBoostExcludedGames); if (changed) { + this.rejectedForeground = null; + this.SyncTimer(); TaskSafety.FireAndForget(this.ReapplyAsync(), ex => this.logger.LogWarning(ex, "Game Boost could not apply the new settings")); } } diff --git a/Services/PowerPlanService.cs b/Services/PowerPlanService.cs index bb5c071..5327f47 100644 --- a/Services/PowerPlanService.cs +++ b/Services/PowerPlanService.cs @@ -172,8 +172,11 @@ public async Task SetActivePowerPlanByGuidAsync(string powerPlanGuid, bool } } - var previousPowerPlan = await this.GetActivePowerPlan().ConfigureAwait(false); - var targetPowerPlan = await this.GetPowerPlanByGuidAsync(powerPlanGuid).ConfigureAwait(false); + // One "/list" names both plans and marks the active one: no separate /getactivescheme, and no + // second /list after the switch (each call starts powercfg.exe). + var plans = await this.GetPowerPlansAsync().ConfigureAwait(false); + var previousPowerPlan = plans.FirstOrDefault(plan => plan.IsActive); + var targetPowerPlan = plans.FirstOrDefault(plan => string.Equals(plan.Guid, powerPlanGuid, StringComparison.OrdinalIgnoreCase)); this.logger.LogInformation( "Attempting to change power plan from '{FromPlan}' to '{ToPlan}'", @@ -194,7 +197,9 @@ await this.enhancedLogger.LogPowerPlanChangeAsync( this.lastActivePowerPlanGuid = powerPlanGuid; } - var newPowerPlan = await this.GetPowerPlanByGuidAsync(powerPlanGuid).ConfigureAwait(false); + var newPowerPlan = targetPowerPlan == null + ? null + : new PowerPlanModel { Guid = targetPowerPlan.Guid, Name = targetPowerPlan.Name, IsActive = true }; this.logger.LogInformation("Power plan successfully changed to '{PowerPlan}'", newPowerPlan?.Name ?? "Unknown"); diff --git a/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs b/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs index 1d760c9..31ec6f7 100644 --- a/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs @@ -61,6 +61,24 @@ public async Task NotAGame_NoSession() Assert.All(this.processes.CpuSets.Values, Assert.Empty); } + [Fact] + public async Task NonGameForeground_IsNotReexaminedEveryTick_ButIsAfterAWhile() + { + this.foregroundPid = ChromePid; + using var service = this.CreateService(); + await service.TickAsync(); + + // Changed behind the service's back (no settings event), so only a fresh look can notice it. + this.settings.GameBoostManualGames.Add("chrome"); + this.clock.Advance(TimeSpan.FromSeconds(3)); + await service.TickAsync(); + Assert.Null(service.ActiveGameName); + + this.clock.Advance(TimeSpan.FromSeconds(30)); + await service.TickAsync(); + Assert.Equal("chrome", service.ActiveGameName); + } + [Fact] public async Task GameExit_RestoresBackground_AndDeletesJournal() { diff --git a/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs b/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs index 596adf5..8a580ba 100644 --- a/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs @@ -118,6 +118,39 @@ public async Task SetActivePowerPlanByGuidAsync_SkipsChange_WhenAlreadyActive() Assert.Equal(new[] { "/getactivescheme" }, invocation.Arguments); } + [Fact] + public async Task SetActivePowerPlanByGuidAsync_SwitchesWithOneListAndOneSetActive() + { + const string balanced = "381b4222-f694-41f0-9685-ff5bb260df2e"; + const string ultimate = "e9a42b02-d5df-448d-aa00-03f14749eb61"; + var runner = new RecordingProcessRunner + { + ResultFactory = invocation => invocation.Arguments[0] == "/list" + ? new ProcessRunResult( + 0, + string.Join( + Environment.NewLine, + "Existing Power Schemes (* Active)", + "-----------------------------------", + $"Power Scheme GUID: {balanced} (Balanced) *", + $"Power Scheme GUID: {ultimate} (Ultimate Performance)"), + string.Empty) + : new ProcessRunResult(0, string.Empty, string.Empty), + }; + var service = CreateService(runner); + PowerPlanChangedEventArgs? changed = null; + service.PowerPlanChanged += (_, e) => changed = e; + + var result = await service.SetActivePowerPlanByGuidAsync(ultimate, preventDuplicateChanges: false); + + Assert.True(result); + Assert.Equal( + new[] { new[] { "/list" }, new[] { "/setactive", ultimate } }, + runner.Invocations.Select(invocation => invocation.Arguments.ToArray())); + Assert.Equal("Balanced", changed?.PreviousPowerPlan?.Name); + Assert.Equal("Ultimate Performance", changed?.NewPowerPlan?.Name); + } + [Fact] public async Task DeletePowerPlanAsync_InvokesPowerCfgDelete_WhenPlanIsNotActive() { From 4fcc3dcb18e8a56c09a582fb6ccd1b54a69c6395 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Wed, 21 Oct 2026 04:13:13 +0200 Subject: [PATCH 12/34] security: close the remaining small gaps found by the audit - Registry priority now writes IFEO\\PerfOptions\CpuPriorityClass, the value Windows reads (the old PriorityClass value did nothing and is cleaned up), and refuses an empty image name that would have targeted the IFEO root. - Profile names must be plain file names, so they stay in the profiles folder. - P/Invoke DLLs load from System32 only; explorer.exe is started by full path. - The elevated restart passes arguments through ArgumentList (correct quoting). - The single-instance mutex no longer crashes startup when another session owns it. - Log size and retention are clamped and the size math is done in long. - BinaryFormatter is disabled: the app only copies text to the clipboard. - Two services registered but never resolved are no longer registered. - build/install-threadpilot.ps1 verifies the installer against SHA256SUMS.txt. --- App.xaml.cs | 11 +++- AssemblyInfo.cs | 3 ++ Services/ElevationService.cs | 35 +++--------- Services/EnhancedLoggingService.cs | 4 +- Services/ProcessService.cs | 53 ++++++++++++++----- Services/ServiceConfiguration.cs | 2 - .../ProcessServiceTests.cs | 14 +++++ ThreadPilot.csproj | 3 ++ ViewModels/LogViewerViewModel.cs | 4 +- ViewModels/ProcessViewModel.cs | 2 +- build/install-threadpilot.ps1 | 13 +++++ 11 files changed, 94 insertions(+), 50 deletions(-) diff --git a/App.xaml.cs b/App.xaml.cs index 3daa19e..f36845a 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -135,7 +135,16 @@ protected override void OnStartup(StartupEventArgs e) // Enforce single-instance after elevation bootstrap logic to avoid mutex races during handoff. bool createdNew; - this.singleInstanceMutex = new Mutex(initiallyOwned: true, name: "Global\\ThreadPilot_SingleInstance", createdNew: out createdNew); + try + { + this.singleInstanceMutex = new Mutex(initiallyOwned: true, name: "Global\\ThreadPilot_SingleInstance", createdNew: out createdNew); + } + catch (UnauthorizedAccessException) + { + // Created by another user's session with an ACL we cannot open: an instance is already running there. + createdNew = false; + } + if (!createdNew) { System.Windows.MessageBox.Show( diff --git a/AssemblyInfo.cs b/AssemblyInfo.cs index f5ca8da..cb9d74c 100644 --- a/AssemblyInfo.cs +++ b/AssemblyInfo.cs @@ -3,6 +3,9 @@ [assembly: InternalsVisibleTo("ThreadPilot.Core.Tests")] +// The app runs elevated: never resolve a P/Invoke DLL (powrprof, dwmapi...) from the app folder. +[assembly: System.Runtime.InteropServices.DefaultDllImportSearchPaths(System.Runtime.InteropServices.DllImportSearchPath.System32)] + [assembly: ThemeInfo( ResourceDictionaryLocation.None, // where theme specific resource dictionaries are located // (used if a resource is not found in the page, diff --git a/Services/ElevationService.cs b/Services/ElevationService.cs index 83cd81d..398682e 100644 --- a/Services/ElevationService.cs +++ b/Services/ElevationService.cs @@ -5,7 +5,6 @@ namespace ThreadPilot.Services using System.IO; using System.Linq; using System.Security.Principal; - using System.Text; using System.Threading; using System.Threading.Tasks; using System.Windows; @@ -85,18 +84,22 @@ public async Task RestartWithElevation(string[]? arguments = null) // Combine current arguments with any additional arguments var currentArgs = Environment.GetCommandLineArgs().Skip(1).ToArray(); var allArgs = arguments != null ? currentArgs.Concat(arguments).ToArray() : currentArgs; - var argumentString = string.Join(" ", allArgs.Select(EscapeCommandLineArgument)); + var argumentString = string.Join(" ", allArgs); var workingDirectory = Path.GetDirectoryName(executablePath); var startInfo = new ProcessStartInfo { FileName = executablePath, - Arguments = argumentString, UseShellExecute = true, Verb = "runas", // This triggers UAC elevation WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? Environment.SystemDirectory : workingDirectory, }; + foreach (var argument in allArgs) + { + startInfo.ArgumentList.Add(argument); + } + LogStartingElevatedProcess(this.logger, executablePath, argumentString); var elevatedProcess = Process.Start(startInfo); @@ -157,32 +160,6 @@ public string GetElevationStatus() : "Running with limited privileges"; } - private static string EscapeCommandLineArgument(string argument) - { - if (string.IsNullOrEmpty(argument)) - { - return "\"\""; - } - - var escaped = new StringBuilder(); - escaped.Append('"'); - - foreach (var c in argument) - { - if (c == '"') - { - escaped.Append("\\\""); - } - else - { - escaped.Append(c); - } - } - - escaped.Append('"'); - return escaped.ToString(); - } - [LoggerMessage(EventId = 4100, Level = LogLevel.Debug, Message = "Administrator privilege check: {IsAdmin}")] private static partial void LogAdministratorPrivilegeCheck(ILogger logger, bool isAdmin); diff --git a/Services/EnhancedLoggingService.cs b/Services/EnhancedLoggingService.cs index 4158c1f..206132e 100644 --- a/Services/EnhancedLoggingService.cs +++ b/Services/EnhancedLoggingService.cs @@ -321,7 +321,7 @@ private string FormatLogEntry(LogEntry entry) private async Task CheckLogRotationAsync() { var fileInfo = new FileInfo(this.currentLogFilePath); - var maxSizeBytes = this.settingsService.Settings.MaxLogFileSizeMb * 1024 * 1024; + var maxSizeBytes = Math.Clamp(this.settingsService.Settings.MaxLogFileSizeMb, 1, 1024) * 1024L * 1024L; if (fileInfo.Exists && fileInfo.Length > maxSizeBytes) { @@ -430,7 +430,7 @@ public async Task CleanupOldLogsAsync() await this.fileLock.WaitAsync(); try { - var retentionDate = DateTime.UtcNow.AddDays(-this.settingsService.Settings.LogRetentionDays); + var retentionDate = DateTime.UtcNow.AddDays(-Math.Clamp(this.settingsService.Settings.LogRetentionDays, 1, 365)); var logFiles = Directory.GetFiles(this.logDirectory, "*.log"); foreach (var logFile in logFiles) diff --git a/Services/ProcessService.cs b/Services/ProcessService.cs index 7ee5202..bf65e00 100644 --- a/Services/ProcessService.cs +++ b/Services/ProcessService.cs @@ -638,6 +638,11 @@ public async Task SaveProcessProfile(string profileName, ProcessModel proc this.cpuSelectionMigrationService.PrepareProcessProfileForSave(profile, topology); } + if (!IsValidProfileName(profileName)) + { + return false; + } + var filePath = Path.Combine(this.ProfilesDirectory, $"{profileName}.json"); var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); await AtomicFileWriter.WriteAllTextAsync(filePath, json, Encoding.UTF8).ConfigureAwait(false); @@ -646,6 +651,11 @@ public async Task SaveProcessProfile(string profileName, ProcessModel proc public async Task LoadProcessProfile(string profileName, ProcessModel process) { + if (!IsValidProfileName(profileName)) + { + return false; + } + var filePath = Path.Combine(this.ProfilesDirectory, $"{profileName}.json"); if (!File.Exists(filePath)) { @@ -1079,34 +1089,42 @@ public async Task SetRegistryPriorityAsync(ProcessModel process, bool enab this.EnsureProcessOperationAllowed(process, "SetProcessPriority"); + // Without an image name the key below would be the IFEO root itself. + var imageName = Path.GetFileName(process.ExecutablePath); + if (string.IsNullOrWhiteSpace(imageName)) + { + this.AuditProcessOperation("SetProcessPriority", process.Name, success: false); + return false; + } + return await Task.Run(() => { try { - using var key = Microsoft.Win32.Registry.LocalMachine.CreateSubKey( - @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\" + - Path.GetFileName(process.ExecutablePath)); + var imageKeyPath = @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\" + imageName; + using (var legacyKey = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(imageKeyPath, writable: true)) + { + // Earlier versions wrote this value, which Windows never reads. + legacyKey?.DeleteValue("PriorityClass", false); + } + using var key = Microsoft.Win32.Registry.LocalMachine.CreateSubKey(imageKeyPath + @"\PerfOptions"); if (enable) { - // Convert ProcessPriorityClass to registry priority value int priorityValue = priority switch { - ProcessPriorityClass.Idle => 4, - ProcessPriorityClass.BelowNormal => 6, - ProcessPriorityClass.Normal => 8, - ProcessPriorityClass.AboveNormal => 10, - ProcessPriorityClass.High => 13, - ProcessPriorityClass.RealTime => 24, - _ => 8, // Default to Normal + ProcessPriorityClass.Idle => 1, + ProcessPriorityClass.BelowNormal => 5, + ProcessPriorityClass.AboveNormal => 6, + ProcessPriorityClass.High => 3, + _ => 2, // Normal }; - key.SetValue("PriorityClass", priorityValue, Microsoft.Win32.RegistryValueKind.DWord); + key.SetValue("CpuPriorityClass", priorityValue, Microsoft.Win32.RegistryValueKind.DWord); } else { - // Remove the registry entry to disable enforcement - key.DeleteValue("PriorityClass", false); + key.DeleteValue("CpuPriorityClass", false); } this.AuditProcessOperation("SetProcessPriority", process.Name, success: true); @@ -1121,6 +1139,13 @@ public async Task SetRegistryPriorityAsync(ProcessModel process, bool enab }).ConfigureAwait(false); } + // A plain file name: no folders, no "..", nothing Windows rejects in a file name. + internal static bool IsValidProfileName(string? profileName) => + !string.IsNullOrWhiteSpace(profileName) + && profileName.Trim('.').Length > 0 + && string.Equals(Path.GetFileName(profileName), profileName, StringComparison.Ordinal) + && profileName.IndexOfAny(Path.GetInvalidFileNameChars()) < 0; + private void EnsureProcessOperationAllowed(ProcessModel process, string operation) { ArgumentNullException.ThrowIfNull(process); diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs index 39e734b..4933c18 100644 --- a/Services/ServiceConfiguration.cs +++ b/Services/ServiceConfiguration.cs @@ -87,7 +87,6 @@ private static IServiceCollection ConfigureCoreSystemServices(this IServiceColle services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -134,7 +133,6 @@ private static IServiceCollection ConfigureApplicationLevelServices(this IServic // User interface services services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); // System integration services diff --git a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs index 1022c55..820396f 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs @@ -329,6 +329,20 @@ public async Task SetProcessPriority_WithRealtime_AuditsFailureAndThrowsBlockedM } } + [Theory] + [InlineData("Gaming", true)] + [InlineData("My profile 2", true)] + [InlineData(@"..\..\Windows\evil", false)] + [InlineData(@"C:\Temp\evil", false)] + [InlineData("sub/evil", false)] + [InlineData("..", false)] + [InlineData("", false)] + [InlineData("bad|name", false)] + public void IsValidProfileName_KeepsProfilesInsideTheirFolder(string name, bool expected) + { + Assert.Equal(expected, ProcessService.IsValidProfileName(name)); + } + [Fact] public async Task SetRegistryPriorityAsync_WithRealtime_ReturnsFalse() { diff --git a/ThreadPilot.csproj b/ThreadPilot.csproj index 658b952..fab4bee 100644 --- a/ThreadPilot.csproj +++ b/ThreadPilot.csproj @@ -18,6 +18,9 @@ false + + false CS1998;CS0067;CS0414;WFAC010;IL3000;MVVMTK0034 1.7.8-beta.1 1.7.8.0 diff --git a/ViewModels/LogViewerViewModel.cs b/ViewModels/LogViewerViewModel.cs index 4176751..9cadc9d 100644 --- a/ViewModels/LogViewerViewModel.cs +++ b/ViewModels/LogViewerViewModel.cs @@ -304,7 +304,9 @@ private void OpenLogDirectory() var logDirectory = this.loggingService.LogDirectoryPath; if (Directory.Exists(logDirectory)) { - System.Diagnostics.Process.Start("explorer.exe", logDirectory); + System.Diagnostics.Process.Start( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "explorer.exe"), + logDirectory); } else { diff --git a/ViewModels/ProcessViewModel.cs b/ViewModels/ProcessViewModel.cs index 9ae4c29..8b56b6d 100644 --- a/ViewModels/ProcessViewModel.cs +++ b/ViewModels/ProcessViewModel.cs @@ -309,7 +309,7 @@ private static void OpenExecutableLocationInExplorer(string executablePath) { var startInfo = new ProcessStartInfo { - FileName = "explorer.exe", + FileName = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "explorer.exe"), Arguments = $"/select,\"{executablePath}\"", UseShellExecute = true, }; diff --git a/build/install-threadpilot.ps1 b/build/install-threadpilot.ps1 index c3323ea..ab02019 100644 --- a/build/install-threadpilot.ps1 +++ b/build/install-threadpilot.ps1 @@ -25,9 +25,22 @@ if (-not $asset) { $exePath = Join-Path $destPath $asset.name +$checksumAsset = $release.assets | Where-Object { $_.name -eq "SHA256SUMS.txt" } | Select-Object -First 1 +if (-not $checksumAsset) { + throw "The latest release has no SHA256SUMS.txt, so the installer cannot be verified." +} + Write-Host "Downloading installer $($asset.name)..." Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $exePath -UseBasicParsing +$checksums = (Invoke-WebRequest -Uri $checksumAsset.browser_download_url -UseBasicParsing).Content +$expected = ($checksums -split "`n" | Where-Object { $_ -match "^([0-9a-fA-F]{64})\s+\*?$([regex]::Escape($asset.name))\s*$" } | ForEach-Object { $Matches[1] } | Select-Object -First 1) +$actual = (Get-FileHash -Path $exePath -Algorithm SHA256).Hash +if (-not $expected -or $actual -ne $expected) { + Remove-Item -LiteralPath $exePath -Force + throw "Installer checksum does not match SHA256SUMS.txt; the download was deleted." +} + # Optional machine PATH update for command-line convenience. $currentPath = [Environment]::GetEnvironmentVariable("Path", "Machine") if ($currentPath -notmatch [regex]::Escape($destPath)) { From 9b233cab493269b4ecff4186b3d1d09cc7900556 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Wed, 21 Oct 2026 04:25:05 +0200 Subject: [PATCH 13/34] security: follow-ups from the validation pass - The launch task is re-registered on every elevated start again (now in the background), so a changed definition or another admin never keeps a stale one. - Admin-only folders are owned by Administrators when elevated, so the user cannot rewrite their DACL with UAC off or a non-default owner policy. - The update provider trusts only folders it created itself. - Game Boost does not remember a foreground process as not-a-game while its path is still unreadable. --- Services/AdminOnlyDirectory.cs | 7 ++++++- Services/ElevatedTaskService.cs | 5 ----- Services/GameSessionService.cs | 7 ++++++- Services/UpdateTempDirectoryProvider.cs | 14 ++++++++++---- .../AdminOnlyDirectoryTests.cs | 6 +++++- .../ElevatedTaskServiceTests.cs | 12 +++++++----- 6 files changed, 34 insertions(+), 17 deletions(-) diff --git a/Services/AdminOnlyDirectory.cs b/Services/AdminOnlyDirectory.cs index 09ee8ac..9d39f51 100644 --- a/Services/AdminOnlyDirectory.cs +++ b/Services/AdminOnlyDirectory.cs @@ -27,7 +27,12 @@ public static string Create(string path) // Not elevated (tests, dev runs): there is no privilege gap to protect, and without this the // process could not write its own files. using var identity = WindowsIdentity.GetCurrent(); - if (!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator) && identity.User != null) + if (new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator)) + { + // Owned by the group, not the user: with UAC off or another owner policy the user could otherwise rewrite the DACL. + security.SetOwner(new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null)); + } + else if (identity.User != null) { Allow(security, identity.User); } diff --git a/Services/ElevatedTaskService.cs b/Services/ElevatedTaskService.cs index 31dc8fb..d6ca71e 100644 --- a/Services/ElevatedTaskService.cs +++ b/Services/ElevatedTaskService.cs @@ -96,11 +96,6 @@ public async Task EnsureLaunchTaskAsync() return false; } - if (string.Equals(await this.QueryTaskCommandAsync(this.LaunchTaskName), executablePath, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - // schtasks reads the definition from a file: keep it where only admins can change it before it does. var taskXmlDirectory = AdminOnlyDirectory.Create(Path.Combine(AdminOnlyDirectory.DefaultRoot, $"ThreadPilot-task-{Guid.NewGuid():N}")); var taskXmlPath = Path.Combine(taskXmlDirectory, "launch-task.xml"); diff --git a/Services/GameSessionService.cs b/Services/GameSessionService.cs index 652e838..5ce7b1f 100644 --- a/Services/GameSessionService.cs +++ b/Services/GameSessionService.cs @@ -362,7 +362,12 @@ private async Task TryBeginSessionAsync() if (game != null && !GameBoostProcessLists.IsGame(game.Name, game.ExecutablePath, this.Settings.GameBoostManualGames, this.Settings.GameBoostExcludedGames)) { - this.rejectedForeground = (processId, this.timeProvider.GetTimestamp()); + // Remembered only when the path was readable: a game just starting may not expose it yet. + if (!string.IsNullOrEmpty(game.ExecutablePath)) + { + this.rejectedForeground = (processId, this.timeProvider.GetTimestamp()); + } + return; } diff --git a/Services/UpdateTempDirectoryProvider.cs b/Services/UpdateTempDirectoryProvider.cs index b46047d..447a899 100644 --- a/Services/UpdateTempDirectoryProvider.cs +++ b/Services/UpdateTempDirectoryProvider.cs @@ -15,6 +15,7 @@ public sealed class UpdateTempDirectoryProvider : IUpdateTempDirectoryProvider private const string FolderPrefix = "ThreadPilot-update-"; private readonly string rootDirectory; + private readonly System.Collections.Concurrent.ConcurrentDictionary created = new(StringComparer.OrdinalIgnoreCase); public UpdateTempDirectoryProvider() : this(AdminOnlyDirectory.DefaultRoot) @@ -28,7 +29,9 @@ public UpdateTempDirectoryProvider(string rootDirectory) public string CreateUpdateTempDirectory(SemanticVersion version) { - return AdminOnlyDirectory.Create(Path.Combine(this.rootDirectory, $"{FolderPrefix}{version}-{Guid.NewGuid():N}")); + var directory = AdminOnlyDirectory.Create(Path.Combine(this.rootDirectory, $"{FolderPrefix}{version}-{Guid.NewGuid():N}")); + this.created[directory] = 0; + return directory; } public bool IsSafeUpdateTempPath(string path) @@ -38,9 +41,11 @@ public bool IsSafeUpdateTempPath(string path) return false; } - var fullPath = Path.GetFullPath(path); - var prefix = Path.TrimEndingDirectorySeparator(this.rootDirectory) + Path.DirectorySeparatorChar + FolderPrefix; - return fullPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + // Only folders this instance created: anyone may create a ThreadPilot-update-* folder in the root. + var fullPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + return this.created.Keys.Any(directory => + string.Equals(fullPath, directory, StringComparison.OrdinalIgnoreCase) + || fullPath.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)); } public void Cleanup(string path) @@ -51,6 +56,7 @@ public void Cleanup(string path) } Directory.Delete(path, recursive: true); + this.created.TryRemove(Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)), out _); } } } diff --git a/Tests/ThreadPilot.Core.Tests/AdminOnlyDirectoryTests.cs b/Tests/ThreadPilot.Core.Tests/AdminOnlyDirectoryTests.cs index d5c41a5..bbe9a8a 100644 --- a/Tests/ThreadPilot.Core.Tests/AdminOnlyDirectoryTests.cs +++ b/Tests/ThreadPilot.Core.Tests/AdminOnlyDirectoryTests.cs @@ -27,7 +27,11 @@ public void Create_GrantsOnlyAdministratorsAndSystem_WithoutInheritingTheParentA new(WellKnownSidType.BuiltinAdministratorsSid, null), }; using var identity = WindowsIdentity.GetCurrent(); - if (!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator)) + if (new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator)) + { + Assert.Equal(new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null), security.GetOwner(typeof(SecurityIdentifier))); + } + else { expected.Add(identity.User!); } diff --git a/Tests/ThreadPilot.Core.Tests/ElevatedTaskServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ElevatedTaskServiceTests.cs index e999195..8277bf5 100644 --- a/Tests/ThreadPilot.Core.Tests/ElevatedTaskServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ElevatedTaskServiceTests.cs @@ -143,22 +143,24 @@ public async Task EnsureLaunchTaskAsync_FromAWritableFolder_DeletesStaleTasksAnd } [Fact] - public async Task EnsureLaunchTaskAsync_TaskAlreadyPointsAtThisExe_NotRegisteredAgain() + public async Task EnsureLaunchTaskAsync_KeepsATaskInProgramFiles_AndParsesQuotedEscapedCommands() { var executablePath = CreateTemporaryExecutablePath(); + var installed = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Tools & Co", "ThreadPilot.exe"); var runner = new RecordingProcessRunner { ResultFactory = invocation => invocation.Arguments[0] == "/Query" - ? TaskXml(executablePath.Replace("&", "&")) + ? TaskXml($"\"{installed.Replace("&", "&")}\"") : new ProcessRunResult(0, string.Empty, string.Empty), }; try { - var service = CreateService(runner, executablePathProvider: () => executablePath, isProtectedInstallPath: _ => true); + var service = CreateService(runner, executablePathProvider: () => executablePath); + + await service.EnsureLaunchTaskAsync(); - Assert.True(await service.EnsureLaunchTaskAsync()); - Assert.All(runner.Invocations, invocation => Assert.Equal("/Query", invocation.Arguments[0])); + Assert.DoesNotContain(runner.Invocations, invocation => invocation.Arguments[0] == "/Delete"); } finally { From c6593cda25ec94897b1c5269565e9f56a424f92a Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Wed, 21 Oct 2026 04:50:09 +0200 Subject: [PATCH 14/34] perf: keep tiered JIT but turn off dynamic PGO TieredCompilation=false saved the most startup CPU but cost about 1% of a core whenever the window is open (no tier-1 code). Turning off only dynamic PGO keeps most of the startup saving and the steady-state speed. Measured (interleaved, 2 rounds): startup CPU 7.8 s -> 6.1 s, window-open idle 2.4% (same as before), tray 0 ms over 90 s, window back to 1.3 s. --- ThreadPilot.csproj | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ThreadPilot.csproj b/ThreadPilot.csproj index fab4bee..be0f636 100644 --- a/ThreadPilot.csproj +++ b/ThreadPilot.csproj @@ -15,9 +15,10 @@ false link true - - false + + false false From 83debfb80bcd700177b14b649125b3863aed9e66 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 22 Oct 2026 01:45:20 +0200 Subject: [PATCH 15/34] perf: Game Boost holds the game open and rescans only listed names During a session the game was looked up twice every 3 s (identity + CPU time, each a GetProcessById and the identity a full process snapshot for SessionId), and every 15 s a model was built for every process on the machine to find a few listed background names. - The game is held open for the session (Process + Exited): its exit ends the session at once, under the same lock as the tick; CPU time for the load sampling is read from the same handle. The identity is re-checked through the held handle, which also pins the PID. - The handle is verified with one wait before the exit wait is registered, so a handle without SYNCHRONIZE (anti-cheat) falls into the polling fallback instead of failing on the runtime's wait thread. A denied or protected game keeps the previous polling. - The rescan filters the GetProcesses snapshot by listed name before any model is built; exact paths are still checked afterwards. - Session id read with ProcessIdToSessionId instead of Process.SessionId (a full snapshot per listed background process every 15 s). Ownership, journal and restore unchanged. +8 tests (903/903). --- Services/GameBoostProcessLists.cs | 6 + Services/GameSessionService.cs | 120 ++++++++++- Services/IProcessService.cs | 4 +- Services/ProcessService.cs | 12 +- .../GameSessionNativeIntegrationTests.cs | 41 ++++ .../GameSessionServiceTests.cs | 188 +++++++++++++++++- 6 files changed, 359 insertions(+), 12 deletions(-) diff --git a/Services/GameBoostProcessLists.cs b/Services/GameBoostProcessLists.cs index fe99b04..41808fa 100644 --- a/Services/GameBoostProcessLists.cs +++ b/Services/GameBoostProcessLists.cs @@ -91,6 +91,12 @@ public static bool IsListedBackground(string? processName, string? executablePat || (mode == GameBoostMode.Isolation && IsolationExtraProcesses.Any(entry => Normalize(entry).Equals(name, StringComparison.OrdinalIgnoreCase)))); } + /// Every name can match (path entries by file name): a cheap prefilter, not the decision. + public static HashSet BackgroundNames(GameBoostMode mode, IReadOnlyCollection? userBackground) => + new HashSet( + (userBackground ?? []).Concat(mode == GameBoostMode.Isolation ? IsolationExtraProcesses : []).Select(Normalize).Where(name => name.Length > 0), + StringComparer.OrdinalIgnoreCase); + /// Process name without directory and ".exe", so "C:\x\Game.exe", "Game.exe" and "game" all match. public static string Normalize(string? processName) { diff --git a/Services/GameSessionService.cs b/Services/GameSessionService.cs index 5ce7b1f..b16d99f 100644 --- a/Services/GameSessionService.cs +++ b/Services/GameSessionService.cs @@ -5,6 +5,7 @@ namespace ThreadPilot.Services using System.Diagnostics; using System.IO; using System.Linq; + using System.Runtime.InteropServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -51,6 +52,18 @@ public interface IGameBoostProcessProbe /// Total CPU time a running process has used; false when it is gone or unreadable. bool TryGetCpuTime(int processId, out TimeSpan totalProcessorTime); + + /// + /// Holds the process open and calls once when it ends. Null when it cannot be + /// opened (anti-cheat, protected, gone) or the PID now belongs to a process with another start time. + /// + IGameProcessHandle? TryOpen(int processId, long startTimeUtcTicks, Func onExited); + } + + /// The game held open for its session: the PID cannot be reused while this is alive. + public interface IGameProcessHandle : IDisposable + { + bool TryGetCpuTime(out TimeSpan totalProcessorTime); } public sealed class GameBoostProcessProbe : IGameBoostProcessProbe @@ -61,7 +74,13 @@ public bool TryGetIdentity(int processId, out long startTimeUtcTicks, out int se { using var process = Process.GetProcessById(processId); startTimeUtcTicks = process.StartTime.ToUniversalTime().Ticks; - sessionId = process.SessionId; + + // Process.SessionId would take a snapshot of every process on the machine for this one value. + if (!ProcessIdToSessionId(processId, out sessionId)) + { + return false; + } + return !process.HasExited; } catch @@ -86,6 +105,65 @@ public bool TryGetCpuTime(int processId, out TimeSpan totalProcessorTime) return false; } } + + public IGameProcessHandle? TryOpen(int processId, long startTimeUtcTicks, Func onExited) + { + Process? process = null; + try + { + process = Process.GetProcessById(processId); + + // Holds the handle the exit wait will use. shortcut: Process opens it with PROCESS_ALL_ACCESS, so a + // protected (PPL) game falls back to polling; a SYNCHRONIZE|QUERY_LIMITED handle would cover it too. + _ = process.SafeHandle; + + // One wait on that handle first: an anti-cheat that stripped SYNCHRONIZE throws here, into the catch, + // instead of on the runtime's shared wait thread. The start time read through it proves the identity. + if (process.WaitForExit(0) || process.StartTime.ToUniversalTime().Ticks != startTimeUtcTicks) + { + process.Dispose(); + return null; + } + + // Exited is raised inside the Process object's lock: the session restore runs outside it. + process.Exited += (_, _) => _ = Task.Run(onExited); + process.EnableRaisingEvents = true; + if (!process.HasExited) + { + return new GameProcessHandle(process); + } + } + catch + { + // Denied (anti-cheat, protected) or gone: the caller polls instead. + } + + process?.Dispose(); + return null; + } + + private sealed class GameProcessHandle(Process process) : IGameProcessHandle + { + public bool TryGetCpuTime(out TimeSpan totalProcessorTime) + { + try + { + totalProcessorTime = process.TotalProcessorTime; + return true; + } + catch + { + totalProcessorTime = TimeSpan.Zero; + return false; + } + } + + public void Dispose() => process.Dispose(); + } + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ProcessIdToSessionId(int processId, out int sessionId); } public interface IGameSessionService : IDisposable @@ -144,6 +222,7 @@ public sealed class GameSessionService : IGameSessionService private bool started; private (int ProcessId, long Timestamp)? rejectedForeground; private GameSession? session; + private IGameProcessHandle? gameHandle; private GameBoostDecision? cachedDecision; private GameBoostMode cachedDecisionMode; private int tickCount; @@ -283,6 +362,8 @@ public void Dispose() this.timer?.Dispose(); this.timer = null; } + + this.gameHandle?.Dispose(); } // Ticks only while Game Boost is on (it is off by default); turning it off restores through ReapplyAsync. @@ -322,7 +403,8 @@ internal async Task TickAsync() return; } - if (!this.IsSameProcessAlive(this.session.Game.ProcessId, this.session.GameStartTimeUtcTicks)) + // A held game reports its exit through OnGameExitedAsync; only a game that denied the handle is polled. + if (this.gameHandle == null && !this.IsSameProcessAlive(this.session.Game.ProcessId, this.session.GameStartTimeUtcTicks)) { await this.EndSessionAsync("the game exited").ConfigureAwait(false); return; @@ -452,7 +534,8 @@ private async Task ApplyToSessionAsync(bool includeGame) var moved = new List(); var eco = new List(); var notMoved = new List(); - var processes = await this.processService.GetProcessesWithPathsAsync().ConfigureAwait(false); + var listedNames = GameBoostProcessLists.BackgroundNames(this.Settings.GameBoostMode, this.Settings.GameBoostBackgroundProcesses); + var processes = await this.processService.GetProcessesWithPathsAsync(name => listedNames.Contains(GameBoostProcessLists.Normalize(name))).ConfigureAwait(false); foreach (var process in processes) { if (!this.IsBackgroundCandidate(process, currentSession) @@ -525,6 +608,30 @@ private void ResetSessionState() this.tickCount = 0; this.loadTracker = null; this.lastLoadSample = null; + this.gameHandle?.Dispose(); + var current = this.session!; + this.gameHandle = this.processProbe.TryOpen(current.Game.ProcessId, current.GameStartTimeUtcTicks, () => this.OnGameExitedAsync(current)); + } + + // Raised on a thread-pool thread by the held game handle, so it takes the same lock as the tick. + private async Task OnGameExitedAsync(GameSession exited) + { + await this.stateLock.WaitAsync().ConfigureAwait(false); + try + { + if (this.session == exited && !this.disposed) + { + await this.EndSessionAsync("the game exited").ConfigureAwait(false); + } + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Game Boost could not end the session after the game exited"); + } + finally + { + this.stateLock.Release(); + } } // The dynamic part: a confined game that keeps its cores saturated gets every core back from Windows, @@ -539,7 +646,10 @@ private async Task AdaptGameCpuSetsAsync() var game = currentSession.Game; var now = this.timeProvider.GetTimestamp(); - if (!this.processProbe.TryGetCpuTime(game.ProcessId, out var cpuTime)) + var read = this.gameHandle != null + ? this.gameHandle.TryGetCpuTime(out var cpuTime) + : this.processProbe.TryGetCpuTime(game.ProcessId, out cpuTime); + if (!read) { return; } @@ -807,6 +917,8 @@ private async Task EndSessionAsync(string reason) } this.session = null; + this.gameHandle?.Dispose(); + this.gameHandle = null; this.attempted.Clear(); var (restored, left, failed) = await this.RestoreAllAsync().ConfigureAwait(false); await this.AuditAsync( diff --git a/Services/IProcessService.cs b/Services/IProcessService.cs index 47a5754..c025780 100644 --- a/Services/IProcessService.cs +++ b/Services/IProcessService.cs @@ -1,5 +1,6 @@ namespace ThreadPilot.Services { + using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; @@ -34,7 +35,8 @@ public interface IProcessService Task IsProcessRunningAsync(string executableName); - Task> GetProcessesWithPathsAsync(); + /// Models only for processes whose snapshot name (no ".exe") passes , so the rest cost no queries. + Task> GetProcessesWithPathsAsync(Func nameFilter); Task> GetActiveApplicationsAsync(); diff --git a/Services/ProcessService.cs b/Services/ProcessService.cs index bf65e00..8658a5e 100644 --- a/Services/ProcessService.cs +++ b/Services/ProcessService.cs @@ -114,7 +114,7 @@ public async Task> GetProcessesAsync() new ObservableCollection(this.EnumerateProcessModels())).ConfigureAwait(false); } - private List EnumerateProcessModels(Func? filter = null) + private List EnumerateProcessModels(Func? filter = null, Func? nameFilter = null) { var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); var models = new List(); @@ -122,6 +122,12 @@ private List EnumerateProcessModels(Func? filt { using (process) { + // The name is already in the snapshot GetProcesses took, so skipping here costs no query. + if (nameFilter != null && !nameFilter(process.ProcessName)) + { + continue; + } + var model = this.TryCreateProcessModel(process, foregroundProcessId); if (model != null && (filter == null || filter(model))) { @@ -1037,10 +1043,10 @@ public async Task IsProcessRunningAsync(string executableName) return processes.Any(); } - public async Task> GetProcessesWithPathsAsync() + public async Task> GetProcessesWithPathsAsync(Func nameFilter) { return await Task.Run>(() => - this.EnumerateProcessModels(process => !string.IsNullOrEmpty(process.ExecutablePath))).ConfigureAwait(false); + this.EnumerateProcessModels(process => !string.IsNullOrEmpty(process.ExecutablePath), nameFilter)).ConfigureAwait(false); } public async Task> GetActiveApplicationsAsync() diff --git a/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs b/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs index 99ac98d..942168e 100644 --- a/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs +++ b/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs @@ -122,6 +122,37 @@ public async Task RealProcesses_ApplyThenRestore_AndRecoverAfterCrash() Assert.Equal(ProcessEfficiencyMode.SystemManaged, await this.ReadEfficiencyAsync(browser)); Assert.False(File.Exists(journal)); await second.StopAsync(); + + // 5. The game exits: its held handle ends the session and restores without waiting for a tick. + using var third = this.CreateService(topology, settings, game.Id, journal); + await third.TickAsync(); + Assert.Equal(backgroundCpus, ReadDefaultCpuSetIndexes(browser, topology)); + game.Kill(); + await WaitUntilAsync(() => !File.Exists(journal)); + Assert.Empty(ReadDefaultCpuSetIndexes(browser, topology)); + Assert.Null(third.ActiveGameName); + } + + [Fact] + public async Task Probe_HoldsTheSameProcess_ReportsItsExit_AndRejectsAnotherStartTime() + { + var game = this.StartCopyOfPing(Path.Combine(this.root, "game"), "FakeGame.exe"); + var probe = new GameBoostProcessProbe(); + Assert.True(probe.TryGetIdentity(game.Id, out var startTicks, out _)); + var exited = new TaskCompletionSource(); + + Assert.Null(probe.TryOpen(game.Id, startTicks + 1, () => Task.CompletedTask)); // the PID now names another process + using var handle = probe.TryOpen(game.Id, startTicks, () => + { + exited.TrySetResult(); + return Task.CompletedTask; + }); + Assert.NotNull(handle); + Assert.True(handle.TryGetCpuTime(out _)); + Assert.False(exited.Task.IsCompleted); + + game.Kill(); + await exited.Task.WaitAsync(TimeSpan.FromSeconds(10)); } private GameSessionService CreateService(CpuTopologySnapshot topology, ApplicationSettingsModel settings, int gamePid, string journal) @@ -175,6 +206,16 @@ private Process StartCopyOfPing(string directory, string fileName) return process; } + private static async Task WaitUntilAsync(Func condition) + { + for (var i = 0; i < 100 && !condition(); i++) + { + await Task.Delay(100); + } + + Assert.True(condition()); + } + private Task ReadEfficiencyAsync(Process process) => this.efficiency.GetEfficiencyModeAsync(new ProcessModel { ProcessId = process.Id, Name = process.ProcessName }); diff --git a/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs b/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs index 31ec6f7..38efead 100644 --- a/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs @@ -93,6 +93,128 @@ public async Task GameExit_RestoresBackground_AndDeletesJournal() Assert.False(File.Exists(this.journalPath)); } + [Fact] + public async Task HeldGame_ExitEvent_EndsTheSessionAndRestores_WithoutATick() + { + this.processes.HandleAllowed = true; + using var service = this.CreateService(); + await service.TickAsync(); + Assert.Equal(1, this.processes.OpenHandles); + + await this.processes.ExitWithEventAsync(GamePid); + + Assert.Null(service.ActiveGameName); + Assert.Empty(this.processes.CpuSets[ChromePid]); + Assert.False(File.Exists(this.journalPath)); + Assert.Equal(0, this.processes.OpenHandles); + var messages = (await this.audit.GetEntriesAsync()).Select(entry => entry.Message).ToList(); + Assert.Contains("Session ended for Game: the game exited", messages); + } + + [Fact] + public async Task HeldGame_TicksDoNotLookItUp_AndItsLoadIsReadFromTheHandle() + { + this.processes.HandleAllowed = true; + using var service = this.CreateService(); + await service.TickAsync(); + this.processes.Lookups.Clear(); + + await this.RunLoadAsync(service, cpusBusy: 8, ticks: GameLoadTracker.ReleaseSamples); + Assert.False(this.processes.Lookups.ContainsKey(GamePid)); + + await this.RunLoadAsync(service, cpusBusy: 16, ticks: GameLoadTracker.ReleaseSamples + 1); + Assert.Empty(this.processes.CpuSets[GamePid]); + } + + [Fact] + public async Task HeldGame_SettingsChange_KeepsWatchingTheSameGame() + { + this.processes.HandleAllowed = true; + using var service = this.CreateService(); + await service.TickAsync(); + + this.settings.GameBoostMode = GameBoostMode.Isolation; + await service.ReapplyAsync(); + Assert.Equal(1, this.processes.OpenHandles); + + await this.processes.ExitWithEventAsync(GamePid); + Assert.Null(service.ActiveGameName); + Assert.False(File.Exists(this.journalPath)); + } + + [Fact] + public async Task HeldGame_HandleReleased_WhenDisposedOrStopped() + { + this.processes.HandleAllowed = true; + using (var crashed = this.CreateService()) + { + await crashed.TickAsync(); + } + + Assert.Equal(0, this.processes.OpenHandles); + + using var service = this.CreateService(); + await service.StartAsync(); + await service.TickAsync(); + Assert.Equal(1, this.processes.OpenHandles); + await service.StopAsync(); + Assert.Equal(0, this.processes.OpenHandles); + Assert.False(File.Exists(this.journalPath)); + } + + [Fact] + public async Task HeldGame_LateExitEventOfAnEndedSession_DoesNotEndTheNextOne() + { + this.processes.HandleAllowed = true; + this.processes.Add(500, "Other", @"D:\SteamLibrary\steamapps\common\Other\Other.exe"); + using var service = this.CreateService(); + await service.TickAsync(); + var lateExit = this.processes.ExitCallbacks.Single(); + + this.settings.GameBoostEnabled = false; + await service.ReapplyAsync(); + this.settings.GameBoostEnabled = true; + this.foregroundPid = 500; + await service.TickAsync(); + await lateExit(); + + Assert.Equal("Other", service.ActiveGameName); + Assert.Equal(VCacheCpus, this.processes.CpuSets[500]); + } + + [Fact] + public async Task HandleDenied_PidReusedDuringTheSession_EndsIt_WithoutTouchingTheNewProcess() + { + using var service = this.CreateService(); // the handle is denied, as by an anti-cheat: polled every tick + await service.TickAsync(); + Assert.Equal(0, this.processes.OpenHandles); + + this.processes.Restart(GamePid); + await service.TickAsync(); + + Assert.Null(service.ActiveGameName); + Assert.Equal(VCacheCpus, this.processes.CpuSets[GamePid]); // the new process is not ours + Assert.Empty(this.processes.CpuSets[ChromePid]); + Assert.False(File.Exists(this.journalPath)); + } + + [Fact] + public async Task Rescan_BuildsModelsOnlyForListedNames_ThenChecksTheExactPath() + { + this.settings.GameBoostBackgroundProcesses = ["chrome", @"C:\Tools\Sync\sync.exe"]; + this.processes.Add(600, "sync", @"C:\Tools\Sync\sync.exe"); + this.processes.Add(601, "sync", @"D:\Other\sync.exe"); // same name, another executable + this.processes.Add(602, "notepad", @"C:\Windows\notepad.exe"); + using var service = this.CreateService(); + + await service.TickAsync(); + + Assert.Equal(["chrome", "sync", "sync"], this.processes.ScannedModels.Order()); + Assert.Equal(OtherCcdCpus, this.processes.CpuSets[ChromePid]); + Assert.Equal(OtherCcdCpus, this.processes.CpuSets[600]); + Assert.Empty(this.processes.CpuSets[601]); + } + [Fact] public async Task AltTab_KeepsTheSession() { @@ -525,14 +647,20 @@ private sealed class FakeProcesses : IGameBoostProcessProbe { private readonly Dictionary models = new(); private readonly Dictionary startTimes = new(); + private readonly Dictionary handles = new(); public FakeProcesses() { var processService = new Mock(); processService.Setup(s => s.GetProcessByIdAsync(It.IsAny())) .ReturnsAsync((int pid) => this.startTimes.ContainsKey(pid) ? this.Snapshot(pid) : null); - processService.Setup(s => s.GetProcessesWithPathsAsync()) - .ReturnsAsync(() => this.startTimes.Keys.Select(this.Snapshot).ToList()); + processService.Setup(s => s.GetProcessesWithPathsAsync(It.IsAny>())) + .ReturnsAsync((Func nameFilter) => + { + var listed = this.startTimes.Keys.Where(pid => nameFilter(this.models[pid].Name)).ToList(); + this.ScannedModels.AddRange(listed.Select(pid => this.models[pid].Name)); + return listed.Select(this.Snapshot).ToList(); + }); processService.Setup(s => s.GetCpuAssignmentLogicalProcessorIndexesAsync(It.IsAny(), CpuAssignmentMode.CpuSets)) .ReturnsAsync((ProcessModel p, CpuAssignmentMode _) => this.Unreadable.Contains(p.ProcessId) ? null : this.CpuSets[p.ProcessId].ToList()); processService.Setup(s => s.ClearProcessCpuSetAsync(It.IsAny())) @@ -590,6 +718,9 @@ public FakeProcesses() public List ApplyCalls { get; } = new(); + /// Gets the names the background rescans built a full model for. + public List ScannedModels { get; } = new(); + public void Add(int pid, string name, string path) { this.models[pid] = new ProcessModel { ProcessId = pid, Name = name, ExecutablePath = path }; @@ -600,18 +731,67 @@ public void Add(int pid, string name, string path) this.CpuTime[pid] = TimeSpan.Zero; } + /// Gets or sets a value indicating whether the game can be held open; false acts like an anti-cheat that denies the handle. + public bool HandleAllowed { get; set; } + + public int OpenHandles => this.handles.Count; + + /// Gets every exit callback handed out, including those of handles already released. + public List> ExitCallbacks { get; } = new(); + + public Dictionary Lookups { get; } = new(); + public void Exit(int pid) => this.startTimes.Remove(pid); + /// The game exits and its held handle raises the exit event; completes when the service has handled it. + public Task ExitWithEventAsync(int pid) + { + this.Exit(pid); + return this.handles.TryGetValue(pid, out var handle) ? handle.OnExited() : Task.CompletedTask; + } + public void Restart(int pid) => this.startTimes[pid] += 1; public bool TryGetIdentity(int processId, out long startTimeUtcTicks, out int sessionId) { + this.Lookups[processId] = this.Lookups.GetValueOrDefault(processId) + 1; sessionId = 1; return this.startTimes.TryGetValue(processId, out startTimeUtcTicks); } - public bool TryGetCpuTime(int processId, out TimeSpan totalProcessorTime) => - this.CpuTime.TryGetValue(processId, out totalProcessorTime) && this.startTimes.ContainsKey(processId); + public bool TryGetCpuTime(int processId, out TimeSpan totalProcessorTime) + { + this.Lookups[processId] = this.Lookups.GetValueOrDefault(processId) + 1; + return this.CpuTime.TryGetValue(processId, out totalProcessorTime) && this.startTimes.ContainsKey(processId); + } + + public IGameProcessHandle? TryOpen(int processId, long startTimeUtcTicks, Func onExited) + { + if (!this.HandleAllowed || !this.startTimes.TryGetValue(processId, out var current) || current != startTimeUtcTicks) + { + return null; + } + + var handle = new FakeHandle(this, processId, onExited); + this.ExitCallbacks.Add(onExited); + this.handles[processId] = handle; + return handle; + } + + private sealed class FakeHandle(FakeProcesses owner, int pid, Func onExited) : IGameProcessHandle + { + public Func OnExited { get; } = onExited; + + public bool TryGetCpuTime(out TimeSpan totalProcessorTime) => owner.CpuTime.TryGetValue(pid, out totalProcessorTime); + + public void Dispose() + { + if (owner.handles.TryGetValue(pid, out var held) && held == this) + { + owner.handles.Remove(pid); + } + } + } private ProcessModel Snapshot(int pid) { From 5ed1ad0a286500d2c91320f6d7f2c5b7ad3dea5b Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 22 Oct 2026 02:07:09 +0200 Subject: [PATCH 16/34] fix: hold the game with SYNCHRONIZE|QUERY_LIMITED only Process.EnableRaisingEvents opens the game with PROCESS_ALL_ACCESS and the session kept that handle for the whole game: exactly the kind of outside handle anti-cheats scan for, and a protected (PPL) game refused it and fell back to polling. The game is now opened with only SYNCHRONIZE and PROCESS_QUERY_LIMITED_INFORMATION; start time and CPU time come from GetProcessTimes on that handle, and the exit wait is a thread-pool registration on it, still checked with one zero wait first so a handle stripped of SYNCHRONIZE falls back to polling. +1 native test (a protected process can be held). --- Services/GameSessionService.cs | 83 +++++++++++-------- .../GameSessionNativeIntegrationTests.cs | 19 +++++ 2 files changed, 69 insertions(+), 33 deletions(-) diff --git a/Services/GameSessionService.cs b/Services/GameSessionService.cs index b16d99f..ccba419 100644 --- a/Services/GameSessionService.cs +++ b/Services/GameSessionService.cs @@ -11,6 +11,7 @@ namespace ThreadPilot.Services using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Win32; + using Microsoft.Win32.SafeHandles; using ThreadPilot.Models; public enum GameBoostSetting @@ -108,62 +109,78 @@ public bool TryGetCpuTime(int processId, out TimeSpan totalProcessorTime) public IGameProcessHandle? TryOpen(int processId, long startTimeUtcTicks, Func onExited) { - Process? process = null; + // Only the rights anti-cheats leave to outside tools: a full-access handle held for the whole game is + // what they look for, and a protected (PPL) game refuses it. + var wait = new ProcessWaitHandle(OpenProcess(Synchronize | ProcessQueryLimitedInformation, false, processId)); try { - process = Process.GetProcessById(processId); - - // Holds the handle the exit wait will use. shortcut: Process opens it with PROCESS_ALL_ACCESS, so a - // protected (PPL) game falls back to polling; a SYNCHRONIZE|QUERY_LIMITED handle would cover it too. - _ = process.SafeHandle; - - // One wait on that handle first: an anti-cheat that stripped SYNCHRONIZE throws here, into the catch, - // instead of on the runtime's shared wait thread. The start time read through it proves the identity. - if (process.WaitForExit(0) || process.StartTime.ToUniversalTime().Ticks != startTimeUtcTicks) - { - process.Dispose(); - return null; - } - - // Exited is raised inside the Process object's lock: the session restore runs outside it. - process.Exited += (_, _) => _ = Task.Run(onExited); - process.EnableRaisingEvents = true; - if (!process.HasExited) + // One wait first: a handle stripped of SYNCHRONIZE throws here, not on the runtime's shared wait thread. + // The start time read through the handle proves it is the same process. + if (!wait.SafeWaitHandle.IsInvalid + && !wait.WaitOne(0) + && GetProcessTimes(wait.SafeWaitHandle, out var created, out _, out _, out _) + && DateTime.FromFileTime(created).ToUniversalTime().Ticks == startTimeUtcTicks) { - return new GameProcessHandle(process); + var registration = ThreadPool.RegisterWaitForSingleObject(wait, (_, _) => _ = onExited(), null, Timeout.Infinite, executeOnlyOnce: true); + return new GameProcessHandle(wait, registration); } } catch { - // Denied (anti-cheat, protected) or gone: the caller polls instead. + // Rights stripped: the caller polls instead. } - process?.Dispose(); + wait.Dispose(); return null; } - private sealed class GameProcessHandle(Process process) : IGameProcessHandle + private const uint Synchronize = 0x00100000; + private const uint ProcessQueryLimitedInformation = 0x1000; + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern SafeWaitHandle OpenProcess(uint access, bool inheritHandle, int processId); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetProcessTimes(SafeWaitHandle process, out long creationTime, out long exitTime, out long kernelTime, out long userTime); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ProcessIdToSessionId(int processId, out int sessionId); + + private sealed class ProcessWaitHandle : WaitHandle + { + public ProcessWaitHandle(SafeWaitHandle process) => this.SafeWaitHandle = process; + } + + private sealed class GameProcessHandle(ProcessWaitHandle wait, RegisteredWaitHandle registration) : IGameProcessHandle { public bool TryGetCpuTime(out TimeSpan totalProcessorTime) { try { - totalProcessorTime = process.TotalProcessorTime; - return true; + if (GetProcessTimes(wait.SafeWaitHandle, out _, out _, out var kernel, out var user)) + { + totalProcessorTime = TimeSpan.FromTicks(kernel + user); + return true; + } } - catch + catch (ObjectDisposedException) { - totalProcessorTime = TimeSpan.Zero; - return false; + // Disposed by a concurrent shutdown. } + + totalProcessorTime = TimeSpan.Zero; + return false; } - public void Dispose() => process.Dispose(); + // The registration keeps its own reference to the handle, so closing it here is safe while a wait ends. + public void Dispose() + { + registration.Unregister(null); + wait.Dispose(); + } } - - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool ProcessIdToSessionId(int processId, out int sessionId); } public interface IGameSessionService : IDisposable diff --git a/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs b/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs index 942168e..5188f2f 100644 --- a/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs +++ b/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs @@ -155,6 +155,25 @@ public async Task Probe_HoldsTheSameProcess_ReportsItsExit_AndRejectsAnotherStar await exited.Task.WaitAsync(TimeSpan.FromSeconds(10)); } + [Fact] + public void Probe_HoldsAProtectedProcess_WithLimitedRights() + { + // A protected (PPL) process refuses full access, as an anti-cheat-protected game may: only the minimal + // rights an anti-cheat leaves to outside tools can open it, and only those are held for a session. + var csrss = Process.GetProcessesByName("csrss").First(); + var probe = new GameBoostProcessProbe(); + if (!probe.TryGetIdentity(csrss.Id, out var startTicks, out _)) + { + output.WriteLine("Skipped: not elevated, csrss cannot be queried."); + return; + } + + using var handle = probe.TryOpen(csrss.Id, startTicks, () => Task.CompletedTask); + Assert.NotNull(handle); + Assert.True(handle.TryGetCpuTime(out var cpu)); + Assert.True(cpu > TimeSpan.Zero); + } + private GameSessionService CreateService(CpuTopologySnapshot topology, ApplicationSettingsModel settings, int gamePid, string journal) { var settingsService = new Mock(); From 27b74a1ac9c8175761ad79479cc3475b8296dffc Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 22 Oct 2026 01:06:13 +0200 Subject: [PATCH 17/34] feat: autostart for portable copies through the official installer, installs always in Program Files - A portable copy that turns on autostart now offers to download, verify and install the official version through the update path (checksum required, admin-only folder); it never downgrades. The installed copy turns autostart on at its first start when the setting asks for it, after the stale-task cleanup, so it also self-heals a removed task. - The installer no longer asks for a folder: always Program Files, the only place a non-admin program cannot replace the elevated exe. Installs in another folder move there on upgrade; only ThreadPilot's own files are removed from the old folder (the old uninstaller is not run: it deletes the user's settings). Verified in Windows Sandbox: old install in C:\Apps moved to Program Files, user file and settings kept, the installed app registered both tasks itself. --- App.xaml.cs | 35 +++++++++----- Installer/setup.iss | 40 ++++++++++++++++ Locales/de-DE.xaml | 2 + Locales/en-US.xaml | 2 + Locales/es-ES.xaml | 2 + Locales/fr-FR.xaml | 2 + Locales/it-IT.xaml | 2 + Locales/ru-RU.xaml | 2 + Locales/zh-CN.xaml | 2 + Services/AutostartService.cs | 10 ++++ Services/IAutostartService.cs | 3 ++ .../PackagingMetadataTests.cs | 16 +++++++ .../SettingsViewModelThemeTests.cs | 41 ++++++++++++++++ ViewModels/SettingsViewModel.cs | 47 +++++++++++++++++-- 14 files changed, 191 insertions(+), 15 deletions(-) diff --git a/App.xaml.cs b/App.xaml.cs index f36845a..0173422 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -77,17 +77,6 @@ protected override void OnStartup(StartupEventArgs e) if (isRunningAsAdministrator) { logger.LogInformation("Application is running with administrator privileges"); - - // Only the next launch needs the task, so the schtasks calls no longer hold up the first window. - TaskSafety.FireAndForget( - Task.Run(async () => - { - if (!await elevatedTaskService.EnsureLaunchTaskAsync()) - { - logger.LogInformation("Elevated launch task not available; the next launch will ask for elevation."); - } - }), - ex => logger.LogWarning(ex, "Failed to ensure managed elevated launch task during startup")); } else { @@ -202,6 +191,30 @@ protected override void OnStartup(StartupEventArgs e) logger.LogWarning(ex, "Failed to preload theme settings during startup"); } + if (isRunningAsAdministrator) + { + // Off the UI thread and in this order: the launch-task pass deletes tasks pointing outside Program + // Files, then autostart is restored for this copy if the user asked for it (e.g. right after a + // portable copy installed itself in Program Files). + var autostartService = this.ServiceProvider.GetRequiredService(); + var wantsAutostart = loadedSettings?.AutostartWithWindows == true; + var autostartMinimized = loadedSettings?.StartMinimized ?? true; + TaskSafety.FireAndForget( + Task.Run(async () => + { + if (!await elevatedTaskService.EnsureLaunchTaskAsync()) + { + logger.LogInformation("Elevated launch task not available; the next launch will ask for elevation."); + } + + if (wantsAutostart) + { + await autostartService.EnsureAutostartAsync(autostartMinimized); + } + }), + ex => logger.LogWarning(ex, "Failed to ensure elevated tasks during startup")); + } + var mainWindow = this.ServiceProvider.GetRequiredService(); this.MainWindow = mainWindow; diff --git a/Installer/setup.iss b/Installer/setup.iss index 7e6fb7e..cde61ef 100644 --- a/Installer/setup.iss +++ b/Installer/setup.iss @@ -43,6 +43,10 @@ ArchitecturesAllowed=x64compatible MinVersion=10.0.17763 UninstallDisplayIcon={app}\{#MyAppExeName} DisableReadyPage=yes +; Always Program Files: ThreadPilot runs elevated, and only there can a non-admin program not replace it. +; Installs in another folder move here on upgrade (see MovePreviousInstall below). +DisableDirPage=yes +UsePreviousAppDir=no [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" @@ -81,6 +85,10 @@ Type: filesandordirs; Name: "{userappdata}\ThreadPilot" const LegacyBetaUninstallKey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{A2A4C8B5-4A9A-4B1B-93F4-5F8B1C7E8C2A}_is1'; LegacyBetaDisplayName = 'ThreadPilot 0.1.0-beta'; + CurrentUninstallKey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{E8F7A3B2-5C4D-4E6F-8A9B-1C2D3E4F5A6B}_is1'; + +var + PreviousInstallDir: string; function IsLegacyThreadPilotInstallPath(InstallLocation: string): Boolean; var @@ -110,9 +118,41 @@ function InitializeSetup(): Boolean; begin DeleteLegacyBetaUninstallEntry(HKLM); DeleteLegacyBetaUninstallEntry(HKCU); + if not RegQueryStringValue(HKLM, CurrentUninstallKey, 'InstallLocation', PreviousInstallDir) then + PreviousInstallDir := ''; Result := True; end; +// An earlier install in another folder: remove only the program files ThreadPilot put there. +// The old uninstaller is not run because it also deletes the user's settings in AppData. +procedure MovePreviousInstall(); +var + OldDir: string; +begin + OldDir := RemoveBackslashUnlessRoot(RemoveQuotes(PreviousInstallDir)); + if (OldDir = '') or (CompareText(OldDir, RemoveBackslashUnlessRoot(ExpandConstant('{app}'))) = 0) or + not FileExists(AddBackslash(OldDir) + '{#MyAppExeName}') then + exit; + + DeleteFile(AddBackslash(OldDir) + '{#MyAppExeName}'); + DeleteFile(AddBackslash(OldDir) + 'ThreadPilot.pdb'); + DeleteFile(AddBackslash(OldDir) + 'D3DCompiler_47_cor3.dll'); + DeleteFile(AddBackslash(OldDir) + 'PenImc_cor3.dll'); + DeleteFile(AddBackslash(OldDir) + 'PresentationNative_cor3.dll'); + DeleteFile(AddBackslash(OldDir) + 'vcruntime140_cor3.dll'); + DeleteFile(AddBackslash(OldDir) + 'wpfgfx_cor3.dll'); + DeleteFile(AddBackslash(OldDir) + 'unins000.exe'); + DeleteFile(AddBackslash(OldDir) + 'unins000.dat'); + DeleteFile(AddBackslash(OldDir) + 'unins000.msg'); + RemoveDir(OldDir); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssPostInstall then + MovePreviousInstall(); +end; + function IsThreadPilotUpdate(): Boolean; begin Result := CompareText(ExpandConstant('{param:THREADPILOTUPDATE|0}'), '1') = 0; diff --git a/Locales/de-DE.xaml b/Locales/de-DE.xaml index 66ab31c..e2cef4f 100644 --- a/Locales/de-DE.xaml +++ b/Locales/de-DE.xaml @@ -501,6 +501,8 @@ ThreadPilot-Konfiguration importieren Der Autostart von Windows konnte nicht aktualisiert werden. Den vorherigen Autostart-Status beibehalten. Der Autostart erfordert eine Installation von ThreadPilot unter „Programme“: Eine portable Kopie könnte von jedem Programm ersetzt und dann als Administrator gestartet werden. + ThreadPilot für den Autostart installieren + Der Autostart erfordert ThreadPilot unter „Programme“. Jetzt die offizielle Version herunterladen, prüfen und installieren? ThreadPilot startet danach aus „Programme“ mit aktivem Autostart neu; diesen portablen Ordner kannst du anschließend löschen. ThreadPilot Einstellungen diff --git a/Locales/en-US.xaml b/Locales/en-US.xaml index 6319147..a2223bc 100644 --- a/Locales/en-US.xaml +++ b/Locales/en-US.xaml @@ -501,6 +501,8 @@ Import ThreadPilot Configuration Failed to update Windows autostart. Keeping previous autostart state. Autostart needs ThreadPilot installed in Program Files: a portable copy could be replaced by any program and then started as administrator. + Install ThreadPilot for autostart + Autostart needs ThreadPilot installed in Program Files. Download, verify and install the official version now? ThreadPilot restarts from Program Files with autostart on; you can delete this portable folder afterwards. ThreadPilot Settings diff --git a/Locales/es-ES.xaml b/Locales/es-ES.xaml index 2a8c29e..971db83 100644 --- a/Locales/es-ES.xaml +++ b/Locales/es-ES.xaml @@ -501,6 +501,8 @@ Importar configuración ThreadPilot No se pudo actualizar el inicio automático Windows. Manteniendo el estado de inicio automático anterior. El inicio automático requiere ThreadPilot instalado en Archivos de programa: cualquier programa podría reemplazar una copia portable y ejecutarla como administrador. + Instalar ThreadPilot para el inicio automático + El inicio automático requiere ThreadPilot instalado en Archivos de programa. ¿Descargar, verificar e instalar ahora la versión oficial? ThreadPilot se reiniciará desde Archivos de programa con el inicio automático activado; después podrás eliminar esta carpeta portable. ThreadPilot Configuración diff --git a/Locales/fr-FR.xaml b/Locales/fr-FR.xaml index 571e602..069d0f4 100644 --- a/Locales/fr-FR.xaml +++ b/Locales/fr-FR.xaml @@ -501,6 +501,8 @@ Importer la configuration ThreadPilot Échec de la mise à jour du démarrage automatique Windows. Conserver l'état de démarrage automatique précédent. Le démarrage automatique nécessite ThreadPilot installé dans Program Files : n'importe quel programme pourrait remplacer une copie portable, qui serait ensuite lancée en administrateur. + Installer ThreadPilot pour le démarrage automatique + Le démarrage automatique nécessite ThreadPilot installé dans Program Files. Télécharger, vérifier et installer la version officielle maintenant ? ThreadPilot redémarrera depuis Program Files avec le démarrage automatique activé ; vous pourrez ensuite supprimer ce dossier portable. ThreadPilot Paramètres diff --git a/Locales/it-IT.xaml b/Locales/it-IT.xaml index d1cd34e..8f07792 100644 --- a/Locales/it-IT.xaml +++ b/Locales/it-IT.xaml @@ -501,6 +501,8 @@ Importa ThreadPilot Configurazione Impossibile aggiornare l'avvio automatico Windows. Mantenimento dello stato di avvio automatico precedente. L'avvio automatico richiede ThreadPilot installato in Programmi: una copia portable potrebbe essere sostituita da qualsiasi programma e poi avviata come amministratore. + Installa ThreadPilot per l'avvio automatico + L'avvio automatico richiede ThreadPilot installato in Programmi. Scaricare, verificare e installare ora la versione ufficiale? ThreadPilot si riavvierà da Programmi con l'avvio automatico attivo; poi potrai eliminare questa cartella portable. ThreadPilot Impostazioni diff --git a/Locales/ru-RU.xaml b/Locales/ru-RU.xaml index 8cdc0ab..12a6486 100644 --- a/Locales/ru-RU.xaml +++ b/Locales/ru-RU.xaml @@ -501,6 +501,8 @@ Импорт конфигурации ThreadPilot Не удалось обновить автозапуск Windows. Сохранение предыдущего состояния автозапуска. Для автозапуска ThreadPilot должен быть установлен в Program Files: портативную копию может подменить любая программа, и она запустится от имени администратора. + Установить ThreadPilot для автозапуска + Для автозапуска ThreadPilot должен быть установлен в Program Files. Скачать, проверить и установить официальную версию сейчас? ThreadPilot перезапустится из Program Files с включённым автозапуском; после этого портативную папку можно удалить. ThreadPilot Настройки diff --git a/Locales/zh-CN.xaml b/Locales/zh-CN.xaml index 8d85cd2..5887ced 100644 --- a/Locales/zh-CN.xaml +++ b/Locales/zh-CN.xaml @@ -501,6 +501,8 @@ 导入 ThreadPilot 配置 无法更新 Windows 自启动。将保留之前的自启动状态。 自启动需要将 ThreadPilot 安装在 Program Files 中:便携版可能被任何程序替换,并以管理员身份启动。 + 安装 ThreadPilot 以启用自启动 + 自启动需要将 ThreadPilot 安装在 Program Files 中。现在下载、验证并安装官方版本吗?ThreadPilot 将从 Program Files 重新启动并启用自启动;之后可以删除此便携版文件夹。 ThreadPilot 设置 diff --git a/Services/AutostartService.cs b/Services/AutostartService.cs index caa1e05..0f487aa 100644 --- a/Services/AutostartService.cs +++ b/Services/AutostartService.cs @@ -38,6 +38,16 @@ public AutostartService(ILogger logger, IElevationService elev public bool IsAutostartSupported => ElevatedTaskService.IsProtectedInstallPath(this.GetExecutablePath()); + public async Task EnsureAutostartAsync(bool startMinimized) + { + if (!this.IsAutostartSupported || await this.CheckAutostartStatusAsync()) + { + return; + } + + await this.EnableAutostartAsync(startMinimized); + } + public async Task EnableAutostartAsync(bool startMinimized = true) { try diff --git a/Services/IAutostartService.cs b/Services/IAutostartService.cs index ff3fdae..3004fe7 100644 --- a/Services/IAutostartService.cs +++ b/Services/IAutostartService.cs @@ -22,6 +22,9 @@ public interface IAutostartService Task UpdateAutostartAsync(bool startMinimized = true); + /// Turns autostart on when the user asked for it but this copy has no task yet (e.g. just installed). + Task EnsureAutostartAsync(bool startMinimized); + string GetAutostartArguments(bool startMinimized = true); } diff --git a/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs b/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs index 9419f68..6287959 100644 --- a/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs @@ -29,6 +29,22 @@ public void InnoInstallers_UseStableDisplayNameAndSeparateVersionMetadata() } } + [Fact] + public void PrimaryInstaller_AlwaysInstallsInProgramFiles_AndMovesOldFoldersWithoutTheOldUninstaller() + { + var root = FindRepositoryRoot(); + var script = File.ReadAllText(Path.Combine(root, "Installer", "setup.iss")); + + Assert.Contains(@"DefaultDirName={autopf}\{#MyAppName}", script, StringComparison.Ordinal); + Assert.Contains("DisableDirPage=yes", script, StringComparison.Ordinal); + Assert.Contains("UsePreviousAppDir=no", script, StringComparison.Ordinal); + Assert.Contains("MovePreviousInstall", script, StringComparison.Ordinal); + + // The old uninstaller would also delete %AppData%\ThreadPilot, the user's settings. + Assert.DoesNotContain("unins000.exe'", script.Replace("DeleteFile(AddBackslash(OldDir) + 'unins000.exe');", string.Empty), StringComparison.Ordinal); + Assert.DoesNotContain("DelTree", script, StringComparison.Ordinal); + } + [Fact] public void PrimaryInstaller_RemovesThreadPilotOwnedDataOnlyDuringUninstall() { diff --git a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs index 3f22c60..d546708 100644 --- a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs @@ -152,6 +152,47 @@ public async Task SaveSettingsCommand_PersistsSelectedLanguage() Assert.False(viewModel.HasUnsavedChanges); } + [Theory] + [InlineData(true, 1, 4, 0, true)] // accepted, release 1.4.0 >= current 1.3.1: installs, autostart stays on + [InlineData(false, 1, 4, 0, false)] // declined: nothing installed, autostart back off + [InlineData(true, 1, 2, 0, false)] // only an older release exists: never downgrade + public async Task SaveSettingsCommand_PortableAutostart_OffersTheVerifiedInstaller( + bool accept, int major, int minor, int patch, bool expectInstall) + { + var harness = new Harness(); + harness.Autostart.Setup(service => service.CheckAutostartStatusAsync()).ReturnsAsync(false); + harness.Autostart.Setup(service => service.EnableAutostartAsync(It.IsAny())).ReturnsAsync(false); + harness.Autostart.SetupGet(service => service.IsAutostartSupported).Returns(false); + var release = new UpdateReleaseInfo( + new SemanticVersion(major, minor, patch), + $"v{major}.{minor}.{patch}", + new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases"), + false, + []); + harness.Updates + .Setup(service => service.CheckForUpdatesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UpdateCheckResult(UpdateCheckStatus.UpdateAvailable, new SemanticVersion(1, 3, 1), release, "ok")); + harness.Updates + .Setup(service => service.DownloadAndInstallAsync(release, It.IsAny())) + .ReturnsAsync(new UpdateInstallResult(UpdateInstallStatus.Started, "started")); + var viewModel = harness.CreateViewModel(); + var prompts = 0; + viewModel.ConfirmPrompt = (_, _) => + { + prompts++; + return accept; + }; + viewModel.Settings.AutostartWithWindows = true; + + await ((IAsyncRelayCommand)viewModel.SaveSettingsCommand).ExecuteAsync(null); + + Assert.Equal(1, prompts); + harness.Updates.Verify( + service => service.DownloadAndInstallAsync(It.IsAny(), It.IsAny()), + expectInstall ? Times.Once() : Times.Never()); + Assert.Equal(expectInstall, viewModel.Settings.AutostartWithWindows); + } + [Fact] public async Task SaveSettingsCommand_WhenStartMinimizedIsDisabled_UpdatesEnabledAutostart() { diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs index 08edf3a..e2a7b11 100644 --- a/ViewModels/SettingsViewModel.cs +++ b/ViewModels/SettingsViewModel.cs @@ -341,10 +341,17 @@ private async Task SaveSettingsAsync() if (!autostartUpdated && this.Settings.AutostartWithWindows && !this.autostartService.IsAutostartSupported) { - warnings.Add(this.GetLocalizedString( - "Settings_WarningAutostartNeedsInstall", - "Autostart needs ThreadPilot installed in Program Files: a portable copy could be replaced by any program and then started as administrator.")); - this.Settings.AutostartWithWindows = currentAutostartState; + if (await this.TryInstallForAutostartAsync()) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateInstallerStarted", "Update installer started."); + } + else + { + warnings.Add(this.GetLocalizedString( + "Settings_WarningAutostartNeedsInstall", + "Autostart needs ThreadPilot installed in Program Files: a portable copy could be replaced by any program and then started as administrator.")); + this.Settings.AutostartWithWindows = currentAutostartState; + } } else if (!autostartUpdated) { @@ -745,6 +752,38 @@ private async Task RefreshPowerPlansAsync() } } + internal Func ConfirmPrompt { get; set; } = (message, title) => + System.Windows.MessageBox.Show(message, title, MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes; + + // A portable copy cannot autostart safely, so offer the official installer through the verified update + // path; the installed copy turns autostart on at its first start (App.OnStartup). + private async Task TryInstallForAutostartAsync() + { + var confirmed = this.ConfirmPrompt( + this.GetLocalizedString( + "Settings_AutostartInstallConfirmMessage", + "Autostart needs ThreadPilot installed in Program Files. Download, verify and install the official version now? ThreadPilot restarts from Program Files with autostart on; you can delete this portable folder afterwards."), + this.GetLocalizedString("Settings_AutostartInstallConfirmTitle", "Install ThreadPilot for autostart")); + if (!confirmed) + { + return false; + } + + var check = await this.updateService.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Manual)); + + // Never downgrade: a portable copy newer than the latest release (a beta or dev build) has nothing to install. + if (check.Release == null || check.Release.Version < check.CurrentVersion) + { + return false; + } + + // Saved before the installer starts, since this copy is closed for it. + this.Settings.AutostartWithWindows = true; + await this.settingsService.UpdateSettingsAsync(this.Settings); + var result = await this.updateService.DownloadAndInstallAsync(check.Release); + return result.Status == UpdateInstallStatus.Started; + } + private async Task CheckUpdatesAsync() { try From e04d4cb9af0691738ae42b8967ab1f80d8ca18be Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 22 Oct 2026 03:13:11 +0200 Subject: [PATCH 18/34] fix: process table sizes to its columns and the details panel takes the free width In the wide layout the Priority column no longer stretches: the table is as wide as its visible columns (capped so the details keep at least 400 px) and the details panel gets the rest. A wide details panel shows Priority and CPU cores side by side, and the no-selection hint sits centered. The narrow layout (< 960 px) is unchanged. --- .../ProcessViewXamlBindingTests.cs | 23 ++++++++++ Views/ProcessView.xaml | 45 +++++++++++++------ Views/ProcessView.xaml.cs | 23 +++++++++- 3 files changed, 76 insertions(+), 15 deletions(-) diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs index 90c08db..29cef3e 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs @@ -292,6 +292,29 @@ public void MainWindow_QueuesStartupUpdateCheckOnceWithoutBlockingStartup() Assert.Contains("Startup update check ignored failure", updateCheckSection, StringComparison.Ordinal); } + [Fact] + public void WideLayout_TableSizesToItsColumnsAndLeavesTheRestToTheDetails() + { + // A star column in the process table swallowed all free width and left a wide empty strip + // next to a fixed 400 px details panel. The table must size to its columns; the code-behind + // switches Priority to star only in the narrow layout, where the details sit below the table. + var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); + var columns = document.Descendants() + .Single(element => element.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "ProcessList") + .Descendants() + .Where(element => element.Name.LocalName.StartsWith("DataGrid", StringComparison.Ordinal) + && element.Name.LocalName.EndsWith("Column", StringComparison.Ordinal)) + .ToList(); + + Assert.NotEmpty(columns); + Assert.All(columns, column => Assert.DoesNotContain("*", column.Attribute("Width")?.Value ?? string.Empty, StringComparison.Ordinal)); + + var codeBehind = File.ReadAllText(ProcessViewPath + ".cs"); + Assert.Contains("this.TableColumn.Width = wide ? System.Windows.GridLength.Auto", codeBehind, StringComparison.Ordinal); + Assert.Contains("this.ProcessTable.MaxWidth = wide ? e.NewSize.Width - DetailMinWidth", codeBehind, StringComparison.Ordinal); + Assert.Contains("private const double DetailMinWidth = 400;", codeBehind, StringComparison.Ordinal); + } + [Fact] public void LegacyActionSidePanel_IsGoneAndItsControlsLiveInTheVisiblePicker() { diff --git a/Views/ProcessView.xaml b/Views/ProcessView.xaml index c29baac..89f52d6 100644 --- a/Views/ProcessView.xaml +++ b/Views/ProcessView.xaml @@ -180,7 +180,7 @@ - + @@ -259,7 +259,7 @@ - + - + @@ -471,16 +471,33 @@ - - - + + + - + + + + - + + + + + + + + + + + - + - + @@ -580,7 +597,7 @@ - + @@ -703,7 +720,7 @@ - + - - + + diff --git a/Views/ProcessView.xaml.cs b/Views/ProcessView.xaml.cs index dcafe57..2550d86 100644 --- a/Views/ProcessView.xaml.cs +++ b/Views/ProcessView.xaml.cs @@ -29,10 +29,21 @@ public ProcessView() } } + private const double DetailMinWidth = 400; + private void ProcessWorkspace_SizeChanged(object sender, System.Windows.SizeChangedEventArgs e) { var wide = e.NewSize.Width >= 960; - this.DetailColumn.Width = new System.Windows.GridLength(wide ? 400 : 0); + + // Wide: the table is only as wide as its visible columns and the details panel takes the rest. + // Narrow: the table fills the width (Priority stretches) and the details go below it. + var star = new System.Windows.GridLength(1, System.Windows.GridUnitType.Star); + this.TableColumn.Width = wide ? System.Windows.GridLength.Auto : star; + this.DetailColumn.Width = wide ? star : new System.Windows.GridLength(0); + this.ProcessTable.MaxWidth = wide ? e.NewSize.Width - DetailMinWidth : double.PositiveInfinity; + this.PriorityColumn.Width = wide ? new DataGridLength(120) : new DataGridLength(1, DataGridLengthUnitType.Star); + this.SelectProcessHint.HorizontalAlignment = wide ? System.Windows.HorizontalAlignment.Center : System.Windows.HorizontalAlignment.Stretch; + this.SelectProcessHint.VerticalAlignment = wide ? System.Windows.VerticalAlignment.Center : System.Windows.VerticalAlignment.Top; Grid.SetRow(this.ProcessDetails, wide ? 1 : 2); Grid.SetColumn(this.ProcessDetails, wide ? 1 : 0); this.ProcessDetails.MaxHeight = wide ? double.PositiveInfinity : Math.Max(160, e.NewSize.Height * 0.48); @@ -45,6 +56,16 @@ private void ProcessWorkspace_SizeChanged(object sender, System.Windows.SizeChan this.ToolbarActions.Margin = stacked ? new System.Windows.Thickness(-8, 8, 0, 0) : default; } + private void ProcessDetails_SizeChanged(object sender, System.Windows.SizeChangedEventArgs e) + { + // A wide details panel puts Priority and CPU cores side by side instead of stretching every picker. + var twoColumns = this.DetailColumn.Width.IsStar && e.NewSize.Width >= 880; + this.DetailSecondColumn.Width = twoColumns ? new System.Windows.GridLength(1, System.Windows.GridUnitType.Star) : new System.Windows.GridLength(0); + Grid.SetRow(this.CpuCoresCard, twoColumns ? 1 : 2); + Grid.SetColumn(this.CpuCoresCard, twoColumns ? 1 : 0); + this.PriorityCard.Margin = new System.Windows.Thickness(0, 0, twoColumns ? 10 : 0, 10); + } + private void ProcessRow_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e) { if (sender is not DataGridRow row) From a4a0111bc7847ba6207c56d3d7cc4bc49d9682fc Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 22 Oct 2026 05:43:19 +0200 Subject: [PATCH 19/34] fix: reopen the main window maximized when it was closed maximized App applied a fixed Normal state after MainWindow had restored the saved placement, so the saved maximized flag never took effect (since 1.7.7). --- App.xaml.cs | 2 +- Helpers/StartupWindowBehavior.cs | 4 ++-- .../StartupWindowBehaviorTests.cs | 12 ++++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/App.xaml.cs b/App.xaml.cs index 0173422..f312684 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -229,7 +229,7 @@ protected override void OnStartup(StartupEventArgs e) throw new InvalidOperationException("MainWindow could not be created"); } - var startupWindowBehavior = StartupWindowBehavior.Resolve(startupMode.IsAutostart, effectiveStartMinimized); + var startupWindowBehavior = StartupWindowBehavior.Resolve(startupMode.IsAutostart, effectiveStartMinimized, UiStateStore.Current.WindowMaximized); var showStartupSuggestion = loadedSettings != null && StartupMinimizedSuggestionPolicy.ShouldShow(loadedSettings, startupWindowBehavior); mainWindow.ConfigureStartupMode( diff --git a/Helpers/StartupWindowBehavior.cs b/Helpers/StartupWindowBehavior.cs index 1021880..0700511 100644 --- a/Helpers/StartupWindowBehavior.cs +++ b/Helpers/StartupWindowBehavior.cs @@ -10,7 +10,7 @@ public readonly record struct StartupWindowBehavior( bool HideAfterShow, bool ActivateAfterShow) { - public static StartupWindowBehavior Resolve(bool isAutostart, bool startMinimized) + public static StartupWindowBehavior Resolve(bool isAutostart, bool startMinimized, bool restoreMaximized = false) { if (isAutostart && startMinimized) { @@ -38,7 +38,7 @@ public static StartupWindowBehavior Resolve(bool isAutostart, bool startMinimize ShouldShowWindow: true, ShowInTaskbar: true, Visibility: Visibility.Visible, - WindowState: WindowState.Normal, + WindowState: restoreMaximized ? WindowState.Maximized : WindowState.Normal, HideAfterShow: false, ActivateAfterShow: true); } diff --git a/Tests/ThreadPilot.Core.Tests/StartupWindowBehaviorTests.cs b/Tests/ThreadPilot.Core.Tests/StartupWindowBehaviorTests.cs index 8808dd8..3194f4d 100644 --- a/Tests/ThreadPilot.Core.Tests/StartupWindowBehaviorTests.cs +++ b/Tests/ThreadPilot.Core.Tests/StartupWindowBehaviorTests.cs @@ -18,6 +18,18 @@ public void Resolve_ShowsNormalWindow_ForManualLaunchWithoutStartMinimized() Assert.True(behavior.ActivateAfterShow); } + [Fact] + public void Resolve_KeepsAWindowTheUserLeftMaximized() + { + // App applies this state after MainWindow restored its saved placement, so a plain + // Normal here turned every saved maximized window back into a normal one. + var visible = StartupWindowBehavior.Resolve(isAutostart: false, startMinimized: false, restoreMaximized: true); + var hidden = StartupWindowBehavior.Resolve(isAutostart: false, startMinimized: true, restoreMaximized: true); + + Assert.Equal(WindowState.Maximized, visible.WindowState); + Assert.Equal(WindowState.Minimized, hidden.WindowState); + } + [Fact] public void Resolve_HidesToTray_ForManualLaunchWithStartMinimized() { From 622e641ad3a63f58381a075632541e53d925694f Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 22 Oct 2026 06:06:03 +0200 Subject: [PATCH 20/34] fix: compare numeric prerelease parts as numbers so beta.10 is newer than beta.9 --- Services/SemanticVersion.cs | 21 ++++++++++++++++++- .../UpdateServiceTests.cs | 15 +++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Services/SemanticVersion.cs b/Services/SemanticVersion.cs index d006f2a..4086eb3 100644 --- a/Services/SemanticVersion.cs +++ b/Services/SemanticVersion.cs @@ -84,7 +84,26 @@ public int CompareTo(SemanticVersion other) return -1; } - return string.Compare(this.Prerelease, other.Prerelease, StringComparison.OrdinalIgnoreCase); + return ComparePrerelease(this.Prerelease ?? string.Empty, other.Prerelease ?? string.Empty); + } + + // SemVer: dot-separated parts, numeric parts by value (beta.10 > beta.9), more parts wins a tie. + private static int ComparePrerelease(string left, string right) + { + var a = left.Split('.'); + var b = right.Split('.'); + for (var i = 0; i < Math.Min(a.Length, b.Length); i++) + { + var result = long.TryParse(a[i], out var x) && long.TryParse(b[i], out var y) + ? x.CompareTo(y) + : string.Compare(a[i], b[i], StringComparison.OrdinalIgnoreCase); + if (result != 0) + { + return result; + } + } + + return a.Length.CompareTo(b.Length); } public override string ToString() diff --git a/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs b/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs index a948af2..74f9752 100644 --- a/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs @@ -21,6 +21,21 @@ public void SemanticVersion_OrdersStableAbovePrerelease() Assert.True(SemanticVersion.TryParse("1.4.0", out var stable)); Assert.True(stable > prerelease); + Assert.Equal(0, stable.CompareTo(new SemanticVersion(1, 4, 0))); + } + + [Theory] + [InlineData("1.7.8-beta.10", "1.7.8-beta.9")] + [InlineData("1.7.8-rc.1", "1.7.8-beta.10")] + [InlineData("1.7.8-beta.1.1", "1.7.8-beta.1")] + public void SemanticVersion_ComparesNumericPrereleasePartsAsNumbers(string newer, string older) + { + // Text order put beta.10 before beta.9, so a beta.9 user was never offered beta.10. + Assert.True(SemanticVersion.TryParse(newer, out var high)); + Assert.True(SemanticVersion.TryParse(older, out var low)); + + Assert.True(high > low); + Assert.True(low < high); } [Fact] From 5d9165e66ef6cbeca8b7885267b4e32da1a0476b Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 22 Oct 2026 06:06:53 +0200 Subject: [PATCH 21/34] fix: Game Boost keeps Above Normal on the game after a settings change during a session ReapplyAsync put back the session's old process model, which still said AboveNormal after the restore had set the game back to Normal, so the priority was not applied again. --- Services/GameSessionService.cs | 4 +++- Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Services/GameSessionService.cs b/Services/GameSessionService.cs index ccba419..f234065 100644 --- a/Services/GameSessionService.cs +++ b/Services/GameSessionService.cs @@ -1132,7 +1132,9 @@ internal async Task ReapplyAsync() return; } - this.session = previous; + // The restore just changed the live process; the old model still shows what Game Boost had applied. + var fresh = await this.processService.GetProcessByIdAsync(previous.Game.ProcessId).ConfigureAwait(false); + this.session = fresh == null ? previous : previous with { Game = fresh }; this.ResetSessionState(); await this.AuditSessionStartAsync(previous.Game).ConfigureAwait(false); await this.ApplyToSessionAsync(includeGame: true).ConfigureAwait(false); diff --git a/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs b/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs index 38efead..97cf9e7 100644 --- a/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs @@ -670,7 +670,12 @@ public FakeProcesses() return true; }); processService.Setup(s => s.SetProcessPriority(It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((ProcessModel p, ProcessPriorityClass priority, ProcessPriorityWriteSource _) => this.Priorities[p.ProcessId] = priority) + .Callback((ProcessModel p, ProcessPriorityClass priority, ProcessPriorityWriteSource _) => + { + // Like ProcessService: the write also updates the model it was given. + this.Priorities[p.ProcessId] = priority; + p.Priority = priority; + }) .Returns(Task.CompletedTask); this.ProcessService = processService.Object; From afc47fccc9c4920d00108270b4c4bba3f13b0866 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 22 Oct 2026 06:07:34 +0200 Subject: [PATCH 22/34] fix: a Game Boost tick queued before stop or dispose no longer starts a session Timer.Dispose does not wait for a callback already on the thread pool; such a tick could apply changes after the shutdown restore and leave the game handle open. --- Services/GameSessionService.cs | 10 +++++++++ .../GameSessionServiceTests.cs | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/Services/GameSessionService.cs b/Services/GameSessionService.cs index f234065..8f8ff5c 100644 --- a/Services/GameSessionService.cs +++ b/Services/GameSessionService.cs @@ -237,6 +237,9 @@ public sealed class GameSessionService : IGameSessionService private List journal = new(); private Timer? timer; private bool started; + + // Set by StopAsync: a tick already queued when the timer was disposed must not begin a session. + private bool stopped; private (int ProcessId, long Timestamp)? rejectedForeground; private GameSession? session; private IGameProcessHandle? gameHandle; @@ -337,6 +340,7 @@ await this.AuditAsync( } this.started = true; + this.stopped = false; this.SyncTimer(); } finally @@ -350,6 +354,7 @@ public async Task StopAsync() lock (this.timerGate) { this.started = false; + this.stopped = true; this.timer?.Dispose(); this.timer = null; } @@ -408,6 +413,11 @@ internal async Task TickAsync() try { + if (this.stopped || this.disposed) + { + return; + } + if (!this.Settings.GameBoostEnabled) { await this.EndSessionAsync("Game Boost turned off").ConfigureAwait(false); diff --git a/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs b/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs index 97cf9e7..274dd68 100644 --- a/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs @@ -162,6 +162,27 @@ public async Task HeldGame_HandleReleased_WhenDisposedOrStopped() Assert.False(File.Exists(this.journalPath)); } + [Fact] + public async Task TickQueuedBeforeStopOrDispose_StartsNoSession() + { + // Timer.Dispose does not wait for a callback already queued on the thread pool. + var stopped = this.CreateService(); + await stopped.StartAsync(); + await stopped.StopAsync(); + await stopped.TickAsync(); + Assert.Null(stopped.ActiveGameName); + + var disposed = this.CreateService(); + disposed.Dispose(); + await disposed.TickAsync(); + Assert.Null(disposed.ActiveGameName); + + Assert.Equal(0, this.processes.OpenHandles); + Assert.Empty(this.processes.ApplyCalls); + Assert.False(File.Exists(this.journalPath)); + stopped.Dispose(); + } + [Fact] public async Task HeldGame_LateExitEventOfAnEndedSession_DoesNotEndTheNextOne() { From d494bbd0f1acc9bda301b37d66d1bdfddc008140 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 22 Oct 2026 06:08:27 +0200 Subject: [PATCH 23/34] fix: reset process changes on exit before the monitor stops tracking them Stopping the process monitor untracks every process it changed, so the "reset on exit" step that ran afterwards always found nothing to reset (default-on setting, broken since before 1.7.7). --- MainWindow.Behaviors.partial.cs | 26 +++++++++---------- .../SystemTweaksStartupAndStyleTests.cs | 15 +++++++++++ 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/MainWindow.Behaviors.partial.cs b/MainWindow.Behaviors.partial.cs index d56ad6d..5342057 100644 --- a/MainWindow.Behaviors.partial.cs +++ b/MainWindow.Behaviors.partial.cs @@ -710,7 +710,7 @@ private async Task PerformGracefulShutdownAsync(bool validateUnsavedChanges = tr this.LogDebug("Starting graceful shutdown..."); this.selfResourceManagementService.RestoreForegroundMode(); - // 1. Stop monitoring services; Game Boost first restores what it changed + // 1. Game Boost restores what it changed try { await this.serviceProvider.GetRequiredService().StopAsync(); @@ -720,18 +720,7 @@ private async Task PerformGracefulShutdownAsync(bool validateUnsavedChanges = tr this.LogDebug($"Error stopping Game Boost: {ex.Message}"); } - try - { - this.LogDebug("Stopping process monitoring manager..."); - await this.processMonitorManagerService.StopAsync(); - this.LogDebug("Process monitoring manager stopped"); - } - catch (Exception ex) - { - this.LogDebug($"Error stopping process monitoring: {ex.Message}"); - } - - // 2. Cleanup applied CPU masks (like CPU Set Setter's ClearAllProcessMasksNoSave) + // 2. Reset tracked masks/priorities before the monitor stops: stopping it untracks every process if (this.settingsService.Settings.ClearMasksOnClose) { try @@ -760,6 +749,17 @@ private async Task PerformGracefulShutdownAsync(bool validateUnsavedChanges = tr } } + try + { + this.LogDebug("Stopping process monitoring manager..."); + await this.processMonitorManagerService.StopAsync(); + this.LogDebug("Process monitoring manager stopped"); + } + catch (Exception ex) + { + this.LogDebug($"Error stopping process monitoring: {ex.Message}"); + } + // 3. Restore default power plan if configured if (this.settingsService.Settings.RestoreDefaultPowerPlanOnExit) { diff --git a/Tests/ThreadPilot.Core.Tests/SystemTweaksStartupAndStyleTests.cs b/Tests/ThreadPilot.Core.Tests/SystemTweaksStartupAndStyleTests.cs index 584edfe..0436b23 100644 --- a/Tests/ThreadPilot.Core.Tests/SystemTweaksStartupAndStyleTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SystemTweaksStartupAndStyleTests.cs @@ -16,6 +16,21 @@ public void Startup_LoadsPersistedTweakStateBeforePageNavigation() Assert.Contains("this.initializedSections.Add(\"Tweaks\");", startupSection, StringComparison.Ordinal); } + [Fact] + public void Shutdown_ResetsTrackedChangesBeforeTheMonitorForgetsThem() + { + // ProcessMonitorManagerService.StopAsync untracks every process it changed, so + // "reset process changes on exit" found nothing to reset when it ran afterwards. + var source = File.ReadAllText(GetRepositoryFilePath("MainWindow.Behaviors.partial.cs")); + var start = source.IndexOf("private async Task PerformGracefulShutdownAsync(", StringComparison.Ordinal); + var shutdown = source[start..source.IndexOf("// 3. Restore default power plan", start, StringComparison.Ordinal)]; + + var reset = shutdown.IndexOf("await processService.ClearAllAppliedMasksAsync();", StringComparison.Ordinal); + var stopMonitor = shutdown.IndexOf("await this.processMonitorManagerService.StopAsync();", StringComparison.Ordinal); + Assert.True(reset >= 0 && stopMonitor >= 0); + Assert.True(reset < stopMonitor); + } + [Fact] public void TweaksView_UsesWindows11ToggleWithStateLabel() { From 8a4398172e062f029beff5067745482105c881d7 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 22 Oct 2026 06:09:56 +0200 Subject: [PATCH 24/34] fix: in-app update closes through the graceful shutdown Application.Shutdown ignored the window's cancel, so Game Boost, reset-on-exit and the default power plan were never restored before the installer took over. --- MainWindow.Behaviors.partial.cs | 3 +++ Services/WpfApplicationShutdownService.cs | 12 +++++++++++- .../SystemTweaksStartupAndStyleTests.cs | 12 ++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/MainWindow.Behaviors.partial.cs b/MainWindow.Behaviors.partial.cs index 5342057..3ebc08b 100644 --- a/MainWindow.Behaviors.partial.cs +++ b/MainWindow.Behaviors.partial.cs @@ -691,6 +691,9 @@ private void OnDashboardRequested(object? sender, EventArgs e) this.ShowWindowFromTray("Process"); } + // An update must not stop at the unsaved-settings prompt: the installer is already running. + internal Task ShutdownForUpdateAsync() => this.PerformGracefulShutdownAsync(validateUnsavedChanges: false); + private async Task PerformGracefulShutdownAsync(bool validateUnsavedChanges = true) { if (this.isPerformingShutdown) diff --git a/Services/WpfApplicationShutdownService.cs b/Services/WpfApplicationShutdownService.cs index fcca799..2e22fe3 100644 --- a/Services/WpfApplicationShutdownService.cs +++ b/Services/WpfApplicationShutdownService.cs @@ -15,7 +15,17 @@ public void RequestShutdownForUpdate() return; } - application.Dispatcher.InvokeAsync(application.Shutdown); + // The graceful path restores Game Boost, process changes and the power plan, then always exits. + application.Dispatcher.InvokeAsync(async () => + { + if (application.MainWindow is MainWindow window) + { + await window.ShutdownForUpdateAsync(); + return; + } + + application.Shutdown(); + }); } } } diff --git a/Tests/ThreadPilot.Core.Tests/SystemTweaksStartupAndStyleTests.cs b/Tests/ThreadPilot.Core.Tests/SystemTweaksStartupAndStyleTests.cs index 0436b23..00e8769 100644 --- a/Tests/ThreadPilot.Core.Tests/SystemTweaksStartupAndStyleTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SystemTweaksStartupAndStyleTests.cs @@ -31,6 +31,18 @@ public void Shutdown_ResetsTrackedChangesBeforeTheMonitorForgetsThem() Assert.True(reset < stopMonitor); } + [Fact] + public void UpdateShutdown_GoesThroughTheGracefulShutdown() + { + // Application.Shutdown ignores the window's cancel, so the restore of Game Boost, process + // changes and power plan in PerformGracefulShutdownAsync never ran before an update. + var service = File.ReadAllText(GetRepositoryFilePath("Services", "WpfApplicationShutdownService.cs")); + var window = File.ReadAllText(GetRepositoryFilePath("MainWindow.Behaviors.partial.cs")); + + Assert.Contains("ShutdownForUpdateAsync()", service, StringComparison.Ordinal); + Assert.Contains("internal Task ShutdownForUpdateAsync() => this.PerformGracefulShutdownAsync(validateUnsavedChanges: false);", window, StringComparison.Ordinal); + } + [Fact] public void TweaksView_UsesWindows11ToggleWithStateLabel() { From e72c21895ba4f075fe68e87ce706c063391500a1 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 22 Oct 2026 06:10:56 +0200 Subject: [PATCH 25/34] build: keep the old Debug-only test harness out of the Release exe --- ThreadPilot.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ThreadPilot.csproj b/ThreadPilot.csproj index be0f636..5e290db 100644 --- a/ThreadPilot.csproj +++ b/ThreadPilot.csproj @@ -54,6 +54,8 @@ + + From 10279f5779348b819fc3113483e8c3b7c05ecfdc Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 22 Oct 2026 06:12:09 +0200 Subject: [PATCH 26/34] fix: a leftover HKCU Run entry no longer counts as autostart Windows does not start an admin-only exe from Run at logon, so the entry kept autostart off while the setting showed it on and the startup repair skipped creating the scheduled task. --- Services/AutostartService.cs | 5 +++-- .../AutostartServiceTests.cs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Services/AutostartService.cs b/Services/AutostartService.cs index 0f487aa..55b70b3 100644 --- a/Services/AutostartService.cs +++ b/Services/AutostartService.cs @@ -163,7 +163,8 @@ public async Task CheckAutostartStatusAsync() var taskRegistered = await this.elevatedTaskService.IsAutostartTaskRegisteredAsync(); var legacyRegistryValue = this.TryReadLegacyRegistryAutostart(); - this.isAutostartEnabled = taskRegistered || !string.IsNullOrWhiteSpace(legacyRegistryValue); + // Only the task starts an admin-only exe at logon; a leftover HKCU\Run entry does not. + this.isAutostartEnabled = taskRegistered; this.autostartPath = taskRegistered ? $"task://{this.elevatedTaskService.AutostartTaskName}" : legacyRegistryValue; @@ -233,7 +234,7 @@ public string GetAutostartArguments(bool startMinimized = true) } } - private string? TryReadLegacyRegistryAutostart() + internal virtual string? TryReadLegacyRegistryAutostart() { try { diff --git a/Tests/ThreadPilot.Core.Tests/AutostartServiceTests.cs b/Tests/ThreadPilot.Core.Tests/AutostartServiceTests.cs index 91307f7..9358eb6 100644 --- a/Tests/ThreadPilot.Core.Tests/AutostartServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/AutostartServiceTests.cs @@ -96,6 +96,25 @@ public async Task DisableAutostartAsync_WhenAdmin_RemovesScheduledTask() elevatedTaskService.Verify(x => x.RemoveAutostartTaskAsync(), Times.Once); } + [Fact] + public async Task LegacyRunEntryAlone_DoesNotCountAsAutostart() + { + // Windows does not start an admin-only exe from HKCU\Run at logon, so a leftover entry from an + // old version must not stop the scheduled task from being created or show autostart as on. + var elevatedTask = new Mock(MockBehavior.Loose); + elevatedTask.Setup(x => x.IsAutostartTaskRegisteredAsync()).ReturnsAsync(false); + var service = new LegacyRunEntryAutostartService(new Mock().Object, elevatedTask.Object); + + Assert.False(await service.CheckAutostartStatusAsync()); + Assert.False(service.IsAutostartEnabled); + } + + private sealed class LegacyRunEntryAutostartService(IElevationService elevation, IElevatedTaskService elevatedTask) + : AutostartService(NullLogger.Instance, elevation, elevatedTask) + { + internal override string? TryReadLegacyRegistryAutostart() => @"""C:\Apps\ThreadPilot\ThreadPilot.exe"" --autostart"; + } + private static AutostartService CreateService( Mock? elevationService = null, Mock? elevatedTaskService = null) From 942b03826503ef93e538aadd5e757846fd345b73 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 22 Oct 2026 06:14:01 +0200 Subject: [PATCH 27/34] fix: delete update folders left in Windows\Temp by earlier updates A successful update exits right after starting the installer, so its ~55 MB folder was never removed. At elevated startup, folders older than a day are deleted, only when they are owned by Administrators (a user cannot create those or change what is inside). --- App.xaml.cs | 2 ++ Services/UpdateTempDirectoryProvider.cs | 28 +++++++++++++++++ .../UpdateServiceTests.cs | 30 +++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/App.xaml.cs b/App.xaml.cs index f312684..7f75966 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -211,6 +211,8 @@ protected override void OnStartup(StartupEventArgs e) { await autostartService.EnsureAutostartAsync(autostartMinimized); } + + new UpdateTempDirectoryProvider().CleanupStale(TimeSpan.FromDays(1)); }), ex => logger.LogWarning(ex, "Failed to ensure elevated tasks during startup")); } diff --git a/Services/UpdateTempDirectoryProvider.cs b/Services/UpdateTempDirectoryProvider.cs index 447a899..d29fa51 100644 --- a/Services/UpdateTempDirectoryProvider.cs +++ b/Services/UpdateTempDirectoryProvider.cs @@ -5,6 +5,7 @@ namespace ThreadPilot.Services { using System; using System.IO; + using System.Security.Principal; /// /// Each download gets its own admin-only folder directly under the root, with an unguessable name, so a @@ -58,5 +59,32 @@ public void Cleanup(string path) Directory.Delete(path, recursive: true); this.created.TryRemove(Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)), out _); } + + /// + /// Deletes update folders left by earlier runs (a successful update exits before it can clean up). + /// Only Administrators-owned folders: a user cannot create one, nor change what is inside it. + /// + public void CleanupStale(TimeSpan olderThan) + { + var administrators = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); + foreach (var directory in new DirectoryInfo(this.rootDirectory).EnumerateDirectories(FolderPrefix + "*")) + { + try + { + if (directory.Attributes.HasFlag(FileAttributes.ReparsePoint) + || DateTime.UtcNow - directory.CreationTimeUtc < olderThan + || !administrators.Equals(directory.GetAccessControl().GetOwner(typeof(SecurityIdentifier)))) + { + continue; + } + + directory.Delete(recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + // In use or not ours: the next start tries again. + } + } + } } } diff --git a/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs b/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs index 74f9752..251a10b 100644 --- a/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs @@ -179,6 +179,36 @@ public async Task DownloadAndInstallAsync_StartsInstallerAndRequestsShutdown() harness.TempProvider.Verify(provider => provider.Cleanup(It.IsAny()), Times.Never); } + [Fact] + public void CleanupStale_DeletesOnlyOldAdminOwnedUpdateFolders() + { + // A successful update exits right after launching the installer, so its folder (~55 MB) was never deleted. + using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); + if (!new System.Security.Principal.WindowsPrincipal(identity).IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator)) + { + return; // only an elevated process creates Administrators-owned folders + } + + using var tempRoot = new TempDirectory(); + var old = new UpdateTempDirectoryProvider(tempRoot.Path).CreateUpdateTempDirectory(new SemanticVersion(1, 5, 0)); + var recent = new UpdateTempDirectoryProvider(tempRoot.Path).CreateUpdateTempDirectory(new SemanticVersion(1, 6, 0)); + var userOwned = Directory.CreateDirectory(Path.Combine(tempRoot.Path, "ThreadPilot-update-planted")).FullName; + var userSecurity = new DirectoryInfo(userOwned).GetAccessControl(); + userSecurity.SetOwner(identity.User!); + new DirectoryInfo(userOwned).SetAccessControl(userSecurity); + File.WriteAllText(Path.Combine(old, "setup.exe"), "installer"); + foreach (var directory in new[] { old, userOwned }) + { + Directory.SetCreationTimeUtc(directory, DateTime.UtcNow.AddDays(-2)); + } + + new UpdateTempDirectoryProvider(tempRoot.Path).CleanupStale(TimeSpan.FromDays(1)); + + Assert.False(Directory.Exists(old)); + Assert.True(Directory.Exists(recent)); + Assert.True(Directory.Exists(userOwned)); + } + [Fact] public async Task UpdateInstallerService_UsesSilentRestartingUpdateMode() { From f4c3bb3c2682668cc51bf953532976591967ed6a Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 22 Oct 2026 06:15:29 +0200 Subject: [PATCH 28/34] fix: installer moves an old install only when no junction is on its path The old folder outside Program Files may be user-writable; replaced by a junction, the admin deletes would land elsewhere. The old exe is deleted with retries (a copy still closing after an in-app update holds it), and the other files go only once the exe is gone. --- Installer/setup.iss | 33 ++++++++++++++++++- .../PackagingMetadataTests.cs | 11 +++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/Installer/setup.iss b/Installer/setup.iss index cde61ef..d848bb7 100644 --- a/Installer/setup.iss +++ b/Installer/setup.iss @@ -123,6 +123,29 @@ begin Result := True; end; +function GetFileAttributes(lpFileName: string): Cardinal; + external 'GetFileAttributesW@kernel32.dll stdcall'; + +// True when the folder or any folder above it is a junction/symlink (or unreadable). +function HasReparsePointOnPath(Dir: string): Boolean; +var + Attributes: Cardinal; + Parent: string; +begin + Result := True; + while True do + begin + Attributes := GetFileAttributes(Dir); + if (Attributes = $FFFFFFFF) or ((Attributes and $400) <> 0) then + exit; + Parent := ExtractFileDir(Dir); + if (Parent = '') or (Length(Parent) >= Length(Dir)) then + break; + Dir := Parent; + end; + Result := False; +end; + // An earlier install in another folder: remove only the program files ThreadPilot put there. // The old uninstaller is not run because it also deletes the user's settings in AppData. procedure MovePreviousInstall(); @@ -134,7 +157,15 @@ begin not FileExists(AddBackslash(OldDir) + '{#MyAppExeName}') then exit; - DeleteFile(AddBackslash(OldDir) + '{#MyAppExeName}'); + // The old folder may be user-writable: if it was swapped for a junction, admin deletes would land elsewhere. + if HasReparsePointOnPath(OldDir) then + exit; + + // A copy that is still closing (in-app update) holds the exe for a moment. + DelayDeleteFile(AddBackslash(OldDir) + '{#MyAppExeName}', 40); + if FileExists(AddBackslash(OldDir) + '{#MyAppExeName}') then + exit; + DeleteFile(AddBackslash(OldDir) + 'ThreadPilot.pdb'); DeleteFile(AddBackslash(OldDir) + 'D3DCompiler_47_cor3.dll'); DeleteFile(AddBackslash(OldDir) + 'PenImc_cor3.dll'); diff --git a/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs b/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs index 6287959..28c9ce6 100644 --- a/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs @@ -43,6 +43,17 @@ public void PrimaryInstaller_AlwaysInstallsInProgramFiles_AndMovesOldFoldersWith // The old uninstaller would also delete %AppData%\ThreadPilot, the user's settings. Assert.DoesNotContain("unins000.exe'", script.Replace("DeleteFile(AddBackslash(OldDir) + 'unins000.exe');", string.Empty), StringComparison.Ordinal); Assert.DoesNotContain("DelTree", script, StringComparison.Ordinal); + + // The old folder may be user-writable: swapped for a junction, admin deletes would land elsewhere. + var move = script[script.IndexOf("procedure MovePreviousInstall()", StringComparison.Ordinal)..]; + Assert.True( + move.IndexOf("if HasReparsePointOnPath(OldDir) then", StringComparison.Ordinal) + < move.IndexOf("DelayDeleteFile(", StringComparison.Ordinal)); + Assert.Contains("external 'GetFileAttributesW@kernel32.dll stdcall'", script, StringComparison.Ordinal); + + // A copy still closing (in-app update) holds the exe for a moment; the rest goes only once it is gone. + Assert.Contains("DelayDeleteFile(AddBackslash(OldDir) + '{#MyAppExeName}', 40);", move, StringComparison.Ordinal); + Assert.Contains("if FileExists(AddBackslash(OldDir) + '{#MyAppExeName}') then", move, StringComparison.Ordinal); } [Fact] From 804f29c9a2062caa92d0a6e05f4abb8e2bdb1733 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 22 Oct 2026 06:17:37 +0200 Subject: [PATCH 29/34] fix: portable autostart says why nothing was installed On a preview build with prerelease updates off only the older stable release is found, and the user saw only the generic "needs Program Files" warning; network and download errors were dropped too. The reason is now added to the warning (new string in 7 languages). --- Locales/de-DE.xaml | 1 + Locales/en-US.xaml | 1 + Locales/es-ES.xaml | 1 + Locales/fr-FR.xaml | 1 + Locales/it-IT.xaml | 1 + Locales/ru-RU.xaml | 1 + Locales/zh-CN.xaml | 1 + .../SettingsViewModelThemeTests.cs | 28 +++++++++++++++++++ ViewModels/SettingsViewModel.cs | 25 +++++++++++++---- 9 files changed, 54 insertions(+), 6 deletions(-) diff --git a/Locales/de-DE.xaml b/Locales/de-DE.xaml index e2cef4f..9d7d6ed 100644 --- a/Locales/de-DE.xaml +++ b/Locales/de-DE.xaml @@ -501,6 +501,7 @@ ThreadPilot-Konfiguration importieren Der Autostart von Windows konnte nicht aktualisiert werden. Den vorherigen Autostart-Status beibehalten. Der Autostart erfordert eine Installation von ThreadPilot unter „Programme“: Eine portable Kopie könnte von jedem Programm ersetzt und dann als Administrator gestartet werden. + Es wurde kein offizielles Installationsprogramm dieser oder einer neueren Version gefunden. Bei einer Vorabversion aktivieren Sie „Fügen Sie Vorabversionsaktualisierungen hinzu“ und speichern Sie erneut. ThreadPilot für den Autostart installieren Der Autostart erfordert ThreadPilot unter „Programme“. Jetzt die offizielle Version herunterladen, prüfen und installieren? ThreadPilot startet danach aus „Programme“ mit aktivem Autostart neu; diesen portablen Ordner kannst du anschließend löschen. diff --git a/Locales/en-US.xaml b/Locales/en-US.xaml index a2223bc..22cb72e 100644 --- a/Locales/en-US.xaml +++ b/Locales/en-US.xaml @@ -501,6 +501,7 @@ Import ThreadPilot Configuration Failed to update Windows autostart. Keeping previous autostart state. Autostart needs ThreadPilot installed in Program Files: a portable copy could be replaced by any program and then started as administrator. + No official installer of this version or newer was found. For a preview version, turn on "Include prerelease updates" and save again. Install ThreadPilot for autostart Autostart needs ThreadPilot installed in Program Files. Download, verify and install the official version now? ThreadPilot restarts from Program Files with autostart on; you can delete this portable folder afterwards. diff --git a/Locales/es-ES.xaml b/Locales/es-ES.xaml index 971db83..f199574 100644 --- a/Locales/es-ES.xaml +++ b/Locales/es-ES.xaml @@ -501,6 +501,7 @@ Importar configuración ThreadPilot No se pudo actualizar el inicio automático Windows. Manteniendo el estado de inicio automático anterior. El inicio automático requiere ThreadPilot instalado en Archivos de programa: cualquier programa podría reemplazar una copia portable y ejecutarla como administrador. + No se encontró ningún instalador oficial de esta versión o de una más reciente. Si es una versión preliminar, activa «Incluir actualizaciones preliminares» y guarda de nuevo. Instalar ThreadPilot para el inicio automático El inicio automático requiere ThreadPilot instalado en Archivos de programa. ¿Descargar, verificar e instalar ahora la versión oficial? ThreadPilot se reiniciará desde Archivos de programa con el inicio automático activado; después podrás eliminar esta carpeta portable. diff --git a/Locales/fr-FR.xaml b/Locales/fr-FR.xaml index 069d0f4..ea3d136 100644 --- a/Locales/fr-FR.xaml +++ b/Locales/fr-FR.xaml @@ -501,6 +501,7 @@ Importer la configuration ThreadPilot Échec de la mise à jour du démarrage automatique Windows. Conserver l'état de démarrage automatique précédent. Le démarrage automatique nécessite ThreadPilot installé dans Program Files : n'importe quel programme pourrait remplacer une copie portable, qui serait ensuite lancée en administrateur. + Aucun programme d'installation officiel de cette version ou d'une version plus récente n'a été trouvé. Pour une préversion, activez « Inclure les mises à jour préliminaires » et enregistrez à nouveau. Installer ThreadPilot pour le démarrage automatique Le démarrage automatique nécessite ThreadPilot installé dans Program Files. Télécharger, vérifier et installer la version officielle maintenant ? ThreadPilot redémarrera depuis Program Files avec le démarrage automatique activé ; vous pourrez ensuite supprimer ce dossier portable. diff --git a/Locales/it-IT.xaml b/Locales/it-IT.xaml index 8f07792..8496980 100644 --- a/Locales/it-IT.xaml +++ b/Locales/it-IT.xaml @@ -501,6 +501,7 @@ Importa ThreadPilot Configurazione Impossibile aggiornare l'avvio automatico Windows. Mantenimento dello stato di avvio automatico precedente. L'avvio automatico richiede ThreadPilot installato in Programmi: una copia portable potrebbe essere sostituita da qualsiasi programma e poi avviata come amministratore. + Non è stato trovato un installer ufficiale di questa versione o di una più recente. Per una versione di anteprima, attiva «Includere aggiornamenti preliminari» e salva di nuovo. Installa ThreadPilot per l'avvio automatico L'avvio automatico richiede ThreadPilot installato in Programmi. Scaricare, verificare e installare ora la versione ufficiale? ThreadPilot si riavvierà da Programmi con l'avvio automatico attivo; poi potrai eliminare questa cartella portable. diff --git a/Locales/ru-RU.xaml b/Locales/ru-RU.xaml index 12a6486..12a2591 100644 --- a/Locales/ru-RU.xaml +++ b/Locales/ru-RU.xaml @@ -501,6 +501,7 @@ Импорт конфигурации ThreadPilot Не удалось обновить автозапуск Windows. Сохранение предыдущего состояния автозапуска. Для автозапуска ThreadPilot должен быть установлен в Program Files: портативную копию может подменить любая программа, и она запустится от имени администратора. + Официальный установщик этой или более новой версии не найден. Для предварительной версии включите «Включить предварительные обновления» и сохраните снова. Установить ThreadPilot для автозапуска Для автозапуска ThreadPilot должен быть установлен в Program Files. Скачать, проверить и установить официальную версию сейчас? ThreadPilot перезапустится из Program Files с включённым автозапуском; после этого портативную папку можно удалить. diff --git a/Locales/zh-CN.xaml b/Locales/zh-CN.xaml index 5887ced..7e73372 100644 --- a/Locales/zh-CN.xaml +++ b/Locales/zh-CN.xaml @@ -501,6 +501,7 @@ 导入 ThreadPilot 配置 无法更新 Windows 自启动。将保留之前的自启动状态。 自启动需要将 ThreadPilot 安装在 Program Files 中:便携版可能被任何程序替换,并以管理员身份启动。 + 未找到此版本或更新版本的官方安装程序。如果是预览版,请开启“包含预发布版本”后再次保存。 安装 ThreadPilot 以启用自启动 自启动需要将 ThreadPilot 安装在 Program Files 中。现在下载、验证并安装官方版本吗?ThreadPilot 将从 Program Files 重新启动并启用自启动;之后可以删除此便携版文件夹。 diff --git a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs index d546708..4411d18 100644 --- a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs @@ -193,6 +193,34 @@ public async Task SaveSettingsCommand_PortableAutostart_OffersTheVerifiedInstall Assert.Equal(expectInstall, viewModel.Settings.AutostartWithWindows); } + [Fact] + public async Task SaveSettingsCommand_PortableBetaAutostart_SaysWhyNothingWasInstalled() + { + // A beta copy with prerelease updates off finds only the older stable release: it must say so + // instead of repeating the generic "needs Program Files" warning. + var harness = new Harness(); + harness.Autostart.Setup(service => service.CheckAutostartStatusAsync()).ReturnsAsync(false); + harness.Autostart.Setup(service => service.EnableAutostartAsync(It.IsAny())).ReturnsAsync(false); + harness.Autostart.SetupGet(service => service.IsAutostartSupported).Returns(false); + var stable = new UpdateReleaseInfo( + new SemanticVersion(1, 7, 7), + "v1.7.7", + new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases"), + false, + []); + harness.Updates + .Setup(service => service.CheckForUpdatesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UpdateCheckResult(UpdateCheckStatus.UpToDate, new SemanticVersion(1, 7, 8, "beta.1"), stable, "ok")); + var viewModel = harness.CreateViewModel(); + viewModel.ConfirmPrompt = (_, _) => true; + viewModel.Settings.AutostartWithWindows = true; + + await ((IAsyncRelayCommand)viewModel.SaveSettingsCommand).ExecuteAsync(null); + + Assert.Contains("Include prerelease updates", viewModel.StatusMessage, StringComparison.Ordinal); + Assert.False(viewModel.Settings.AutostartWithWindows); + } + [Fact] public async Task SaveSettingsCommand_WhenStartMinimizedIsDisabled_UpdatesEnabledAutostart() { diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs index e2a7b11..0612717 100644 --- a/ViewModels/SettingsViewModel.cs +++ b/ViewModels/SettingsViewModel.cs @@ -341,7 +341,8 @@ private async Task SaveSettingsAsync() if (!autostartUpdated && this.Settings.AutostartWithWindows && !this.autostartService.IsAutostartSupported) { - if (await this.TryInstallForAutostartAsync()) + var installFailure = await this.TryInstallForAutostartAsync(); + if (installFailure == null) { this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateInstallerStarted", "Update installer started."); } @@ -350,6 +351,11 @@ private async Task SaveSettingsAsync() warnings.Add(this.GetLocalizedString( "Settings_WarningAutostartNeedsInstall", "Autostart needs ThreadPilot installed in Program Files: a portable copy could be replaced by any program and then started as administrator.")); + if (installFailure.Length > 0) + { + warnings.Add(installFailure); + } + this.Settings.AutostartWithWindows = currentAutostartState; } } @@ -757,7 +763,8 @@ private async Task RefreshPowerPlansAsync() // A portable copy cannot autostart safely, so offer the official installer through the verified update // path; the installed copy turns autostart on at its first start (App.OnStartup). - private async Task TryInstallForAutostartAsync() + // Null when the installer started; otherwise why not (empty when the user declined). + private async Task TryInstallForAutostartAsync() { var confirmed = this.ConfirmPrompt( this.GetLocalizedString( @@ -766,22 +773,28 @@ private async Task TryInstallForAutostartAsync() this.GetLocalizedString("Settings_AutostartInstallConfirmTitle", "Install ThreadPilot for autostart")); if (!confirmed) { - return false; + return string.Empty; } var check = await this.updateService.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Manual)); + if (check.Release == null) + { + return check.Message; + } // Never downgrade: a portable copy newer than the latest release (a beta or dev build) has nothing to install. - if (check.Release == null || check.Release.Version < check.CurrentVersion) + if (check.Release.Version < check.CurrentVersion) { - return false; + return this.GetLocalizedString( + "Settings_WarningAutostartNoInstallableRelease", + "No official installer of this version or newer was found. For a preview version, turn on \"Include prerelease updates\" and save again."); } // Saved before the installer starts, since this copy is closed for it. this.Settings.AutostartWithWindows = true; await this.settingsService.UpdateSettingsAsync(this.Settings); var result = await this.updateService.DownloadAndInstallAsync(check.Release); - return result.Status == UpdateInstallStatus.Started; + return result.Status == UpdateInstallStatus.Started ? null : result.Message; } private async Task CheckUpdatesAsync() From 1a664b36f13c7d0fd4fb131608e347f702c18e57 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 22 Oct 2026 06:18:31 +0200 Subject: [PATCH 30/34] refactor: small cleanups from the final review Game Boost StopAsync and Dispose reuse SyncTimer for the timer; the always-true UpdateDownloadResult.ChecksumVerified and an unused using are gone. No behavior change. --- MainWindow.Behaviors.partial.cs | 1 - Services/GameSessionService.cs | 10 ++-------- Services/UpdateDownloadService.cs | 1 - Services/UpdateModels.cs | 1 - Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs | 2 -- 5 files changed, 2 insertions(+), 13 deletions(-) diff --git a/MainWindow.Behaviors.partial.cs b/MainWindow.Behaviors.partial.cs index 3ebc08b..e749691 100644 --- a/MainWindow.Behaviors.partial.cs +++ b/MainWindow.Behaviors.partial.cs @@ -6,7 +6,6 @@ namespace ThreadPilot using System.Linq; using System.Threading; using System.Threading.Tasks; - using System.Timers; using System.Windows; using System.Windows.Controls; using System.Windows.Input; diff --git a/Services/GameSessionService.cs b/Services/GameSessionService.cs index 8f8ff5c..fdcf6d0 100644 --- a/Services/GameSessionService.cs +++ b/Services/GameSessionService.cs @@ -355,8 +355,7 @@ public async Task StopAsync() { this.started = false; this.stopped = true; - this.timer?.Dispose(); - this.timer = null; + this.SyncTimer(); } await this.stateLock.WaitAsync().ConfigureAwait(false); @@ -379,12 +378,7 @@ public void Dispose() this.disposed = true; this.settingsService.SettingsChanged -= this.OnSettingsChanged; - lock (this.timerGate) - { - this.timer?.Dispose(); - this.timer = null; - } - + this.SyncTimer(); this.gameHandle?.Dispose(); } diff --git a/Services/UpdateDownloadService.cs b/Services/UpdateDownloadService.cs index 26d948a..36cb8df 100644 --- a/Services/UpdateDownloadService.cs +++ b/Services/UpdateDownloadService.cs @@ -73,7 +73,6 @@ await this.downloadClient.DownloadFileAsync(installerAsset.DownloadUrl, installe return new UpdateDownloadResult( installerPath, tempDirectory, - true, signatureStatus, "Installer checksum verified."); } diff --git a/Services/UpdateModels.cs b/Services/UpdateModels.cs index 68f8f05..364578d 100644 --- a/Services/UpdateModels.cs +++ b/Services/UpdateModels.cs @@ -58,7 +58,6 @@ public sealed record UpdateCheckResult( public sealed record UpdateDownloadResult( string InstallerPath, string TempDirectory, - bool ChecksumVerified, UpdateSignatureStatus SignatureStatus, string Message); diff --git a/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs b/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs index 251a10b..6bdeadf 100644 --- a/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs @@ -103,7 +103,6 @@ public async Task DownloadInstallerAsync_VerifiesChecksum() var result = await service.DownloadInstallerAsync(CreateReleaseWithInstallerAndChecksum()); - Assert.True(result.ChecksumVerified); Assert.True(File.Exists(result.InstallerPath)); } @@ -166,7 +165,6 @@ public async Task DownloadAndInstallAsync_StartsInstallerAndRequestsShutdown() .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"); From c3d16bc7bbbca47dd8c88d9e4854217550bd7e07 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 22 Oct 2026 07:38:44 +0200 Subject: [PATCH 31/34] perf: size the process table in pixels instead of an Auto column An Auto grid column re-measured the DataGrid at infinite width on every list refresh: with the window open ThreadPilot used ~5.3% of a core instead of ~2.5% (ABBA A/B, 6 runs each). The width is now the sum of the visible columns, recomputed only when a column or the window changes; measured again at ~2.4%. --- .../ProcessViewXamlBindingTests.cs | 6 ++-- Views/ProcessView.xaml | 2 +- Views/ProcessView.xaml.cs | 36 ++++++++++++++++--- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs index 29cef3e..a6fba0e 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs @@ -309,9 +309,11 @@ public void WideLayout_TableSizesToItsColumnsAndLeavesTheRestToTheDetails() Assert.NotEmpty(columns); Assert.All(columns, column => Assert.DoesNotContain("*", column.Attribute("Width")?.Value ?? string.Empty, StringComparison.Ordinal)); + // A pixel width from the visible columns, never Auto: an Auto column re-measured the DataGrid at + // infinite width on every list refresh and doubled the CPU with the window open. var codeBehind = File.ReadAllText(ProcessViewPath + ".cs"); - Assert.Contains("this.TableColumn.Width = wide ? System.Windows.GridLength.Auto", codeBehind, StringComparison.Ordinal); - Assert.Contains("this.ProcessTable.MaxWidth = wide ? e.NewSize.Width - DetailMinWidth", codeBehind, StringComparison.Ordinal); + Assert.DoesNotContain("GridLength.Auto", codeBehind, StringComparison.Ordinal); + Assert.Contains("Math.Min(columns + chrome, this.workspaceWidth - DetailMinWidth)", codeBehind, StringComparison.Ordinal); Assert.Contains("private const double DetailMinWidth = 400;", codeBehind, StringComparison.Ordinal); } diff --git a/Views/ProcessView.xaml b/Views/ProcessView.xaml index 89f52d6..ca604a6 100644 --- a/Views/ProcessView.xaml +++ b/Views/ProcessView.xaml @@ -259,7 +259,7 @@ - +