From 5d19a46fc1f0d4dcb5cf4735b7730456db43b683 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Wed, 14 Oct 2026 17:16:36 +0200 Subject: [PATCH 01/16] feat: per-process efficiency mode (EcoQoS) for live processes and rules Closes #66. - Native: SetProcessInformation/GetProcessInformation with ProcessPowerThrottling (EXECUTION_SPEED), three states: Windows decides, On (EcoQoS), Off (never throttle / HighQoS). - Process tab: context menu entries, a picker with Apply in the details panel, current state in the summary, undo of the last change, included when saving current settings as a rule (only if explicitly set). - Rules: EfficiencyMode + ApplyEfficiencyModeOnStart, applied by the rules engine, editable in the saved-rule editor, validated on import and part of conflict detection. - The summary flags when the global Power Throttling tweak voids it. CPU priority is left alone, unlike Task Manager's efficiency mode. - Localized in all seven languages. --- Locales/de-DE.xaml | 11 ++ Locales/en-US.xaml | 11 ++ Locales/es-ES.xaml | 11 ++ Locales/fr-FR.xaml | 11 ++ Locales/it-IT.xaml | 11 ++ Locales/ru-RU.xaml | 11 ++ Locales/zh-CN.xaml | 11 ++ Models/PersistentProcessRule.cs | 6 + Models/ProcessMemoryPriority.cs | 8 ++ .../IProcessMemoryPriorityNativeApi.cs | 25 ++++ .../ProcessMemoryPriorityNativeMethods.cs | 16 +++ Services/ConfigurationBackupService.cs | 2 + Services/IProcessMemoryPriorityService.cs | 9 ++ Services/PersistentRuleResolution.cs | 1 + Services/PersistentRulesEngine.cs | 29 +++++ Services/ProcessMemoryPriorityService.cs | 108 +++++++++++++++++- Services/ProcessRuleCreationService.cs | 12 ++ Services/SystemTweaksService.cs | 14 +++ .../PersistentRulesEngineTests.cs | 17 +++ ...essMemoryPriorityNativeIntegrationTests.cs | 24 ++++ .../ProcessMemoryPriorityServiceTests.cs | 48 ++++++++ .../ProcessRuleCreationServiceTests.cs | 19 +++ .../SelectedProcessSummaryViewModelTests.cs | 15 +++ .../ProcessPowerPlanAssociationViewModel.cs | 15 +++ .../ProcessViewModel.Behaviors.partial.cs | 7 +- ViewModels/ProcessViewModel.PriorityEditor.cs | 58 +++++++++- ViewModels/ProcessViewModel.cs | 3 +- ViewModels/SelectedProcessSummaryViewModel.cs | 56 ++++++++- Views/ProcessPowerPlanAssociationView.xaml | 7 ++ Views/ProcessView.xaml | 32 ++++++ 30 files changed, 596 insertions(+), 12 deletions(-) diff --git a/Locales/de-DE.xaml b/Locales/de-DE.xaml index c4e1b78..0f46f79 100644 --- a/Locales/de-DE.xaml +++ b/Locales/de-DE.xaml @@ -776,4 +776,15 @@ Prozesse angezeigt Nicht gespeicherte Änderungen Vorherige Priorität wiederhergestellt. + + + Effizienzmodus (EcoQoS) + Effizienzmodus (EcoQoS) + Ein: Windows führt den Prozess mit niedrigerem Takt aus und bevorzugt Effizienzkerne. Aus: Windows drosselt ihn nie. Anders als im Task-Manager bleibt die CPU-Priorität unverändert. Ohne Wirkung, solange die Energiedrosselung in den Optimierungen deaktiviert ist. + Windows entscheidet + Ein (EcoQoS) + Aus (nie drosseln) + Effizienzmodus: {0} + Effizienzmodus nicht verfügbar + Effizienzmodus: {0} (ohne Wirkung: Energiedrosselung ist in den Optimierungen deaktiviert) diff --git a/Locales/en-US.xaml b/Locales/en-US.xaml index 1158a7e..7058e89 100644 --- a/Locales/en-US.xaml +++ b/Locales/en-US.xaml @@ -776,4 +776,15 @@ processes shown Unsaved changes Previous priority restored. + + + Efficiency mode (EcoQoS) + Efficiency mode (EcoQoS) + On: Windows runs the process at lower clock speeds and prefers efficiency cores. Off: Windows never throttles it. Unlike Task Manager, CPU priority is not changed. No effect while Windows power throttling is disabled in Tweaks. + Windows decides + On (EcoQoS) + Off (never throttle) + Efficiency mode: {0} + Efficiency mode unavailable + Efficiency mode: {0} (no effect: power throttling is disabled in Tweaks) diff --git a/Locales/es-ES.xaml b/Locales/es-ES.xaml index 589a17f..85330ae 100644 --- a/Locales/es-ES.xaml +++ b/Locales/es-ES.xaml @@ -776,4 +776,15 @@ procesos mostrados Cambios sin guardar Prioridad anterior restaurada. + + + Modo de eficiencia (EcoQoS) + Modo de eficiencia (EcoQoS) + Activado: Windows ejecuta el proceso a menor frecuencia y prefiere los núcleos de eficiencia. Desactivado: Windows nunca lo limita. A diferencia del Administrador de tareas, no cambia la prioridad de CPU. Sin efecto si la limitación de energía de Windows está desactivada en Ajustes. + Decide Windows + Activado (EcoQoS) + Desactivado (nunca limitar) + Modo de eficiencia: {0} + Modo de eficiencia no disponible + Modo de eficiencia: {0} (sin efecto: la limitación de energía está desactivada en Ajustes) diff --git a/Locales/fr-FR.xaml b/Locales/fr-FR.xaml index 6083a0e..c00e597 100644 --- a/Locales/fr-FR.xaml +++ b/Locales/fr-FR.xaml @@ -776,4 +776,15 @@ processus affichés Modifications non enregistrées Priorité précédente restaurée. + + + Mode efficacité (EcoQoS) + Mode efficacité (EcoQoS) + Activé : Windows exécute le processus à fréquence réduite et privilégie les cœurs à efficacité énergétique. Désactivé : Windows ne le bride jamais. Contrairement au Gestionnaire des tâches, la priorité CPU ne change pas. Sans effet si la limitation d'alimentation de Windows est désactivée dans les Optimisations. + Windows décide + Activé (EcoQoS) + Désactivé (jamais brider) + Mode efficacité : {0} + Mode efficacité indisponible + Mode efficacité : {0} (sans effet : la limitation d'alimentation est désactivée dans les Optimisations) diff --git a/Locales/it-IT.xaml b/Locales/it-IT.xaml index 10501a1..095b2ae 100644 --- a/Locales/it-IT.xaml +++ b/Locales/it-IT.xaml @@ -776,4 +776,15 @@ processi visibili Modifiche non salvate Priorità precedente ripristinata. + + + Modalità efficienza (EcoQoS) + Modalità efficienza (EcoQoS) + Attiva: Windows esegue il processo a frequenze più basse e preferisce i core efficienti. Disattivata: Windows non lo rallenta mai. A differenza del Task Manager, la priorità CPU non cambia. Nessun effetto se il power throttling di Windows è disattivato nelle Ottimizzazioni. + Decide Windows + Attiva (EcoQoS) + Disattivata (mai rallentare) + Modalità efficienza: {0} + Modalità efficienza non disponibile + Modalità efficienza: {0} (nessun effetto: il power throttling è disattivato nelle Ottimizzazioni) diff --git a/Locales/ru-RU.xaml b/Locales/ru-RU.xaml index c3a38c9..1727c2e 100644 --- a/Locales/ru-RU.xaml +++ b/Locales/ru-RU.xaml @@ -776,4 +776,15 @@ процессов показано Несохранённые изменения Предыдущий приоритет восстановлен. + + + Режим эффективности (EcoQoS) + Режим эффективности (EcoQoS) + Вкл.: Windows запускает процесс на пониженной частоте и предпочитает энергоэффективные ядра. Выкл.: Windows никогда его не замедляет. В отличие от диспетчера задач, приоритет ЦП не меняется. Не действует, если регулирование мощности Windows отключено в оптимизациях. + Решает Windows + Вкл. (EcoQoS) + Выкл. (не замедлять) + Режим эффективности: {0} + Режим эффективности недоступен + Режим эффективности: {0} (не действует: регулирование мощности отключено в оптимизациях) diff --git a/Locales/zh-CN.xaml b/Locales/zh-CN.xaml index 3217dc2..73de758 100644 --- a/Locales/zh-CN.xaml +++ b/Locales/zh-CN.xaml @@ -776,4 +776,15 @@ 个进程已显示 未保存的更改 已恢复之前的优先级。 + + + 效率模式 (EcoQoS) + 效率模式 (EcoQoS) + 开启:Windows 以较低频率运行该进程并优先使用能效核心。关闭:Windows 从不限制它。与任务管理器不同,不会更改 CPU 优先级。如果在优化中禁用了 Windows 电源限制,则此设置无效。 + 由 Windows 决定 + 开启 (EcoQoS) + 关闭(从不限制) + 效率模式:{0} + 效率模式不可用 + 效率模式:{0}(无效:已在优化中禁用电源限制) diff --git a/Models/PersistentProcessRule.cs b/Models/PersistentProcessRule.cs index 65271d3..75dc52d 100644 --- a/Models/PersistentProcessRule.cs +++ b/Models/PersistentProcessRule.cs @@ -30,6 +30,8 @@ public sealed record PersistentProcessRule public ProcessIoPriority? IoPriority { get; init; } + public ProcessEfficiencyMode? EfficiencyMode { get; init; } + public bool ApplyAffinityOnStart { get; init; } public bool ApplyPriorityOnStart { get; init; } @@ -38,6 +40,8 @@ public sealed record PersistentProcessRule public bool ApplyIoPriorityOnStart { get; init; } + public bool ApplyEfficiencyModeOnStart { get; init; } + public bool PreventSystemSleepWhileRunning { get; init; } public DateTime CreatedAt { get; init; } = DateTime.UtcNow; @@ -75,6 +79,8 @@ public sealed record PersistentRuleApplyResult public bool IoPriorityApplied { get; init; } + public bool EfficiencyModeApplied { get; init; } + public bool PreventSleepApplied { get; init; } public string? ErrorCode { get; init; } diff --git a/Models/ProcessMemoryPriority.cs b/Models/ProcessMemoryPriority.cs index 645dd8a..8e58b63 100644 --- a/Models/ProcessMemoryPriority.cs +++ b/Models/ProcessMemoryPriority.cs @@ -19,4 +19,12 @@ public enum ProcessIoPriority Normal = 2, High = 3, } + + // Windows 11 "efficiency mode" (EcoQoS). Off forces full speed (HighQoS); SystemManaged lets Windows decide. + public enum ProcessEfficiencyMode + { + SystemManaged = 0, + On = 1, + Off = 2, + } } diff --git a/Platforms/Windows/IProcessMemoryPriorityNativeApi.cs b/Platforms/Windows/IProcessMemoryPriorityNativeApi.cs index e1ef5c5..0a312d9 100644 --- a/Platforms/Windows/IProcessMemoryPriorityNativeApi.cs +++ b/Platforms/Windows/IProcessMemoryPriorityNativeApi.cs @@ -35,6 +35,10 @@ int QueryIoPriority(SafeProcessHandle process, ref int priority, out uint return int SetIoPriority(SafeProcessHandle process, ref int priority) => unchecked((int)0xC0000002); + bool GetPowerThrottling(SafeProcessHandle process, ref PowerThrottlingState state) => false; + + bool SetPowerThrottling(SafeProcessHandle process, ref PowerThrottlingState state) => false; + IDisposable? AcquirePrivilegeScope(string privilegeName) => null; } @@ -99,6 +103,14 @@ public int SetIoPriority(SafeProcessHandle process, ref int priority) => ref priority, sizeof(int)); + public bool GetPowerThrottling(SafeProcessHandle process, ref PowerThrottlingState state) => + ProcessMemoryPriorityNativeMethods.GetProcessPowerThrottling( + process, ProcessInformationClass.ProcessPowerThrottling, ref state, (uint)Marshal.SizeOf()); + + public bool SetPowerThrottling(SafeProcessHandle process, ref PowerThrottlingState state) => + ProcessMemoryPriorityNativeMethods.SetProcessPowerThrottling( + process, ProcessInformationClass.ProcessPowerThrottling, ref state, (uint)Marshal.SizeOf()); + public IDisposable? AcquirePrivilegeScope(string privilegeName) => ProcessMemoryPriorityNativeMethods.AcquirePrivilegeScope(privilegeName); } @@ -106,6 +118,19 @@ public int SetIoPriority(SafeProcessHandle process, ref int priority) => public enum ProcessInformationClass { ProcessMemoryPriority = 0, + ProcessPowerThrottling = 4, + } + + // PROCESS_POWER_THROTTLING_STATE: ControlMask picks the mechanism, StateMask turns it on or off. + [StructLayout(LayoutKind.Sequential)] + public struct PowerThrottlingState + { + public const uint CurrentVersion = 1; + public const uint ExecutionSpeed = 0x1; + + public uint Version; + public uint ControlMask; + public uint StateMask; } [StructLayout(LayoutKind.Sequential)] diff --git a/Platforms/Windows/ProcessMemoryPriorityNativeMethods.cs b/Platforms/Windows/ProcessMemoryPriorityNativeMethods.cs index 6970f27..de19d7a 100644 --- a/Platforms/Windows/ProcessMemoryPriorityNativeMethods.cs +++ b/Platforms/Windows/ProcessMemoryPriorityNativeMethods.cs @@ -62,6 +62,22 @@ public static partial bool SetProcessInformation( ref MemoryPriorityInformation processInformation, uint processInformationSize); + [LibraryImport("kernel32.dll", EntryPoint = "GetProcessInformation", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool GetProcessPowerThrottling( + SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref PowerThrottlingState processInformation, + uint processInformationSize); + + [LibraryImport("kernel32.dll", EntryPoint = "SetProcessInformation", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool SetProcessPowerThrottling( + SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref PowerThrottlingState processInformation, + uint processInformationSize); + [LibraryImport("ntdll.dll")] public static partial int NtQueryInformationProcess( SafeProcessHandle process, diff --git a/Services/ConfigurationBackupService.cs b/Services/ConfigurationBackupService.cs index 1d271c7..71d06a2 100644 --- a/Services/ConfigurationBackupService.cs +++ b/Services/ConfigurationBackupService.cs @@ -71,9 +71,11 @@ public static void Validate(ConfigurationBackup backup) (rule.Priority.HasValue && (!Enum.IsDefined(rule.Priority.Value) || rule.Priority == ProcessPriorityClass.RealTime)) || (rule.MemoryPriority.HasValue && !Enum.IsDefined(rule.MemoryPriority.Value)) || (rule.IoPriority.HasValue && !Enum.IsDefined(rule.IoPriority.Value)) || + (rule.EfficiencyMode.HasValue && !Enum.IsDefined(rule.EfficiencyMode.Value)) || (rule.ApplyPriorityOnStart && !rule.Priority.HasValue) || (rule.ApplyMemoryPriorityOnStart && !rule.MemoryPriority.HasValue) || (rule.ApplyIoPriorityOnStart && !rule.IoPriority.HasValue) || + (rule.ApplyEfficiencyModeOnStart && !rule.EfficiencyMode.HasValue) || (rule.ApplyAffinityOnStart && rule.CpuSelection == null && rule.LegacyAffinityMask.GetValueOrDefault() == 0)) { throw new InvalidDataException("Invalid persistent process rule."); diff --git a/Services/IProcessMemoryPriorityService.cs b/Services/IProcessMemoryPriorityService.cs index 5769858..07f0118 100644 --- a/Services/IProcessMemoryPriorityService.cs +++ b/Services/IProcessMemoryPriorityService.cs @@ -19,5 +19,14 @@ Task SetIoPriorityAsync(ProcessModel process, ProcessIoP "Unsupported", "I/O priority is unavailable on this system.", "The configured priority service does not support I/O priority.")); + + Task GetEfficiencyModeAsync(ProcessModel process) => + Task.FromResult(null); + + Task SetEfficiencyModeAsync(ProcessModel process, ProcessEfficiencyMode mode) => + Task.FromResult(ProcessOperationResult.Failed( + "Unsupported", + "Efficiency mode is unavailable on this system.", + "The configured priority service does not support efficiency mode.")); } } diff --git a/Services/PersistentRuleResolution.cs b/Services/PersistentRuleResolution.cs index c94d04d..48ae10a 100644 --- a/Services/PersistentRuleResolution.cs +++ b/Services/PersistentRuleResolution.cs @@ -19,6 +19,7 @@ public static bool HasConflicts(IReadOnlyList rules) => rules.Where(r => r.ApplyPriorityOnStart).Select(r => r.Priority).Distinct().Skip(1).Any() || rules.Where(r => r.ApplyMemoryPriorityOnStart).Select(r => r.MemoryPriority).Distinct().Skip(1).Any() || rules.Where(r => r.ApplyIoPriorityOnStart).Select(r => r.IoPriority).Distinct().Skip(1).Any() || + rules.Where(r => r.ApplyEfficiencyModeOnStart).Select(r => r.EfficiencyMode).Distinct().Skip(1).Any() || rules.Where(r => r.ApplyAffinityOnStart) .Select(r => $"{r.CpuAssignmentMode}:{r.LegacyAffinityMask}:{string.Join(",", (r.CpuSelection?.GlobalLogicalProcessorIndexes ?? []).Order())}") .Distinct().Skip(1).Any(); diff --git a/Services/PersistentRulesEngine.cs b/Services/PersistentRulesEngine.cs index 98b3926..073b408 100644 --- a/Services/PersistentRulesEngine.cs +++ b/Services/PersistentRulesEngine.cs @@ -85,6 +85,7 @@ private async Task ApplyRuleAsync( !rule.ApplyPriorityOnStart && !rule.ApplyMemoryPriorityOnStart && !rule.ApplyIoPriorityOnStart && + !rule.ApplyEfficiencyModeOnStart && !rule.PreventSystemSleepWhileRunning) { return MarkRuleConfigurationFailure( @@ -206,6 +207,34 @@ private async Task ApplyRuleAsync( } } + if (rule.ApplyEfficiencyModeOnStart && !result.IsProcessExited) + { + if (!rule.EfficiencyMode.HasValue) + { + success = false; + result = MarkRuleConfigurationFailure( + result, + rule, + "PersistentRuleMissingEfficiencyMode", + "This saved rule has no efficiency mode value to apply."); + } + else + { + var efficiencyResult = await this.memoryPriorityService + .SetEfficiencyModeAsync(process, rule.EfficiencyMode.Value) + .ConfigureAwait(false); + if (efficiencyResult.Success) + { + result = result with { EfficiencyModeApplied = true }; + } + else + { + success = false; + result = this.MergeMemoryPriorityFailure(result, efficiencyResult); + } + } + } + if (rule.PreventSystemSleepWhileRunning && !result.IsProcessExited) { var applied = this.powerRequestService != null && diff --git a/Services/ProcessMemoryPriorityService.cs b/Services/ProcessMemoryPriorityService.cs index e712ec3..5a922ba 100644 --- a/Services/ProcessMemoryPriorityService.cs +++ b/Services/ProcessMemoryPriorityService.cs @@ -311,6 +311,108 @@ public Task SetIoPriorityAsync(ProcessModel process, Pro } } + public Task GetEfficiencyModeAsync(ProcessModel process) + { + if (!this.nativeApi.IsSupported || !IsValidProcess(process)) + { + return Task.FromResult(null); + } + + try + { + using var handle = this.nativeApi.OpenProcess( + ProcessAccessFlags.PROCESS_QUERY_LIMITED_INFORMATION, + inheritHandle: false, + (uint)process.ProcessId); + var state = new PowerThrottlingState { Version = PowerThrottlingState.CurrentVersion }; + if (handle.IsInvalid || !this.nativeApi.GetPowerThrottling(handle, ref state)) + { + return Task.FromResult(null); + } + + return Task.FromResult( + (state.ControlMask & PowerThrottlingState.ExecutionSpeed) == 0 ? ProcessEfficiencyMode.SystemManaged + : (state.StateMask & PowerThrottlingState.ExecutionSpeed) != 0 ? ProcessEfficiencyMode.On + : ProcessEfficiencyMode.Off); + } + catch (Exception ex) + { + this.logger.LogDebug(ex, "Could not read efficiency mode for process {ProcessId}", process.ProcessId); + return Task.FromResult(null); + } + } + + public Task SetEfficiencyModeAsync(ProcessModel process, ProcessEfficiencyMode mode) + { + const string FailureMessage = "ThreadPilot could not change the efficiency mode."; + if (!IsValidProcess(process)) + { + return Task.FromResult(ProcessOperationResult.Failed( + InvalidProcessErrorCode, + ProcessOperationUserMessages.ProcessExited, + "Process is null or has an invalid PID.")); + } + + if (!Enum.IsDefined(mode)) + { + return Task.FromResult(ProcessOperationResult.Failed( + "InvalidEfficiencyMode", + "This efficiency mode value is not supported.", + $"Efficiency mode value '{mode}' is not supported.")); + } + + try + { + using var handle = this.nativeApi.OpenProcess( + ProcessAccessFlags.PROCESS_SET_INFORMATION, + inheritHandle: false, + (uint)process.ProcessId); + if (handle.IsInvalid) + { + return Task.FromResult(this.FromLastError("OpenProcess failed before SetProcessInformation(ProcessPowerThrottling).", FailureMessage)); + } + + var state = new PowerThrottlingState + { + Version = PowerThrottlingState.CurrentVersion, + ControlMask = mode == ProcessEfficiencyMode.SystemManaged ? 0 : PowerThrottlingState.ExecutionSpeed, + StateMask = mode == ProcessEfficiencyMode.On ? PowerThrottlingState.ExecutionSpeed : 0, + }; + if (!this.nativeApi.SetPowerThrottling(handle, ref state)) + { + return Task.FromResult(this.FromLastError("SetProcessInformation(ProcessPowerThrottling) failed.", FailureMessage)); + } + + return Task.FromResult(ProcessOperationResult.Succeeded( + "Efficiency mode applied.", + $"Process {process.Name} (PID: {process.ProcessId}) efficiency mode set to {mode}.")); + } + catch (Exception ex) when (AffinityApplyExceptionClassifier.IsProcessExited(ex)) + { + return Task.FromResult(ProcessOperationResult.Failed( + AffinityApplyErrorCodes.ProcessExited, + ProcessOperationUserMessages.ProcessExited, + ex.Message, + isProcessExited: true)); + } + catch (Exception ex) when (AffinityApplyExceptionClassifier.IsAccessDenied(ex)) + { + return Task.FromResult(ProcessOperationResult.Failed( + AffinityApplyErrorCodes.AccessDenied, + ProcessOperationUserMessages.AccessDenied, + ex.Message, + isAccessDenied: true)); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Efficiency mode apply failed for process {ProcessId}", process.ProcessId); + return Task.FromResult(ProcessOperationResult.Failed( + AffinityApplyErrorCodes.NativeApplyFailed, + FailureMessage, + ex.Message)); + } + } + private static bool IsValidProcess(ProcessModel? process) => process != null && process.ProcessId > 0; @@ -342,7 +444,9 @@ private static ProcessOperationResult Unsupported(string technicalMessage) => UnsupportedUserMessage, technicalMessage); - private ProcessOperationResult FromLastError(string context) + private ProcessOperationResult FromLastError( + string context, + string failureMessage = "ThreadPilot could not apply the memory priority change.") { var error = this.nativeApi.GetLastWin32Error(); var technicalMessage = $"{context} Win32 error {error}."; @@ -362,7 +466,7 @@ private ProcessOperationResult FromLastError(string context) isProcessExited: true), _ => ProcessOperationResult.Failed( AffinityApplyErrorCodes.NativeApplyFailed, - "ThreadPilot could not apply the memory priority change.", + failureMessage, technicalMessage), }; } diff --git a/Services/ProcessRuleCreationService.cs b/Services/ProcessRuleCreationService.cs index facdd8f..ed8c070 100644 --- a/Services/ProcessRuleCreationService.cs +++ b/Services/ProcessRuleCreationService.cs @@ -21,6 +21,7 @@ Task SaveCurrentSettingsAsRuleAsync( CpuAssignmentMode cpuAssignmentMode = CpuAssignmentMode.Automatic, ProcessIoPriority? currentIoPriority = null, bool preventSystemSleepWhileRunning = false, + ProcessEfficiencyMode? currentEfficiencyMode = null, CancellationToken cancellationToken = default); Task DeleteRuleAsync( @@ -56,6 +57,8 @@ public sealed record ProcessRuleCreationPayload public ProcessIoPriority? IoPriority { get; init; } + public ProcessEfficiencyMode? EfficiencyMode { get; init; } + public bool PreventSystemSleepWhileRunning { get; init; } public CpuAssignmentMode CpuAssignmentMode { get; init; } = CpuAssignmentMode.Automatic; @@ -121,6 +124,7 @@ public async Task SaveCurrentSettingsAsRuleAsync( CpuAssignmentMode cpuAssignmentMode = CpuAssignmentMode.Automatic, ProcessIoPriority? currentIoPriority = null, bool preventSystemSleepWhileRunning = false, + ProcessEfficiencyMode? currentEfficiencyMode = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(process); @@ -132,6 +136,8 @@ public async Task SaveCurrentSettingsAsRuleAsync( : null, MemoryPriority = currentMemoryPriority, IoPriority = currentIoPriority, + // Only an explicit choice is worth saving; "Windows decides" is the default anyway. + EfficiencyMode = currentEfficiencyMode is ProcessEfficiencyMode.SystemManaged ? null : currentEfficiencyMode, PreventSystemSleepWhileRunning = preventSystemSleepWhileRunning, CpuAssignmentMode = cpuAssignmentMode, }; @@ -240,11 +246,13 @@ public async Task UpdateRuleWithCoreSelectionAsync( Priority = rule.Priority, MemoryPriority = rule.MemoryPriority, IoPriority = rule.IoPriority, + EfficiencyMode = rule.EfficiencyMode, CpuAssignmentMode = rule.CpuAssignmentMode, PreventSystemSleepWhileRunning = rule.PreventSystemSleepWhileRunning, ApplyPriorityOnStart = rule.Priority.HasValue, ApplyMemoryPriorityOnStart = rule.MemoryPriority.HasValue, ApplyIoPriorityOnStart = rule.IoPriority.HasValue, + ApplyEfficiencyModeOnStart = rule.EfficiencyMode.HasValue, UpdatedAt = DateTime.UtcNow, }; @@ -406,6 +414,7 @@ public async Task SaveRuleAsync( var priority = payload.Priority ?? existing?.Priority; var memoryPriority = payload.MemoryPriority ?? existing?.MemoryPriority; var ioPriority = payload.IoPriority ?? existing?.IoPriority; + var efficiencyMode = payload.EfficiencyMode ?? existing?.EfficiencyMode; var rule = new PersistentProcessRule { @@ -420,10 +429,12 @@ public async Task SaveRuleAsync( Priority = priority, MemoryPriority = memoryPriority, IoPriority = ioPriority, + EfficiencyMode = efficiencyMode, ApplyAffinityOnStart = HasSelectionPayload(cpuSelection) || legacyAffinityMask.HasValue, ApplyPriorityOnStart = priority.HasValue, ApplyMemoryPriorityOnStart = memoryPriority.HasValue, ApplyIoPriorityOnStart = ioPriority.HasValue, + ApplyEfficiencyModeOnStart = efficiencyMode.HasValue, PreventSystemSleepWhileRunning = payload.PreventSystemSleepWhileRunning, CreatedAt = existing?.CreatedAt ?? now, UpdatedAt = now, @@ -518,6 +529,7 @@ private static bool HasActionablePayload(ProcessRuleCreationPayload payload) => payload.Priority.HasValue || payload.MemoryPriority.HasValue || payload.IoPriority.HasValue || + payload.EfficiencyMode.HasValue || payload.PreventSystemSleepWhileRunning; private static bool HasSelectionPayload(CpuSelection? selection) => diff --git a/Services/SystemTweaksService.cs b/Services/SystemTweaksService.cs index 1447280..a616d21 100644 --- a/Services/SystemTweaksService.cs +++ b/Services/SystemTweaksService.cs @@ -517,6 +517,20 @@ public async Task SetPrefetchAsync(bool enabled) } } + // PowerThrottlingOff=1 turns Windows power throttling off system-wide, which also voids per-process efficiency mode. + public static bool IsPowerThrottlingOff() + { + try + { + using var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\Power\PowerThrottling"); + return key?.GetValue("PowerThrottlingOff") is int value && value == 1; + } + catch (Exception ex) when (ex is System.Security.SecurityException or UnauthorizedAccessException or IOException) + { + return false; + } + } + public Task GetPowerThrottlingStatusAsync() { try diff --git a/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs index 9c1123a..d925878 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs @@ -169,6 +169,23 @@ public async Task ApplyMatchingRulesAsync_WithMemoryPriority_AppliesMemoryPriori Times.Once); } + [Fact] + public async Task ApplyMatchingRulesAsync_WithEfficiencyMode_AppliesIt() + { + var rule = CreateRule() with { EfficiencyMode = ProcessEfficiencyMode.On, ApplyEfficiencyModeOnStart = true }; + var memoryPriorityService = new Mock(MockBehavior.Strict); + memoryPriorityService + .Setup(s => s.SetEfficiencyModeAsync(It.IsAny(), ProcessEfficiencyMode.On)) + .ReturnsAsync(ProcessOperationResult.Succeeded("Efficiency mode applied.", "test")); + var engine = CreateEngine([rule], CreateAffinityService().Object, CreateProcessService().Object, memoryPriorityService.Object); + + var result = Assert.Single(await engine.ApplyMatchingRulesAsync(CreateProcess())); + + Assert.True(result.Success); + Assert.True(result.EfficiencyModeApplied); + memoryPriorityService.Verify(s => s.SetEfficiencyModeAsync(It.IsAny(), ProcessEfficiencyMode.On), Times.Once); + } + [Fact] public async Task ApplyMatchingRulesAsync_WithRealtimePriority_ReturnsControlledFailure() { diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityNativeIntegrationTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityNativeIntegrationTests.cs index 2950ac4..d92449c 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityNativeIntegrationTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityNativeIntegrationTests.cs @@ -11,6 +11,30 @@ namespace ThreadPilot.Core.Tests [SupportedOSPlatform("windows10.0.22000.0")] public sealed class ProcessMemoryPriorityNativeIntegrationTests { + [Fact] + public async Task EfficiencyMode_RoundTripsOnTheCurrentProcess() + { + // Real SetProcessInformation/GetProcessInformation calls: catches a wrong struct layout or class number. + var service = new ThreadPilot.Services.ProcessMemoryPriorityService( + ProcessMemoryPriorityNativeApi.Instance, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var self = new ThreadPilot.Models.ProcessModel { ProcessId = Environment.ProcessId, Name = "testhost" }; + try + { + foreach (var mode in new[] { ThreadPilot.Models.ProcessEfficiencyMode.On, ThreadPilot.Models.ProcessEfficiencyMode.Off }) + { + Assert.True((await service.SetEfficiencyModeAsync(self, mode)).Success); + Assert.Equal(mode, await service.GetEfficiencyModeAsync(self)); + } + } + finally + { + await service.SetEfficiencyModeAsync(self, ThreadPilot.Models.ProcessEfficiencyMode.SystemManaged); + } + + Assert.Equal(ThreadPilot.Models.ProcessEfficiencyMode.SystemManaged, await service.GetEfficiencyModeAsync(self)); + } + [Fact] public void AcquirePrivilegeScope_WithSeIncreaseBasePriorityPrivilege_RestoresOriginalState() { diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs index 7f9af56..8df9c63 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs @@ -27,6 +27,38 @@ public async Task SetMemoryPriorityAsync_WithValidProcess_CallsNativeApi() Assert.Equal("Memory priority applied.", result.UserMessage); } + [Theory] + [InlineData(ProcessEfficiencyMode.On, 1u, 1u)] + [InlineData(ProcessEfficiencyMode.Off, 1u, 0u)] + [InlineData(ProcessEfficiencyMode.SystemManaged, 0u, 0u)] + public async Task SetEfficiencyModeAsync_MapsEachModeToItsThrottlingMasks(ProcessEfficiencyMode mode, uint control, uint state) + { + var nativeApi = new FakeProcessMemoryPriorityNativeApi(); + + var result = await CreateService(nativeApi).SetEfficiencyModeAsync(CreateProcess(), mode); + + Assert.True(result.Success); + Assert.Equal(ProcessAccessFlags.PROCESS_SET_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); + + var readBack = await CreateService(new FakeProcessMemoryPriorityNativeApi { ThrottlingToReturn = nativeApi.LastSetThrottling.Value }) + .GetEfficiencyModeAsync(CreateProcess()); + Assert.Equal(mode, readBack); + } + + [Fact] + public async Task SetEfficiencyModeAsync_WhenWindowsDenies_ReportsAccessDenied() + { + var nativeApi = new FakeProcessMemoryPriorityNativeApi { SetResult = false, LastError = 5 }; + + var result = await CreateService(nativeApi).SetEfficiencyModeAsync(CreateProcess(), ProcessEfficiencyMode.On); + + Assert.False(result.Success); + Assert.True(result.IsAccessDenied); + } + [Fact] public async Task GetMemoryPriorityAsync_WithValidProcess_ReadsNativeApi() { @@ -273,6 +305,10 @@ private sealed class FakeProcessMemoryPriorityNativeApi : IProcessMemoryPriority public ProcessIoPriority? LastSetIoPriority { get; private set; } + public PowerThrottlingState ThrottlingToReturn { get; init; } + + public PowerThrottlingState? LastSetThrottling { get; private set; } + public Exception? OpenException { get; init; } public Exception? SetException { get; init; } @@ -327,6 +363,18 @@ public bool SetProcessInformation( public int GetLastWin32Error() => this.LastError; + public bool GetPowerThrottling(SafeProcessHandle process, ref PowerThrottlingState state) + { + state = this.ThrottlingToReturn; + return true; + } + + public bool SetPowerThrottling(SafeProcessHandle process, ref PowerThrottlingState state) + { + this.LastSetThrottling = state; + return this.SetResult; + } + public int QueryIoPriority(SafeProcessHandle process, ref int priority, out uint returnLength) { priority = (int)this.IoPriorityToReturn; diff --git a/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs index 9cfdf21..61b517a 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs @@ -426,6 +426,25 @@ private static CpuSelection CreateCpuSelection() => GlobalLogicalProcessorIndexes = [0], }; + [Theory] + [InlineData(ProcessEfficiencyMode.On, true)] + [InlineData(ProcessEfficiencyMode.SystemManaged, false)] + public async Task SaveCurrentSettingsAsRuleAsync_SavesOnlyAnExplicitEfficiencyMode(ProcessEfficiencyMode current, bool saved) + { + var store = new CapturingRuleStore([]); + var service = CreateService(store); + + await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.High, affinity: 0), + currentCoreSelection: null, + currentMemoryPriority: null, + currentEfficiencyMode: current); + + var rule = Assert.Single(store.SavedRules); + Assert.Equal(saved, rule.ApplyEfficiencyModeOnStart); + Assert.Equal(saved ? current : null, rule.EfficiencyMode); + } + [Fact] public async Task SaveRuleAsync_KeepsFieldsTheCallerDidNotCarry() { diff --git a/Tests/ThreadPilot.Core.Tests/SelectedProcessSummaryViewModelTests.cs b/Tests/ThreadPilot.Core.Tests/SelectedProcessSummaryViewModelTests.cs index 7aca5a5..8c47013 100644 --- a/Tests/ThreadPilot.Core.Tests/SelectedProcessSummaryViewModelTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SelectedProcessSummaryViewModelTests.cs @@ -68,6 +68,21 @@ public async Task UpdateAsync_WhenMemoryPriorityReadSucceeds_PopulatesMemoryPrio Assert.Equal("Memory priority: BelowNormal", viewModel.MemoryPriorityText); } + [Theory] + [InlineData(false, "Efficiency mode: On")] + [InlineData(true, "Efficiency mode: On (no effect: power throttling is disabled in Tweaks)")] + public async Task UpdateAsync_FlagsEfficiencyModeVoidedByTheGlobalTweak(bool throttlingOff, string expected) + { + var service = new Mock(); + service.Setup(s => s.GetEfficiencyModeAsync(It.IsAny())).ReturnsAsync(ProcessEfficiencyMode.On); + var viewModel = new SelectedProcessSummaryViewModel(service.Object, isPowerThrottlingOff: () => throttlingOff); + + await viewModel.UpdateAsync(CreateProcess()); + + Assert.Equal(ProcessEfficiencyMode.On, viewModel.EfficiencyMode); + Assert.Equal(expected, viewModel.EfficiencyModeText); + } + [Fact] public async Task UpdateAsync_WhenMemoryPriorityUnavailable_ShowsUnavailableWithoutThrowing() { diff --git a/ViewModels/ProcessPowerPlanAssociationViewModel.cs b/ViewModels/ProcessPowerPlanAssociationViewModel.cs index f8c6db2..1de8e09 100644 --- a/ViewModels/ProcessPowerPlanAssociationViewModel.cs +++ b/ViewModels/ProcessPowerPlanAssociationViewModel.cs @@ -215,6 +215,10 @@ private async Task DeleteSavedProcessRuleAsync(SavedProcessRule? rule) [NotifyPropertyChangedFor(nameof(HasPendingRuleChanges))] private string savedRuleIoPriority = NoneOption; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPendingRuleChanges))] + private string savedRuleEfficiencyMode = NoneOption; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasPendingRuleChanges))] private string savedRuleCpuAssignmentMode = nameof(CpuAssignmentMode.AffinityMask); @@ -239,6 +243,9 @@ private async Task DeleteSavedProcessRuleAsync(SavedProcessRule? rule) public IReadOnlyList SavedRuleIoPriorityOptions { get; } = [NoneOption, .. Enum.GetNames()]; + public IReadOnlyList SavedRuleEfficiencyModeOptions { get; } = + [NoneOption, .. Enum.GetNames()]; + public IReadOnlyList SavedRuleCpuAssignmentModeOptions { get; } = [.. Enum.GetNames()]; @@ -256,6 +263,7 @@ partial void OnSelectedSavedProcessRuleChanged(SavedProcessRule? value) this.SavedRulePriority = rule.Priority?.ToString() ?? NoneOption; this.SavedRuleMemoryPriority = rule.MemoryPriority?.ToString() ?? NoneOption; this.SavedRuleIoPriority = rule.IoPriority?.ToString() ?? NoneOption; + this.SavedRuleEfficiencyMode = rule.EfficiencyMode?.ToString() ?? NoneOption; this.SavedRuleCpuAssignmentMode = rule.CpuAssignmentMode.ToString(); } @@ -273,6 +281,7 @@ partial void OnSelectedSavedProcessRuleChanged(SavedProcessRule? value) this.SavedRulePriority != (selected.Source.Priority?.ToString() ?? NoneOption) || this.SavedRuleMemoryPriority != (selected.Source.MemoryPriority?.ToString() ?? NoneOption) || this.SavedRuleIoPriority != (selected.Source.IoPriority?.ToString() ?? NoneOption) || + this.SavedRuleEfficiencyMode != (selected.Source.EfficiencyMode?.ToString() ?? NoneOption) || this.SavedRuleCpuAssignmentMode != selected.Source.CpuAssignmentMode.ToString()); [RelayCommand] @@ -291,6 +300,7 @@ private async Task SaveSavedProcessRuleAsync() Priority = ParseOption(this.SavedRulePriority), MemoryPriority = ParseOption(this.SavedRuleMemoryPriority), IoPriority = ParseOption(this.SavedRuleIoPriority), + EfficiencyMode = ParseOption(this.SavedRuleEfficiencyMode), CpuAssignmentMode = Enum.TryParse(this.SavedRuleCpuAssignmentMode, out var mode) ? mode : selected.Source.CpuAssignmentMode, @@ -344,6 +354,11 @@ public static SavedProcessRule From(PersistentProcessRule rule) applies.Add($"I/O {rule.IoPriority}"); } + if (rule.ApplyEfficiencyModeOnStart && rule.EfficiencyMode.HasValue) + { + applies.Add($"efficiency {rule.EfficiencyMode}"); + } + if (rule.PreventSystemSleepWhileRunning) { applies.Add("keeps the system awake"); diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index 851855d..9171fec 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -858,7 +858,8 @@ private async Task SaveCurrentSettingsAsRule(ProcessModel? process) this.SelectedProcessSummary.MemoryPriority, this.SelectedCpuAssignmentMode, this.SelectedProcessSummary.IoPriority, - this.IsIdleServerDisabled); + this.IsIdleServerDisabled, + this.SelectedProcessSummary.EfficiencyMode); this.ApplyRuleCreationResultStatus(result); await this.LogUserActionAsync( @@ -934,7 +935,8 @@ await this.LogUserActionAsync( this.SelectedProcessSummary.MemoryPriority, cpuAssignmentMode: this.SelectedCpuAssignmentMode, currentIoPriority: this.SelectedProcessSummary.IoPriority, - preventSystemSleepWhileRunning: this.IsIdleServerDisabled); + preventSystemSleepWhileRunning: this.IsIdleServerDisabled, + currentEfficiencyMode: this.SelectedProcessSummary.EfficiencyMode); this.ApplyRuleCreationResultStatus(saveResult); await this.LogUserActionAsync( @@ -1695,6 +1697,7 @@ private async Task CopyContextProcessInfo(ProcessModel? process) .AppendLine($"CPU priority: {process.Priority}") .AppendLine($"Memory priority: {this.SelectedProcessSummary.MemoryPriority?.ToString() ?? "unavailable"}") .AppendLine($"I/O priority: {this.SelectedProcessSummary.IoPriority?.ToString() ?? "unavailable"}") + .AppendLine($"Efficiency mode: {this.SelectedProcessSummary.EfficiencyMode?.ToString() ?? "unavailable"}") .AppendLine($"Affinity: 0x{process.ProcessorAffinity:X}") .AppendLine($"Rule status: {this.SelectedProcessSummary.RuleStatusText}"); diff --git a/ViewModels/ProcessViewModel.PriorityEditor.cs b/ViewModels/ProcessViewModel.PriorityEditor.cs index b4bd26e..e85e760 100644 --- a/ViewModels/ProcessViewModel.PriorityEditor.cs +++ b/ViewModels/ProcessViewModel.PriorityEditor.cs @@ -17,6 +17,9 @@ public partial class ProcessViewModel [ObservableProperty] private ProcessIoPriority pendingIoPriority = ProcessIoPriority.Normal; + [ObservableProperty] + private ProcessEfficiencyMode pendingEfficiencyMode = ProcessEfficiencyMode.SystemManaged; + private PriorityUndoSnapshot? priorityUndo; [RelayCommand] @@ -28,6 +31,48 @@ public partial class ProcessViewModel [RelayCommand] private Task ApplySelectedIoPriority() => this.SetContextIoPriorityAsync(this.SelectedProcess, this.PendingIoPriority); + [RelayCommand] + private Task ApplySelectedEfficiencyMode() => this.SetContextEfficiencyModeAsync(this.SelectedProcess, this.PendingEfficiencyMode); + + [RelayCommand] + private Task SetContextEfficiencyOn(ProcessModel? process) => this.SetContextEfficiencyModeAsync(process, ProcessEfficiencyMode.On); + + [RelayCommand] + private Task SetContextEfficiencyOff(ProcessModel? process) => this.SetContextEfficiencyModeAsync(process, ProcessEfficiencyMode.Off); + + [RelayCommand] + private Task SetContextEfficiencySystemManaged(ProcessModel? process) => this.SetContextEfficiencyModeAsync(process, ProcessEfficiencyMode.SystemManaged); + + private async Task SetContextEfficiencyModeAsync(ProcessModel? process, ProcessEfficiencyMode mode) + { + if (process == null || this.memoryPriorityService == null) + { + return; + } + + var previous = await this.memoryPriorityService.GetEfficiencyModeAsync(process); + var undo = previous.HasValue ? CapturePriorityUndo(process, efficiency: previous) : null; + var result = await this.memoryPriorityService.SetEfficiencyModeAsync(process, mode); + if (!result.Success) + { + this.SetContextError(result.UserMessage); + await this.LogUserActionAsync( + "ProcessEfficiencyModeFailed", + result.UserMessage, + $"Process: {process.Name}, PID: {process.ProcessId}, Mode: {mode}"); + await this.UpdateSelectedProcessSummaryAsync(process); + return; + } + + this.RememberPriority(undo); + this.SetStatus($"Efficiency mode applied to {process.Name}: {mode}.", false); + await this.LogUserActionAsync( + "ProcessEfficiencyModeChanged", + $"Efficiency mode changed for {process.Name}: {mode}", + $"PID: {process.ProcessId}"); + await this.UpdateSelectedProcessSummaryAsync(process); + } + private bool CanUndoPriority() => this.priorityUndo != null; private void RememberPriority(PriorityUndoSnapshot? snapshot) @@ -37,13 +82,14 @@ private void RememberPriority(PriorityUndoSnapshot? snapshot) } private static PriorityUndoSnapshot? CapturePriorityUndo( - ProcessModel process, bool cpu = false, ProcessMemoryPriority? memory = null, ProcessIoPriority? io = null) + ProcessModel process, bool cpu = false, ProcessMemoryPriority? memory = null, ProcessIoPriority? io = null, + ProcessEfficiencyMode? efficiency = null) { try { using var native = Process.GetProcessById(process.ProcessId); return new(process.ProcessId, native.StartTime.ToUniversalTime(), process.Name, - cpu ? native.PriorityClass : null, memory, io); + cpu ? native.PriorityClass : null, memory, io, efficiency); } catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) { @@ -82,9 +128,9 @@ private async Task UndoPriorityAsync() return; } - var result = undo.Memory.HasValue - ? await this.memoryPriorityService.SetMemoryPriorityAsync(process, undo.Memory.Value) - : await this.memoryPriorityService.SetIoPriorityAsync(process, undo.Io!.Value); + var result = undo.Memory.HasValue ? await this.memoryPriorityService.SetMemoryPriorityAsync(process, undo.Memory.Value) + : undo.Io.HasValue ? await this.memoryPriorityService.SetIoPriorityAsync(process, undo.Io.Value) + : await this.memoryPriorityService.SetEfficiencyModeAsync(process, undo.Efficiency!.Value); if (!result.Success) { this.SetContextError(result.UserMessage); @@ -113,7 +159,7 @@ private string UiText(string key, string fallback) } internal sealed record PriorityUndoSnapshot(int ProcessId, DateTime StartedUtc, string Name, - ProcessPriorityClass? Cpu, ProcessMemoryPriority? Memory, ProcessIoPriority? Io) + ProcessPriorityClass? Cpu, ProcessMemoryPriority? Memory, ProcessIoPriority? Io, ProcessEfficiencyMode? Efficiency) { public bool Matches(int processId, DateTime startedUtc) => this.ProcessId == processId && this.StartedUtc == startedUtc; } diff --git a/ViewModels/ProcessViewModel.cs b/ViewModels/ProcessViewModel.cs index 5e2c877..4284250 100644 --- a/ViewModels/ProcessViewModel.cs +++ b/ViewModels/ProcessViewModel.cs @@ -248,7 +248,8 @@ public ProcessViewModel( memoryPriorityService, persistentRuleStore, persistentRuleMatcher, - localizationService); + localizationService, + SystemTweaksService.IsPowerThrottlingOff); // Subscribe to topology detection events this.cpuTopologyService.TopologyDetected += this.OnTopologyDetected; diff --git a/ViewModels/SelectedProcessSummaryViewModel.cs b/ViewModels/SelectedProcessSummaryViewModel.cs index 3fbae50..bf96d19 100644 --- a/ViewModels/SelectedProcessSummaryViewModel.cs +++ b/ViewModels/SelectedProcessSummaryViewModel.cs @@ -15,6 +15,7 @@ public sealed class SelectedProcessSummaryViewModel : ObservableObject private readonly IPersistentProcessRuleStore? persistentRuleStore; private readonly IPersistentProcessRuleMatcher? persistentRuleMatcher; private readonly ILocalizationService? localizationService; + private readonly Func isPowerThrottlingOff; private bool hasSelection; private int processId; private string processName = string.Empty; @@ -25,6 +26,8 @@ public sealed class SelectedProcessSummaryViewModel : ObservableObject private long processorAffinity; private ProcessMemoryPriority? memoryPriority; private ProcessIoPriority? ioPriority; + private ProcessEfficiencyMode? efficiencyMode; + private string efficiencyModeText = "Efficiency mode unavailable"; private string processTitle = "No process selected"; private string currentProcessStatusText = "No process selected"; private string cpuUsageText = "CPU: unavailable"; @@ -44,8 +47,10 @@ public SelectedProcessSummaryViewModel( IProcessMemoryPriorityService? memoryPriorityService = null, IPersistentProcessRuleStore? persistentRuleStore = null, IPersistentProcessRuleMatcher? persistentRuleMatcher = null, - ILocalizationService? localizationService = null) + ILocalizationService? localizationService = null, + Func? isPowerThrottlingOff = null) { + this.isPowerThrottlingOff = isPowerThrottlingOff ?? (() => false); this.memoryPriorityService = memoryPriorityService; this.persistentRuleStore = persistentRuleStore; this.persistentRuleMatcher = persistentRuleMatcher; @@ -113,6 +118,18 @@ public ProcessIoPriority? IoPriority private set => this.SetProperty(ref this.ioPriority, value); } + public ProcessEfficiencyMode? EfficiencyMode + { + get => this.efficiencyMode; + private set => this.SetProperty(ref this.efficiencyMode, value); + } + + public string EfficiencyModeText + { + get => this.efficiencyModeText; + private set => this.SetProperty(ref this.efficiencyModeText, value); + } + public string ProcessTitle { get => this.processTitle; @@ -234,6 +251,12 @@ public async Task UpdateAsync( return; } + await this.UpdateEfficiencyModeAsync(process, version); + if (!this.IsCurrentVersion(version)) + { + return; + } + await this.UpdateRuleStatusAsync(process, version); } @@ -265,6 +288,7 @@ private void Clear(int version, string? lastOperationMessage, bool lastOperation this.ProcessorAffinity = 0; this.MemoryPriority = null; this.IoPriority = null; + this.EfficiencyMode = null; this.IsProtectedOrAccessDenied = false; this.HasThreadPilotRule = false; this.ProcessTitle = this.L("ProcessView_NoProcessSelected", "No process selected"); @@ -274,6 +298,7 @@ private void Clear(int version, string? lastOperationMessage, bool lastOperation this.CpuPriorityText = this.L("ProcessSummary_CpuPriorityUnavailable", "CPU priority: unavailable"); this.MemoryPriorityText = this.L("ProcessSummary_MemoryPriorityUnavailable", "Memory priority unavailable"); this.IoPriorityText = this.L("ProcessSummary_IoPriorityUnavailable", "I/O priority unavailable"); + this.EfficiencyModeText = this.L("ProcessSummary_EfficiencyUnavailable", "Efficiency mode unavailable"); this.AffinityText = this.L("ProcessSummary_AffinityUnavailable", "Affinity: unavailable"); this.RuleStatusText = this.L("ProcessSummary_NoSavedRule", "No saved rule"); this.UpdateLastOperation(lastOperationMessage, lastOperationIsError); @@ -343,6 +368,35 @@ private async Task UpdateIoPriorityAsync(ProcessModel process, int version) } } + private async Task UpdateEfficiencyModeAsync(ProcessModel process, int version) + { + this.EfficiencyMode = null; + this.EfficiencyModeText = this.L("ProcessSummary_EfficiencyUnavailable", "Efficiency mode unavailable"); + if (this.memoryPriorityService == null) + { + return; + } + + try + { + var mode = await this.memoryPriorityService.GetEfficiencyModeAsync(process); + if (!this.IsCurrentVersion(version) || mode == null) + { + return; + } + + this.EfficiencyMode = mode.Value; + var label = this.L($"EfficiencyMode_{mode.Value}", mode.Value.ToString()); + this.EfficiencyModeText = mode == ProcessEfficiencyMode.On && this.isPowerThrottlingOff() + ? this.L("ProcessSummary_EfficiencyVoidedFormat", "Efficiency mode: {0} (no effect: power throttling is disabled in Tweaks)", label) + : this.L("ProcessSummary_EfficiencyFormat", "Efficiency mode: {0}", label); + } + catch (Exception) + { + this.EfficiencyMode = null; + } + } + private async Task UpdateRuleStatusAsync(ProcessModel process, int version) { this.HasThreadPilotRule = false; diff --git a/Views/ProcessPowerPlanAssociationView.xaml b/Views/ProcessPowerPlanAssociationView.xaml index cd397a2..097e5fd 100644 --- a/Views/ProcessPowerPlanAssociationView.xaml +++ b/Views/ProcessPowerPlanAssociationView.xaml @@ -384,6 +384,13 @@ SelectedItem="{Binding SavedRuleIoPriority, Mode=TwoWay}" Margin="0,0,0,10"/> + + + + + + + + + @@ -551,6 +571,18 @@ + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ToolTip="{DynamicResource ProcessView_CpuAssignmentModeTooltip}" + AutomationProperties.Name="{DynamicResource ProcessView_CpuAssignmentMode}" + AutomationProperties.HelpText="{DynamicResource ProcessView_CpuAssignmentModeTooltip}"> + + + + - - - - - - + + + + + + + + + + + - - - - - - - + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/UX_REDESIGN_PLAN.md b/docs/UX_REDESIGN_PLAN.md index f3fea03..5c75e93 100644 --- a/docs/UX_REDESIGN_PLAN.md +++ b/docs/UX_REDESIGN_PLAN.md @@ -23,8 +23,10 @@ Branch `feat/ux-clarity`, impilato su #69 (EcoQoS). Nessuna modifica al formato - [x] Processi: ricerca con testo guida; filtri e colonne in due menu a tendina; dettagli in tre riquadri (Processo, Priorità, Core CPU) più la riga della regola; selettori allineati allo stato attuale; testi di aiuto nei tooltip. - [x] Regole: scheda "Regole dei processi" per prima, nomi delle schede più chiari, etichette localizzate nell'editor. - [x] Sottotitoli accorciati in tutte le lingue. -- [ ] Impostazioni e Ottimizzazioni con righe in stile Impostazioni di Windows 11 (`CardControl`): prossimo passo, richiede una riscrittura più ampia delle due viste. -- [ ] Area unica per gli avvisi della finestra principale: da valutare dopo la verifica sul campo. +- [x] Impostazioni e Ottimizzazioni in una colonna di righe in stile Impostazioni di Windows 11 (titolo e descrizione a sinistra, interruttore a destra); sezioni più usate in alto. +- [x] Avvisi della finestra principale: già mostrati uno alla volta (coda elevazione → avvio ridotto → modalità CPU in `MainWindow.Behaviors.partial.cs`); nessuna modifica necessaria. +- [x] Elenco regole: colonne "Applica" e "Modalità" localizzate. +- [x] Rimosse 76 stringhe non più usate da tutte le lingue. ## Verifica From 20abf7006c0fd2bc67859817f3ec1dc0a6cc236b Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Wed, 14 Oct 2026 18:42:05 +0200 Subject: [PATCH 04/16] fix: issues found testing the beta in a clean Windows 11 VM - Process counter could show more visible processes than the total ('157 / 151'): the batch total was a snapshot from the first load. - Narrow windows (or 150% scaling): toolbar buttons move under the search box instead of squeezing it to a few letters. - Rules list keeps the 'Applies' column readable when narrow. - Settings status message gets its own line instead of being cut. Tested in a Hyper-V Windows 11 VM: clean install, upgrade, uninstall, light and dark theme, 100% and 150% scaling. --- .../ProcessViewModelContextMenuTests.cs | 19 +++ .../ProcessViewModel.Behaviors.partial.cs | 7 ++ Views/ProcessPowerPlanAssociationView.xaml | 3 +- Views/ProcessView.xaml | 108 +++++++++--------- Views/ProcessView.xaml.cs | 6 + Views/SettingsView.xaml | 20 ++-- 6 files changed, 99 insertions(+), 64 deletions(-) diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs index 56a4ca6..0b026a7 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs @@ -285,6 +285,25 @@ public async Task PriorityPickers_StartFromTheSelectedProcessCurrentValues() Assert.Null(viewModel.PendingIoPriority); // unreadable: the picker stays empty instead of guessing } + [Fact] + public void ProcessCounter_NeverShowsMoreVisibleThanTotal() + { + var viewModel = CreateViewModel(CreateProcessService().Object); + viewModel.TotalProcessCount = 1; // stale snapshot from an earlier batch load + foreach (var pid in new[] { 101, 102, 103 }) + { + viewModel.Processes.Add(new ProcessModel { ProcessId = pid, Name = $"p{pid}" }); + } + + // Filtering is debounced through the dispatcher in the app; call the filter step directly. + typeof(ProcessViewModel) + .GetMethod("FilterProcesses", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .Invoke(viewModel, null); + + Assert.Equal(3, viewModel.FilteredProcesses.Count); + Assert.Equal(3, viewModel.TotalProcessCount); + } + [Fact] public async Task CopyContextProcessInfo_IncludesNamePidAndPath() { diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index 9171fec..b1ad7ba 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -2219,6 +2219,13 @@ private void FilterProcesses() var filteredResults = this.processFilterService.FilterAndSort(this.Processes, criteria); this.FilteredProcesses = new ObservableCollection(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. + if (!this.HasMoreBatches || this.Processes.Count > this.TotalProcessCount) + { + this.TotalProcessCount = this.Processes.Count; + } } while (this.filterRefreshPending); } diff --git a/Views/ProcessPowerPlanAssociationView.xaml b/Views/ProcessPowerPlanAssociationView.xaml index e3e3912..f9305fe 100644 --- a/Views/ProcessPowerPlanAssociationView.xaml +++ b/Views/ProcessPowerPlanAssociationView.xaml @@ -120,7 +120,8 @@ Width="150"/> + Width="*" + MinWidth="160"/> diff --git a/Views/ProcessView.xaml b/Views/ProcessView.xaml index 007abb1..c29baac 100644 --- a/Views/ProcessView.xaml +++ b/Views/ProcessView.xaml @@ -194,66 +194,68 @@ - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - -