diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs new file mode 100644 index 0000000..42a8ae2 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs @@ -0,0 +1,386 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Collections.ObjectModel; + using System.Diagnostics; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public sealed class ProcessViewModelContextMenuTests + { + [Fact] + public async Task ContextCpuPriorityCommand_CallsSafePriorityServicePath() + { + var processService = CreateProcessService(); + var viewModel = CreateViewModel(processService.Object); + var process = CreateProcess(priority: ProcessPriorityClass.Normal); + + await viewModel.SetContextHighPriorityCommand.ExecuteAsync(process); + + processService.Verify( + service => service.SetProcessPriority(process, ProcessPriorityClass.High), + Times.Once); + Assert.Equal(ProcessOperationUserMessages.HighPriorityWarning, viewModel.StatusMessage); + Assert.False(viewModel.HasError); + } + + [Fact] + public async Task ApplyContextAffinityCommand_UsesProvidedRowProcess() + { + var processService = CreateProcessService(); + var coordinator = CreateAffinityCoordinator(); + var viewModel = CreateViewModel( + processService.Object, + processAffinityApplyCoordinator: coordinator.Object); + viewModel.CpuCores = + [ + new CpuCoreModel { LogicalCoreId = 0, IsSelected = true }, + new CpuCoreModel { LogicalCoreId = 1, IsSelected = false }, + ]; + var rowProcess = CreateProcess(processId: 100); + + await viewModel.ApplyContextAffinityCommand.ExecuteAsync(rowProcess); + + coordinator.Verify( + service => service.ApplyCoreSelectionAsync( + rowProcess, + It.Is>(mask => mask.Count == 2 && mask[0] && !mask[1]), + "Manual Process tab context menu CPU selection", + default), + Times.Once); + Assert.Same(rowProcess, viewModel.SelectedProcess); + } + + [Fact] + public async Task ApplyContextAffinityCommand_WhenRowProcessDiffersFromSelectedProcess_UsesRowProcess() + { + var processService = CreateProcessService(); + var coordinator = CreateAffinityCoordinator(); + var viewModel = CreateViewModel( + processService.Object, + processAffinityApplyCoordinator: coordinator.Object); + viewModel.CpuCores = + [ + new CpuCoreModel { LogicalCoreId = 0, IsSelected = true }, + new CpuCoreModel { LogicalCoreId = 1, IsSelected = true }, + ]; + var oldSelectedProcess = CreateProcess(processId: 1, name: "Old.exe"); + var rowProcess = CreateProcess(processId: 2, name: "Row.exe"); + viewModel.SelectedProcess = oldSelectedProcess; + + await viewModel.ApplyContextAffinityCommand.ExecuteAsync(rowProcess); + + coordinator.Verify( + service => service.ApplyCoreSelectionAsync( + rowProcess, + It.IsAny>(), + "Manual Process tab context menu CPU selection", + default), + Times.Once); + coordinator.Verify( + service => service.ApplyCoreSelectionAsync( + oldSelectedProcess, + It.IsAny>(), + It.IsAny(), + default), + Times.Never); + Assert.Same(rowProcess, viewModel.SelectedProcess); + } + + [Fact] + public async Task ApplyContextAffinityCommand_DoesNotCallLegacyLongDirectly() + { + var processService = CreateProcessService(); + var coordinator = CreateAffinityCoordinator(); + var viewModel = CreateViewModel( + processService.Object, + processAffinityApplyCoordinator: coordinator.Object); + viewModel.CpuCores = + [ + new CpuCoreModel { LogicalCoreId = 0, IsSelected = true }, + ]; + var rowProcess = CreateProcess(); + + await viewModel.ApplyContextAffinityCommand.ExecuteAsync(rowProcess); + + coordinator.Verify( + service => service.ApplyCoreSelectionAsync( + rowProcess, + It.IsAny>(), + "Manual Process tab context menu CPU selection", + default), + Times.Once); + processService.Verify( + service => service.SetProcessorAffinity(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public void ContextCpuPriorityActions_DoNotExposeRealtimeAsNormalAction() + { + var viewModel = CreateViewModel(CreateProcessService().Object); + + Assert.DoesNotContain(ProcessPriorityClass.RealTime, viewModel.ContextMenuCpuPriorityActions); + Assert.Contains(ProcessPriorityClass.High, viewModel.ContextMenuCpuPriorityActions); + } + + [Fact] + public async Task ContextMemoryPriorityCommand_CallsMemoryPriorityService() + { + var memoryPriorityService = new Mock(MockBehavior.Strict); + memoryPriorityService + .Setup(service => service.SetMemoryPriorityAsync(It.IsAny(), ProcessMemoryPriority.Low)) + .ReturnsAsync(ProcessOperationResult.Succeeded("Memory priority applied.", "ok")); + memoryPriorityService + .Setup(service => service.GetMemoryPriorityAsync(It.IsAny())) + .ReturnsAsync(ProcessMemoryPriority.Low); + var process = CreateProcess(); + var viewModel = CreateViewModel( + CreateProcessService().Object, + memoryPriorityService: memoryPriorityService.Object); + + await viewModel.SetContextMemoryPriorityLowCommand.ExecuteAsync(process); + + memoryPriorityService.Verify( + service => service.SetMemoryPriorityAsync(process, ProcessMemoryPriority.Low), + Times.Once); + } + + [Fact] + public async Task ContextMemoryPriorityCommand_WhenServiceFails_ShowsSafeUserMessage() + { + var memoryPriorityService = new Mock(MockBehavior.Strict); + memoryPriorityService + .Setup(service => service.SetMemoryPriorityAsync(It.IsAny(), ProcessMemoryPriority.Normal)) + .ReturnsAsync(ProcessOperationResult.Failed( + "AccessDenied", + ProcessOperationUserMessages.AccessDenied, + "Access is denied.", + isAccessDenied: true)); + var process = CreateProcess(); + var viewModel = CreateViewModel( + CreateProcessService().Object, + memoryPriorityService: memoryPriorityService.Object); + + await viewModel.SetContextMemoryPriorityNormalCommand.ExecuteAsync(process); + + Assert.Equal(ProcessOperationUserMessages.AccessDenied, viewModel.StatusMessage); + Assert.True(viewModel.HasError); + } + + [Fact] + public async Task ContextMemoryPriorityCommand_WhenSuccessful_UpdatesSelectedProcessSummary() + { + var memoryPriorityService = new Mock(MockBehavior.Strict); + memoryPriorityService + .Setup(service => service.SetMemoryPriorityAsync(It.IsAny(), ProcessMemoryPriority.BelowNormal)) + .ReturnsAsync(ProcessOperationResult.Succeeded("Memory priority applied.", "ok")); + memoryPriorityService + .Setup(service => service.GetMemoryPriorityAsync(It.IsAny())) + .ReturnsAsync(ProcessMemoryPriority.BelowNormal); + var process = CreateProcess(); + var viewModel = CreateViewModel( + CreateProcessService().Object, + memoryPriorityService: memoryPriorityService.Object); + + await viewModel.SetContextMemoryPriorityBelowNormalCommand.ExecuteAsync(process); + + Assert.Equal(ProcessMemoryPriority.BelowNormal, viewModel.SelectedProcessSummary.MemoryPriority); + Assert.Equal("Memory priority: BelowNormal", viewModel.SelectedProcessSummary.MemoryPriorityText); + } + + [Fact] + public async Task CopyContextProcessInfo_IncludesNamePidAndPath() + { + string? copiedText = null; + var process = CreateProcess(); + var viewModel = CreateViewModel( + CreateProcessService().Object, + clipboardSetter: text => copiedText = text); + + await viewModel.CopyContextProcessInfoCommand.ExecuteAsync(process); + + Assert.NotNull(copiedText); + Assert.Contains("Name: Game.exe", copiedText); + Assert.Contains("PID: 42", copiedText); + Assert.Contains(@"Path: C:\Games\Game.exe", copiedText); + } + + [Fact] + public async Task CopyContextProcessInfo_WhenPathMissing_DoesNotThrow() + { + string? copiedText = null; + var process = CreateProcess(path: string.Empty); + var viewModel = CreateViewModel( + CreateProcessService().Object, + clipboardSetter: text => copiedText = text); + + var exception = await Record.ExceptionAsync( + () => viewModel.CopyContextProcessInfoCommand.ExecuteAsync(process)); + + Assert.Null(exception); + Assert.Contains("Path: unavailable", copiedText); + } + + [Fact] + public async Task OpenContextExecutableLocation_WhenPathMissing_DoesNotThrow() + { + var viewModel = CreateViewModel(CreateProcessService().Object); + + var exception = await Record.ExceptionAsync( + () => viewModel.OpenContextExecutableLocationCommand.ExecuteAsync(CreateProcess(path: string.Empty))); + + Assert.Null(exception); + Assert.Equal("Executable path is unavailable for Game.exe.", viewModel.StatusMessage); + Assert.True(viewModel.HasError); + } + + [Fact] + public async Task ClearContextCpuSetsCommand_CallsSafeCpuSetClearPath() + { + var processService = CreateProcessService(); + processService + .Setup(service => service.ClearProcessCpuSetAsync(It.IsAny())) + .ReturnsAsync(true); + var process = CreateProcess(); + var viewModel = CreateViewModel(processService.Object); + + await viewModel.ClearContextCpuSetsCommand.ExecuteAsync(process); + + processService.Verify(service => service.ClearProcessCpuSetAsync(process), Times.Once); + } + + [Fact] + public async Task RefreshContextProcessInfoCommand_RefreshesSelectedProcessInfo() + { + var processService = CreateProcessService(); + var process = CreateProcess(); + var viewModel = CreateViewModel(processService.Object); + + await viewModel.RefreshContextProcessInfoCommand.ExecuteAsync(process); + + processService.Verify(service => service.RefreshProcessInfo(process), Times.Once); + Assert.Equal("Process info refreshed for Game.exe.", viewModel.StatusMessage); + } + + [Fact] + public async Task ContextMenuActions_DoNotCreatePersistentRules() + { + var processService = CreateProcessService(); + var memoryPriorityService = new Mock(MockBehavior.Strict); + memoryPriorityService + .Setup(service => service.SetMemoryPriorityAsync(It.IsAny(), ProcessMemoryPriority.VeryLow)) + .ReturnsAsync(ProcessOperationResult.Succeeded("Memory priority applied.", "ok")); + memoryPriorityService + .Setup(service => service.GetMemoryPriorityAsync(It.IsAny())) + .ReturnsAsync(ProcessMemoryPriority.VeryLow); + var ruleStore = new Mock(MockBehavior.Strict); + ruleStore + .Setup(store => store.LoadAsync()) + .ReturnsAsync(Array.Empty()); + var viewModel = CreateViewModel( + processService.Object, + memoryPriorityService: memoryPriorityService.Object, + persistentRuleStore: ruleStore.Object, + clipboardSetter: _ => { }); + var process = CreateProcess(); + + await viewModel.SetContextAboveNormalPriorityCommand.ExecuteAsync(process); + await viewModel.SetContextMemoryPriorityVeryLowCommand.ExecuteAsync(process); + await viewModel.CopyContextProcessInfoCommand.ExecuteAsync(process); + + ruleStore.Verify(store => store.SaveAsync(It.IsAny>()), Times.Never); + } + + private static Mock CreateProcessService() + { + var processService = new Mock(MockBehavior.Loose); + processService + .Setup(service => service.GetProcessesAsync()) + .ReturnsAsync(new ObservableCollection()); + processService + .Setup(service => service.GetActiveApplicationsAsync()) + .ReturnsAsync(new ObservableCollection()); + processService + .Setup(service => service.IsProcessStillRunning(It.IsAny())) + .ReturnsAsync(true); + processService + .Setup(service => service.RefreshProcessInfo(It.IsAny())) + .Returns(Task.CompletedTask); + return processService; + } + + private static Mock CreateAffinityCoordinator() + { + var coordinator = new Mock(MockBehavior.Strict); + coordinator + .Setup(service => service.ApplyCoreSelectionAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + default)) + .ReturnsAsync(AffinityApplyResult.Succeeded(1, 1)); + return coordinator; + } + + private static ProcessViewModel CreateViewModel( + IProcessService processService, + IProcessAffinityApplyCoordinator? processAffinityApplyCoordinator = null, + IProcessMemoryPriorityService? memoryPriorityService = null, + IPersistentProcessRuleStore? persistentRuleStore = null, + Action? clipboardSetter = null, + Action? executableLocationOpener = null) + { + 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); + var gameModeService = 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.Object, + processAffinityApplyCoordinator: processAffinityApplyCoordinator, + memoryPriorityService: memoryPriorityService, + persistentRuleStore: persistentRuleStore, + persistentRuleMatcher: new PersistentProcessRuleMatcher(), + clipboardSetter: clipboardSetter, + executableLocationOpener: executableLocationOpener); + } + + private static ProcessModel CreateProcess( + string name = "Game.exe", + int processId = 42, + string path = @"C:\Games\Game.exe", + ProcessPriorityClass priority = ProcessPriorityClass.Normal) + => new() + { + ProcessId = processId, + Name = name, + ExecutablePath = path, + CpuUsage = 1.5, + MemoryUsage = 128 * 1024 * 1024, + Priority = priority, + ProcessorAffinity = 0xF, + Classification = ProcessClassification.ForegroundApp, + }; + } +} diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index 6f0bd72..d5990b5 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -20,7 +20,9 @@ namespace ThreadPilot.ViewModels using System.Collections.ObjectModel; using System.ComponentModel; using System.Diagnostics; + using System.IO; using System.Linq; + using System.Text; using System.Threading.Tasks; using System.Windows.Input; using CommunityToolkit.Mvvm.ComponentModel; @@ -179,10 +181,15 @@ partial void OnSelectedProcessChanged(ProcessModel? value) private void UpdateSelectedProcessSummary(ProcessModel? process) { TaskSafety.FireAndForget( - this.SelectedProcessSummary.UpdateAsync(process, this.StatusMessage, this.HasError), + this.UpdateSelectedProcessSummaryAsync(process), ex => this.Logger.LogWarning(ex, "Failed to update selected process summary")); } + private Task UpdateSelectedProcessSummaryAsync(ProcessModel? process) + { + return this.SelectedProcessSummary.UpdateAsync(process, this.StatusMessage, this.HasError); + } + private async Task HandleSelectedProcessChangedAsync(ProcessModel value) { try @@ -721,11 +728,32 @@ private async Task SetAffinity() return; } + await this.ApplyAffinityToProcessAsync(selectedProcess, "Manual Process tab CPU selection"); + } + + [RelayCommand] + private async Task ApplyContextAffinity(ProcessModel? process) + { + if (process == null) + { + return; + } + + if (!ReferenceEquals(this.SelectedProcess, process)) + { + this.SelectedProcess = process; + } + + await this.ApplyAffinityToProcessAsync(process, "Manual Process tab context menu CPU selection"); + } + + private async Task ApplyAffinityToProcessAsync(ProcessModel selectedProcess, string selectionReason) + { try { var pendingSelection = this.GetPendingCoreSelectionMask(); - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + await InvokeOnUiAsync(() => { this.SetStatus($"Setting affinity for {selectedProcess.Name}..."); }); @@ -733,9 +761,9 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => var result = await this.processAffinityApplyCoordinator.ApplyCoreSelectionAsync( selectedProcess, pendingSelection, - "Manual Process tab CPU selection"); + selectionReason); - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + await InvokeOnUiAsync(() => { if (!result.UsedCpuSets) { @@ -782,13 +810,15 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => _ = this.notificationService.ShowNotificationAsync("Affinity error", result.Message, NotificationType.Error); } }); + + await this.UpdateSelectedProcessSummaryAsync(selectedProcess); } catch (Exception ex) { var friendly = ex.Message; _ = this.notificationService.ShowNotificationAsync("Affinity error", friendly, NotificationType.Error); - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + await InvokeOnUiAsync(() => { this.SetCriticalStatus($"Error setting affinity: {friendly}"); }); @@ -801,7 +831,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => await this.processService.RefreshProcessInfo(this.SelectedProcess); } - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + await InvokeOnUiAsync(() => { if (this.SelectedProcess != null) { @@ -814,16 +844,30 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { // Process may have terminated } + + await this.UpdateSelectedProcessSummaryAsync(selectedProcess); } finally { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + await InvokeOnUiAsync(() => { this.ClearStatus(); }); } } + private static Task InvokeOnUiAsync(Action action) + { + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher == null || dispatcher.CheckAccess()) + { + action(); + return Task.CompletedTask; + } + + return dispatcher.InvokeAsync(action).Task; + } + [RelayCommand] private async Task ApplyAffinityPreset(CpuAffinityPreset preset) { @@ -1254,6 +1298,285 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => } } + [RelayCommand] + private Task SetContextBelowNormalPriority(ProcessModel? process) => + this.SetContextCpuPriorityAsync(process, ProcessPriorityClass.BelowNormal); + + [RelayCommand] + private Task SetContextNormalPriority(ProcessModel? process) => + this.SetContextCpuPriorityAsync(process, ProcessPriorityClass.Normal); + + [RelayCommand] + private Task SetContextAboveNormalPriority(ProcessModel? process) => + this.SetContextCpuPriorityAsync(process, ProcessPriorityClass.AboveNormal); + + [RelayCommand] + private Task SetContextHighPriority(ProcessModel? process) => + this.SetContextCpuPriorityAsync(process, ProcessPriorityClass.High); + + [RelayCommand] + private Task SetContextMemoryPriorityVeryLow(ProcessModel? process) => + this.SetContextMemoryPriorityAsync(process, ProcessMemoryPriority.VeryLow); + + [RelayCommand] + private Task SetContextMemoryPriorityLow(ProcessModel? process) => + this.SetContextMemoryPriorityAsync(process, ProcessMemoryPriority.Low); + + [RelayCommand] + private Task SetContextMemoryPriorityMedium(ProcessModel? process) => + this.SetContextMemoryPriorityAsync(process, ProcessMemoryPriority.Medium); + + [RelayCommand] + private Task SetContextMemoryPriorityBelowNormal(ProcessModel? process) => + this.SetContextMemoryPriorityAsync(process, ProcessMemoryPriority.BelowNormal); + + [RelayCommand] + private Task SetContextMemoryPriorityNormal(ProcessModel? process) => + this.SetContextMemoryPriorityAsync(process, ProcessMemoryPriority.Normal); + + [RelayCommand] + private async Task ClearContextCpuSets(ProcessModel? process) + { + if (process == null) + { + return; + } + + try + { + var success = await this.processService.ClearProcessCpuSetAsync(process); + if (!success) + { + this.SetContextError(ProcessOperationUserMessages.AccessDenied); + await this.UpdateSelectedProcessSummaryAsync(process); + return; + } + + await this.processService.RefreshProcessInfo(process); + this.SetStatus($"CPU Sets cleared for {process.Name}.", false); + await this.UpdateSelectedProcessSummaryAsync(process); + } + catch (Exception ex) + { + this.SetContextError(MapProcessOperationException(ex)); + await this.TryRefreshContextProcessSummaryAsync(process); + } + } + + [RelayCommand] + private async Task RefreshContextProcessInfo(ProcessModel? process) + { + if (process == null) + { + return; + } + + try + { + await this.processService.RefreshProcessInfo(process); + this.SetStatus($"Process info refreshed for {process.Name}.", false); + await this.UpdateSelectedProcessSummaryAsync(process); + } + catch (Exception ex) + { + this.SetContextError(MapProcessOperationException(ex)); + await this.TryRefreshContextProcessSummaryAsync(process); + } + } + + [RelayCommand] + private async Task OpenContextExecutableLocation(ProcessModel? process) + { + if (process == null) + { + return; + } + + var path = process.ExecutablePath; + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + this.SetContextError($"Executable path is unavailable for {process.Name}."); + await this.UpdateSelectedProcessSummaryAsync(process); + return; + } + + try + { + this.executableLocationOpener(path); + this.SetStatus($"Opened executable location for {process.Name}.", false); + await this.UpdateSelectedProcessSummaryAsync(process); + } + catch (Exception ex) + { + this.SetContextError($"Could not open executable location: {ex.Message}"); + await this.UpdateSelectedProcessSummaryAsync(process); + } + } + + [RelayCommand] + private async Task CopyContextProcessInfo(ProcessModel? process) + { + if (process == null) + { + return; + } + + await this.UpdateSelectedProcessSummaryAsync(process); + + var path = string.IsNullOrWhiteSpace(process.ExecutablePath) + ? "unavailable" + : process.ExecutablePath; + var builder = new StringBuilder() + .AppendLine($"Name: {process.Name}") + .AppendLine($"PID: {process.ProcessId}") + .AppendLine($"Path: {path}") + .AppendLine($"CPU priority: {process.Priority}") + .AppendLine($"Memory priority: {this.SelectedProcessSummary.MemoryPriority?.ToString() ?? "unavailable"}") + .AppendLine($"Affinity: 0x{process.ProcessorAffinity:X}") + .AppendLine($"Rule status: {this.SelectedProcessSummary.RuleStatusText}"); + + try + { + this.clipboardSetter(builder.ToString().TrimEnd()); + this.SetStatus($"Copied process info for {process.Name}.", false); + await this.UpdateSelectedProcessSummaryAsync(process); + } + catch (Exception ex) + { + this.SetContextError($"Could not copy process info: {ex.Message}"); + await this.UpdateSelectedProcessSummaryAsync(process); + } + } + + private async Task SetContextCpuPriorityAsync(ProcessModel? process, ProcessPriorityClass priority) + { + if (process == null) + { + return; + } + + if (ProcessPriorityGuardrails.IsBlocked(priority)) + { + this.SetContextError(ProcessOperationUserMessages.RealtimePriorityBlocked); + await this.UpdateSelectedProcessSummaryAsync(process); + return; + } + + try + { + await this.processService.SetProcessPriority(process, priority); + await this.processService.RefreshProcessInfo(process); + + var warning = ProcessPriorityGuardrails.GetWarning(priority); + if (!string.IsNullOrWhiteSpace(warning)) + { + this.SetCriticalStatus(warning); + _ = this.notificationService.ShowNotificationAsync("Priority warning", warning, NotificationType.Warning); + } + else + { + this.SetStatus($"Priority applied successfully to {process.Name}: {priority}.", false); + _ = this.notificationService.ShowNotificationAsync("Priority applied", $"{process.Name}: {priority}", NotificationType.Success); + } + + await this.UpdateSelectedProcessSummaryAsync(process); + } + catch (Exception ex) + { + var message = MapProcessOperationException(ex); + this.SetContextError(message); + _ = this.notificationService.ShowNotificationAsync("Priority blocked", message, NotificationType.Warning); + await this.TryRefreshContextProcessSummaryAsync(process); + } + } + + private async Task SetContextMemoryPriorityAsync(ProcessModel? process, ProcessMemoryPriority priority) + { + if (process == null) + { + return; + } + + if (this.memoryPriorityService == null) + { + this.SetContextError("Memory priority is unavailable on this system."); + await this.UpdateSelectedProcessSummaryAsync(process); + return; + } + + try + { + var result = await this.memoryPriorityService.SetMemoryPriorityAsync(process, priority); + if (!result.Success) + { + this.SetContextError(string.IsNullOrWhiteSpace(result.UserMessage) + ? ProcessOperationUserMessages.AccessDenied + : result.UserMessage); + await this.UpdateSelectedProcessSummaryAsync(process); + return; + } + + this.SetStatus($"Memory priority applied successfully to {process.Name}: {priority}.", false); + await this.UpdateSelectedProcessSummaryAsync(process); + } + catch (Exception ex) + { + this.SetContextError(MapProcessOperationException(ex)); + await this.UpdateSelectedProcessSummaryAsync(process); + } + } + + private async Task TryRefreshContextProcessSummaryAsync(ProcessModel process) + { + try + { + await this.processService.RefreshProcessInfo(process); + } + catch + { + // The selected process may have exited or become inaccessible; keep the safe user message. + } + + await this.UpdateSelectedProcessSummaryAsync(process); + } + + private void SetContextError(string message) + { + this.SetStatus(message, false); + this.SetError(message); + } + + private static string MapProcessOperationException(Exception exception) + { + var message = exception.Message ?? string.Empty; + if (message.Contains("Realtime priority", StringComparison.OrdinalIgnoreCase)) + { + return ProcessOperationUserMessages.RealtimePriorityBlocked; + } + + if (message.Contains("anti-cheat", StringComparison.OrdinalIgnoreCase) || + message.Contains("protected", StringComparison.OrdinalIgnoreCase)) + { + return ProcessOperationUserMessages.AntiCheatProtectedLikely; + } + + if (message.Contains("exited", StringComparison.OrdinalIgnoreCase) || + message.Contains("terminated", StringComparison.OrdinalIgnoreCase) || + message.Contains("no longer exists", StringComparison.OrdinalIgnoreCase)) + { + return ProcessOperationUserMessages.ProcessExited; + } + + if (exception is UnauthorizedAccessException || + message.Contains("access denied", StringComparison.OrdinalIgnoreCase) || + message.Contains("denied", StringComparison.OrdinalIgnoreCase)) + { + return ProcessOperationUserMessages.AccessDenied; + } + + return string.IsNullOrWhiteSpace(message) ? ProcessOperationUserMessages.AccessDenied : message; + } + [RelayCommand] private async Task SaveProfile() { diff --git a/ViewModels/ProcessViewModel.cs b/ViewModels/ProcessViewModel.cs index b86559f..34ec29b 100644 --- a/ViewModels/ProcessViewModel.cs +++ b/ViewModels/ProcessViewModel.cs @@ -17,6 +17,7 @@ namespace ThreadPilot.ViewModels { using System; + using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; using System.Diagnostics; @@ -46,6 +47,9 @@ public partial class ProcessViewModel : BaseViewModel private readonly IGameModeService gameModeService; private readonly IAffinityApplyService affinityApplyService; private readonly IProcessAffinityApplyCoordinator processAffinityApplyCoordinator; + private readonly IProcessMemoryPriorityService? memoryPriorityService; + private readonly Action clipboardSetter; + private readonly Action executableLocationOpener; private System.Timers.Timer? refreshTimer; private bool isUiRefreshPaused; private bool isProcessViewActive = true; @@ -183,7 +187,9 @@ public ProcessViewModel( IEnhancedLoggingService? enhancedLoggingService = null, IProcessMemoryPriorityService? memoryPriorityService = null, IPersistentProcessRuleStore? persistentRuleStore = null, - IPersistentProcessRuleMatcher? persistentRuleMatcher = null) + IPersistentProcessRuleMatcher? persistentRuleMatcher = null, + Action? clipboardSetter = null, + Action? executableLocationOpener = null) : base(logger, enhancedLoggingService) { this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); @@ -205,6 +211,9 @@ public ProcessViewModel( cpuTopologyProvider, new CpuSelectionMigrationService(), NullLogger.Instance); + this.memoryPriorityService = memoryPriorityService; + this.clipboardSetter = clipboardSetter ?? System.Windows.Clipboard.SetText; + this.executableLocationOpener = executableLocationOpener ?? OpenExecutableLocationInExplorer; this.SelectedProcessSummary = new SelectedProcessSummaryViewModel( memoryPriorityService, persistentRuleStore, @@ -231,6 +240,26 @@ public ProcessViewModel( // Note: InitializeAsync() will be called explicitly by MainWindow loading overlay } + public IReadOnlyList ContextMenuCpuPriorityActions { get; } = + [ + ProcessPriorityClass.BelowNormal, + ProcessPriorityClass.Normal, + ProcessPriorityClass.AboveNormal, + ProcessPriorityClass.High, + ]; + public SelectedProcessSummaryViewModel SelectedProcessSummary { get; } + + private static void OpenExecutableLocationInExplorer(string executablePath) + { + var startInfo = new ProcessStartInfo + { + FileName = "explorer.exe", + Arguments = $"/select,\"{executablePath}\"", + UseShellExecute = true, + }; + + Process.Start(startInfo); + } } } diff --git a/Views/ProcessView.xaml b/Views/ProcessView.xaml index 543bd9b..b635084 100644 --- a/Views/ProcessView.xaml +++ b/Views/ProcessView.xaml @@ -142,6 +142,70 @@ ScrollViewer.CanContentScroll="True" AutomationProperties.Name="Running process list" AutomationProperties.HelpText="Virtualized process table with sorting and selection"> + + + diff --git a/Views/ProcessView.xaml.cs b/Views/ProcessView.xaml.cs index 81c4af4..70d1767 100644 --- a/Views/ProcessView.xaml.cs +++ b/Views/ProcessView.xaml.cs @@ -17,6 +17,7 @@ namespace ThreadPilot.Views { using System.Windows.Controls; + using System.Windows.Input; using ThreadPilot.Helpers; using ThreadPilot.ViewModels; @@ -27,5 +28,16 @@ public ProcessView() this.InitializeComponent(); this.DataContext = ServiceProviderExtensions.GetService(); } + + private void ProcessRow_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e) + { + if (sender is not DataGridRow row) + { + return; + } + + row.IsSelected = true; + row.Focus(); + } } }