diff --git a/MainWindow.Behaviors.partial.cs b/MainWindow.Behaviors.partial.cs
index 0e205f8..bcf8d1d 100644
--- a/MainWindow.Behaviors.partial.cs
+++ b/MainWindow.Behaviors.partial.cs
@@ -782,23 +782,23 @@ private async Task OnMonitoringToggleRequestedAsync(MonitoringToggleEventArgs e)
{
await this.processMonitorManagerService.StartAsync();
await this.notificationService.ShowSuccessNotificationAsync(
- "Monitoring Enabled",
- "Process monitoring and power plan management has been enabled");
+ "Automation Monitoring Enabled",
+ "Process rule automation and power plan management have been enabled.");
}
else
{
await this.processMonitorManagerService.StopAsync();
await this.notificationService.ShowNotificationAsync(
- "Monitoring Disabled",
- "Process monitoring and power plan management has been disabled",
+ "Automation Monitoring Disabled",
+ "Process rule automation and power plan management have been disabled.",
Models.NotificationType.Warning);
}
}
catch (Exception ex)
{
await this.notificationService.ShowErrorNotificationAsync(
- "Monitoring Error",
- "Failed to toggle process monitoring",
+ "Automation Monitoring Error",
+ "Failed to toggle automation monitoring.",
ex);
}
}
@@ -969,10 +969,7 @@ private async Task HandleShortcutActionAsync(string actionName)
{
this.ShowInTaskbar = false;
this.Hide();
- this.SuspendHiddenModeRefreshes();
- this.processViewModel.PauseRefresh();
- this.processViewModel.SetProcessViewActive(false);
- _ = this.performanceViewModel.SuspendBackgroundMonitoringAsync();
+ this.ApplyAppRefreshPolicy(AppActivityState.TrayHidden);
}
else
{
@@ -1273,8 +1270,8 @@ private void OnMonitoringStatusChanged(object? sender, MonitoringStatusEventArgs
if (e.Error != null && this.settingsService.Settings.EnableErrorNotifications)
{
this.notificationService.ShowErrorNotificationAsync(
- "Monitoring Error",
- e.StatusMessage ?? "An error occurred with process monitoring",
+ "Automation Monitoring Error",
+ e.StatusMessage ?? "An error occurred with automation monitoring.",
e.Error);
}
}
@@ -1288,8 +1285,8 @@ private void OnProcessMonitorManagerStatusChanged(object? sender, ServiceStatusE
if (!e.IsRunning && e.Error != null && this.settingsService.Settings.EnableErrorNotifications)
{
this.notificationService.ShowErrorNotificationAsync(
- "Process Monitoring Error",
- e.Details ?? "Process monitoring manager encountered an error",
+ "Automation Monitoring Error",
+ e.Details ?? "Automation monitoring encountered an error.",
e.Error);
}
}
@@ -1300,42 +1297,22 @@ protected override void OnStateChanged(EventArgs e)
{
if (this.WindowState == WindowState.Minimized)
{
+ var activityState = AppActivityState.Minimized;
if (this.settingsService.Settings.MinimizeToTray)
{
this.ShowInTaskbar = false;
this.Hide();
this.systemTrayService.Show();
+ activityState = AppActivityState.TrayHidden;
}
- this.SuspendHiddenModeRefreshes();
-
- if (this.processViewModel != null)
- {
- this.processViewModel.PauseRefresh();
- this.processViewModel.SetProcessViewActive(false);
- }
-
- if (this.performanceViewModel != null)
- {
- _ = this.performanceViewModel.SuspendBackgroundMonitoringAsync();
- }
+ this.ApplyAppRefreshPolicy(activityState);
}
else if (this.WindowState == WindowState.Normal || this.WindowState == WindowState.Maximized)
{
this.ShowInTaskbar = true;
- this.ResumeForegroundRefreshes();
-
- if (this.processViewModel != null)
- {
- this.processViewModel.SetProcessViewActive(this.ProcessManagementTab.Visibility == Visibility.Visible);
- this.processViewModel.ResumeRefresh();
- }
-
- if (this.performanceViewModel != null)
- {
- _ = this.performanceViewModel.ResumeBackgroundMonitoringAsync();
- }
+ this.ApplyAppRefreshPolicy(this.GetForegroundActivityState());
}
}
catch (Exception ex)
@@ -1364,7 +1341,6 @@ private void ResumeForegroundRefreshes()
this.systemTrayUpdateTimer.Interval = SystemTrayUpdateBaseIntervalMs;
}
this.systemTrayUpdateTimer?.Start();
- this.powerPlanViewModel.ResumeAutoRefresh(refreshImmediately: true);
_ = this.Dispatcher.InvokeAsync(async () =>
{
@@ -1380,6 +1356,52 @@ private void ResumeForegroundRefreshes()
});
}
+ private AppActivityState GetForegroundActivityState()
+ {
+ return this.ProcessManagementTab.Visibility == Visibility.Visible
+ ? AppActivityState.ForegroundProcessView
+ : AppActivityState.ForegroundOtherTab;
+ }
+
+ private void ApplyAppRefreshPolicy(AppActivityState state)
+ {
+ var decision = AppRefreshPolicy.Evaluate(state);
+ var isHiddenState = state is AppActivityState.Minimized or AppActivityState.TrayHidden;
+ var isProcessViewActive = state == AppActivityState.ForegroundProcessView;
+
+ if (isHiddenState)
+ {
+ this.isSystemTrayUpdatesSuspended = true;
+ this.systemTrayUpdateTimer?.Stop();
+ Interlocked.Exchange(ref this.isSystemTrayUpdateInProgress, 0);
+ }
+ else
+ {
+ this.ResumeForegroundRefreshes();
+ }
+
+ this.processViewModel.SetProcessViewActive(isProcessViewActive);
+ this.processViewModel.ApplyRefreshDecision(decision);
+
+ if (decision.PowerPlanUiRefreshEnabled)
+ {
+ this.powerPlanViewModel.ResumeAutoRefresh(refreshImmediately: state != AppActivityState.ForegroundOtherTab);
+ }
+ else
+ {
+ this.powerPlanViewModel.PauseAutoRefresh();
+ }
+
+ if (decision.PerformanceUiMonitoringEnabled)
+ {
+ _ = this.performanceViewModel.ResumeBackgroundMonitoringAsync();
+ }
+ else
+ {
+ _ = this.performanceViewModel.SuspendBackgroundMonitoringAsync();
+ }
+ }
+
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
@@ -1447,15 +1469,16 @@ private void ShowWindowFromTray(string? tabTag = null)
this.Activate();
this.Focus();
this.Topmost = false;
+ this.Activate();
+ this.Focus();
var processViewWillBeActive = tabTag == null
? this.ProcessManagementTab.Visibility == Visibility.Visible
: string.Equals(tabTag, "Process", StringComparison.Ordinal);
- this.ResumeForegroundRefreshes();
- this.processViewModel.SetProcessViewActive(processViewWillBeActive);
- this.processViewModel.ResumeRefresh();
- _ = this.performanceViewModel.ResumeBackgroundMonitoringAsync();
+ this.ApplyAppRefreshPolicy(processViewWillBeActive
+ ? AppActivityState.ForegroundProcessView
+ : AppActivityState.ForegroundOtherTab);
if (tabTag != null)
{
@@ -1664,10 +1687,21 @@ private void ApplySectionVisibility(string tag)
this.NavTweaks.IsActive = tag == "Tweaks";
this.NavSettings.IsActive = tag == "Settings";
- this.processViewModel.SetProcessViewActive(
- string.Equals(tag, "Process", StringComparison.Ordinal) &&
- this.IsVisible &&
- this.WindowState != WindowState.Minimized);
+ if (!this.IsVisible)
+ {
+ this.ApplyAppRefreshPolicy(AppActivityState.TrayHidden);
+ return;
+ }
+
+ if (this.WindowState == WindowState.Minimized)
+ {
+ this.ApplyAppRefreshPolicy(AppActivityState.Minimized);
+ return;
+ }
+
+ this.ApplyAppRefreshPolicy(string.Equals(tag, "Process", StringComparison.Ordinal)
+ ? AppActivityState.ForegroundProcessView
+ : AppActivityState.ForegroundOtherTab);
}
private void NavMenuItem_Click(object sender, RoutedEventArgs e)
diff --git a/MainWindow.xaml b/MainWindow.xaml
index dfea231..edaa0af 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -323,7 +323,7 @@
Margin="0,0,0,6"/>
.
+ */
+namespace ThreadPilot.Services
+{
+ using Microsoft.Extensions.Logging;
+ using ThreadPilot.Models;
+
+ public enum AffinityApplyFailureReason
+ {
+ None,
+ InvalidMask,
+ ProcessTerminated,
+ AccessDenied,
+ VerificationMismatch,
+ ApplyFailed,
+ }
+
+ public sealed record AffinityApplyResult(
+ bool Success,
+ long RequestedMask,
+ long VerifiedMask,
+ AffinityApplyFailureReason FailureReason,
+ string Message)
+ {
+ public static AffinityApplyResult Succeeded(long requestedMask, long verifiedMask) =>
+ new(true, requestedMask, verifiedMask, AffinityApplyFailureReason.None, "Affinity applied successfully.");
+
+ public static AffinityApplyResult Failed(
+ long requestedMask,
+ long verifiedMask,
+ AffinityApplyFailureReason failureReason,
+ string message) =>
+ new(false, requestedMask, verifiedMask, failureReason, message);
+ }
+
+ public interface IAffinityApplyService
+ {
+ Task ApplyAsync(ProcessModel process, long requestedMask);
+ }
+
+ public sealed class AffinityApplyService : IAffinityApplyService
+ {
+ private readonly IProcessService processService;
+ private readonly ICpuTopologyService cpuTopologyService;
+ private readonly ILogger logger;
+
+ public AffinityApplyService(
+ IProcessService processService,
+ ICpuTopologyService cpuTopologyService,
+ ILogger logger)
+ {
+ this.processService = processService ?? throw new ArgumentNullException(nameof(processService));
+ this.cpuTopologyService = cpuTopologyService ?? throw new ArgumentNullException(nameof(cpuTopologyService));
+ this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task ApplyAsync(ProcessModel process, long requestedMask)
+ {
+ ArgumentNullException.ThrowIfNull(process);
+
+ var startingMask = process.ProcessorAffinity;
+
+ if (requestedMask == 0)
+ {
+ return AffinityApplyResult.Failed(
+ requestedMask,
+ startingMask,
+ AffinityApplyFailureReason.InvalidMask,
+ "Affinity mask cannot be zero.");
+ }
+
+ if (!this.cpuTopologyService.IsAffinityMaskValid(requestedMask))
+ {
+ return AffinityApplyResult.Failed(
+ requestedMask,
+ startingMask,
+ AffinityApplyFailureReason.InvalidMask,
+ "Affinity mask is not valid for this CPU topology.");
+ }
+
+ if (!await this.IsProcessRunningAsync(process).ConfigureAwait(false))
+ {
+ return AffinityApplyResult.Failed(
+ requestedMask,
+ startingMask,
+ AffinityApplyFailureReason.ProcessTerminated,
+ "Process is no longer running.");
+ }
+
+ try
+ {
+ await this.processService.SetProcessorAffinity(process, requestedMask).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (IsAccessDenied(ex))
+ {
+ this.logger.LogWarning(
+ ex,
+ "Affinity apply blocked for process {ProcessName} (PID: {ProcessId})",
+ process.Name,
+ process.ProcessId);
+
+ await this.TryRefreshProcessInfoAsync(process).ConfigureAwait(false);
+ return AffinityApplyResult.Failed(
+ requestedMask,
+ process.ProcessorAffinity,
+ AffinityApplyFailureReason.AccessDenied,
+ "Affinity change blocked (anti-cheat or insufficient privileges).");
+ }
+ catch (Exception ex) when (IsProcessTerminated(ex))
+ {
+ this.logger.LogDebug(
+ ex,
+ "Process terminated while applying affinity to {ProcessName} (PID: {ProcessId})",
+ process.Name,
+ process.ProcessId);
+
+ return AffinityApplyResult.Failed(
+ requestedMask,
+ process.ProcessorAffinity,
+ AffinityApplyFailureReason.ProcessTerminated,
+ "Process exited before affinity could be applied.");
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogWarning(
+ ex,
+ "Affinity apply failed for process {ProcessName} (PID: {ProcessId})",
+ process.Name,
+ process.ProcessId);
+
+ await this.TryRefreshProcessInfoAsync(process).ConfigureAwait(false);
+ return AffinityApplyResult.Failed(
+ requestedMask,
+ process.ProcessorAffinity,
+ AffinityApplyFailureReason.ApplyFailed,
+ $"Failed to set processor affinity: {ex.Message}");
+ }
+
+ if (!await this.TryRefreshProcessInfoAsync(process).ConfigureAwait(false))
+ {
+ return AffinityApplyResult.Failed(
+ requestedMask,
+ process.ProcessorAffinity,
+ AffinityApplyFailureReason.ProcessTerminated,
+ "Process exited before affinity could be verified.");
+ }
+
+ var verifiedMask = process.ProcessorAffinity;
+ if (verifiedMask != requestedMask)
+ {
+ return AffinityApplyResult.Failed(
+ requestedMask,
+ verifiedMask,
+ AffinityApplyFailureReason.VerificationMismatch,
+ $"Windows reported affinity 0x{verifiedMask:X} after requesting 0x{requestedMask:X}.");
+ }
+
+ return AffinityApplyResult.Succeeded(requestedMask, verifiedMask);
+ }
+
+ private static bool IsAccessDenied(Exception ex)
+ {
+ var message = ex.Message ?? string.Empty;
+ return ex is UnauthorizedAccessException ||
+ message.Contains("access denied", StringComparison.OrdinalIgnoreCase) ||
+ message.Contains("anti-cheat", StringComparison.OrdinalIgnoreCase) ||
+ message.Contains("anti cheat", StringComparison.OrdinalIgnoreCase) ||
+ message.Contains("protected", StringComparison.OrdinalIgnoreCase) ||
+ message.Contains("insufficient privileges", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsProcessTerminated(Exception ex)
+ {
+ var message = ex.Message ?? string.Empty;
+ return ex is ArgumentException ||
+ (ex is InvalidOperationException &&
+ (message.Contains("process", StringComparison.OrdinalIgnoreCase) &&
+ (message.Contains("exit", StringComparison.OrdinalIgnoreCase) ||
+ message.Contains("terminated", StringComparison.OrdinalIgnoreCase) ||
+ message.Contains("not running", StringComparison.OrdinalIgnoreCase))));
+ }
+
+ private async Task IsProcessRunningAsync(ProcessModel process)
+ {
+ try
+ {
+ return await this.processService.IsProcessStillRunning(process).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (IsAccessDenied(ex))
+ {
+ this.logger.LogDebug(
+ ex,
+ "Could not confirm process state before affinity apply for {ProcessName} (PID: {ProcessId})",
+ process.Name,
+ process.ProcessId);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogDebug(
+ ex,
+ "Process state check failed before affinity apply for {ProcessName} (PID: {ProcessId})",
+ process.Name,
+ process.ProcessId);
+ return false;
+ }
+ }
+
+ private async Task TryRefreshProcessInfoAsync(ProcessModel process)
+ {
+ try
+ {
+ await this.processService.RefreshProcessInfo(process).ConfigureAwait(false);
+ return true;
+ }
+ catch (Exception ex) when (IsAccessDenied(ex))
+ {
+ this.logger.LogDebug(
+ ex,
+ "Could not refresh process after affinity apply for {ProcessName} (PID: {ProcessId})",
+ process.Name,
+ process.ProcessId);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogDebug(
+ ex,
+ "Process refresh failed after affinity apply for {ProcessName} (PID: {ProcessId})",
+ process.Name,
+ process.ProcessId);
+ return false;
+ }
+ }
+ }
+}
diff --git a/Services/AppRefreshPolicy.cs b/Services/AppRefreshPolicy.cs
new file mode 100644
index 0000000..80a6cbf
--- /dev/null
+++ b/Services/AppRefreshPolicy.cs
@@ -0,0 +1,81 @@
+/*
+ * ThreadPilot - Advanced Windows Process and Power Plan Manager
+ * Copyright (C) 2025 Prime Build
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, version 3 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+namespace ThreadPilot.Services
+{
+ ///
+ /// Represents the current UI activity state used to decide refresh work.
+ ///
+ public enum AppActivityState
+ {
+ ForegroundProcessView,
+ ForegroundOtherTab,
+ Minimized,
+ TrayHidden,
+ }
+
+ ///
+ /// Describes refresh and monitoring work allowed for a UI activity state.
+ ///
+ public sealed record AppRefreshDecision(
+ bool ProcessUiRefreshEnabled,
+ bool ImmediateProcessRefresh,
+ bool VirtualizedPreloadEnabled,
+ bool PerformanceUiMonitoringEnabled,
+ bool PowerPlanUiRefreshEnabled,
+ bool BackgroundAutomationEnabled);
+
+ ///
+ /// Central policy for foreground/background refresh decisions.
+ ///
+ public static class AppRefreshPolicy
+ {
+ public static AppRefreshDecision Evaluate(AppActivityState state)
+ {
+ return state switch
+ {
+ AppActivityState.ForegroundProcessView => new AppRefreshDecision(
+ ProcessUiRefreshEnabled: true,
+ ImmediateProcessRefresh: true,
+ VirtualizedPreloadEnabled: true,
+ PerformanceUiMonitoringEnabled: true,
+ PowerPlanUiRefreshEnabled: true,
+ BackgroundAutomationEnabled: true),
+ AppActivityState.ForegroundOtherTab => new AppRefreshDecision(
+ ProcessUiRefreshEnabled: false,
+ ImmediateProcessRefresh: false,
+ VirtualizedPreloadEnabled: false,
+ PerformanceUiMonitoringEnabled: true,
+ PowerPlanUiRefreshEnabled: true,
+ BackgroundAutomationEnabled: true),
+ AppActivityState.Minimized or AppActivityState.TrayHidden => new AppRefreshDecision(
+ ProcessUiRefreshEnabled: false,
+ ImmediateProcessRefresh: false,
+ VirtualizedPreloadEnabled: false,
+ PerformanceUiMonitoringEnabled: false,
+ PowerPlanUiRefreshEnabled: false,
+ BackgroundAutomationEnabled: true),
+ _ => new AppRefreshDecision(
+ ProcessUiRefreshEnabled: false,
+ ImmediateProcessRefresh: false,
+ VirtualizedPreloadEnabled: false,
+ PerformanceUiMonitoringEnabled: false,
+ PowerPlanUiRefreshEnabled: false,
+ BackgroundAutomationEnabled: true),
+ };
+ }
+ }
+}
diff --git a/Services/ForegroundProcessService.cs b/Services/ForegroundProcessService.cs
new file mode 100644
index 0000000..d3c6541
--- /dev/null
+++ b/Services/ForegroundProcessService.cs
@@ -0,0 +1,74 @@
+/*
+ * ThreadPilot - Advanced Windows Process and Power Plan Manager
+ * Copyright (C) 2025 Prime Build
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, version 3 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using Microsoft.Extensions.Logging;
+
+ public readonly record struct ForegroundWindowSnapshot(
+ IntPtr WindowHandle,
+ int ProcessId,
+ bool IsVisible,
+ bool IsCloaked);
+
+ public interface IForegroundWindowProvider
+ {
+ bool TryGetForegroundWindow(out ForegroundWindowSnapshot snapshot);
+ }
+
+ public interface IForegroundProcessService
+ {
+ int? TryGetForegroundProcessId();
+ }
+
+ public sealed class ForegroundProcessService : IForegroundProcessService
+ {
+ private readonly IForegroundWindowProvider foregroundWindowProvider;
+ private readonly ILogger logger;
+
+ public ForegroundProcessService(
+ IForegroundWindowProvider foregroundWindowProvider,
+ ILogger logger)
+ {
+ this.foregroundWindowProvider = foregroundWindowProvider ?? throw new ArgumentNullException(nameof(foregroundWindowProvider));
+ this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public int? TryGetForegroundProcessId()
+ {
+ try
+ {
+ if (!this.foregroundWindowProvider.TryGetForegroundWindow(out var snapshot))
+ {
+ return null;
+ }
+
+ if (snapshot.ProcessId <= 0 || !snapshot.IsVisible || snapshot.IsCloaked)
+ {
+ return null;
+ }
+
+ return snapshot.ProcessId;
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogDebug(ex, "Foreground process detection failed");
+ return null;
+ }
+ }
+ }
+}
diff --git a/Services/PassiveProcessErrorThrottle.cs b/Services/PassiveProcessErrorThrottle.cs
new file mode 100644
index 0000000..75ab89b
--- /dev/null
+++ b/Services/PassiveProcessErrorThrottle.cs
@@ -0,0 +1,70 @@
+/*
+ * ThreadPilot - Advanced Windows Process and Power Plan Manager
+ * Copyright (C) 2025 Prime Build
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, version 3 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.Collections.Concurrent;
+
+ public enum PassiveProcessErrorKind
+ {
+ AccessDenied,
+ Terminated,
+ Unknown,
+ }
+
+ public interface IPassiveProcessErrorThrottle
+ {
+ bool ShouldLog(int processId, PassiveProcessErrorKind errorKind);
+ }
+
+ public sealed class PassiveProcessErrorThrottle : IPassiveProcessErrorThrottle
+ {
+ private readonly ConcurrentDictionary<(int ProcessId, PassiveProcessErrorKind ErrorKind), DateTimeOffset> lastLogByError = new();
+ private readonly Func nowProvider;
+ private readonly TimeSpan ttl;
+
+ public PassiveProcessErrorThrottle()
+ : this(TimeSpan.FromMinutes(5), () => DateTimeOffset.UtcNow)
+ {
+ }
+
+ public PassiveProcessErrorThrottle(TimeSpan ttl, Func nowProvider)
+ {
+ if (ttl <= TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(ttl), "TTL must be greater than zero.");
+ }
+
+ this.ttl = ttl;
+ this.nowProvider = nowProvider ?? throw new ArgumentNullException(nameof(nowProvider));
+ }
+
+ public bool ShouldLog(int processId, PassiveProcessErrorKind errorKind)
+ {
+ var now = this.nowProvider();
+ var key = (processId, errorKind);
+
+ if (this.lastLogByError.TryGetValue(key, out var lastLog) && now - lastLog < this.ttl)
+ {
+ return false;
+ }
+
+ this.lastLogByError[key] = now;
+ return true;
+ }
+ }
+}
diff --git a/Services/PowerPlanTransitionGate.cs b/Services/PowerPlanTransitionGate.cs
new file mode 100644
index 0000000..41f8b08
--- /dev/null
+++ b/Services/PowerPlanTransitionGate.cs
@@ -0,0 +1,78 @@
+/*
+ * ThreadPilot - Advanced Windows Process and Power Plan Manager
+ * Copyright (C) 2025 Prime Build
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, version 3 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+namespace ThreadPilot.Services
+{
+ public enum PowerPlanTransitionSuppressionReason
+ {
+ None,
+ AlreadyActive,
+ RecentDuplicateRequest,
+ }
+
+ public sealed record PowerPlanTransitionDecision(
+ bool ShouldApply,
+ PowerPlanTransitionSuppressionReason SuppressionReason);
+
+ public sealed class PowerPlanTransitionGate
+ {
+ private readonly TimeSpan duplicateWindow;
+ private readonly Func nowProvider;
+ private readonly object lockObject = new();
+ private string? lastRequestedPowerPlanGuid;
+ private DateTimeOffset lastRequestTime;
+
+ public PowerPlanTransitionGate()
+ : this(TimeSpan.FromSeconds(2), () => DateTimeOffset.UtcNow)
+ {
+ }
+
+ public PowerPlanTransitionGate(TimeSpan duplicateWindow, Func nowProvider)
+ {
+ this.duplicateWindow = duplicateWindow < TimeSpan.Zero ? TimeSpan.Zero : duplicateWindow;
+ this.nowProvider = nowProvider ?? throw new ArgumentNullException(nameof(nowProvider));
+ }
+
+ public PowerPlanTransitionDecision ShouldApply(string targetPowerPlanGuid, string? currentPowerPlanGuid)
+ {
+ if (string.Equals(targetPowerPlanGuid, currentPowerPlanGuid, StringComparison.OrdinalIgnoreCase))
+ {
+ return new PowerPlanTransitionDecision(false, PowerPlanTransitionSuppressionReason.AlreadyActive);
+ }
+
+ lock (this.lockObject)
+ {
+ var now = this.nowProvider();
+ if (string.Equals(this.lastRequestedPowerPlanGuid, targetPowerPlanGuid, StringComparison.OrdinalIgnoreCase) &&
+ now - this.lastRequestTime < this.duplicateWindow)
+ {
+ return new PowerPlanTransitionDecision(false, PowerPlanTransitionSuppressionReason.RecentDuplicateRequest);
+ }
+ }
+
+ return new PowerPlanTransitionDecision(true, PowerPlanTransitionSuppressionReason.None);
+ }
+
+ public void RecordAttempt(string targetPowerPlanGuid)
+ {
+ lock (this.lockObject)
+ {
+ this.lastRequestedPowerPlanGuid = targetPowerPlanGuid;
+ this.lastRequestTime = this.nowProvider();
+ }
+ }
+ }
+}
diff --git a/Services/ProcessClassifier.cs b/Services/ProcessClassifier.cs
new file mode 100644
index 0000000..2d737af
--- /dev/null
+++ b/Services/ProcessClassifier.cs
@@ -0,0 +1,78 @@
+/*
+ * ThreadPilot - Advanced Windows Process and Power Plan Manager
+ * Copyright (C) 2025 Prime Build
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, version 3 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using ThreadPilot.Models;
+
+ public readonly record struct ProcessClassificationContext(
+ int? ForegroundProcessId,
+ bool AccessDenied = false,
+ bool Terminated = false);
+
+ public interface IProcessClassifier
+ {
+ ProcessClassification Classify(ProcessModel process, ProcessClassificationContext context);
+ }
+
+ public sealed class ProcessClassifier : IProcessClassifier
+ {
+ private readonly ProcessFilterService processFilterService;
+
+ public ProcessClassifier(ProcessFilterService processFilterService)
+ {
+ this.processFilterService = processFilterService ?? throw new ArgumentNullException(nameof(processFilterService));
+ }
+
+ public ProcessClassification Classify(ProcessModel process, ProcessClassificationContext context)
+ {
+ ArgumentNullException.ThrowIfNull(process);
+
+ if (context.Terminated)
+ {
+ return ProcessClassification.Terminated;
+ }
+
+ if (context.AccessDenied)
+ {
+ return ProcessClassification.ProtectedOrAccessDenied;
+ }
+
+ if (context.ForegroundProcessId == process.ProcessId)
+ {
+ return ProcessClassification.ForegroundApp;
+ }
+
+ if (this.processFilterService.IsSystemProcess(process))
+ {
+ return ProcessClassification.System;
+ }
+
+ if (process.HasVisibleWindow)
+ {
+ return ProcessClassification.VisibleWindowApp;
+ }
+
+ if (!string.IsNullOrWhiteSpace(process.Name))
+ {
+ return ProcessClassification.BackgroundUser;
+ }
+
+ return ProcessClassification.Unknown;
+ }
+ }
+}
diff --git a/Services/ProcessFilterService.cs b/Services/ProcessFilterService.cs
index d5f05ae..3c4871e 100644
--- a/Services/ProcessFilterService.cs
+++ b/Services/ProcessFilterService.cs
@@ -66,7 +66,7 @@ public IReadOnlyList FilterAndSort(IEnumerable sourc
if (criteria.HideSystemProcesses)
{
- filtered = filtered.Where(p => !IsSystemProcess(p));
+ filtered = filtered.Where(p => !this.IsSystemProcess(p));
}
if (criteria.HideIdleProcesses)
@@ -86,7 +86,7 @@ public IReadOnlyList FilterAndSort(IEnumerable sourc
return sorted.ToList();
}
- private static bool IsSystemProcess(ProcessModel process)
+ public bool IsSystemProcess(ProcessModel process)
{
if (process == null)
{
diff --git a/Services/ProcessListDeltaUpdater.cs b/Services/ProcessListDeltaUpdater.cs
new file mode 100644
index 0000000..0ced855
--- /dev/null
+++ b/Services/ProcessListDeltaUpdater.cs
@@ -0,0 +1,103 @@
+/*
+ * ThreadPilot - Advanced Windows Process and Power Plan Manager
+ * Copyright (C) 2025 Prime Build
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, version 3 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Collections.ObjectModel;
+ using System.Linq;
+ using ThreadPilot.Models;
+
+ public sealed record ProcessListDeltaResult(ProcessModel? SelectedProcess, bool SelectedProcessTerminated);
+
+ ///
+ /// Applies process snapshots to the UI collection while preserving existing models by PID.
+ ///
+ public static class ProcessListDeltaUpdater
+ {
+ public static ProcessListDeltaResult ApplyDelta(
+ ObservableCollection target,
+ IEnumerable snapshot,
+ int? selectedProcessId)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ ArgumentNullException.ThrowIfNull(snapshot);
+
+ var currentByPid = target
+ .GroupBy(process => process.ProcessId)
+ .ToDictionary(group => group.Key, group => group.First());
+ var snapshotByPid = new Dictionary();
+ foreach (var process in snapshot)
+ {
+ snapshotByPid[process.ProcessId] = process;
+ }
+
+ var seenPids = new HashSet();
+ ProcessModel? selectedProcess = null;
+
+ foreach (var incoming in snapshotByPid.Values)
+ {
+ seenPids.Add(incoming.ProcessId);
+
+ if (currentByPid.TryGetValue(incoming.ProcessId, out var existing))
+ {
+ CopyProcessState(incoming, existing);
+ if (selectedProcessId == incoming.ProcessId)
+ {
+ selectedProcess = existing;
+ }
+
+ continue;
+ }
+
+ target.Add(incoming);
+ if (selectedProcessId == incoming.ProcessId)
+ {
+ selectedProcess = incoming;
+ }
+ }
+
+ for (int i = target.Count - 1; i >= 0; i--)
+ {
+ if (!seenPids.Contains(target[i].ProcessId))
+ {
+ target.RemoveAt(i);
+ }
+ }
+
+ var selectedProcessTerminated = selectedProcessId.HasValue && selectedProcess == null;
+ return new ProcessListDeltaResult(selectedProcess, selectedProcessTerminated);
+ }
+
+ private static void CopyProcessState(ProcessModel source, ProcessModel target)
+ {
+ target.Name = source.Name;
+ target.ExecutablePath = source.ExecutablePath;
+ target.CpuUsage = source.CpuUsage;
+ target.MemoryUsage = source.MemoryUsage;
+ target.Priority = source.Priority;
+ target.ProcessorAffinity = source.ProcessorAffinity;
+ target.MainWindowHandle = source.MainWindowHandle;
+ target.MainWindowTitle = source.MainWindowTitle;
+ target.HasVisibleWindow = source.HasVisibleWindow;
+ target.IsForeground = source.IsForeground;
+ target.Classification = source.Classification;
+ target.IsIdleServerDisabled = source.IsIdleServerDisabled;
+ target.IsRegistryPriorityEnabled = source.IsRegistryPriorityEnabled;
+ }
+ }
+}
diff --git a/Services/ProcessMonitorManagerService.cs b/Services/ProcessMonitorManagerService.cs
index 792609a..6311a78 100644
--- a/Services/ProcessMonitorManagerService.cs
+++ b/Services/ProcessMonitorManagerService.cs
@@ -38,6 +38,8 @@ public class ProcessMonitorManagerService : IProcessMonitorManagerService
private readonly IApplicationSettingsService settingsService;
private readonly IProcessService processService;
private readonly ICoreMaskService coreMaskService;
+ private readonly IAffinityApplyService affinityApplyService;
+ private readonly PowerPlanTransitionGate powerPlanTransitionGate;
private readonly ILogger logger;
private readonly IEnhancedLoggingService enhancedLogger;
private readonly object lockObject = new();
@@ -71,6 +73,8 @@ public ProcessMonitorManagerService(
IApplicationSettingsService settingsService,
IProcessService processService,
ICoreMaskService coreMaskService,
+ IAffinityApplyService affinityApplyService,
+ PowerPlanTransitionGate powerPlanTransitionGate,
ILogger logger,
IEnhancedLoggingService enhancedLogger)
{
@@ -81,6 +85,8 @@ public ProcessMonitorManagerService(
this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
this.processService = processService ?? throw new ArgumentNullException(nameof(processService));
this.coreMaskService = coreMaskService ?? throw new ArgumentNullException(nameof(coreMaskService));
+ this.affinityApplyService = affinityApplyService ?? throw new ArgumentNullException(nameof(affinityApplyService));
+ this.powerPlanTransitionGate = powerPlanTransitionGate ?? throw new ArgumentNullException(nameof(powerPlanTransitionGate));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.enhancedLogger = enhancedLogger ?? throw new ArgumentNullException(nameof(enhancedLogger));
@@ -264,6 +270,19 @@ public async Task ForceDefaultPowerPlanAsync()
await this.powerPlanChangeSemaphore.WaitAsync();
var currentPowerPlan = await this.powerPlanService.GetActivePowerPlan();
+ var decision = this.powerPlanTransitionGate.ShouldApply(
+ this.configuration.DefaultPowerPlanGuid,
+ currentPowerPlan?.Guid);
+ if (!decision.ShouldApply)
+ {
+ this.logger.LogDebug(
+ "Default power plan restore suppressed for {PowerPlanGuid}: {Reason}",
+ this.configuration.DefaultPowerPlanGuid,
+ decision.SuppressionReason);
+ return;
+ }
+
+ this.powerPlanTransitionGate.RecordAttempt(this.configuration.DefaultPowerPlanGuid);
var success = await this.powerPlanService.SetActivePowerPlanByGuidAsync(
this.configuration.DefaultPowerPlanGuid,
this.configuration.PreventDuplicatePowerPlanChanges);
@@ -284,6 +303,12 @@ await this.notificationService.ShowPowerPlanChangeNotificationAsync(
newPowerPlan?.Name ?? this.configuration.DefaultPowerPlanName,
string.Empty);
}
+ else
+ {
+ this.logger.LogWarning(
+ "Failed to restore default power plan {PowerPlanGuid}",
+ this.configuration.DefaultPowerPlanGuid);
+ }
}
catch (Exception ex)
{
@@ -505,6 +530,19 @@ private async Task ChangePowerPlanForProcess(ProcessModel process, ProcessPowerP
await this.powerPlanChangeSemaphore.WaitAsync();
var currentPowerPlan = await this.powerPlanService.GetActivePowerPlan();
+ var decision = this.powerPlanTransitionGate.ShouldApply(
+ association.PowerPlanGuid,
+ currentPowerPlan?.Guid);
+ if (!decision.ShouldApply)
+ {
+ this.logger.LogDebug(
+ "Power plan change suppressed for {PowerPlanGuid}: {Reason}",
+ association.PowerPlanGuid,
+ decision.SuppressionReason);
+ return;
+ }
+
+ this.powerPlanTransitionGate.RecordAttempt(association.PowerPlanGuid);
var success = await this.powerPlanService.SetActivePowerPlanByGuidAsync(
association.PowerPlanGuid,
this.configuration?.PreventDuplicatePowerPlanChanges ?? true);
@@ -521,6 +559,14 @@ await this.notificationService.ShowPowerPlanChangeNotificationAsync(
newPowerPlan?.Name ?? association.PowerPlanName,
process.Name);
}
+ else
+ {
+ this.logger.LogWarning(
+ "Failed to change power plan to {PowerPlanGuid} for process {ProcessName} (PID: {ProcessId})",
+ association.PowerPlanGuid,
+ process.Name,
+ process.ProcessId);
+ }
}
catch (Exception ex)
{
@@ -583,18 +629,30 @@ private async Task ApplyCoreMaskAndPriorityAsync(ProcessModel process, ProcessPo
var affinity = coreMask.ToProcessorAffinity();
if (affinity > 0)
{
- await this.processService.SetProcessorAffinity(process, affinity);
- this.processService.TrackAppliedMask(process.ProcessId, coreMask.Id);
- this.coreMaskService.RegisterMaskApplication(process.ProcessId, coreMask.Id);
-
- this.logger.LogInformation(
- "Applied CPU mask '{MaskName}' (affinity: 0x{Affinity:X}) to process {ProcessName} (PID: {ProcessId})",
- coreMask.Name, affinity, process.Name, process.ProcessId);
-
- await this.enhancedLogger.LogProcessMonitoringEventAsync(
- LogEventTypes.ProcessMonitoring.AssociationTriggered,
- process.Name, process.ProcessId,
- $"CPU mask '{coreMask.Name}' applied automatically from association");
+ var result = await this.affinityApplyService.ApplyAsync(process, affinity);
+ if (!result.Success)
+ {
+ this.logger.LogWarning(
+ "Failed to apply CPU mask '{MaskName}' to process {ProcessName} (PID: {ProcessId}): {Message}",
+ coreMask.Name,
+ process.Name,
+ process.ProcessId,
+ result.Message);
+ }
+ else
+ {
+ this.processService.TrackAppliedMask(process.ProcessId, coreMask.Id);
+ this.coreMaskService.RegisterMaskApplication(process.ProcessId, coreMask.Id);
+
+ this.logger.LogInformation(
+ "Applied CPU mask '{MaskName}' (affinity: 0x{Affinity:X}) to process {ProcessName} (PID: {ProcessId})",
+ coreMask.Name, affinity, process.Name, process.ProcessId);
+
+ await this.enhancedLogger.LogProcessMonitoringEventAsync(
+ LogEventTypes.ProcessMonitoring.AssociationTriggered,
+ process.Name, process.ProcessId,
+ $"CPU mask '{coreMask.Name}' applied automatically from association");
+ }
}
}
catch (Exception ex)
diff --git a/Services/ProcessService.cs b/Services/ProcessService.cs
index 57ec0b7..6b8304b 100644
--- a/Services/ProcessService.cs
+++ b/Services/ProcessService.cs
@@ -38,6 +38,9 @@ public class ProcessService : IProcessService
private readonly ConcurrentDictionary cpuSetHandlers = new();
private readonly ILogger? logger;
private readonly ISecurityService? securityService;
+ private readonly IForegroundProcessService? foregroundProcessService;
+ private readonly IProcessClassifier processClassifier;
+ private readonly IPassiveProcessErrorThrottle passiveProcessErrorThrottle;
private readonly Func profilesDirectoryProvider;
private string ProfilesDirectory => this.profilesDirectoryProvider();
@@ -51,10 +54,16 @@ public class ProcessService : IProcessService
public ProcessService(
ILogger? logger = null,
ISecurityService? securityService = null,
- Func? profilesDirectoryProvider = null)
+ Func? profilesDirectoryProvider = null,
+ IForegroundProcessService? foregroundProcessService = null,
+ IProcessClassifier? processClassifier = null,
+ IPassiveProcessErrorThrottle? passiveProcessErrorThrottle = null)
{
this.logger = logger;
this.securityService = securityService;
+ this.foregroundProcessService = foregroundProcessService;
+ this.processClassifier = processClassifier ?? new ProcessClassifier(new ProcessFilterService());
+ this.passiveProcessErrorThrottle = passiveProcessErrorThrottle ?? new PassiveProcessErrorThrottle();
this.profilesDirectoryProvider = profilesDirectoryProvider ?? (() => StoragePaths.ProfilesDirectory);
StoragePaths.EnsureAppDataDirectories();
@@ -72,9 +81,10 @@ public async Task> GetProcessesAsync()
{
return await Task.Run(() =>
{
+ var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId();
var processes = Process.GetProcesses()
- .Select(this.CreateProcessModel)
- .Where(p => p != null)
+ .Select(process => this.TryCreateProcessModel(process, foregroundProcessId))
+ .OfType()
.OrderBy(p => p.Name);
return new ObservableCollection(processes);
@@ -139,40 +149,212 @@ private double CalculateCpuUsage(Process process)
}
public ProcessModel CreateProcessModel(Process process)
+ {
+ return this.CreateProcessModel(process, this.foregroundProcessService?.TryGetForegroundProcessId());
+ }
+
+ private ProcessModel? TryCreateProcessModel(Process process, int? foregroundProcessId)
+ {
+ try
+ {
+ return this.CreateProcessModel(process, foregroundProcessId);
+ }
+ catch (Exception ex) when (IsTerminatedProcessException(ex))
+ {
+ var processId = TryGetProcessId(process);
+ if (processId.HasValue)
+ {
+ this.CleanupProcessResources(processId.Value);
+ this.LogPassiveProcessReadFailure(processId.Value, PassiveProcessErrorKind.Terminated, ex);
+ }
+
+ return CreateMinimalProcessModel(process, processId, ProcessClassification.Terminated);
+ }
+ catch (Exception ex) when (IsPassiveProcessAccessException(ex))
+ {
+ var processId = TryGetProcessId(process);
+ if (processId.HasValue)
+ {
+ this.LogPassiveProcessReadFailure(processId.Value, PassiveProcessErrorKind.AccessDenied, ex);
+ }
+
+ return CreateMinimalProcessModel(process, processId, ProcessClassification.ProtectedOrAccessDenied);
+ }
+ catch (Exception ex)
+ {
+ var processId = TryGetProcessId(process);
+ if (processId.HasValue)
+ {
+ this.LogPassiveProcessReadFailure(processId.Value, PassiveProcessErrorKind.Unknown, ex);
+ }
+
+ return CreateMinimalProcessModel(process, processId, ProcessClassification.Unknown);
+ }
+ }
+
+ private ProcessModel CreateProcessModel(Process process, int? foregroundProcessId)
{
var model = new ProcessModel();
+ var accessDenied = false;
+ var terminated = false;
+
try
{
model.ProcessId = process.Id;
+ }
+ catch
+ {
+ model.Classification = ProcessClassification.Unknown;
+ return model;
+ }
+
+ try
+ {
model.Name = process.ProcessName;
- model.MemoryUsage = process.PrivateMemorySize64;
- model.Priority = process.PriorityClass;
- model.ProcessorAffinity = (long)process.ProcessorAffinity;
- model.CpuUsage = this.CalculateCpuUsage(process);
+ }
+ catch (Exception ex) when (IsAccessDeniedException(ex))
+ {
+ accessDenied = true;
+ model.Name = $"PID_{model.ProcessId}";
+ this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex);
+ }
+ catch (Exception ex) when (IsTerminatedProcessException(ex))
+ {
+ terminated = true;
+ model.Name = $"PID_{model.ProcessId}";
+ }
- // Capture window information
- model.MainWindowHandle = process.MainWindowHandle;
- model.MainWindowTitle = process.MainWindowTitle ?? string.Empty;
- model.HasVisibleWindow = model.MainWindowHandle != IntPtr.Zero && !string.IsNullOrWhiteSpace(model.MainWindowTitle);
+ if (!terminated)
+ {
+ try
+ {
+ if (process.HasExited)
+ {
+ terminated = true;
+ }
+ }
+ catch (Exception ex) when (IsTerminatedProcessException(ex))
+ {
+ terminated = true;
+ }
+ catch (Exception ex) when (IsAccessDeniedException(ex))
+ {
+ accessDenied = true;
+ this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex);
+ }
+ }
- // Try to get executable path
+ if (!terminated)
+ {
try
{
- model.ExecutablePath = process.MainModule?.FileName ?? string.Empty;
+ model.MemoryUsage = process.PrivateMemorySize64;
}
- catch
+ catch (Exception ex) when (IsAccessDeniedException(ex))
{
- model.ExecutablePath = string.Empty;
+ accessDenied = true;
+ this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex);
+ }
+ catch (Exception ex) when (IsTerminatedProcessException(ex))
+ {
+ terminated = true;
+ }
+
+ if (!terminated)
+ {
+ try
+ {
+ model.Priority = process.PriorityClass;
+ }
+ catch (Exception ex) when (IsAccessDeniedException(ex))
+ {
+ accessDenied = true;
+ model.MainWindowHandle = IntPtr.Zero;
+ model.MainWindowTitle = string.Empty;
+ model.HasVisibleWindow = false;
+ this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex);
+ }
+ catch (Exception ex) when (IsPassiveProcessAccessException(ex))
+ {
+ accessDenied = true;
+ model.MainWindowHandle = IntPtr.Zero;
+ model.MainWindowTitle = string.Empty;
+ model.HasVisibleWindow = false;
+ this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex);
+ }
+ catch (Exception ex) when (IsTerminatedProcessException(ex))
+ {
+ terminated = true;
+ }
+ }
+
+ if (!terminated)
+ {
+ try
+ {
+ model.ProcessorAffinity = (long)process.ProcessorAffinity;
+ }
+ catch (Exception ex) when (IsAccessDeniedException(ex))
+ {
+ accessDenied = true;
+ this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex);
+ }
+ catch (Exception ex) when (IsTerminatedProcessException(ex))
+ {
+ terminated = true;
+ }
+ }
+
+ if (!terminated)
+ {
+ model.CpuUsage = this.CalculateCpuUsage(process);
+ }
+
+ if (!terminated)
+ {
+ try
+ {
+ model.MainWindowHandle = process.MainWindowHandle;
+ model.MainWindowTitle = process.MainWindowTitle ?? string.Empty;
+ model.HasVisibleWindow = model.MainWindowHandle != IntPtr.Zero && !string.IsNullOrWhiteSpace(model.MainWindowTitle);
+ }
+ catch (Exception ex) when (IsTerminatedProcessException(ex))
+ {
+ terminated = true;
+ }
+ }
+
+ if (!terminated)
+ {
+ try
+ {
+ model.ExecutablePath = process.MainModule?.FileName ?? string.Empty;
+ }
+ catch (Exception ex) when (IsAccessDeniedException(ex))
+ {
+ accessDenied = true;
+ model.ExecutablePath = string.Empty;
+ this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex);
+ }
+ catch (Exception ex) when (IsPassiveProcessAccessException(ex))
+ {
+ accessDenied = true;
+ model.ExecutablePath = string.Empty;
+ this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex);
+ }
+ catch (Exception ex) when (IsTerminatedProcessException(ex))
+ {
+ terminated = true;
+ }
}
}
- catch
+
+ if (terminated)
{
- // Process may have terminated or access denied
- // Return a minimal model
- model.ProcessId = process.Id;
- model.Name = process.ProcessName;
+ this.CleanupProcessResources(model.ProcessId);
}
+ this.ApplyProcessClassification(model, foregroundProcessId, accessDenied, terminated);
return model;
}
@@ -384,22 +566,128 @@ await Task.Run(() =>
process.MainWindowHandle = p.MainWindowHandle;
process.MainWindowTitle = p.MainWindowTitle ?? string.Empty;
process.HasVisibleWindow = process.MainWindowHandle != IntPtr.Zero && !string.IsNullOrWhiteSpace(process.MainWindowTitle);
+ this.ApplyProcessClassification(
+ process,
+ this.foregroundProcessService?.TryGetForegroundProcessId(),
+ accessDenied: false,
+ terminated: false);
}
catch (ArgumentException)
{
// Process with the specified ID does not exist
this.CleanupProcessResources(process.ProcessId);
+ this.ApplyProcessClassification(process, null, accessDenied: false, terminated: true);
throw new InvalidOperationException("Process no longer exists");
}
+ catch (Exception ex) when (IsAccessDeniedException(ex))
+ {
+ this.ApplyProcessClassification(process, null, accessDenied: true, terminated: false);
+ throw new InvalidOperationException("Access denied while refreshing process information.", ex);
+ }
catch (Exception ex) when (ex.Message.Contains("exited") || ex.Message.Contains("terminated"))
{
// Process has terminated
this.CleanupProcessResources(process.ProcessId);
+ this.ApplyProcessClassification(process, null, accessDenied: false, terminated: true);
throw new InvalidOperationException("Process has terminated");
}
}).ConfigureAwait(false);
}
+ private void ApplyProcessClassification(
+ ProcessModel process,
+ int? foregroundProcessId,
+ bool accessDenied,
+ bool terminated)
+ {
+ process.IsForeground = foregroundProcessId == process.ProcessId && !accessDenied && !terminated;
+ process.Classification = this.processClassifier.Classify(
+ process,
+ new ProcessClassificationContext(foregroundProcessId, accessDenied, terminated));
+ }
+
+ private void LogPassiveProcessReadFailure(int processId, PassiveProcessErrorKind errorKind, Exception exception)
+ {
+ if (this.passiveProcessErrorThrottle.ShouldLog(processId, errorKind))
+ {
+ this.logger?.LogDebug(
+ exception,
+ "Passive process read returned {ErrorKind} for PID {ProcessId}",
+ errorKind,
+ processId);
+ }
+ }
+
+ internal static bool IsPassiveProcessAccessException(Exception exception)
+ {
+ return IsAccessDeniedException(exception) ||
+ exception is Win32Exception { NativeErrorCode: 299 } ||
+ exception.Message.Contains("enumerate the process modules", StringComparison.OrdinalIgnoreCase) ||
+ exception.Message.Contains("access modules", StringComparison.OrdinalIgnoreCase) ||
+ exception.Message.Contains("ReadProcessMemory", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsAccessDeniedException(Exception exception)
+ {
+ return exception is UnauthorizedAccessException ||
+ exception is Win32Exception { NativeErrorCode: 5 };
+ }
+
+ private static bool IsTerminatedProcessException(Exception exception)
+ {
+ return exception is ArgumentException ||
+ (exception is InvalidOperationException invalidOperationException &&
+ (invalidOperationException.Message.Contains("exited", StringComparison.OrdinalIgnoreCase) ||
+ invalidOperationException.Message.Contains("terminated", StringComparison.OrdinalIgnoreCase) ||
+ invalidOperationException.Message.Contains("no longer exists", StringComparison.OrdinalIgnoreCase)));
+ }
+
+ private static int? TryGetProcessId(Process process)
+ {
+ try
+ {
+ return process.Id;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static ProcessModel? CreateMinimalProcessModel(
+ Process process,
+ int? processId,
+ ProcessClassification classification)
+ {
+ if (!processId.HasValue)
+ {
+ return null;
+ }
+
+ return new ProcessModel
+ {
+ ProcessId = processId.Value,
+ Name = TryGetProcessName(process, processId.Value),
+ ExecutablePath = string.Empty,
+ MainWindowHandle = IntPtr.Zero,
+ MainWindowTitle = string.Empty,
+ HasVisibleWindow = false,
+ Classification = classification,
+ };
+ }
+
+ private static string TryGetProcessName(Process process, int processId)
+ {
+ try
+ {
+ return process.ProcessName;
+ }
+ catch
+ {
+ return $"PID_{processId}";
+ }
+ }
+
///
/// Cleanup resources associated with a process.
///
@@ -518,9 +806,10 @@ public async Task> GetProcessesByNameAsync(string exec
{
try
{
+ var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId();
var processes = Process.GetProcessesByName(executableName)
- .Select(this.CreateProcessModel)
- .Where(p => p != null);
+ .Select(process => this.TryCreateProcessModel(process, foregroundProcessId))
+ .OfType();
return processes;
}
@@ -541,9 +830,11 @@ public async Task> GetProcessesWithPathsAsync()
{
return await Task.Run(() =>
{
+ var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId();
var processes = Process.GetProcesses()
- .Select(this.CreateProcessModel)
- .Where(p => p != null && !string.IsNullOrEmpty(p.ExecutablePath))
+ .Select(process => this.TryCreateProcessModel(process, foregroundProcessId))
+ .OfType()
+ .Where(p => !string.IsNullOrEmpty(p.ExecutablePath))
.OrderBy(p => p.Name);
return processes;
@@ -554,9 +845,11 @@ public async Task> GetActiveApplicationsAsync
{
return await Task.Run(() =>
{
+ var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId();
var processes = Process.GetProcesses()
- .Select(this.CreateProcessModel)
- .Where(p => p != null && p.HasVisibleWindow)
+ .Select(process => this.TryCreateProcessModel(process, foregroundProcessId))
+ .OfType()
+ .Where(p => p.HasVisibleWindow)
.OrderBy(p => p.Name);
return new ObservableCollection(processes);
diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs
index a93caeb..e311e55 100644
--- a/Services/ServiceConfiguration.cs
+++ b/Services/ServiceConfiguration.cs
@@ -99,11 +99,17 @@ private static IServiceCollection ConfigureServiceInfrastructure(this IServiceCo
private static IServiceCollection ConfigureCoreSystemServices(this IServiceCollection services)
{
// Core system interaction services
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
// CoreMaskService needs IServiceProvider for checking profile references
diff --git a/Services/SystemTrayService.cs b/Services/SystemTrayService.cs
index bd3b936..bcb6143 100644
--- a/Services/SystemTrayService.cs
+++ b/Services/SystemTrayService.cs
@@ -134,7 +134,7 @@ private void CreateContextMenu()
this.performanceMenuItem.Click += this.OnPerformanceDashboardClick;
this.contextMenu.Items.Add(this.performanceMenuItem);
- this.monitoringToggleMenuItem = new ToolStripMenuItem("Pause Monitoring");
+ this.monitoringToggleMenuItem = new ToolStripMenuItem("Pause Automation Monitoring");
this.monitoringToggleMenuItem.Click += this.OnMonitoringToggleClick;
this.contextMenu.Items.Add(this.monitoringToggleMenuItem);
@@ -157,7 +157,7 @@ private void CreateContextMenu()
this.contextMenu.Items.Add(this.selectedProcessMenuItem);
// Quick apply command
- this.quickApplyMenuItem = new ToolStripMenuItem("Quick Apply to Selected Process")
+ this.quickApplyMenuItem = new ToolStripMenuItem("Apply Pending Settings to Selected Process")
{
Enabled = false,
};
@@ -237,13 +237,13 @@ public void UpdateContextMenu(string? selectedProcessName = null, bool hasSelect
{
this.selectedProcessMenuItem.Text = $"Selected: {selectedProcessName}";
this.quickApplyMenuItem.Enabled = true;
- this.quickApplyMenuItem.Text = $"Quick Apply to {selectedProcessName}";
+ this.quickApplyMenuItem.Text = $"Apply Pending Settings to {selectedProcessName}";
}
else
{
this.selectedProcessMenuItem.Text = "No process selected";
this.quickApplyMenuItem.Enabled = false;
- this.quickApplyMenuItem.Text = "Quick Apply to Selected Process";
+ this.quickApplyMenuItem.Text = "Apply Pending Settings to Selected Process";
}
}
@@ -307,7 +307,7 @@ public void UpdateMonitoringStatus(bool isMonitoring, bool isWmiAvailable = true
if (this.monitoringToggleMenuItem != null)
{
- this.monitoringToggleMenuItem.Text = isMonitoring ? "Pause Monitoring" : "Resume Monitoring";
+ this.monitoringToggleMenuItem.Text = isMonitoring ? "Pause Automation Monitoring" : "Resume Automation Monitoring";
this.monitoringToggleMenuItem.Enabled = isWmiAvailable;
}
@@ -317,8 +317,8 @@ public void UpdateMonitoringStatus(bool isMonitoring, bool isWmiAvailable = true
this.UpdateTrayIcon(iconState);
// Update tooltip
- var status = !isWmiAvailable ? "WMI Error" :
- isMonitoring ? "Monitoring Active" : "Monitoring Disabled";
+ var status = !isWmiAvailable ? "Automation WMI Error" :
+ isMonitoring ? "Automation Active" : "Automation Disabled";
this.UpdateTooltip($"ThreadPilot - {status}");
}
diff --git a/Services/VirtualizedProcessService.cs b/Services/VirtualizedProcessService.cs
index 0b4b54c..ff2bc5b 100644
--- a/Services/VirtualizedProcessService.cs
+++ b/Services/VirtualizedProcessService.cs
@@ -274,7 +274,7 @@ private void BackgroundPreloadCallback(object? state)
{
TaskSafety.FireAndForget(this.BackgroundPreloadCallbackAsync(), ex =>
{
- this.logger.LogWarning(ex, "Background process refresh failed");
+ this.logger.LogDebug(ex, "Background process refresh failed");
});
}
diff --git a/Services/WindowsForegroundWindowProvider.cs b/Services/WindowsForegroundWindowProvider.cs
new file mode 100644
index 0000000..57fc17e
--- /dev/null
+++ b/Services/WindowsForegroundWindowProvider.cs
@@ -0,0 +1,78 @@
+/*
+ * ThreadPilot - Advanced Windows Process and Power Plan Manager
+ * Copyright (C) 2025 Prime Build
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, version 3 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.Runtime.InteropServices;
+
+ public sealed class WindowsForegroundWindowProvider : IForegroundWindowProvider
+ {
+ private const int DwmwaCloaked = 14;
+
+ public bool TryGetForegroundWindow(out ForegroundWindowSnapshot snapshot)
+ {
+ snapshot = default;
+
+ var windowHandle = GetForegroundWindow();
+ if (windowHandle == IntPtr.Zero)
+ {
+ return false;
+ }
+
+ _ = GetWindowThreadProcessId(windowHandle, out var processId);
+ if (processId == 0)
+ {
+ return false;
+ }
+
+ snapshot = new ForegroundWindowSnapshot(
+ windowHandle,
+ unchecked((int)processId),
+ IsWindowVisible(windowHandle),
+ IsWindowCloaked(windowHandle));
+ return true;
+ }
+
+ private static bool IsWindowCloaked(IntPtr windowHandle)
+ {
+ var result = DwmGetWindowAttribute(
+ windowHandle,
+ DwmwaCloaked,
+ out int cloaked,
+ Marshal.SizeOf());
+
+ return result == 0 && cloaked != 0;
+ }
+
+ [DllImport("dwmapi.dll", PreserveSig = true)]
+ private static extern int DwmGetWindowAttribute(
+ IntPtr hwnd,
+ int dwAttribute,
+ out int pvAttribute,
+ int cbAttribute);
+
+ [DllImport("user32.dll")]
+ private static extern IntPtr GetForegroundWindow();
+
+ [DllImport("user32.dll")]
+ private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool IsWindowVisible(IntPtr hWnd);
+ }
+}
diff --git a/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs b/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs
new file mode 100644
index 0000000..af592a6
--- /dev/null
+++ b/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs
@@ -0,0 +1,211 @@
+namespace ThreadPilot.Core.Tests
+{
+ using Microsoft.Extensions.Logging.Abstractions;
+ using Moq;
+ using ThreadPilot.Models;
+ using ThreadPilot.Services;
+
+ public sealed class AffinityApplyServiceTests
+ {
+ [Fact]
+ public async Task ApplyAsync_WhenVerifiedMaskMatches_ReturnsSuccess()
+ {
+ var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 };
+ var processService = CreateProcessService(processStillRunning: true);
+ processService
+ .Setup(service => service.SetProcessorAffinity(process, 1))
+ .Returns(Task.CompletedTask);
+ processService
+ .Setup(service => service.RefreshProcessInfo(process))
+ .Callback(() => process.ProcessorAffinity = 1)
+ .Returns(Task.CompletedTask);
+
+ var service = CreateService(processService);
+
+ var result = await service.ApplyAsync(process, 1);
+
+ Assert.True(result.Success);
+ Assert.Equal(1, result.RequestedMask);
+ Assert.Equal(1, result.VerifiedMask);
+ Assert.Equal(AffinityApplyFailureReason.None, result.FailureReason);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_WhenProcessIsTerminated_ReturnsFailureWithoutApplying()
+ {
+ var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 };
+ var processService = CreateProcessService(processStillRunning: false);
+ var service = CreateService(processService);
+
+ var result = await service.ApplyAsync(process, 1);
+
+ Assert.False(result.Success);
+ Assert.Equal(AffinityApplyFailureReason.ProcessTerminated, result.FailureReason);
+ processService.Verify(
+ service => service.SetProcessorAffinity(It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_WhenAccessDenied_ReturnsAccessDeniedFailure()
+ {
+ var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 };
+ var processService = CreateProcessService(processStillRunning: true);
+ processService
+ .Setup(service => service.SetProcessorAffinity(process, 1))
+ .ThrowsAsync(new InvalidOperationException("Access denied while setting processor affinity."));
+
+ var service = CreateService(processService);
+
+ var result = await service.ApplyAsync(process, 1);
+
+ Assert.False(result.Success);
+ Assert.Equal(AffinityApplyFailureReason.AccessDenied, result.FailureReason);
+ Assert.Equal(3, result.VerifiedMask);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_WhenVerifiedMaskDiffers_ReturnsMismatchFailure()
+ {
+ var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 };
+ var processService = CreateProcessService(processStillRunning: true);
+ processService
+ .Setup(service => service.SetProcessorAffinity(process, 1))
+ .Returns(Task.CompletedTask);
+ processService
+ .Setup(service => service.RefreshProcessInfo(process))
+ .Callback(() => process.ProcessorAffinity = 2)
+ .Returns(Task.CompletedTask);
+
+ var service = CreateService(processService);
+
+ var result = await service.ApplyAsync(process, 1);
+
+ Assert.False(result.Success);
+ Assert.Equal(AffinityApplyFailureReason.VerificationMismatch, result.FailureReason);
+ Assert.Equal(1, result.RequestedMask);
+ Assert.Equal(2, result.VerifiedMask);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_WhenMaskIsZero_ReturnsInvalidMaskFailure()
+ {
+ var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 };
+ var processService = CreateProcessService(processStillRunning: true);
+ var service = CreateService(processService);
+
+ var result = await service.ApplyAsync(process, 0);
+
+ Assert.False(result.Success);
+ Assert.Equal(AffinityApplyFailureReason.InvalidMask, result.FailureReason);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_WhenTopologyRejectsMask_ReturnsInvalidMaskFailure()
+ {
+ var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 };
+ var processService = CreateProcessService(processStillRunning: true);
+ var topologyService = new Mock(MockBehavior.Strict);
+ topologyService.Setup(service => service.IsAffinityMaskValid(8)).Returns(false);
+ var service = CreateService(processService, topologyService);
+
+ var result = await service.ApplyAsync(process, 8);
+
+ Assert.False(result.Success);
+ Assert.Equal(AffinityApplyFailureReason.InvalidMask, result.FailureReason);
+ processService.Verify(
+ service => service.SetProcessorAffinity(It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_WhenProcessStateCheckIsAccessDenied_StillAttemptsApply()
+ {
+ var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 };
+ var processService = new Mock(MockBehavior.Strict);
+ processService
+ .Setup(service => service.IsProcessStillRunning(process))
+ .ThrowsAsync(new UnauthorizedAccessException("Access denied."));
+ processService
+ .Setup(service => service.SetProcessorAffinity(process, 1))
+ .Returns(Task.CompletedTask);
+ processService
+ .Setup(service => service.RefreshProcessInfo(process))
+ .Callback(() => process.ProcessorAffinity = 1)
+ .Returns(Task.CompletedTask);
+ var service = CreateService(processService);
+
+ var result = await service.ApplyAsync(process, 1);
+
+ Assert.True(result.Success);
+ processService.Verify(service => service.SetProcessorAffinity(process, 1), Times.Once);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_WhenRefreshAfterApplyIsAccessDenied_ReportsVerificationMismatch()
+ {
+ var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 };
+ var processService = CreateProcessService(processStillRunning: true);
+ processService
+ .Setup(service => service.SetProcessorAffinity(process, 1))
+ .Returns(Task.CompletedTask);
+ processService
+ .Setup(service => service.RefreshProcessInfo(process))
+ .ThrowsAsync(new UnauthorizedAccessException("Access denied."));
+ var service = CreateService(processService);
+
+ var result = await service.ApplyAsync(process, 1);
+
+ Assert.False(result.Success);
+ Assert.Equal(AffinityApplyFailureReason.VerificationMismatch, result.FailureReason);
+ Assert.Equal(3, result.VerifiedMask);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_WhenApplyThrowsUnexpectedError_ReturnsApplyFailed()
+ {
+ var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 };
+ var processService = CreateProcessService(processStillRunning: true);
+ processService
+ .Setup(service => service.SetProcessorAffinity(process, 1))
+ .ThrowsAsync(new InvalidOperationException("Driver rejected request."));
+ processService
+ .Setup(service => service.RefreshProcessInfo(process))
+ .Returns(Task.CompletedTask);
+ var service = CreateService(processService);
+
+ var result = await service.ApplyAsync(process, 1);
+
+ Assert.False(result.Success);
+ Assert.Equal(AffinityApplyFailureReason.ApplyFailed, result.FailureReason);
+ Assert.Equal(3, result.VerifiedMask);
+ }
+
+ private static AffinityApplyService CreateService(Mock processService)
+ {
+ var topologyService = new Mock(MockBehavior.Loose);
+ topologyService.Setup(service => service.IsAffinityMaskValid(It.IsAny())).Returns(true);
+
+ return CreateService(processService, topologyService);
+ }
+
+ private static AffinityApplyService CreateService(
+ Mock processService,
+ Mock topologyService)
+ {
+ return new AffinityApplyService(
+ processService.Object,
+ topologyService.Object,
+ NullLogger.Instance);
+ }
+
+ private static Mock CreateProcessService(bool processStillRunning)
+ {
+ var processService = new Mock(MockBehavior.Strict);
+ processService
+ .Setup(service => service.IsProcessStillRunning(It.IsAny()))
+ .ReturnsAsync(processStillRunning);
+ return processService;
+ }
+ }
+}
diff --git a/Tests/ThreadPilot.Core.Tests/AppRefreshPolicyTests.cs b/Tests/ThreadPilot.Core.Tests/AppRefreshPolicyTests.cs
new file mode 100644
index 0000000..f78bca4
--- /dev/null
+++ b/Tests/ThreadPilot.Core.Tests/AppRefreshPolicyTests.cs
@@ -0,0 +1,44 @@
+namespace ThreadPilot.Core.Tests
+{
+ using ThreadPilot.Services;
+
+ public sealed class AppRefreshPolicyTests
+ {
+ [Theory]
+ [InlineData(AppActivityState.ForegroundProcessView, true, true, true, true, true, true)]
+ [InlineData(AppActivityState.ForegroundOtherTab, false, false, false, true, true, true)]
+ [InlineData(AppActivityState.Minimized, false, false, false, false, false, true)]
+ [InlineData(AppActivityState.TrayHidden, false, false, false, false, false, true)]
+ public void Evaluate_ReturnsExpectedRefreshDecision(
+ AppActivityState state,
+ bool processUiRefreshEnabled,
+ bool immediateProcessRefresh,
+ bool virtualizedPreloadEnabled,
+ bool performanceUiMonitoringEnabled,
+ bool powerPlanUiRefreshEnabled,
+ bool backgroundAutomationEnabled)
+ {
+ var decision = AppRefreshPolicy.Evaluate(state);
+
+ Assert.Equal(processUiRefreshEnabled, decision.ProcessUiRefreshEnabled);
+ Assert.Equal(immediateProcessRefresh, decision.ImmediateProcessRefresh);
+ Assert.Equal(virtualizedPreloadEnabled, decision.VirtualizedPreloadEnabled);
+ Assert.Equal(performanceUiMonitoringEnabled, decision.PerformanceUiMonitoringEnabled);
+ Assert.Equal(powerPlanUiRefreshEnabled, decision.PowerPlanUiRefreshEnabled);
+ Assert.Equal(backgroundAutomationEnabled, decision.BackgroundAutomationEnabled);
+ }
+
+ [Fact]
+ public void Evaluate_WhenStateIsUnknown_KeepsBackgroundAutomationOnly()
+ {
+ var decision = AppRefreshPolicy.Evaluate((AppActivityState)999);
+
+ Assert.False(decision.ProcessUiRefreshEnabled);
+ Assert.False(decision.ImmediateProcessRefresh);
+ Assert.False(decision.VirtualizedPreloadEnabled);
+ Assert.False(decision.PerformanceUiMonitoringEnabled);
+ Assert.False(decision.PowerPlanUiRefreshEnabled);
+ Assert.True(decision.BackgroundAutomationEnabled);
+ }
+ }
+}
diff --git a/Tests/ThreadPilot.Core.Tests/ForegroundProcessServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ForegroundProcessServiceTests.cs
new file mode 100644
index 0000000..ec31202
--- /dev/null
+++ b/Tests/ThreadPilot.Core.Tests/ForegroundProcessServiceTests.cs
@@ -0,0 +1,68 @@
+namespace ThreadPilot.Core.Tests
+{
+ using Microsoft.Extensions.Logging.Abstractions;
+ using ThreadPilot.Services;
+
+ public sealed class ForegroundProcessServiceTests
+ {
+ [Fact]
+ public void TryGetForegroundProcessId_ReturnsPidFromVisibleForegroundWindow()
+ {
+ var provider = new FakeForegroundWindowProvider(
+ new ForegroundWindowSnapshot(new IntPtr(42), 1234, true, false));
+ var service = new ForegroundProcessService(provider, NullLogger.Instance);
+
+ var result = service.TryGetForegroundProcessId();
+
+ Assert.Equal(1234, result);
+ }
+
+ [Theory]
+ [InlineData(0, true, false)]
+ [InlineData(1234, false, false)]
+ [InlineData(1234, true, true)]
+ public void TryGetForegroundProcessId_ReturnsNullForInvalidForegroundWindow(int processId, bool isVisible, bool isCloaked)
+ {
+ var provider = new FakeForegroundWindowProvider(
+ new ForegroundWindowSnapshot(new IntPtr(42), processId, isVisible, isCloaked));
+ var service = new ForegroundProcessService(provider, NullLogger.Instance);
+
+ var result = service.TryGetForegroundProcessId();
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void TryGetForegroundProcessId_ReturnsNullWhenProviderFails()
+ {
+ var provider = new FakeForegroundWindowProvider(null);
+ var service = new ForegroundProcessService(provider, NullLogger.Instance);
+
+ var result = service.TryGetForegroundProcessId();
+
+ Assert.Null(result);
+ }
+
+ private sealed class FakeForegroundWindowProvider : IForegroundWindowProvider
+ {
+ private readonly ForegroundWindowSnapshot? snapshot;
+
+ public FakeForegroundWindowProvider(ForegroundWindowSnapshot? snapshot)
+ {
+ this.snapshot = snapshot;
+ }
+
+ public bool TryGetForegroundWindow(out ForegroundWindowSnapshot snapshot)
+ {
+ if (this.snapshot == null)
+ {
+ snapshot = default;
+ return false;
+ }
+
+ snapshot = this.snapshot.Value;
+ return true;
+ }
+ }
+ }
+}
diff --git a/Tests/ThreadPilot.Core.Tests/PassiveProcessErrorThrottleTests.cs b/Tests/ThreadPilot.Core.Tests/PassiveProcessErrorThrottleTests.cs
new file mode 100644
index 0000000..26ce17f
--- /dev/null
+++ b/Tests/ThreadPilot.Core.Tests/PassiveProcessErrorThrottleTests.cs
@@ -0,0 +1,59 @@
+namespace ThreadPilot.Core.Tests
+{
+ using ThreadPilot.Services;
+
+ public sealed class PassiveProcessErrorThrottleTests
+ {
+ [Fact]
+ public void ShouldLog_ReturnsFalseForRepeatedErrorInsideTtl()
+ {
+ var now = new DateTimeOffset(2026, 5, 9, 12, 0, 0, TimeSpan.Zero);
+ var throttle = new PassiveProcessErrorThrottle(TimeSpan.FromMinutes(1), () => now);
+
+ Assert.True(throttle.ShouldLog(42, PassiveProcessErrorKind.AccessDenied));
+ Assert.False(throttle.ShouldLog(42, PassiveProcessErrorKind.AccessDenied));
+ }
+
+ [Fact]
+ public void ShouldLog_ReturnsTrueAfterTtlExpires()
+ {
+ var now = new DateTimeOffset(2026, 5, 9, 12, 0, 0, TimeSpan.Zero);
+ var throttle = new PassiveProcessErrorThrottle(TimeSpan.FromMinutes(1), () => now);
+
+ Assert.True(throttle.ShouldLog(42, PassiveProcessErrorKind.AccessDenied));
+ now = now.AddMinutes(2);
+
+ Assert.True(throttle.ShouldLog(42, PassiveProcessErrorKind.AccessDenied));
+ }
+
+ [Fact]
+ public void ShouldLog_TracksPidAndErrorKindSeparately()
+ {
+ var now = new DateTimeOffset(2026, 5, 9, 12, 0, 0, TimeSpan.Zero);
+ var throttle = new PassiveProcessErrorThrottle(TimeSpan.FromMinutes(1), () => now);
+
+ Assert.True(throttle.ShouldLog(42, PassiveProcessErrorKind.AccessDenied));
+ Assert.True(throttle.ShouldLog(42, PassiveProcessErrorKind.Terminated));
+ Assert.True(throttle.ShouldLog(43, PassiveProcessErrorKind.AccessDenied));
+ }
+
+ [Fact]
+ public void ShouldLog_WhenElapsedTimeEqualsTtl_ReturnsTrue()
+ {
+ var now = new DateTimeOffset(2026, 5, 9, 12, 0, 0, TimeSpan.Zero);
+ var throttle = new PassiveProcessErrorThrottle(TimeSpan.FromMinutes(1), () => now);
+
+ Assert.True(throttle.ShouldLog(42, PassiveProcessErrorKind.Unknown));
+ now = now.AddMinutes(1);
+
+ Assert.True(throttle.ShouldLog(42, PassiveProcessErrorKind.Unknown));
+ }
+
+ [Fact]
+ public void Constructor_WhenTtlIsZero_Throws()
+ {
+ Assert.Throws(() =>
+ new PassiveProcessErrorThrottle(TimeSpan.Zero, () => DateTimeOffset.UtcNow));
+ }
+ }
+}
diff --git a/Tests/ThreadPilot.Core.Tests/PowerPlanTransitionGateTests.cs b/Tests/ThreadPilot.Core.Tests/PowerPlanTransitionGateTests.cs
new file mode 100644
index 0000000..5c3071d
--- /dev/null
+++ b/Tests/ThreadPilot.Core.Tests/PowerPlanTransitionGateTests.cs
@@ -0,0 +1,89 @@
+namespace ThreadPilot.Core.Tests
+{
+ using ThreadPilot.Services;
+
+ public sealed class PowerPlanTransitionGateTests
+ {
+ [Fact]
+ public void ShouldApply_WhenTargetWasNotRequested_ReturnsTrue()
+ {
+ var now = DateTimeOffset.Parse("2026-05-09T10:00:00Z");
+ var gate = new PowerPlanTransitionGate(TimeSpan.FromSeconds(2), () => now);
+
+ var decision = gate.ShouldApply("plan-game", "balanced");
+
+ Assert.True(decision.ShouldApply);
+ Assert.Equal(PowerPlanTransitionSuppressionReason.None, decision.SuppressionReason);
+ }
+
+ [Fact]
+ public void ShouldApply_WhenTargetIsAlreadyActive_ReturnsFalse()
+ {
+ var now = DateTimeOffset.Parse("2026-05-09T10:00:00Z");
+ var gate = new PowerPlanTransitionGate(TimeSpan.FromSeconds(2), () => now);
+
+ var decision = gate.ShouldApply("plan-game", "plan-game");
+
+ Assert.False(decision.ShouldApply);
+ Assert.Equal(PowerPlanTransitionSuppressionReason.AlreadyActive, decision.SuppressionReason);
+ }
+
+ [Fact]
+ public void ShouldApply_WhenSameTargetWasRecentlyRequested_ReturnsFalse()
+ {
+ var now = DateTimeOffset.Parse("2026-05-09T10:00:00Z");
+ var gate = new PowerPlanTransitionGate(TimeSpan.FromSeconds(2), () => now);
+
+ Assert.True(gate.ShouldApply("plan-game", "balanced").ShouldApply);
+ gate.RecordAttempt("plan-game");
+ now = now.AddMilliseconds(500);
+
+ var decision = gate.ShouldApply("plan-game", "balanced");
+
+ Assert.False(decision.ShouldApply);
+ Assert.Equal(PowerPlanTransitionSuppressionReason.RecentDuplicateRequest, decision.SuppressionReason);
+ }
+
+ [Fact]
+ public void ShouldApply_WhenDifferentTargetArrives_UsesLatestTarget()
+ {
+ var now = DateTimeOffset.Parse("2026-05-09T10:00:00Z");
+ var gate = new PowerPlanTransitionGate(TimeSpan.FromSeconds(2), () => now);
+
+ gate.RecordAttempt("plan-game");
+ now = now.AddMilliseconds(500);
+
+ var decision = gate.ShouldApply("plan-default", "plan-game");
+
+ Assert.True(decision.ShouldApply);
+ }
+
+ [Fact]
+ public void ShouldApply_WhenDuplicateWindowExpires_ReturnsTrue()
+ {
+ var now = DateTimeOffset.Parse("2026-05-09T10:00:00Z");
+ var gate = new PowerPlanTransitionGate(TimeSpan.FromSeconds(2), () => now);
+
+ gate.RecordAttempt("plan-game");
+ now = now.AddSeconds(3);
+
+ var decision = gate.ShouldApply("plan-game", "balanced");
+
+ Assert.True(decision.ShouldApply);
+ Assert.Equal(PowerPlanTransitionSuppressionReason.None, decision.SuppressionReason);
+ }
+
+ [Fact]
+ public void Constructor_WhenDuplicateWindowIsNegative_UsesZeroWindow()
+ {
+ var now = DateTimeOffset.Parse("2026-05-09T10:00:00Z");
+ var gate = new PowerPlanTransitionGate(TimeSpan.FromSeconds(-1), () => now);
+
+ gate.RecordAttempt("plan-game");
+
+ var decision = gate.ShouldApply("plan-game", "balanced");
+
+ Assert.True(decision.ShouldApply);
+ }
+ }
+}
diff --git a/Tests/ThreadPilot.Core.Tests/ProcessClassifierTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessClassifierTests.cs
new file mode 100644
index 0000000..fe48f47
--- /dev/null
+++ b/Tests/ThreadPilot.Core.Tests/ProcessClassifierTests.cs
@@ -0,0 +1,149 @@
+namespace ThreadPilot.Core.Tests
+{
+ using ThreadPilot.Models;
+ using ThreadPilot.Services;
+
+ public sealed class ProcessClassifierTests
+ {
+ [Fact]
+ public void Classify_ReturnsForegroundAppForForegroundPid()
+ {
+ var classifier = new ProcessClassifier(new ProcessFilterService());
+ var process = new ProcessModel
+ {
+ ProcessId = 10,
+ Name = "Game",
+ HasVisibleWindow = true,
+ };
+
+ var result = classifier.Classify(process, new ProcessClassificationContext(10));
+
+ Assert.Equal(ProcessClassification.ForegroundApp, result);
+ }
+
+ [Fact]
+ public void Classify_ReturnsVisibleWindowAppForVisibleNonForegroundProcess()
+ {
+ var classifier = new ProcessClassifier(new ProcessFilterService());
+ var process = new ProcessModel
+ {
+ ProcessId = 10,
+ Name = "Editor",
+ HasVisibleWindow = true,
+ };
+
+ var result = classifier.Classify(process, new ProcessClassificationContext(20));
+
+ Assert.Equal(ProcessClassification.VisibleWindowApp, result);
+ }
+
+ [Theory]
+ [InlineData("svchost")]
+ [InlineData("svchost.exe")]
+ public void Classify_ReturnsSystemForNormalizedSystemProcessNames(string processName)
+ {
+ var classifier = new ProcessClassifier(new ProcessFilterService());
+ var process = new ProcessModel
+ {
+ ProcessId = 10,
+ Name = processName,
+ };
+
+ var result = classifier.Classify(process, new ProcessClassificationContext(null));
+
+ Assert.Equal(ProcessClassification.System, result);
+ }
+
+ [Fact]
+ public void Classify_ReturnsProtectedOrAccessDeniedWhenAccessWasDenied()
+ {
+ var classifier = new ProcessClassifier(new ProcessFilterService());
+ var process = new ProcessModel
+ {
+ ProcessId = 10,
+ Name = "ProtectedProcess",
+ };
+
+ var result = classifier.Classify(process, new ProcessClassificationContext(null, AccessDenied: true));
+
+ Assert.Equal(ProcessClassification.ProtectedOrAccessDenied, result);
+ }
+
+ [Fact]
+ public void Classify_ReturnsTerminatedWhenProcessTerminated()
+ {
+ var classifier = new ProcessClassifier(new ProcessFilterService());
+ var process = new ProcessModel
+ {
+ ProcessId = 10,
+ Name = "ClosedProcess",
+ };
+
+ var result = classifier.Classify(process, new ProcessClassificationContext(null, Terminated: true));
+
+ Assert.Equal(ProcessClassification.Terminated, result);
+ }
+
+ [Fact]
+ public void Classify_ReturnsBackgroundUserForNonSystemProcessWithoutWindow()
+ {
+ var classifier = new ProcessClassifier(new ProcessFilterService());
+ var process = new ProcessModel
+ {
+ ProcessId = 10,
+ Name = "Worker",
+ };
+
+ var result = classifier.Classify(process, new ProcessClassificationContext(null));
+
+ Assert.Equal(ProcessClassification.BackgroundUser, result);
+ }
+
+ [Fact]
+ public void Classify_TerminatedTakesPrecedenceOverForegroundAndSystem()
+ {
+ var classifier = new ProcessClassifier(new ProcessFilterService());
+ var process = new ProcessModel
+ {
+ ProcessId = 10,
+ Name = "svchost",
+ HasVisibleWindow = true,
+ };
+
+ var result = classifier.Classify(process, new ProcessClassificationContext(10, Terminated: true));
+
+ Assert.Equal(ProcessClassification.Terminated, result);
+ }
+
+ [Fact]
+ public void Classify_AccessDeniedTakesPrecedenceOverForegroundAndWindow()
+ {
+ var classifier = new ProcessClassifier(new ProcessFilterService());
+ var process = new ProcessModel
+ {
+ ProcessId = 10,
+ Name = "ProtectedWindow",
+ HasVisibleWindow = true,
+ };
+
+ var result = classifier.Classify(process, new ProcessClassificationContext(10, AccessDenied: true));
+
+ Assert.Equal(ProcessClassification.ProtectedOrAccessDenied, result);
+ }
+
+ [Fact]
+ public void Classify_ReturnsUnknownWhenNameIsMissing()
+ {
+ var classifier = new ProcessClassifier(new ProcessFilterService());
+ var process = new ProcessModel
+ {
+ ProcessId = 10,
+ Name = string.Empty,
+ };
+
+ var result = classifier.Classify(process, new ProcessClassificationContext(null));
+
+ Assert.Equal(ProcessClassification.Unknown, result);
+ }
+ }
+}
diff --git a/Tests/ThreadPilot.Core.Tests/ProcessListDeltaUpdaterTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessListDeltaUpdaterTests.cs
new file mode 100644
index 0000000..2f203db
--- /dev/null
+++ b/Tests/ThreadPilot.Core.Tests/ProcessListDeltaUpdaterTests.cs
@@ -0,0 +1,134 @@
+namespace ThreadPilot.Core.Tests
+{
+ using System.Collections.ObjectModel;
+ using System.Diagnostics;
+ using ThreadPilot.Models;
+ using ThreadPilot.Services;
+
+ public sealed class ProcessListDeltaUpdaterTests
+ {
+ [Fact]
+ public void ApplyDelta_PreservesExistingInstancesAndUpdatesProperties()
+ {
+ var existing = new ProcessModel
+ {
+ ProcessId = 42,
+ Name = "ThreadPilot",
+ CpuUsage = 1,
+ MemoryUsage = 100,
+ Priority = ProcessPriorityClass.Normal,
+ ProcessorAffinity = 1,
+ };
+ var processes = new ObservableCollection { existing };
+ var snapshot = new[]
+ {
+ new ProcessModel
+ {
+ ProcessId = 42,
+ Name = "ThreadPilot",
+ CpuUsage = 7,
+ MemoryUsage = 500,
+ Priority = ProcessPriorityClass.High,
+ ProcessorAffinity = 3,
+ HasVisibleWindow = true,
+ IsForeground = true,
+ Classification = ProcessClassification.ForegroundApp,
+ MainWindowTitle = "ThreadPilot - Processes",
+ },
+ };
+
+ var result = ProcessListDeltaUpdater.ApplyDelta(processes, snapshot, 42);
+
+ Assert.Same(existing, processes[0]);
+ Assert.Same(existing, result.SelectedProcess);
+ Assert.False(result.SelectedProcessTerminated);
+ Assert.Equal(7, existing.CpuUsage);
+ Assert.Equal(500, existing.MemoryUsage);
+ Assert.Equal(ProcessPriorityClass.High, existing.Priority);
+ Assert.Equal(3, existing.ProcessorAffinity);
+ Assert.True(existing.HasVisibleWindow);
+ Assert.True(existing.IsForeground);
+ Assert.Equal(ProcessClassification.ForegroundApp, existing.Classification);
+ Assert.Equal("ThreadPilot - Processes", existing.MainWindowTitle);
+ }
+
+ [Fact]
+ public void ApplyDelta_AddsNewProcessesAndRemovesDeadProcesses()
+ {
+ var removed = new ProcessModel { ProcessId = 10, Name = "Dead" };
+ var kept = new ProcessModel { ProcessId = 20, Name = "Kept" };
+ var processes = new ObservableCollection { removed, kept };
+ var snapshot = new[]
+ {
+ new ProcessModel { ProcessId = 20, Name = "Kept" },
+ new ProcessModel { ProcessId = 30, Name = "New" },
+ };
+
+ var result = ProcessListDeltaUpdater.ApplyDelta(processes, snapshot, 20);
+
+ Assert.Equal(2, processes.Count);
+ Assert.DoesNotContain(processes, p => p.ProcessId == 10);
+ Assert.Contains(processes, p => p.ProcessId == 30);
+ Assert.Same(kept, result.SelectedProcess);
+ Assert.False(result.SelectedProcessTerminated);
+ }
+
+ [Fact]
+ public void ApplyDelta_ReportsTerminatedSelection()
+ {
+ var selected = new ProcessModel { ProcessId = 10, Name = "Dead" };
+ var processes = new ObservableCollection { selected };
+
+ var result = ProcessListDeltaUpdater.ApplyDelta(processes, Array.Empty(), 10);
+
+ Assert.Empty(processes);
+ Assert.Null(result.SelectedProcess);
+ Assert.True(result.SelectedProcessTerminated);
+ }
+
+ [Fact]
+ public void ApplyDelta_WhenSnapshotContainsDuplicatePid_UsesLatestSnapshot()
+ {
+ var existing = new ProcessModel { ProcessId = 42, Name = "Old" };
+ var processes = new ObservableCollection { existing };
+ var snapshot = new[]
+ {
+ new ProcessModel { ProcessId = 42, Name = "First", CpuUsage = 1 },
+ new ProcessModel { ProcessId = 42, Name = "Latest", CpuUsage = 9 },
+ };
+
+ var result = ProcessListDeltaUpdater.ApplyDelta(processes, snapshot, 42);
+
+ Assert.Single(processes);
+ Assert.Same(existing, processes[0]);
+ Assert.Same(existing, result.SelectedProcess);
+ Assert.Equal("Latest", existing.Name);
+ Assert.Equal(9, existing.CpuUsage);
+ }
+
+ [Fact]
+ public void ApplyDelta_PreservesSelectionDuringAddRemoveChurn()
+ {
+ var selected = new ProcessModel { ProcessId = 20, Name = "Selected" };
+ var processes = new ObservableCollection
+ {
+ new() { ProcessId = 10, Name = "Removed" },
+ selected,
+ };
+
+ var snapshot = new[]
+ {
+ new ProcessModel { ProcessId = 20, Name = "Selected Updated" },
+ new ProcessModel { ProcessId = 30, Name = "Added" },
+ };
+
+ var result = ProcessListDeltaUpdater.ApplyDelta(processes, snapshot, 20);
+
+ Assert.Equal(2, processes.Count);
+ Assert.Same(selected, result.SelectedProcess);
+ Assert.False(result.SelectedProcessTerminated);
+ Assert.DoesNotContain(processes, process => process.ProcessId == 10);
+ Assert.Contains(processes, process => process.ProcessId == 30);
+ }
+ }
+}
diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs
index 27e5c28..0842d8d 100644
--- a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs
+++ b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs
@@ -25,13 +25,15 @@ public async Task StartAsync_LoadsConfiguration_AndStartsMonitoring()
var notificationService = CreateNotificationService();
var processService = CreateProcessService();
var coreMaskService = CreateCoreMaskService();
+ var affinityApplyService = CreateAffinityApplyService();
var manager = CreateService(
processMonitor,
associationService,
powerPlanService,
notificationService,
processService,
- coreMaskService);
+ coreMaskService,
+ affinityApplyService);
await manager.StartAsync();
@@ -68,13 +70,15 @@ public async Task StartAsync_SelectsHighestPriorityAssociation()
var notificationService = CreateNotificationService();
var processService = CreateProcessService();
var coreMaskService = CreateCoreMaskService();
+ var affinityApplyService = CreateAffinityApplyService();
var manager = CreateService(
processMonitor,
associationService,
powerPlanService,
notificationService,
processService,
- coreMaskService);
+ coreMaskService,
+ affinityApplyService);
await manager.StartAsync();
@@ -104,13 +108,15 @@ public async Task ProcessStarted_WithDelay_TriggersSingleReevaluation()
var notificationService = CreateNotificationService();
var processService = CreateProcessService();
var coreMaskService = CreateCoreMaskService();
+ var affinityApplyService = CreateAffinityApplyService();
var manager = CreateService(
processMonitor,
associationService,
powerPlanService,
notificationService,
processService,
- coreMaskService);
+ coreMaskService,
+ affinityApplyService);
await manager.StartAsync();
processMonitor.RaiseProcessStarted(new ProcessModel { ProcessId = 10, Name = "game" });
@@ -123,6 +129,90 @@ public async Task ProcessStarted_WithDelay_TriggersSingleReevaluation()
Times.Once);
}
+ [Fact]
+ public async Task ProcessStarted_SamePlanRequest_IsSuppressedWithinDuplicateWindow()
+ {
+ var processMonitor = new FakeProcessMonitorService();
+ var configuration = new ProcessMonitorConfiguration
+ {
+ PowerPlanChangeDelayMs = 0,
+ Associations =
+ {
+ new ProcessPowerPlanAssociation("game", "plan-game", "Game") { Priority = 5 },
+ },
+ };
+
+ var associationService = CreateAssociationService(configuration);
+ var powerPlanService = CreatePowerPlanService();
+ var notificationService = CreateNotificationService();
+ var processService = CreateProcessService();
+ var coreMaskService = CreateCoreMaskService();
+ var affinityApplyService = CreateAffinityApplyService();
+ var manager = CreateService(
+ processMonitor,
+ associationService,
+ powerPlanService,
+ notificationService,
+ processService,
+ coreMaskService,
+ affinityApplyService);
+
+ await manager.StartAsync();
+ processMonitor.RaiseProcessStarted(new ProcessModel { ProcessId = 10, Name = "game" });
+ processMonitor.RaiseProcessStarted(new ProcessModel { ProcessId = 11, Name = "game" });
+
+ await Task.Delay(100);
+
+ powerPlanService.Verify(
+ x => x.SetActivePowerPlanByGuidAsync("plan-game", true),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task ProcessStarted_WhenPowerPlanChangeFails_DoesNotRetrySamePlanImmediately()
+ {
+ var processMonitor = new FakeProcessMonitorService();
+ var configuration = new ProcessMonitorConfiguration
+ {
+ PowerPlanChangeDelayMs = 0,
+ Associations =
+ {
+ new ProcessPowerPlanAssociation("game", "plan-game", "Game") { Priority = 5 },
+ },
+ };
+
+ var associationService = CreateAssociationService(configuration);
+ var powerPlanService = CreatePowerPlanService();
+ powerPlanService
+ .Setup(x => x.SetActivePowerPlanByGuidAsync("plan-game", true))
+ .ReturnsAsync(false);
+ var notificationService = CreateNotificationService();
+ var processService = CreateProcessService();
+ var coreMaskService = CreateCoreMaskService();
+ var affinityApplyService = CreateAffinityApplyService();
+ var manager = CreateService(
+ processMonitor,
+ associationService,
+ powerPlanService,
+ notificationService,
+ processService,
+ coreMaskService,
+ affinityApplyService);
+
+ await manager.StartAsync();
+ processMonitor.RaiseProcessStarted(new ProcessModel { ProcessId = 10, Name = "game" });
+ processMonitor.RaiseProcessStarted(new ProcessModel { ProcessId = 11, Name = "game" });
+
+ await Task.Delay(100);
+
+ powerPlanService.Verify(
+ x => x.SetActivePowerPlanByGuidAsync("plan-game", true),
+ Times.Once);
+ notificationService.Verify(
+ x => x.ShowPowerPlanChangeNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
[Fact]
public async Task StopAsync_RestoresDefaultPowerPlan_WhenConfigured()
{
@@ -150,13 +240,15 @@ public async Task StopAsync_RestoresDefaultPowerPlan_WhenConfigured()
var notificationService = CreateNotificationService();
var processService = CreateProcessService();
var coreMaskService = CreateCoreMaskService();
+ var affinityApplyService = CreateAffinityApplyService();
var manager = CreateService(
processMonitor,
associationService,
powerPlanService,
notificationService,
processService,
- coreMaskService);
+ coreMaskService,
+ affinityApplyService);
await manager.StartAsync();
await manager.StopAsync();
@@ -168,6 +260,60 @@ public async Task StopAsync_RestoresDefaultPowerPlan_WhenConfigured()
coreMaskService.Verify(x => x.UnregisterMaskApplication(21), Times.Once);
}
+ [Fact]
+ public async Task ProcessStarted_AppliesConfiguredCoreMaskForMatchingProcess()
+ {
+ var process = new ProcessModel { ProcessId = 31, Name = "game" };
+ var processMonitor = new FakeProcessMonitorService();
+
+ var configuration = new ProcessMonitorConfiguration
+ {
+ PowerPlanChangeDelayMs = 0,
+ Associations =
+ {
+ new ProcessPowerPlanAssociation("game", "plan-game", "Game")
+ {
+ CoreMaskId = "mask-game",
+ Priority = 5,
+ },
+ },
+ };
+
+ var associationService = CreateAssociationService(configuration);
+ var powerPlanService = CreatePowerPlanService();
+ var notificationService = CreateNotificationService();
+ var processService = CreateProcessService();
+ processService.Setup(x => x.TrackAppliedMask(31, "mask-game"));
+ var coreMaskService = CreateCoreMaskService();
+ coreMaskService.SetupGet(x => x.AvailableMasks).Returns(new ObservableCollection
+ {
+ new()
+ {
+ Id = "mask-game",
+ Name = "Game Mask",
+ BoolMask = new ObservableCollection { true, false },
+ },
+ });
+ coreMaskService.Setup(x => x.RegisterMaskApplication(31, "mask-game"));
+ var affinityApplyService = CreateAffinityApplyService();
+ var manager = CreateService(
+ processMonitor,
+ associationService,
+ powerPlanService,
+ notificationService,
+ processService,
+ coreMaskService,
+ affinityApplyService);
+
+ await manager.StartAsync();
+ processMonitor.RaiseProcessStarted(process);
+ await Task.Delay(100);
+
+ affinityApplyService.Verify(x => x.ApplyAsync(process, 1), Times.Once);
+ processService.Verify(x => x.TrackAppliedMask(31, "mask-game"), Times.Once);
+ coreMaskService.Verify(x => x.RegisterMaskApplication(31, "mask-game"), Times.Once);
+ }
+
[Fact]
public async Task Dispose_CompletesOnBlockingSynchronizationContext()
{
@@ -185,13 +331,15 @@ public async Task Dispose_CompletesOnBlockingSynchronizationContext()
var notificationService = CreateNotificationService();
var processService = CreateProcessService();
var coreMaskService = CreateCoreMaskService();
+ var affinityApplyService = CreateAffinityApplyService();
var manager = CreateService(
processMonitor,
associationService,
powerPlanService,
notificationService,
processService,
- coreMaskService);
+ coreMaskService,
+ affinityApplyService);
await manager.StartAsync();
@@ -229,7 +377,8 @@ private static ProcessMonitorManagerService CreateService(
Mock powerPlanService,
Mock notificationService,
Mock processService,
- Mock coreMaskService)
+ Mock coreMaskService,
+ Mock affinityApplyService)
{
var enhancedLogger = new Mock(MockBehavior.Loose);
enhancedLogger
@@ -253,6 +402,8 @@ private static ProcessMonitorManagerService CreateService(
settingsService.Object,
processService.Object,
coreMaskService.Object,
+ affinityApplyService.Object,
+ new PowerPlanTransitionGate(TimeSpan.FromSeconds(2), () => DateTimeOffset.UtcNow),
NullLogger.Instance,
enhancedLogger.Object);
}
@@ -303,6 +454,16 @@ private static Mock CreateCoreMaskService()
return coreMaskService;
}
+ private static Mock CreateAffinityApplyService()
+ {
+ var affinityApplyService = new Mock(MockBehavior.Strict);
+ affinityApplyService
+ .Setup(x => x.ApplyAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((ProcessModel process, long affinity) =>
+ AffinityApplyResult.Succeeded(affinity, affinity));
+ return affinityApplyService;
+ }
+
private sealed class FakeProcessMonitorService : IProcessMonitorService
{
public event EventHandler? ProcessStarted;
diff --git a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs
index 6e4825e..b4f1052 100644
--- a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs
+++ b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs
@@ -4,6 +4,7 @@
namespace ThreadPilot.Core.Tests
{
using System.Collections.Concurrent;
+ using System.ComponentModel;
using System.Diagnostics;
using System.Text.Json;
using ThreadPilot.Models;
@@ -67,6 +68,48 @@ public async Task LoadProcessProfile_ReturnsFalse_WhenFileIsMissing()
}
}
+ [Fact]
+ public void IsPassiveProcessAccessException_ReturnsTrue_ForModuleEnumerationFailure()
+ {
+ var exception = new Win32Exception(299, "Unable to enumerate the process modules.");
+
+ var result = ProcessService.IsPassiveProcessAccessException(exception);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void IsPassiveProcessAccessException_ReturnsTrue_ForUnauthorizedAccess()
+ {
+ var exception = new UnauthorizedAccessException("Access denied.");
+
+ var result = ProcessService.IsPassiveProcessAccessException(exception);
+
+ Assert.True(result);
+ }
+
+ [Theory]
+ [InlineData("Unable to access modules for this process.")]
+ [InlineData("ReadProcessMemory failed for protected process.")]
+ public void IsPassiveProcessAccessException_ReturnsTrue_ForKnownPassiveMessages(string message)
+ {
+ var exception = new InvalidOperationException(message);
+
+ var result = ProcessService.IsPassiveProcessAccessException(exception);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void IsPassiveProcessAccessException_ReturnsFalse_ForUnrelatedException()
+ {
+ var exception = new InvalidOperationException("Unexpected parse failure.");
+
+ var result = ProcessService.IsPassiveProcessAccessException(exception);
+
+ Assert.False(result);
+ }
+
[Fact]
public void TrackPriorityChange_PreservesOriginalPriority()
{
diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewModelAffinityTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelAffinityTests.cs
new file mode 100644
index 0000000..513ee29
--- /dev/null
+++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelAffinityTests.cs
@@ -0,0 +1,104 @@
+namespace ThreadPilot.Core.Tests
+{
+ using Microsoft.Extensions.Logging.Abstractions;
+ using Moq;
+ using ThreadPilot.Models;
+ using ThreadPilot.Services;
+ using ThreadPilot.ViewModels;
+
+ public sealed class ProcessViewModelAffinityTests
+ {
+ [Fact]
+ public async Task SelectingCoreMask_DoesNotApplyProcessorAffinity()
+ {
+ var processService = new Mock(MockBehavior.Loose);
+ var gameModeService = new Mock(MockBehavior.Loose);
+ gameModeService
+ .Setup(service => service.DisableGameModeForAffinityAsync())
+ .ReturnsAsync(false);
+ var viewModel = CreateViewModel(processService.Object, gameModeService.Object);
+
+ viewModel.SelectedProcess = new ProcessModel
+ {
+ ProcessId = 1234,
+ Name = "Game",
+ ProcessorAffinity = 3,
+ };
+
+ viewModel.SelectedCoreMask = CoreMask.FromProcessorAffinity(1, 2, "First Core");
+
+ await Task.Delay(100);
+
+ processService.Verify(
+ service => service.SetProcessorAffinity(It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task SelectingCoreMask_ReportsPendingAffinityWithoutChangingCurrentAffinity()
+ {
+ var processService = new Mock(MockBehavior.Loose);
+ var gameModeService = new Mock(MockBehavior.Loose);
+ var viewModel = CreateViewModel(processService.Object, gameModeService.Object);
+ viewModel.CpuTopology = CreateTwoCoreTopology();
+ viewModel.CpuCores = new System.Collections.ObjectModel.ObservableCollection(
+ viewModel.CpuTopology.LogicalCores);
+
+ viewModel.SelectedProcess = new ProcessModel
+ {
+ ProcessId = 1234,
+ Name = "Game",
+ ProcessorAffinity = 3,
+ };
+
+ viewModel.SelectedCoreMask = CoreMask.FromProcessorAffinity(1, 2, "First Core");
+
+ await Task.Delay(100);
+
+ Assert.True(viewModel.HasPendingAffinityEdits);
+ Assert.Equal("Current OS affinity: 0x3", viewModel.CurrentAffinityText);
+ Assert.Equal("Pending core mask: 0x1", viewModel.PendingAffinityText);
+ Assert.Equal("Core mask staged. Use Apply Affinity to change Windows affinity.", viewModel.AffinityEditStateText);
+ }
+
+ private static ProcessViewModel CreateViewModel(IProcessService processService, IGameModeService gameModeService)
+ {
+ var virtualizedProcessService = new Mock(MockBehavior.Loose);
+ virtualizedProcessService.SetupProperty(
+ service => service.Configuration,
+ new VirtualizedProcessConfig());
+
+ var cpuTopologyService = new Mock(MockBehavior.Loose);
+ var powerPlanService = new Mock(MockBehavior.Loose);
+ var notificationService = new Mock(MockBehavior.Loose);
+ var systemTrayService = new Mock(MockBehavior.Loose);
+ var coreMaskService = new Mock(MockBehavior.Loose);
+ var associationService = new Mock(MockBehavior.Loose);
+
+ return new ProcessViewModel(
+ NullLogger.Instance,
+ processService,
+ new ProcessFilterService(),
+ virtualizedProcessService.Object,
+ cpuTopologyService.Object,
+ powerPlanService.Object,
+ notificationService.Object,
+ systemTrayService.Object,
+ coreMaskService.Object,
+ associationService.Object,
+ gameModeService);
+ }
+
+ private static CpuTopologyModel CreateTwoCoreTopology()
+ {
+ return new CpuTopologyModel
+ {
+ LogicalCores =
+ [
+ new CpuCoreModel { LogicalCoreId = 0, PhysicalCoreId = 0, Label = "CPU 0" },
+ new CpuCoreModel { LogicalCoreId = 1, PhysicalCoreId = 1, Label = "CPU 1" },
+ ],
+ };
+ }
+ }
+}
diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs
index d7ea985..06e8050 100644
--- a/ViewModels/MainWindowViewModel.cs
+++ b/ViewModels/MainWindowViewModel.cs
@@ -37,7 +37,7 @@ public partial class MainWindowViewModel : BaseViewModel
private bool isProcessMonitoringActive = false;
[ObservableProperty]
- private string processMonitoringStatusText = "Process Monitoring: Inactive";
+ private string processMonitoringStatusText = "Automation Monitoring: Inactive";
[ObservableProperty]
private bool isRunningAsAdministrator = false;
@@ -109,14 +109,14 @@ await this.ExecuteAsync(
if (this.IsProcessMonitoringActive)
{
await this.processMonitorManagerService.StopAsync();
- await this.LogUserActionAsync("ProcessMonitoring", "Stopped process monitoring", "User action");
+ await this.LogUserActionAsync("ProcessMonitoring", "Stopped automation monitoring", "User action");
}
else
{
await this.processMonitorManagerService.StartAsync();
- await this.LogUserActionAsync("ProcessMonitoring", "Started process monitoring", "User action");
+ await this.LogUserActionAsync("ProcessMonitoring", "Started automation monitoring", "User action");
}
- }, this.IsProcessMonitoringActive ? "Stopping monitoring..." : "Starting monitoring...");
+ }, this.IsProcessMonitoringActive ? "Stopping automation monitoring..." : "Starting automation monitoring...");
}
[RelayCommand]
@@ -151,8 +151,8 @@ private async Task UpdateStatusAsync()
{
this.IsProcessMonitoringActive = this.processMonitorManagerService.IsRunning;
this.ProcessMonitoringStatusText = this.IsProcessMonitoringActive
- ? "Process Monitoring: Active"
- : "Process Monitoring: Inactive";
+ ? "Automation Monitoring: Active"
+ : "Automation Monitoring: Inactive";
}
// Update elevation status
@@ -185,7 +185,7 @@ private void OnServiceStatusChanged(object? sender, ServiceStatusEventArgs e)
System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
this.IsProcessMonitoringActive = e.IsRunning;
- this.ProcessMonitoringStatusText = $"Process Monitoring: {e.Status}";
+ this.ProcessMonitoringStatusText = $"Automation Monitoring: {e.Status}";
});
}
@@ -194,14 +194,14 @@ public void UpdateProcessMonitoringStatus(bool isActive, string status)
if (System.Windows.Application.Current.Dispatcher.CheckAccess())
{
this.IsProcessMonitoringActive = isActive;
- this.ProcessMonitoringStatusText = $"Process Monitoring: {status}";
+ this.ProcessMonitoringStatusText = $"Automation Monitoring: {status}";
}
else
{
System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
this.IsProcessMonitoringActive = isActive;
- this.ProcessMonitoringStatusText = $"Process Monitoring: {status}";
+ this.ProcessMonitoringStatusText = $"Automation Monitoring: {status}";
});
}
}
diff --git a/ViewModels/PerformanceViewModel.cs b/ViewModels/PerformanceViewModel.cs
index 366bd35..768196f 100644
--- a/ViewModels/PerformanceViewModel.cs
+++ b/ViewModels/PerformanceViewModel.cs
@@ -58,7 +58,7 @@ public partial class PerformanceViewModel : BaseViewModel
private bool isMonitoring;
[ObservableProperty]
- private string monitoringStatusText = "Monitoring Stopped";
+ private string monitoringStatusText = "Live metrics stopped";
[ObservableProperty]
private DateTime lastUpdateTime;
@@ -224,9 +224,9 @@ private async Task StartMonitoringAsync()
await this.performanceService.StartMonitoringAsync();
this.IsMonitoring = true;
- this.MonitoringStatusText = "Monitoring Active";
+ this.MonitoringStatusText = "Live metrics active";
this.MonitoringStateText = "Active";
- this.AddTimelineEvent("Monitoring", "Real-time monitoring started.", "Info");
+ this.AddTimelineEvent("Live Metrics", "Live metrics started.", "Info");
this.SetStatus("Performance monitoring started", false);
}
@@ -245,9 +245,9 @@ private async Task StopMonitoringAsync()
await this.performanceService.StopMonitoringAsync();
this.IsMonitoring = false;
- this.MonitoringStatusText = "Monitoring Stopped";
+ this.MonitoringStatusText = "Live metrics stopped";
this.MonitoringStateText = "Stopped";
- this.AddTimelineEvent("Monitoring", "Real-time monitoring stopped.", "Warning");
+ this.AddTimelineEvent("Live Metrics", "Live metrics stopped.", "Warning");
this.SetStatus("Performance monitoring stopped", false);
}
@@ -271,9 +271,9 @@ public async Task SuspendBackgroundMonitoringAsync()
await this.performanceService.StopMonitoringAsync();
this.IsMonitoring = false;
- this.MonitoringStatusText = "Monitoring Paused";
+ this.MonitoringStatusText = "Live metrics paused";
this.MonitoringStateText = "Paused";
- this.AddTimelineEvent("Monitoring", "Monitoring paused while app is minimized.", "Info");
+ this.AddTimelineEvent("Live Metrics", "Live metrics paused while app is minimized.", "Info");
}
catch (Exception ex)
{
@@ -293,9 +293,9 @@ public async Task ResumeBackgroundMonitoringAsync()
await this.performanceService.StartMonitoringAsync();
this.IsMonitoring = true;
- this.MonitoringStatusText = "Monitoring Active";
+ this.MonitoringStatusText = "Live metrics active";
this.MonitoringStateText = "Active";
- this.AddTimelineEvent("Monitoring", "Monitoring resumed after restore.", "Info");
+ this.AddTimelineEvent("Live Metrics", "Live metrics resumed after restore.", "Info");
this.monitoringWasActiveBeforeSuspend = false;
}
catch (Exception ex)
diff --git a/ViewModels/ProcessPowerPlanAssociationViewModel.cs b/ViewModels/ProcessPowerPlanAssociationViewModel.cs
index fc22206..3346e08 100644
--- a/ViewModels/ProcessPowerPlanAssociationViewModel.cs
+++ b/ViewModels/ProcessPowerPlanAssociationViewModel.cs
@@ -371,12 +371,12 @@ public async Task StartMonitoringAsync()
{
try
{
- this.SetStatus("Starting monitoring service...");
+ this.SetStatus("Starting automation monitoring...");
await this.monitorManagerService.StartAsync();
}
catch (Exception ex)
{
- this.SetStatus($"Error starting monitoring: {ex.Message}", false);
+ this.SetStatus($"Error starting automation monitoring: {ex.Message}", false);
}
}
@@ -385,12 +385,12 @@ public async Task StopMonitoringAsync()
{
try
{
- this.SetStatus("Stopping monitoring service...");
+ this.SetStatus("Stopping automation monitoring...");
await this.monitorManagerService.StopAsync();
}
catch (Exception ex)
{
- this.SetStatus($"Error stopping monitoring: {ex.Message}", false);
+ this.SetStatus($"Error stopping automation monitoring: {ex.Message}", false);
}
}
diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs
index 7a41296..7fd87c4 100644
--- a/ViewModels/ProcessViewModel.Behaviors.partial.cs
+++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs
@@ -156,21 +156,22 @@ partial void OnSelectedProcessChanged(ProcessModel? value)
{
if (value != null && CpuTopology != null)
{
- hasPendingAffinityEdits = false;
+ this.HasPendingAffinityEdits = false;
+ this.UpdateAffinityDisplayState();
// Immediately fetch and display real-time process information
TaskSafety.FireAndForget(HandleSelectedProcessChangedAsync(value), ex =>
{
- Logger.LogWarning(ex, "Failed while handling selected process change for {ProcessName}", value.Name);
+ this.Logger.LogWarning(ex, "Failed while handling selected process change for {ProcessName}", value.Name);
});
}
else if (value == null)
{
// Clear selection
- ClearProcessSelection();
+ this.ClearProcessSelection();
}
// Update system tray context menu
- systemTrayService.UpdateContextMenu(value?.Name, value != null);
+ this.systemTrayService.UpdateContextMenu(value?.Name, value != null);
}
private async Task HandleSelectedProcessChangedAsync(ProcessModel value)
@@ -197,6 +198,7 @@ private async Task HandleSelectedProcessChangedAsync(ProcessModel value)
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
this.UpdateCoreSelections(value.ProcessorAffinity);
+ this.UpdateAffinityDisplayState();
value.ForceNotifyProcessorAffinityChanged();
// Update priority display - trigger property change to refresh ComboBox
@@ -254,7 +256,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(async () =>
await this.QuickApplyAffinityAndPowerPlanCommand.ExecuteAsync(null);
this.systemTrayService.ShowBalloonTip(
"ThreadPilot",
- $"Settings applied to {this.SelectedProcess?.Name ?? "selected process"}", 2000);
+ $"Pending settings applied to {this.SelectedProcess?.Name ?? "selected process"}", 2000);
});
}
catch (Exception ex)
@@ -264,7 +266,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
this.systemTrayService.ShowBalloonTip(
"ThreadPilot Error",
- $"Failed to apply settings: {ex.Message}", 3000);
+ $"Failed to apply pending settings: {ex.Message}", 3000);
});
}
}
@@ -324,61 +326,6 @@ private void OnCorePropertyChanged(object? sender, PropertyChangedEventArgs e)
this.Logger.LogDebug("Core property changed but cores are read-only - no action taken");
}
- private async Task AutoApplyAffinityAsync()
- {
- if (this.SelectedProcess == null || !this.hasPendingAffinityEdits)
- {
- return;
- }
-
- try
- {
- var affinityMask = this.CalculateAffinityMask();
- if (affinityMask == 0)
- {
- this.Logger.LogDebug("Affinity mask is zero, skipping auto-apply");
- return;
- }
-
- this.Logger.LogInformation(
- "Auto-applying affinity 0x{AffinityMask:X} to process {ProcessName} (PID: {ProcessId})",
- affinityMask, this.SelectedProcess.Name, this.SelectedProcess.ProcessId);
-
- // Apply the affinity change
- await this.processService.SetProcessorAffinity(this.SelectedProcess, affinityMask);
-
- // Immediately refresh the process to get the actual system state
- await this.processService.RefreshProcessInfo(this.SelectedProcess);
-
- // Update UI to reflect the actual system affinity
- this.UpdateCoreSelections(this.SelectedProcess.ProcessorAffinity, true);
-
- // Notify UI of all changes
- this.OnPropertyChanged(nameof(this.SelectedProcess));
-
- // Clear pending edits flag
- this.hasPendingAffinityEdits = false;
-
- this.Logger.LogInformation("Auto-applied affinity successfully to {ProcessName}", this.SelectedProcess.Name);
- }
- catch (Exception ex)
- {
- this.Logger.LogWarning(ex, "Failed to auto-apply affinity to {ProcessName}", this.SelectedProcess.Name);
-
- // Try to refresh process info even if setting failed, to show current state
- try
- {
- await this.processService.RefreshProcessInfo(this.SelectedProcess);
- this.UpdateCoreSelections(this.SelectedProcess.ProcessorAffinity, true);
- this.OnPropertyChanged(nameof(this.SelectedProcess));
- }
- catch
- {
- // Process may have terminated
- }
- }
- }
-
private void UpdateHyperThreadingStatus()
{
if (this.CpuTopology == null)
@@ -432,7 +379,7 @@ private void UpdateCoreSelections(long affinityMask, bool forceSync = false)
return;
}
- if (this.hasPendingAffinityEdits && !forceSync)
+ if (this.HasPendingAffinityEdits && !forceSync)
{
this.Logger.LogDebug("Skipping affinity sync because user edits are pending");
return;
@@ -492,8 +439,10 @@ private void UpdateCoreSelections(long affinityMask, bool forceSync = false)
if (forceSync)
{
- this.hasPendingAffinityEdits = false;
+ this.HasPendingAffinityEdits = false;
}
+
+ this.UpdateAffinityDisplayState();
}
private long CalculateAffinityMask()
@@ -511,6 +460,34 @@ private long CalculateAffinityMask()
return selectedCores.Aggregate(0L, (mask, core) => mask | core.AffinityMask);
}
+ private void UpdateAffinityDisplayState()
+ {
+ var currentMask = this.SelectedProcess?.ProcessorAffinity;
+ this.CurrentAffinityText = currentMask.HasValue
+ ? $"Current OS affinity: 0x{currentMask.Value:X}"
+ : "Current OS affinity: no process selected";
+
+ if (this.SelectedProcess == null)
+ {
+ this.PendingAffinityText = "Pending core mask: none";
+ this.AffinityEditStateText = "Select a process to view its current Windows affinity.";
+ return;
+ }
+
+ if (!this.HasPendingAffinityEdits)
+ {
+ this.PendingAffinityText = "Pending core mask: none";
+ this.AffinityEditStateText = "Current OS affinity is displayed. Select a core mask to stage a change.";
+ return;
+ }
+
+ var pendingMask = this.CalculateAffinityMask();
+ this.PendingAffinityText = pendingMask > 0
+ ? $"Pending core mask: 0x{pendingMask:X}"
+ : "Pending core mask: no cores selected";
+ this.AffinityEditStateText = "Core mask staged. Use Apply Affinity to change Windows affinity.";
+ }
+
[RelayCommand]
public async Task LoadMoreProcesses()
{
@@ -669,67 +646,32 @@ private async Task RefreshProcesses()
try
{
- // Store the currently selected process ID to preserve selection
var selectedProcessId = this.SelectedProcess?.ProcessId;
var currentProcesses = this.ShowActiveApplicationsOnly
? await this.processService.GetActiveApplicationsAsync()
: await this.processService.GetProcessesAsync();
- // Update UI on the UI thread
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
- // Update existing processes or add new ones
- foreach (var process in currentProcesses)
- {
- var existingProcess = this.Processes.FirstOrDefault(p => p.ProcessId == process.ProcessId);
- if (existingProcess != null)
- {
- // Update existing process data by copying properties
- existingProcess.MemoryUsage = process.MemoryUsage;
- existingProcess.Priority = process.Priority;
- existingProcess.ProcessorAffinity = process.ProcessorAffinity;
- existingProcess.MainWindowHandle = process.MainWindowHandle;
- existingProcess.MainWindowTitle = process.MainWindowTitle;
- existingProcess.HasVisibleWindow = process.HasVisibleWindow;
- existingProcess.CpuUsage = process.CpuUsage;
- }
- else
- {
- this.Processes.Add(process);
- }
- }
-
- // Remove terminated processes
- var terminatedProcesses = this.Processes
- .Where(p => !currentProcesses.Any(cp => cp.ProcessId == p.ProcessId))
- .ToList();
-
- // Check if selected process was terminated
- bool selectedProcessTerminated = false;
- foreach (var terminated in terminatedProcesses)
- {
- if (terminated.ProcessId == selectedProcessId)
- {
- selectedProcessTerminated = true;
- }
- this.Processes.Remove(terminated);
- }
+ var deltaResult = ProcessListDeltaUpdater.ApplyDelta(
+ this.Processes,
+ currentProcesses,
+ selectedProcessId);
this.FilterProcesses();
- // Restore selection if the process still exists
- if (selectedProcessId.HasValue && !selectedProcessTerminated)
+ if (deltaResult.SelectedProcess != null)
{
- var processToSelect = this.FilteredProcesses.FirstOrDefault(p => p.ProcessId == selectedProcessId.Value);
+ var processToSelect = this.FilteredProcesses.FirstOrDefault(
+ p => p.ProcessId == deltaResult.SelectedProcess.ProcessId);
if (processToSelect != null)
{
this.SelectedProcess = processToSelect;
}
}
- else if (selectedProcessTerminated)
+ else if (deltaResult.SelectedProcessTerminated)
{
- // Clear selection and reset UI if selected process was terminated
this.SelectedProcess = null;
this.ClearProcessSelection();
}
@@ -747,7 +689,8 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
[RelayCommand]
private async Task SetAffinity()
{
- if (this.SelectedProcess == null)
+ var selectedProcess = this.SelectedProcess;
+ if (selectedProcess == null)
{
return;
}
@@ -755,65 +698,57 @@ private async Task SetAffinity()
try
{
var affinityMask = this.CalculateAffinityMask();
- if (affinityMask == 0)
- {
- await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
- {
- this.SetStatus("Please select at least one CPU core", false);
- });
- return;
- }
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
- this.SetStatus($"Setting affinity for {this.SelectedProcess.Name}...");
+ this.SetStatus($"Setting affinity for {selectedProcess.Name}...");
});
- // Apply the affinity change
- await this.processService.SetProcessorAffinity(this.SelectedProcess, affinityMask);
-
- // Immediately refresh the process to get the actual system state
- await this.processService.RefreshProcessInfo(this.SelectedProcess);
+ var result = await this.affinityApplyService.ApplyAsync(selectedProcess, affinityMask);
- // Update UI to reflect the actual system affinity (not our calculated one)
- // This ensures we show what the OS actually set, which may differ from our request
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
- this.UpdateCoreSelections(this.SelectedProcess.ProcessorAffinity, true);
-
- // Notify UI of all changes
+ this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true);
+ selectedProcess.ForceNotifyProcessorAffinityChanged();
this.OnPropertyChanged(nameof(this.SelectedProcess));
- // Verify the affinity was set correctly
- if (this.SelectedProcess.ProcessorAffinity == affinityMask)
+ if (result.Success)
+ {
+ this.HasPendingAffinityEdits = false;
+ this.UpdateAffinityDisplayState();
+ this.SetStatus($"Affinity applied successfully to {selectedProcess.Name} (0x{result.VerifiedMask:X}).", false);
+ _ = this.notificationService.ShowNotificationAsync("Affinity applied", $"{selectedProcess.Name}: 0x{result.VerifiedMask:X}", NotificationType.Success);
+ }
+ else if (result.FailureReason == AffinityApplyFailureReason.VerificationMismatch)
+ {
+ this.HasPendingAffinityEdits = false;
+ this.UpdateAffinityDisplayState();
+ this.SetStatus(result.Message, false);
+ _ = this.notificationService.ShowNotificationAsync("Affinity adjusted", result.Message, NotificationType.Warning);
+ }
+ else if (result.FailureReason == AffinityApplyFailureReason.ProcessTerminated)
{
- this.SetStatus($"Affinity applied successfully to {this.SelectedProcess.Name} (0x{affinityMask:X}).", false);
- _ = this.notificationService.ShowNotificationAsync("Affinity applied", $"{this.SelectedProcess.Name}: 0x{affinityMask:X}", NotificationType.Success);
+ this.SelectedProcess = null;
+ this.ClearProcessSelection();
+ this.SetStatus(result.Message, false);
+ _ = this.notificationService.ShowNotificationAsync("Affinity failed", result.Message, NotificationType.Warning);
+ }
+ else if (result.FailureReason == AffinityApplyFailureReason.AccessDenied)
+ {
+ this.SetStatus(result.Message, false);
+ _ = this.notificationService.ShowNotificationAsync("Affinity blocked", result.Message, NotificationType.Warning);
}
else
{
- this.SetStatus($"Affinity adjusted by system for {this.SelectedProcess.Name} to 0x{this.SelectedProcess.ProcessorAffinity:X}.", false);
- _ = this.notificationService.ShowNotificationAsync("Affinity adjusted", $"{this.SelectedProcess.Name}: 0x{this.SelectedProcess.ProcessorAffinity:X}", NotificationType.Warning);
+ this.SetStatus(result.Message, false);
+ _ = this.notificationService.ShowNotificationAsync("Affinity error", result.Message, NotificationType.Error);
}
});
}
catch (Exception ex)
{
var friendly = ex.Message;
- if (friendly.Contains("access denied", StringComparison.OrdinalIgnoreCase) ||
- friendly.Contains("anti-cheat", StringComparison.OrdinalIgnoreCase))
- {
- friendly = "Affinity change blocked (anti-cheat or insufficient privileges).";
- _ = this.notificationService.ShowNotificationAsync("Affinity blocked", friendly, NotificationType.Warning);
- }
- else if (friendly.Contains("invalid affinity", StringComparison.OrdinalIgnoreCase))
- {
- _ = this.notificationService.ShowNotificationAsync("Affinity invalid", friendly, NotificationType.Error);
- }
- else
- {
- _ = this.notificationService.ShowNotificationAsync("Affinity error", friendly, NotificationType.Error);
- }
+ _ = this.notificationService.ShowNotificationAsync("Affinity error", friendly, NotificationType.Error);
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
@@ -823,11 +758,18 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
// Try to refresh process info even if setting failed, to show current state
try
{
- await this.processService.RefreshProcessInfo(this.SelectedProcess);
+ if (this.SelectedProcess != null)
+ {
+ await this.processService.RefreshProcessInfo(this.SelectedProcess);
+ }
+
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
- this.UpdateCoreSelections(this.SelectedProcess.ProcessorAffinity, true);
- this.OnPropertyChanged(nameof(this.SelectedProcess));
+ if (this.SelectedProcess != null)
+ {
+ this.UpdateCoreSelections(this.SelectedProcess.ProcessorAffinity, true);
+ this.OnPropertyChanged(nameof(this.SelectedProcess));
+ }
});
}
catch
@@ -879,11 +821,9 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
this.suppressCoreSelectionEvents = false;
}
- // Trigger auto-apply with the preset mask
- this.hasPendingAffinityEdits = true;
-
- // Apply immediately for presets (no debounce needed)
- _ = this.AutoApplyAffinityAsync();
+ // Keep the preset as a pending selection; affinity changes require an explicit apply command.
+ this.HasPendingAffinityEdits = true;
+ this.UpdateAffinityDisplayState();
});
}
@@ -941,7 +881,8 @@ partial void OnSelectedCoreMaskChanged(CoreMask? oldValue, CoreMask? newValue)
private async Task ApplyCoreMaskToProcessAsync(CoreMask mask)
{
- if (this.SelectedProcess == null || mask == null)
+ var selectedProcess = this.SelectedProcess;
+ if (selectedProcess == null || mask == null)
{
return;
}
@@ -951,7 +892,7 @@ private async Task ApplyCoreMaskToProcessAsync(CoreMask mask)
{
this.Logger.LogInformation(
"Applying mask '{MaskName}' to process {ProcessName} (PID: {ProcessId})",
- mask.Name, this.SelectedProcess.Name, this.SelectedProcess.ProcessId);
+ mask.Name, selectedProcess.Name, selectedProcess.ProcessId);
// Convert mask to affinity
long affinity = mask.ToProcessorAffinity();
@@ -967,34 +908,35 @@ private async Task ApplyCoreMaskToProcessAsync(CoreMask mask)
// Game Mode can interfere with CPU Sets, particularly on AMD systems
await this.gameModeService.DisableGameModeForAffinityAsync();
- // Apply affinity using ProcessService (which uses CPU Sets with fallback)
- await this.processService.SetProcessorAffinity(this.SelectedProcess, affinity);
-
- // Refresh process info to get actual system state
- await this.processService.RefreshProcessInfo(this.SelectedProcess);
+ var result = await this.affinityApplyService.ApplyAsync(selectedProcess, affinity);
- // CRITICAL: Force UI updates on UI thread
- // RefreshProcessInfo runs on background thread, DataGrid won't receive PropertyChanged from non-UI threads
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
- // Force PropertyChanged notification for ProcessorAffinity property
- // This ensures the DataGrid Affinity column binding updates immediately
- this.SelectedProcess.ForceNotifyProcessorAffinityChanged();
-
- // Update Advanced CPU Affinity checkboxes to reflect the mask
- this.UpdateCoreSelectionsFromMask(mask);
-
- // Force complete refresh of SelectedProcess bindings in DataGrid
+ selectedProcess.ForceNotifyProcessorAffinityChanged();
+ this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true);
this.OnPropertyChanged(nameof(this.SelectedProcess));
});
- this.SetStatus($"Applied mask '{mask.Name}' to {this.SelectedProcess.Name}");
- this.Logger.LogInformation("Successfully applied mask '{MaskName}' to {ProcessName}", mask.Name, this.SelectedProcess.Name);
+ if (!result.Success)
+ {
+ this.SetStatus(result.Message);
+ this.Logger.LogWarning(
+ "Failed to apply mask '{MaskName}' to process {ProcessName}: {Message}",
+ mask.Name,
+ selectedProcess.Name,
+ result.Message);
+ return;
+ }
+
+ this.HasPendingAffinityEdits = false;
+ this.UpdateAffinityDisplayState();
+ this.SetStatus($"Applied mask '{mask.Name}' to {selectedProcess.Name}");
+ this.Logger.LogInformation("Successfully applied mask '{MaskName}' to {ProcessName}", mask.Name, selectedProcess.Name);
}
catch (Exception ex)
{
this.Logger.LogError(ex, "Failed to apply mask '{MaskName}' to process {ProcessName}",
- mask.Name, this.SelectedProcess.Name);
+ mask.Name, selectedProcess.Name);
this.SetStatus($"Error applying mask: {ex.Message}");
}
finally
@@ -1020,6 +962,8 @@ private void UpdateCoreSelectionsFromMask(CoreMask mask)
}
this.OnPropertyChanged(nameof(this.CpuCores));
+ this.HasPendingAffinityEdits = this.SelectedProcess != null;
+ this.UpdateAffinityDisplayState();
}
finally
{
@@ -1031,7 +975,8 @@ private void UpdateCoreSelectionsFromMask(CoreMask mask)
[RelayCommand]
private async Task QuickApplyAffinityAndPowerPlan()
{
- if (this.SelectedProcess == null)
+ var selectedProcess = this.SelectedProcess;
+ if (selectedProcess == null)
{
return;
}
@@ -1040,14 +985,28 @@ private async Task QuickApplyAffinityAndPowerPlan()
{
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
- this.SetStatus($"Applying settings to {this.SelectedProcess.Name}...");
+ this.SetStatus($"Applying pending settings to {selectedProcess.Name}...");
});
// Apply CPU affinity
var affinityMask = this.CalculateAffinityMask();
if (affinityMask > 0)
{
- await this.processService.SetProcessorAffinity(this.SelectedProcess, affinityMask);
+ var result = await this.affinityApplyService.ApplyAsync(selectedProcess, affinityMask);
+ if (!result.Success)
+ {
+ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
+ {
+ this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true);
+ selectedProcess.ForceNotifyProcessorAffinityChanged();
+ this.OnPropertyChanged(nameof(this.SelectedProcess));
+ this.SetStatus(result.Message, false);
+ });
+ return;
+ }
+
+ this.HasPendingAffinityEdits = false;
+ this.UpdateAffinityDisplayState();
}
// Apply power plan if selected
@@ -1056,24 +1015,25 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
await this.powerPlanService.SetActivePowerPlan(this.SelectedPowerPlan);
}
- await this.processService.RefreshProcessInfo(this.SelectedProcess);
+ await this.processService.RefreshProcessInfo(selectedProcess);
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
- this.UpdateCoreSelections(this.SelectedProcess.ProcessorAffinity, true);
+ this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true);
+ selectedProcess.ForceNotifyProcessorAffinityChanged();
this.OnPropertyChanged(nameof(this.SelectedProcess));
});
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
- this.SetStatus($"Quick apply completed for {this.SelectedProcess.Name}.", false);
+ this.SetStatus($"Pending settings applied to {selectedProcess.Name}.", false);
});
}
catch (Exception ex)
{
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
- this.SetStatus($"Error applying settings: {ex.Message}", false);
+ this.SetStatus($"Error applying pending settings: {ex.Message}", false);
});
}
}
@@ -1329,6 +1289,14 @@ public void ResumeRefresh()
this.SetUiRefreshEnabled(true, refreshImmediately: true);
}
+ public void ApplyRefreshDecision(AppRefreshDecision decision)
+ {
+ ArgumentNullException.ThrowIfNull(decision);
+
+ this.virtualizedProcessService.Configuration.EnableBackgroundLoading = decision.VirtualizedPreloadEnabled;
+ this.SetUiRefreshEnabled(decision.ProcessUiRefreshEnabled, decision.ImmediateProcessRefresh);
+ }
+
public void SetProcessViewActive(bool isActive)
{
if (this.isProcessViewActive == isActive)
@@ -1515,6 +1483,9 @@ private void ClearProcessSelection()
core.IsSelected = false;
}
+ this.HasPendingAffinityEdits = false;
+ this.UpdateAffinityDisplayState();
+
// Reset power plan to current system default
_ = Task.Run(async () =>
{
diff --git a/ViewModels/ProcessViewModel.cs b/ViewModels/ProcessViewModel.cs
index 58e9f5d..6e0d754 100644
--- a/ViewModels/ProcessViewModel.cs
+++ b/ViewModels/ProcessViewModel.cs
@@ -26,6 +26,7 @@ namespace ThreadPilot.ViewModels
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
+ using Microsoft.Extensions.Logging.Abstractions;
using ThreadPilot.Models;
using ThreadPilot.Services;
@@ -43,6 +44,7 @@ public partial class ProcessViewModel : BaseViewModel
private readonly ICoreMaskService coreMaskService;
private readonly IProcessPowerPlanAssociationService associationService;
private readonly IGameModeService gameModeService;
+ private readonly IAffinityApplyService affinityApplyService;
private System.Timers.Timer? refreshTimer;
private bool isUiRefreshPaused;
private bool isProcessViewActive = true;
@@ -51,7 +53,6 @@ public partial class ProcessViewModel : BaseViewModel
private bool isApplyingFilter;
private bool filterRefreshPending;
private bool suppressCoreSelectionEvents;
- private bool hasPendingAffinityEdits;
[ObservableProperty]
private ObservableCollection processes = new();
@@ -85,6 +86,18 @@ public partial class ProcessViewModel : BaseViewModel
[ObservableProperty]
private CoreMask? selectedCoreMask;
+ [ObservableProperty]
+ private bool hasPendingAffinityEdits;
+
+ [ObservableProperty]
+ private string currentAffinityText = "Current OS affinity: no process selected";
+
+ [ObservableProperty]
+ private string pendingAffinityText = "Pending core mask: none";
+
+ [ObservableProperty]
+ private string affinityEditStateText = "Select a process to view its current Windows affinity.";
+
[ObservableProperty]
private bool isTopologyDetectionSuccessful = false;
@@ -163,6 +176,7 @@ public ProcessViewModel(
ICoreMaskService coreMaskService,
IProcessPowerPlanAssociationService associationService,
IGameModeService gameModeService,
+ IAffinityApplyService? affinityApplyService = null,
IEnhancedLoggingService? enhancedLoggingService = null)
: base(logger, enhancedLoggingService)
{
@@ -176,6 +190,10 @@ public ProcessViewModel(
this.coreMaskService = coreMaskService ?? throw new ArgumentNullException(nameof(coreMaskService));
this.associationService = associationService ?? throw new ArgumentNullException(nameof(associationService));
this.gameModeService = gameModeService ?? throw new ArgumentNullException(nameof(gameModeService));
+ this.affinityApplyService = affinityApplyService ?? new AffinityApplyService(
+ this.processService,
+ this.cpuTopologyService,
+ NullLogger.Instance);
// Subscribe to topology detection events
this.cpuTopologyService.TopologyDetected += this.OnTopologyDetected;
diff --git a/Views/LogViewerView.xaml b/Views/LogViewerView.xaml
index fefad62..77a58ca 100644
--- a/Views/LogViewerView.xaml
+++ b/Views/LogViewerView.xaml
@@ -140,19 +140,20 @@
-
-
-
-
+
+
+
+
+
@@ -215,18 +216,46 @@
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
-
@@ -98,16 +95,20 @@
-
-
+
+
@@ -115,9 +116,11 @@
-
-
+
@@ -158,10 +161,9 @@
-
-
+
-
-
-
+
+ MinWidth="120"
+ Margin="0,0,8,0"
+ ToolTip="Pause live metrics collection; background automation is separate"/>
+ Margin="0,0,8,0"
+ ToolTip="Refresh the current dashboard snapshot"/>
+ Command="{Binding ClearHistoricalDataCommand}"
+ ToolTip="Clear the displayed metrics and timeline history"/>
@@ -124,7 +128,7 @@
-
+
@@ -167,7 +171,8 @@
+ Margin="0,0,8,0"
+ ToolTip="Create or update an automation rule from the selected hotspot process"/>
diff --git a/Views/PowerPlanView.xaml b/Views/PowerPlanView.xaml
index 921cf59..4c84f77 100644
--- a/Views/PowerPlanView.xaml
+++ b/Views/PowerPlanView.xaml
@@ -13,12 +13,11 @@
-
-
+
@@ -29,7 +28,7 @@
@@ -37,13 +36,28 @@
-
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+ FontSize="11"
+ TextTrimming="CharacterEllipsis"/>
@@ -101,25 +136,5 @@
-
-
-
-
-
-
-
diff --git a/Views/ProcessPowerPlanAssociationView.xaml b/Views/ProcessPowerPlanAssociationView.xaml
index 8bab272..d247e6c 100644
--- a/Views/ProcessPowerPlanAssociationView.xaml
+++ b/Views/ProcessPowerPlanAssociationView.xaml
@@ -20,7 +20,7 @@
-
+
@@ -68,7 +68,7 @@
-
+
@@ -77,7 +77,7 @@
-
+
@@ -120,6 +120,34 @@
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
+
+
-
-
@@ -265,7 +312,7 @@
-
+
@@ -281,14 +328,14 @@
-
-
+
+
-
-
diff --git a/Views/ProcessView.xaml b/Views/ProcessView.xaml
index a4d8abd..d743565 100644
--- a/Views/ProcessView.xaml
+++ b/Views/ProcessView.xaml
@@ -3,8 +3,6 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
- xmlns:local="clr-namespace:ThreadPilot.Views"
- xmlns:helpers="clr-namespace:ThreadPilot.Helpers"
xmlns:converters="clr-namespace:ThreadPilot.Converters"
xmlns:Diagnostics="clr-namespace:System.Diagnostics;assembly=System.Diagnostics.Process"
xmlns:sys="clr-namespace:System;assembly=mscorlib"
@@ -26,378 +24,470 @@
12
11
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/SettingsView.xaml b/Views/SettingsView.xaml
index 44ffe62..e22d917 100644
--- a/Views/SettingsView.xaml
+++ b/Views/SettingsView.xaml
@@ -154,12 +154,12 @@
-
-
@@ -198,22 +198,22 @@
-
+
-
+
-
-
-
diff --git a/Views/SystemTweaksView.xaml b/Views/SystemTweaksView.xaml
index 4af4e65..ae53572 100644
--- a/Views/SystemTweaksView.xaml
+++ b/Views/SystemTweaksView.xaml
@@ -156,33 +156,28 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
@@ -215,21 +210,12 @@
-
-
-
+
+
+
-
-
-
-
-
-
-
+ Margin="10,0,0,0"
+ ToolTip="Toggle this Windows tweak"/>
+
+
+
+