diff --git a/App.xaml.cs b/App.xaml.cs index 06dc022..1513528 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -1,497 +1,469 @@ -/* - * 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 . - */ -#if DEBUG -using ThreadPilot.Tests; -#endif -using ThreadPilot.Services; -using ThreadPilot.ViewModels; - -namespace ThreadPilot -{ - using System; - using System.Collections.Generic; - using System.Linq; - using System.Threading; - using System.Threading.Tasks; - using System.Windows; - using System.Windows.Threading; - using Microsoft.Extensions.DependencyInjection; - using Microsoft.Extensions.Logging; - using ThreadPilot.Helpers; - using ThreadPilot.Models; - - public partial class App : System.Windows.Application - { - private const string RegisterLaunchTaskArgument = "--register-launch-task"; - private const string LaunchedViaTaskArgument = "--launched-via-task"; - - private Mutex? singleInstanceMutex; - private int uiExceptionDialogOpen; - private DateTime lastUiExceptionDialogUtc = DateTime.MinValue; - - public IServiceProvider ServiceProvider { get; private set; } - - public App() - { - ServiceCollection services = new ServiceCollection(); - - // Use the new centralized service configuration - services.ConfigureApplicationServices(); - - this.ServiceProvider = services.BuildServiceProvider(); - - // Validate service configuration - ServiceConfiguration.ValidateServiceConfiguration(this.ServiceProvider); - } - - - - protected override void OnStartup(StartupEventArgs e) - { - // Parse command line arguments early so special startup modes can short-circuit normal flow. - var startupMode = StartupMode.Parse(e.Args); - bool effectiveStartMinimized = false; - ApplicationSettingsModel? loadedSettings = null; - - effectiveStartMinimized = startupMode.StartMinimized; - - if (startupMode.IsSmokeTest) - { - var smokeLogger = this.ServiceProvider.GetRequiredService>(); - var smokeTestResult = this.RunSmokeTestWithTimeout(smokeLogger, TimeSpan.FromSeconds(10)); - Environment.ExitCode = smokeTestResult; - this.Shutdown(smokeTestResult); - Environment.Exit(smokeTestResult); - return; - } - - // Set up global exception handlers first - AppDomain.CurrentDomain.UnhandledException += this.OnUnhandledException; - this.DispatcherUnhandledException += this.OnDispatcherUnhandledException; - TaskScheduler.UnobservedTaskException += this.OnUnobservedTaskException; - - // Check elevation status first - var elevationService = this.ServiceProvider.GetRequiredService(); - var elevatedTaskService = this.ServiceProvider.GetRequiredService(); - var logger = this.ServiceProvider.GetRequiredService>(); - var isRunningAsAdministrator = elevationService.IsRunningAsAdministrator(); - - if (isRunningAsAdministrator) - { - logger.LogInformation("Application is running with administrator privileges"); - - var launchTaskEnsured = Task.Run(async () => await elevatedTaskService.EnsureLaunchTaskAsync()).GetAwaiter().GetResult(); - if (!launchTaskEnsured) - { - logger.LogWarning("Failed to ensure managed elevated launch task during startup. Future launches may require one-time elevation again."); - } - } - else - { - if (startupMode.LaunchedViaTask) - { - logger.LogError("Application was launched via managed task marker but is still not elevated."); - } -#if DEBUG - else if (!startupMode.IsTestMode) -#else - else -#endif - { - var launchedElevatedInstance = Task.Run(async () => await elevatedTaskService.TryRunLaunchTaskAsync()).GetAwaiter().GetResult(); - if (launchedElevatedInstance) - { - logger.LogInformation("Managed elevated launch task started successfully. Exiting current non-elevated instance."); - this.Shutdown(); - return; - } - - if (!startupMode.RegisterLaunchTask) - { - logger.LogInformation("Managed elevated launch task is unavailable. Requesting one-time elevation to bootstrap persistent launch."); - var restartInitiated = Task.Run(async () => await elevationService.RestartWithElevation(new[] { RegisterLaunchTaskArgument })).GetAwaiter().GetResult(); - if (restartInitiated) - { - return; - } - } - } - -#if DEBUG - if (!startupMode.IsTestMode) -#else - if (true) -#endif - { - logger.LogError("ThreadPilot requires administrator privileges and cannot continue without elevation."); - this.ShowElevationRequiredMessage(); - this.Shutdown(1); - return; - } - } - - // Enforce single-instance after elevation bootstrap logic to avoid mutex races during handoff. - bool createdNew; - this.singleInstanceMutex = new Mutex(initiallyOwned: true, name: "Global\\ThreadPilot_SingleInstance", createdNew: out createdNew); - if (!createdNew) - { - System.Windows.MessageBox.Show( - "ThreadPilot is already running.", - "Instance already open", - MessageBoxButton.OK, - MessageBoxImage.Information); - - this.Shutdown(); - return; - } - - base.OnStartup(e); - - // Check for test mode -#if DEBUG - if (startupMode.IsTestMode) - { - // Run in console test mode - AllocConsole(); - _ = Task.Run(async () => - { - await TestRunner.RunTests(); - this.Dispatcher.Invoke(() => this.Shutdown()); - }); - return; - } -#endif - - try - { - var settingsService = this.ServiceProvider.GetRequiredService(); - var themeService = this.ServiceProvider.GetRequiredService(); - var localizationService = this.ServiceProvider.GetRequiredService(); - - Task.Run(async () => await settingsService.LoadSettingsAsync()).GetAwaiter().GetResult(); - var settings = settingsService.Settings; - loadedSettings = settings; - localizationService.ApplyLanguage(settings.Language); - effectiveStartMinimized = startupMode.StartMinimized || settings.StartMinimized; - var useDarkTheme = settings.HasUserThemePreference - ? settings.UseDarkTheme - : themeService.GetSystemUsesDarkTheme(); - - if (!settings.HasUserThemePreference && settings.UseDarkTheme != useDarkTheme) - { - settings.UseDarkTheme = useDarkTheme; - Task.Run(async () => await settingsService.UpdateSettingsAsync(settings)).GetAwaiter().GetResult(); - } - - themeService.ApplyTheme(useDarkTheme); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to preload theme settings during startup"); - } - - var mainWindow = this.ServiceProvider.GetRequiredService(); - this.MainWindow = mainWindow; - - // Handle startup behavior with comprehensive error handling - try - { - logger.LogInformation("Attempting to show main window..."); - - // Ensure the window is properly initialized - if (mainWindow == null) - { - throw new InvalidOperationException("MainWindow could not be created"); - } - - var startupWindowBehavior = StartupWindowBehavior.Resolve(startupMode.IsAutostart, effectiveStartMinimized); - var showStartupSuggestion = loadedSettings != null - && StartupMinimizedSuggestionPolicy.ShouldShow(loadedSettings, startupWindowBehavior); - mainWindow.ConfigureStartupMode( - isSilentStartupMode: !startupWindowBehavior.ShouldShowWindow, - showStartupMinimizedSuggestionOnReady: showStartupSuggestion); - - mainWindow.ShowInTaskbar = startupWindowBehavior.ShowInTaskbar; - mainWindow.Visibility = startupWindowBehavior.Visibility; - mainWindow.WindowState = startupWindowBehavior.WindowState; - - if (startupWindowBehavior.ShouldShowWindow) - { - mainWindow.Show(); - - if (startupWindowBehavior.HideAfterShow) - { - mainWindow.Hide(); - } - else if (startupWindowBehavior.ActivateAfterShow) - { - mainWindow.EnsureDashboardVisibleOnScreen(); - mainWindow.Activate(); - } - } - - logger.LogInformation("Startup window behavior applied successfully"); - } - catch (Exception ex) - { - logger.LogError(ex, "Critical error during application startup"); - - // Show error message and exit gracefully - var errorMessage = $"ThreadPilot failed to start:\n\n{ex.Message}\n\nStack Trace:\n{ex.StackTrace}"; - System.Windows.MessageBox.Show(errorMessage, "ThreadPilot Startup Error", - MessageBoxButton.OK, MessageBoxImage.Error); - - // Exit the application - this.Shutdown(1); - return; - } - } - - private int RunSmokeTestWithTimeout(ILogger logger, TimeSpan timeout) - { - var smokeTestTask = Task.Run(() => this.RunSmokeTest(logger)); - if (smokeTestTask.Wait(timeout)) - { - return smokeTestTask.GetAwaiter().GetResult(); - } - - logger.LogError("ThreadPilot smoke test timed out after {TimeoutSeconds} seconds", timeout.TotalSeconds); - return 2; - } - - private int RunSmokeTest(ILogger logger) - { - try - { - logger.LogInformation("Starting ThreadPilot smoke test"); - - _ = this.ServiceProvider.GetRequiredService(); - _ = this.ServiceProvider.GetRequiredService(); - _ = this.ServiceProvider.GetRequiredService(); - _ = this.ServiceProvider.GetRequiredService(); - - if (!System.IO.Directory.Exists(AppContext.BaseDirectory)) - { - throw new InvalidOperationException("Application base directory was not found."); - } - - logger.LogInformation("ThreadPilot smoke test completed successfully"); - return 0; - } - catch (Exception ex) - { - logger.LogError(ex, "ThreadPilot smoke test failed"); - return 1; - } - } - - private readonly struct StartupMode - { - public bool StartMinimized { get; init; } - - public bool IsAutostart { get; init; } - - public bool IsSmokeTest { get; init; } - - public bool RegisterLaunchTask { get; init; } - - public bool LaunchedViaTask { get; init; } - - public bool IsTestMode { get; init; } - - public static StartupMode Parse(IEnumerable args) - { - var mode = default(StartupMode); - foreach (var arg in args) - { - switch (arg.ToLowerInvariant()) - { - case "--test": - mode = mode with { IsTestMode = true }; - break; - case "--smoke-test": - mode = mode with { IsSmokeTest = true }; - break; - case "--start-minimized": - mode = mode with { StartMinimized = true }; - break; - case "--autostart": - mode = mode with { IsAutostart = true }; - break; - case "--startup": - mode = mode with { IsAutostart = true, StartMinimized = true }; - break; - case RegisterLaunchTaskArgument: - mode = mode with { RegisterLaunchTask = true }; - break; - case LaunchedViaTaskArgument: - mode = mode with { LaunchedViaTask = true }; - break; - } - } - - return mode; - } - } - - protected override void OnExit(ExitEventArgs e) - { - AppDomain.CurrentDomain.UnhandledException -= this.OnUnhandledException; - this.DispatcherUnhandledException -= this.OnDispatcherUnhandledException; - TaskScheduler.UnobservedTaskException -= this.OnUnobservedTaskException; - - if (this.singleInstanceMutex != null) - { - try - { - this.singleInstanceMutex.ReleaseMutex(); - } - catch - { - // Ignore; we just want to clean up quietly - } - this.singleInstanceMutex.Dispose(); - this.singleInstanceMutex = null; - } - - base.OnExit(e); - } - -#if DEBUG - [System.Runtime.InteropServices.DllImport("kernel32.dll")] - private static extern bool AllocConsole(); -#endif - - /// - /// Shows a message to the user about elevation requirements. - /// - private void ShowElevationRequiredMessage() - { - // Don't show the message during autostart to avoid interrupting the user - var args = Environment.GetCommandLineArgs(); - if (args.Any(arg => arg.Equals("--autostart", StringComparison.OrdinalIgnoreCase) || - arg.Equals("--startup", StringComparison.OrdinalIgnoreCase))) - { - return; - } - - System.Windows.MessageBox.Show( - "ThreadPilot requires administrator privileges to start.\n\n" + - "Please relaunch the application and approve the UAC prompt.\n\n" + - "This instance will now close.", - "Administrator Privileges Required", - MessageBoxButton.OK, - MessageBoxImage.Warning); - } - - /// - /// Handles unhandled exceptions in the application domain. - /// - private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) - { - var exception = e.ExceptionObject as Exception ?? new InvalidOperationException("Unhandled non-Exception object was raised."); - this.ReportUnhandledException(exception, "AppDomain.CurrentDomain.UnhandledException", LogLevel.Critical); - - var errorMessage = $"A critical error occurred:\n\n{exception?.Message}\n\nThe application will now exit."; - System.Windows.MessageBox.Show(errorMessage, "Critical Error", - MessageBoxButton.OK, MessageBoxImage.Error); - } - - /// - /// Handles unhandled exceptions on the UI thread. - /// - private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) - { - this.ReportUnhandledException(e.Exception, "Application.DispatcherUnhandledException", LogLevel.Error); - - if (Interlocked.CompareExchange(ref this.uiExceptionDialogOpen, 1, 0) != 0) - { - e.Handled = true; - return; - } - - if (DateTime.UtcNow - this.lastUiExceptionDialogUtc < TimeSpan.FromSeconds(2)) - { - e.Handled = true; - Interlocked.Exchange(ref this.uiExceptionDialogOpen, 0); - return; - } - - this.lastUiExceptionDialogUtc = DateTime.UtcNow; - - var errorMessage = $"An error occurred in the user interface:\n\n{e.Exception.Message}\n\nDo you want to continue?"; - var result = System.Windows.MessageBox.Show(errorMessage, "UI Error", - MessageBoxButton.YesNo, MessageBoxImage.Error); - - if (result == MessageBoxResult.Yes) - { - e.Handled = true; // Continue running - } - else - { - e.Handled = false; // Let the application crash - } - - Interlocked.Exchange(ref this.uiExceptionDialogOpen, 0); - } - - /// - /// Handles unobserved task exceptions from fire-and-forget tasks that escaped local handlers. - /// - private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) - { - var exception = e.Exception.Flatten(); - this.ReportUnhandledException(exception, "TaskScheduler.UnobservedTaskException", LogLevel.Error); - e.SetObserved(); - } - - private void ReportUnhandledException(Exception exception, string source, LogLevel level) - { - var logger = this.ServiceProvider?.GetService>(); - if (level == LogLevel.Critical) - { - logger?.LogCritical(exception, "Unhandled exception in {Source}", source); - } - else - { - logger?.LogError(exception, "Unhandled exception in {Source}", source); - } - - var enhancedLogger = this.ServiceProvider?.GetService(); - if (enhancedLogger == null) - { - return; - } - - var errorCode = exception is ThreadPilotException typedException - ? typedException.ErrorCode.ToString() - : ErrorCode.Unhandled.ToString(); - - var context = new Dictionary - { - ["Source"] = source, - [LogProperties.ErrorCode] = errorCode, - [LogProperties.CorrelationId] = enhancedLogger.GetCurrentCorrelationId() ?? "N/A", - ["IsTerminatingLevel"] = level == LogLevel.Critical, - }; - - TaskSafety.FireAndForget( - enhancedLogger.LogErrorAsync(exception, source, context), - logFailure => logger?.LogWarning(logFailure, "Failed to persist unhandled exception report")); - } - } -} +#if DEBUG +using ThreadPilot.Tests; +#endif +using ThreadPilot.Services; +using ThreadPilot.ViewModels; + +namespace ThreadPilot +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using System.Windows; + using System.Windows.Threading; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + using ThreadPilot.Helpers; + using ThreadPilot.Models; + + public partial class App : System.Windows.Application + { + private const string RegisterLaunchTaskArgument = "--register-launch-task"; + private const string LaunchedViaTaskArgument = "--launched-via-task"; + + private Mutex? singleInstanceMutex; + private int uiExceptionDialogOpen; + private DateTime lastUiExceptionDialogUtc = DateTime.MinValue; + + public IServiceProvider ServiceProvider { get; private set; } + + public App() + { + ServiceCollection services = new ServiceCollection(); + + // Use the new centralized service configuration + services.ConfigureApplicationServices(); + + this.ServiceProvider = services.BuildServiceProvider(); + + // Validate service configuration + ServiceConfiguration.ValidateServiceConfiguration(this.ServiceProvider); + } + + + + protected override void OnStartup(StartupEventArgs e) + { + // Parse command line arguments early so special startup modes can short-circuit normal flow. + var startupMode = StartupMode.Parse(e.Args); + bool effectiveStartMinimized = false; + ApplicationSettingsModel? loadedSettings = null; + + effectiveStartMinimized = startupMode.StartMinimized; + + if (startupMode.IsSmokeTest) + { + var smokeLogger = this.ServiceProvider.GetRequiredService>(); + var smokeTestResult = this.RunSmokeTestWithTimeout(smokeLogger, TimeSpan.FromSeconds(10)); + Environment.ExitCode = smokeTestResult; + this.Shutdown(smokeTestResult); + Environment.Exit(smokeTestResult); + return; + } + + // Set up global exception handlers first + AppDomain.CurrentDomain.UnhandledException += this.OnUnhandledException; + this.DispatcherUnhandledException += this.OnDispatcherUnhandledException; + TaskScheduler.UnobservedTaskException += this.OnUnobservedTaskException; + + // Check elevation status first + var elevationService = this.ServiceProvider.GetRequiredService(); + var elevatedTaskService = this.ServiceProvider.GetRequiredService(); + var logger = this.ServiceProvider.GetRequiredService>(); + var isRunningAsAdministrator = elevationService.IsRunningAsAdministrator(); + + if (isRunningAsAdministrator) + { + logger.LogInformation("Application is running with administrator privileges"); + + var launchTaskEnsured = Task.Run(async () => await elevatedTaskService.EnsureLaunchTaskAsync()).GetAwaiter().GetResult(); + if (!launchTaskEnsured) + { + logger.LogWarning("Failed to ensure managed elevated launch task during startup. Future launches may require one-time elevation again."); + } + } + else + { + if (startupMode.LaunchedViaTask) + { + logger.LogError("Application was launched via managed task marker but is still not elevated."); + } +#if DEBUG + else if (!startupMode.IsTestMode) +#else + else +#endif + { + var launchedElevatedInstance = Task.Run(async () => await elevatedTaskService.TryRunLaunchTaskAsync()).GetAwaiter().GetResult(); + if (launchedElevatedInstance) + { + logger.LogInformation("Managed elevated launch task started successfully. Exiting current non-elevated instance."); + this.Shutdown(); + return; + } + + if (!startupMode.RegisterLaunchTask) + { + logger.LogInformation("Managed elevated launch task is unavailable. Requesting one-time elevation to bootstrap persistent launch."); + var restartInitiated = Task.Run(async () => await elevationService.RestartWithElevation(new[] { RegisterLaunchTaskArgument })).GetAwaiter().GetResult(); + if (restartInitiated) + { + return; + } + } + } + +#if DEBUG + if (!startupMode.IsTestMode) +#else + if (true) +#endif + { + logger.LogError("ThreadPilot requires administrator privileges and cannot continue without elevation."); + this.ShowElevationRequiredMessage(); + this.Shutdown(1); + return; + } + } + + // Enforce single-instance after elevation bootstrap logic to avoid mutex races during handoff. + bool createdNew; + this.singleInstanceMutex = new Mutex(initiallyOwned: true, name: "Global\\ThreadPilot_SingleInstance", createdNew: out createdNew); + if (!createdNew) + { + System.Windows.MessageBox.Show( + "ThreadPilot is already running.", + "Instance already open", + MessageBoxButton.OK, + MessageBoxImage.Information); + + this.Shutdown(); + return; + } + + base.OnStartup(e); + + // Check for test mode +#if DEBUG + if (startupMode.IsTestMode) + { + // Run in console test mode + AllocConsole(); + _ = Task.Run(async () => + { + await TestRunner.RunTests(); + this.Dispatcher.Invoke(() => this.Shutdown()); + }); + return; + } +#endif + + try + { + var settingsService = this.ServiceProvider.GetRequiredService(); + var themeService = this.ServiceProvider.GetRequiredService(); + var localizationService = this.ServiceProvider.GetRequiredService(); + + Task.Run(async () => await settingsService.LoadSettingsAsync()).GetAwaiter().GetResult(); + var settings = settingsService.Settings; + loadedSettings = settings; + localizationService.ApplyLanguage(settings.Language); + effectiveStartMinimized = startupMode.StartMinimized || settings.StartMinimized; + var useDarkTheme = settings.HasUserThemePreference + ? settings.UseDarkTheme + : themeService.GetSystemUsesDarkTheme(); + + if (!settings.HasUserThemePreference && settings.UseDarkTheme != useDarkTheme) + { + settings.UseDarkTheme = useDarkTheme; + Task.Run(async () => await settingsService.UpdateSettingsAsync(settings)).GetAwaiter().GetResult(); + } + + themeService.ApplyTheme(useDarkTheme); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to preload theme settings during startup"); + } + + var mainWindow = this.ServiceProvider.GetRequiredService(); + this.MainWindow = mainWindow; + + // Handle startup behavior with comprehensive error handling + try + { + logger.LogInformation("Attempting to show main window..."); + + // Ensure the window is properly initialized + if (mainWindow == null) + { + throw new InvalidOperationException("MainWindow could not be created"); + } + + var startupWindowBehavior = StartupWindowBehavior.Resolve(startupMode.IsAutostart, effectiveStartMinimized); + var showStartupSuggestion = loadedSettings != null + && StartupMinimizedSuggestionPolicy.ShouldShow(loadedSettings, startupWindowBehavior); + mainWindow.ConfigureStartupMode( + isSilentStartupMode: !startupWindowBehavior.ShouldShowWindow, + showStartupMinimizedSuggestionOnReady: showStartupSuggestion); + + mainWindow.ShowInTaskbar = startupWindowBehavior.ShowInTaskbar; + mainWindow.Visibility = startupWindowBehavior.Visibility; + mainWindow.WindowState = startupWindowBehavior.WindowState; + + if (startupWindowBehavior.ShouldShowWindow) + { + mainWindow.Show(); + + if (startupWindowBehavior.HideAfterShow) + { + mainWindow.Hide(); + } + else if (startupWindowBehavior.ActivateAfterShow) + { + mainWindow.EnsureDashboardVisibleOnScreen(); + mainWindow.Activate(); + } + } + + logger.LogInformation("Startup window behavior applied successfully"); + } + catch (Exception ex) + { + logger.LogError(ex, "Critical error during application startup"); + + // Show error message and exit gracefully + var errorMessage = $"ThreadPilot failed to start:\n\n{ex.Message}\n\nStack Trace:\n{ex.StackTrace}"; + System.Windows.MessageBox.Show(errorMessage, "ThreadPilot Startup Error", + MessageBoxButton.OK, MessageBoxImage.Error); + + // Exit the application + this.Shutdown(1); + return; + } + } + + private int RunSmokeTestWithTimeout(ILogger logger, TimeSpan timeout) + { + var smokeTestTask = Task.Run(() => this.RunSmokeTest(logger)); + if (smokeTestTask.Wait(timeout)) + { + return smokeTestTask.GetAwaiter().GetResult(); + } + + logger.LogError("ThreadPilot smoke test timed out after {TimeoutSeconds} seconds", timeout.TotalSeconds); + return 2; + } + + private int RunSmokeTest(ILogger logger) + { + try + { + logger.LogInformation("Starting ThreadPilot smoke test"); + + _ = this.ServiceProvider.GetRequiredService(); + _ = this.ServiceProvider.GetRequiredService(); + _ = this.ServiceProvider.GetRequiredService(); + _ = this.ServiceProvider.GetRequiredService(); + + if (!System.IO.Directory.Exists(AppContext.BaseDirectory)) + { + throw new InvalidOperationException("Application base directory was not found."); + } + + logger.LogInformation("ThreadPilot smoke test completed successfully"); + return 0; + } + catch (Exception ex) + { + logger.LogError(ex, "ThreadPilot smoke test failed"); + return 1; + } + } + + private readonly struct StartupMode + { + public bool StartMinimized { get; init; } + + public bool IsAutostart { get; init; } + + public bool IsSmokeTest { get; init; } + + public bool RegisterLaunchTask { get; init; } + + public bool LaunchedViaTask { get; init; } + + public bool IsTestMode { get; init; } + + public static StartupMode Parse(IEnumerable args) + { + var mode = default(StartupMode); + foreach (var arg in args) + { + switch (arg.ToLowerInvariant()) + { + case "--test": + mode = mode with { IsTestMode = true }; + break; + case "--smoke-test": + mode = mode with { IsSmokeTest = true }; + break; + case "--start-minimized": + mode = mode with { StartMinimized = true }; + break; + case "--autostart": + mode = mode with { IsAutostart = true }; + break; + case "--startup": + mode = mode with { IsAutostart = true, StartMinimized = true }; + break; + case RegisterLaunchTaskArgument: + mode = mode with { RegisterLaunchTask = true }; + break; + case LaunchedViaTaskArgument: + mode = mode with { LaunchedViaTask = true }; + break; + } + } + + return mode; + } + } + + protected override void OnExit(ExitEventArgs e) + { + AppDomain.CurrentDomain.UnhandledException -= this.OnUnhandledException; + this.DispatcherUnhandledException -= this.OnDispatcherUnhandledException; + TaskScheduler.UnobservedTaskException -= this.OnUnobservedTaskException; + + if (this.singleInstanceMutex != null) + { + try + { + this.singleInstanceMutex.ReleaseMutex(); + } + catch + { + // Ignore; we just want to clean up quietly + } + this.singleInstanceMutex.Dispose(); + this.singleInstanceMutex = null; + } + + base.OnExit(e); + } + +#if DEBUG + [System.Runtime.InteropServices.DllImport("kernel32.dll")] + private static extern bool AllocConsole(); +#endif + + private void ShowElevationRequiredMessage() + { + // Don't show the message during autostart to avoid interrupting the user + var args = Environment.GetCommandLineArgs(); + if (args.Any(arg => arg.Equals("--autostart", StringComparison.OrdinalIgnoreCase) || + arg.Equals("--startup", StringComparison.OrdinalIgnoreCase))) + { + return; + } + + System.Windows.MessageBox.Show( + "ThreadPilot requires administrator privileges to start.\n\n" + + "Please relaunch the application and approve the UAC prompt.\n\n" + + "This instance will now close.", + "Administrator Privileges Required", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + + private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) + { + var exception = e.ExceptionObject as Exception ?? new InvalidOperationException("Unhandled non-Exception object was raised."); + this.ReportUnhandledException(exception, "AppDomain.CurrentDomain.UnhandledException", LogLevel.Critical); + + var errorMessage = $"A critical error occurred:\n\n{exception?.Message}\n\nThe application will now exit."; + System.Windows.MessageBox.Show(errorMessage, "Critical Error", + MessageBoxButton.OK, MessageBoxImage.Error); + } + + private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) + { + this.ReportUnhandledException(e.Exception, "Application.DispatcherUnhandledException", LogLevel.Error); + + if (Interlocked.CompareExchange(ref this.uiExceptionDialogOpen, 1, 0) != 0) + { + e.Handled = true; + return; + } + + if (DateTime.UtcNow - this.lastUiExceptionDialogUtc < TimeSpan.FromSeconds(2)) + { + e.Handled = true; + Interlocked.Exchange(ref this.uiExceptionDialogOpen, 0); + return; + } + + this.lastUiExceptionDialogUtc = DateTime.UtcNow; + + var errorMessage = $"An error occurred in the user interface:\n\n{e.Exception.Message}\n\nDo you want to continue?"; + var result = System.Windows.MessageBox.Show(errorMessage, "UI Error", + MessageBoxButton.YesNo, MessageBoxImage.Error); + + if (result == MessageBoxResult.Yes) + { + e.Handled = true; // Continue running + } + else + { + e.Handled = false; // Let the application crash + } + + Interlocked.Exchange(ref this.uiExceptionDialogOpen, 0); + } + + private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) + { + var exception = e.Exception.Flatten(); + this.ReportUnhandledException(exception, "TaskScheduler.UnobservedTaskException", LogLevel.Error); + e.SetObserved(); + } + + private void ReportUnhandledException(Exception exception, string source, LogLevel level) + { + var logger = this.ServiceProvider?.GetService>(); + if (level == LogLevel.Critical) + { + logger?.LogCritical(exception, "Unhandled exception in {Source}", source); + } + else + { + logger?.LogError(exception, "Unhandled exception in {Source}", source); + } + + var enhancedLogger = this.ServiceProvider?.GetService(); + if (enhancedLogger == null) + { + return; + } + + var errorCode = exception is ThreadPilotException typedException + ? typedException.ErrorCode.ToString() + : ErrorCode.Unhandled.ToString(); + + var context = new Dictionary + { + ["Source"] = source, + [LogProperties.ErrorCode] = errorCode, + [LogProperties.CorrelationId] = enhancedLogger.GetCurrentCorrelationId() ?? "N/A", + ["IsTerminatingLevel"] = level == LogLevel.Critical, + }; + + TaskSafety.FireAndForget( + enhancedLogger.LogErrorAsync(exception, source, context), + logFailure => logger?.LogWarning(logFailure, "Failed to persist unhandled exception report")); + } + } +} diff --git a/AssemblyInfo.cs b/AssemblyInfo.cs index 8a7b86f..f5ca8da 100644 --- a/AssemblyInfo.cs +++ b/AssemblyInfo.cs @@ -1,30 +1,14 @@ -/* - * 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 . - */ -using System.Runtime.CompilerServices; -using System.Windows; - -[assembly: InternalsVisibleTo("ThreadPilot.Core.Tests")] - -[assembly: ThemeInfo( - ResourceDictionaryLocation.None, // where theme specific resource dictionaries are located - // (used if a resource is not found in the page, - // or application resource dictionaries) - ResourceDictionaryLocation.SourceAssembly) // where the generic resource dictionary is located - // (used if a resource is not found in the page, - // app, or any theme specific resource dictionaries) -] - +using System.Runtime.CompilerServices; +using System.Windows; + +[assembly: InternalsVisibleTo("ThreadPilot.Core.Tests")] + +[assembly: ThemeInfo( + ResourceDictionaryLocation.None, // where theme specific resource dictionaries are located + // (used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly) // where the generic resource dictionary is located + // (used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +] + diff --git a/Converters/BoolToColorConverter.cs b/Converters/BoolToColorConverter.cs index 3c64eb3..4538402 100644 --- a/Converters/BoolToColorConverter.cs +++ b/Converters/BoolToColorConverter.cs @@ -1,57 +1,41 @@ -/* - * 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 -{ - using System; - using System.Globalization; - using System.Windows; - using System.Windows.Data; - using System.Windows.Media; - - public class BoolToColorConverter : IValueConverter - { - public static readonly BoolToColorConverter Instance = new(); - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is bool boolValue) - { - return boolValue - ? ResolveBrush("TextFillColorPrimaryBrush", System.Windows.Media.Brushes.Black) - : ResolveBrush("TextFillColorSecondaryBrush", System.Windows.Media.Brushes.Gray); - } - - return ResolveBrush("TextFillColorSecondaryBrush", System.Windows.Media.Brushes.Gray); - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - - private static System.Windows.Media.Brush ResolveBrush(string key, System.Windows.Media.Brush fallback) - { - if (System.Windows.Application.Current?.TryFindResource(key) is System.Windows.Media.Brush brush) - { - return brush; - } - - return fallback; - } - } -} - +namespace ThreadPilot +{ + using System; + using System.Globalization; + using System.Windows; + using System.Windows.Data; + using System.Windows.Media; + + public class BoolToColorConverter : IValueConverter + { + public static readonly BoolToColorConverter Instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is bool boolValue) + { + return boolValue + ? ResolveBrush("TextFillColorPrimaryBrush", System.Windows.Media.Brushes.Black) + : ResolveBrush("TextFillColorSecondaryBrush", System.Windows.Media.Brushes.Gray); + } + + return ResolveBrush("TextFillColorSecondaryBrush", System.Windows.Media.Brushes.Gray); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + + private static System.Windows.Media.Brush ResolveBrush(string key, System.Windows.Media.Brush fallback) + { + if (System.Windows.Application.Current?.TryFindResource(key) is System.Windows.Media.Brush brush) + { + return brush; + } + + return fallback; + } + } +} + diff --git a/Converters/BoolToFontWeightConverter.cs b/Converters/BoolToFontWeightConverter.cs index 70fda90..9d51a88 100644 --- a/Converters/BoolToFontWeightConverter.cs +++ b/Converters/BoolToFontWeightConverter.cs @@ -1,43 +1,27 @@ -/* - * 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 -{ - using System; - using System.Globalization; - using System.Windows; - using System.Windows.Data; - - public class BoolToFontWeightConverter : IValueConverter - { - public static readonly BoolToFontWeightConverter Instance = new(); - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is bool boolValue && boolValue) - { - return FontWeights.Bold; - } - return FontWeights.Normal; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} - +namespace ThreadPilot +{ + using System; + using System.Globalization; + using System.Windows; + using System.Windows.Data; + + public class BoolToFontWeightConverter : IValueConverter + { + public static readonly BoolToFontWeightConverter Instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is bool boolValue && boolValue) + { + return FontWeights.Bold; + } + return FontWeights.Normal; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} + diff --git a/Converters/BoolToStringConverter.cs b/Converters/BoolToStringConverter.cs index 50f5350..36d41eb 100644 --- a/Converters/BoolToStringConverter.cs +++ b/Converters/BoolToStringConverter.cs @@ -1,48 +1,29 @@ -/* - * 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.Converters -{ - using System; - using System.Globalization; - using System.Windows.Data; - - /// - /// Converts boolean values to strings based on parameter format. - /// - public class BoolToStringConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is bool boolValue && parameter is string paramString) - { - var parts = paramString.Split('|'); - if (parts.Length == 2) - { - return boolValue ? parts[0] : parts[1]; - } - } - - return value?.ToString() ?? string.Empty; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} - +namespace ThreadPilot.Converters +{ + using System; + using System.Globalization; + using System.Windows.Data; + + public class BoolToStringConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is bool boolValue && parameter is string paramString) + { + var parts = paramString.Split('|'); + if (parts.Length == 2) + { + return boolValue ? parts[0] : parts[1]; + } + } + + return value?.ToString() ?? string.Empty; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} + diff --git a/Converters/BoolToVisibilityConverter.cs b/Converters/BoolToVisibilityConverter.cs index a2c7a4b..c5effd1 100644 --- a/Converters/BoolToVisibilityConverter.cs +++ b/Converters/BoolToVisibilityConverter.cs @@ -1,47 +1,31 @@ -/* - * 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 -{ - using System; - using System.Globalization; - using System.Windows; - using System.Windows.Data; - - public class BoolToVisibilityConverter : IValueConverter - { - public static readonly BoolToVisibilityConverter Instance = new(); - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is bool boolValue && boolValue) - { - return Visibility.Visible; - } - return Visibility.Collapsed; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is Visibility visibility) - { - return visibility == Visibility.Visible; - } - return false; - } - } -} - +namespace ThreadPilot +{ + using System; + using System.Globalization; + using System.Windows; + using System.Windows.Data; + + public class BoolToVisibilityConverter : IValueConverter + { + public static readonly BoolToVisibilityConverter Instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is bool boolValue && boolValue) + { + return Visibility.Visible; + } + return Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is Visibility visibility) + { + return visibility == Visibility.Visible; + } + return false; + } + } +} + diff --git a/Converters/BytesToStringConverter.cs b/Converters/BytesToStringConverter.cs index 0fe952f..f7e8f9b 100644 --- a/Converters/BytesToStringConverter.cs +++ b/Converters/BytesToStringConverter.cs @@ -1,64 +1,45 @@ -/* - * 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.Converters -{ - using System; - using System.Globalization; - using System.Windows.Data; - - /// - /// Converts byte values to human-readable string format. - /// - public class BytesToStringConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is long bytes) - { - return FormatBytes(bytes); - } - - if (value is int intBytes) - { - return FormatBytes(intBytes); - } - - return "0 B"; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - - private static string FormatBytes(long bytes) - { - string[] suffixes = { "B", "KB", "MB", "GB", "TB" }; - int counter = 0; - decimal number = bytes; - - while (Math.Round(number / 1024) >= 1) - { - number /= 1024; - counter++; - } - - return $"{number:n1} {suffixes[counter]}"; - } - } -} - +namespace ThreadPilot.Converters +{ + using System; + using System.Globalization; + using System.Windows.Data; + + public class BytesToStringConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is long bytes) + { + return FormatBytes(bytes); + } + + if (value is int intBytes) + { + return FormatBytes(intBytes); + } + + return "0 B"; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + + private static string FormatBytes(long bytes) + { + string[] suffixes = { "B", "KB", "MB", "GB", "TB" }; + int counter = 0; + decimal number = bytes; + + while (Math.Round(number / 1024) >= 1) + { + number /= 1024; + counter++; + } + + return $"{number:n1} {suffixes[counter]}"; + } + } +} + diff --git a/Converters/CpuTopologyConverters.cs b/Converters/CpuTopologyConverters.cs index 0cd88af..3ea0821 100644 --- a/Converters/CpuTopologyConverters.cs +++ b/Converters/CpuTopologyConverters.cs @@ -1,212 +1,178 @@ -/* - * 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.Converters -{ - using System; - using System.Globalization; - using System.Windows; - using System.Windows.Data; - using System.Windows.Media; - using ThreadPilot.Models; - - /// - /// Converter for CPU core type to color. - /// - public class CoreTypeToColorConverter : IMultiValueConverter - { - public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) - { - if (values.Length < 2) - { - return ResolveBrush("TextFillColorPrimaryBrush", System.Windows.Media.Brushes.Black); - } - - var coreType = values[0] as CpuCoreType? ?? CpuCoreType.Unknown; - var isHyperThreaded = values[1] as bool? ?? false; - - return coreType switch - { - CpuCoreType.PerformanceCore => isHyperThreaded - ? ResolveBrush("SystemAccentColorSecondaryBrush", System.Windows.Media.Brushes.DodgerBlue) - : ResolveBrush("SystemAccentColorPrimaryBrush", System.Windows.Media.Brushes.Blue), - CpuCoreType.EfficiencyCore => isHyperThreaded - ? ResolveBrush("TextFillColorSecondaryBrush", System.Windows.Media.Brushes.DarkGray) - : ResolveBrush("TextFillColorPrimaryBrush", System.Windows.Media.Brushes.Black), - CpuCoreType.Zen or CpuCoreType.ZenPlus or CpuCoreType.Zen2 or CpuCoreType.Zen3 or CpuCoreType.Zen4 => - isHyperThreaded - ? ResolveBrush("SystemAccentColorSecondaryBrush", System.Windows.Media.Brushes.DarkOrange) - : ResolveBrush("SystemAccentColorPrimaryBrush", System.Windows.Media.Brushes.Orange), - _ => ResolveBrush("TextFillColorSecondaryBrush", System.Windows.Media.Brushes.Gray), - }; - } - - public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - - private static System.Windows.Media.Brush ResolveBrush(string key, System.Windows.Media.Brush fallback) - { - if (System.Windows.Application.Current?.TryFindResource(key) is System.Windows.Media.Brush brush) - { - return brush; - } - - return fallback; - } - } - - /// - /// Converter for boolean to color (success/failure indication). - /// - public class BoolToColorConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is bool success) - { - return success - ? ResolveBrush("TextFillColorPrimaryBrush", System.Windows.Media.Brushes.Black) - : ResolveBrush("TextFillColorSecondaryBrush", System.Windows.Media.Brushes.Gray); - } - return ResolveBrush("TextFillColorSecondaryBrush", System.Windows.Media.Brushes.Gray); - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - - private static System.Windows.Media.Brush ResolveBrush(string key, System.Windows.Media.Brush fallback) - { - if (System.Windows.Application.Current?.TryFindResource(key) is System.Windows.Media.Brush brush) - { - return brush; - } - - return fallback; - } - } - - /// - /// Converter for boolean to visibility. - /// - public class BoolToVisibilityConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is bool visible) - { - return visible ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; - } - return System.Windows.Visibility.Collapsed; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } - - /// - /// Converter for affinity mask to readable string. - /// - public class AffinityMaskConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is long mask) - { - if (mask == 0) - { - return "None"; - } - - var cores = new System.Collections.Generic.List(); - for (int i = 0; i < 64; i++) - { - if ((mask & (1L << i)) != 0) - { - cores.Add(i); - } - } - - if (cores.Count == 0) - { - return "None"; - } - - if (cores.Count == 1) - { - return $"Core {cores[0]}"; - } - - if (cores.Count <= 4) - { - return $"Cores {string.Join(", ", cores)}"; - } - - return $"Cores {cores[0]}-{cores[cores.Count - 1]} ({cores.Count} cores)"; - } - return "Unknown"; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } - - /// - /// Converter for bytes to megabytes. - /// - public class BytesToMbConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is long bytes) - { - return (bytes / (1024.0 * 1024.0)).ToString("F1"); - } - return "0.0"; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } - - /// - /// Converter for string to visibility (empty/null = collapsed). - /// - public class StringToVisibilityConverter : IValueConverter - { - public static readonly StringToVisibilityConverter Instance = new(); - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - return string.IsNullOrEmpty(value as string) ? System.Windows.Visibility.Collapsed : System.Windows.Visibility.Visible; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} - +namespace ThreadPilot.Converters +{ + using System; + using System.Globalization; + using System.Windows; + using System.Windows.Data; + using System.Windows.Media; + using ThreadPilot.Models; + + public class CoreTypeToColorConverter : IMultiValueConverter + { + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + if (values.Length < 2) + { + return ResolveBrush("TextFillColorPrimaryBrush", System.Windows.Media.Brushes.Black); + } + + var coreType = values[0] as CpuCoreType? ?? CpuCoreType.Unknown; + var isHyperThreaded = values[1] as bool? ?? false; + + return coreType switch + { + CpuCoreType.PerformanceCore => isHyperThreaded + ? ResolveBrush("SystemAccentColorSecondaryBrush", System.Windows.Media.Brushes.DodgerBlue) + : ResolveBrush("SystemAccentColorPrimaryBrush", System.Windows.Media.Brushes.Blue), + CpuCoreType.EfficiencyCore => isHyperThreaded + ? ResolveBrush("TextFillColorSecondaryBrush", System.Windows.Media.Brushes.DarkGray) + : ResolveBrush("TextFillColorPrimaryBrush", System.Windows.Media.Brushes.Black), + CpuCoreType.Zen or CpuCoreType.ZenPlus or CpuCoreType.Zen2 or CpuCoreType.Zen3 or CpuCoreType.Zen4 => + isHyperThreaded + ? ResolveBrush("SystemAccentColorSecondaryBrush", System.Windows.Media.Brushes.DarkOrange) + : ResolveBrush("SystemAccentColorPrimaryBrush", System.Windows.Media.Brushes.Orange), + _ => ResolveBrush("TextFillColorSecondaryBrush", System.Windows.Media.Brushes.Gray), + }; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + + private static System.Windows.Media.Brush ResolveBrush(string key, System.Windows.Media.Brush fallback) + { + if (System.Windows.Application.Current?.TryFindResource(key) is System.Windows.Media.Brush brush) + { + return brush; + } + + return fallback; + } + } + + public class BoolToColorConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is bool success) + { + return success + ? ResolveBrush("TextFillColorPrimaryBrush", System.Windows.Media.Brushes.Black) + : ResolveBrush("TextFillColorSecondaryBrush", System.Windows.Media.Brushes.Gray); + } + return ResolveBrush("TextFillColorSecondaryBrush", System.Windows.Media.Brushes.Gray); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + + private static System.Windows.Media.Brush ResolveBrush(string key, System.Windows.Media.Brush fallback) + { + if (System.Windows.Application.Current?.TryFindResource(key) is System.Windows.Media.Brush brush) + { + return brush; + } + + return fallback; + } + } + + public class BoolToVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is bool visible) + { + return visible ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + } + return System.Windows.Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } + + public class AffinityMaskConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is long mask) + { + if (mask == 0) + { + return "None"; + } + + var cores = new System.Collections.Generic.List(); + for (int i = 0; i < 64; i++) + { + if ((mask & (1L << i)) != 0) + { + cores.Add(i); + } + } + + if (cores.Count == 0) + { + return "None"; + } + + if (cores.Count == 1) + { + return $"Core {cores[0]}"; + } + + if (cores.Count <= 4) + { + return $"Cores {string.Join(", ", cores)}"; + } + + return $"Cores {cores[0]}-{cores[cores.Count - 1]} ({cores.Count} cores)"; + } + return "Unknown"; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } + + public class BytesToMbConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is long bytes) + { + return (bytes / (1024.0 * 1024.0)).ToString("F1"); + } + return "0.0"; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } + + public class StringToVisibilityConverter : IValueConverter + { + public static readonly StringToVisibilityConverter Instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return string.IsNullOrEmpty(value as string) ? System.Windows.Visibility.Collapsed : System.Windows.Visibility.Visible; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} + diff --git a/Converters/InverseBooleanConverter.cs b/Converters/InverseBooleanConverter.cs index 8578c25..e9fba2d 100644 --- a/Converters/InverseBooleanConverter.cs +++ b/Converters/InverseBooleanConverter.cs @@ -1,49 +1,30 @@ -/* - * 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.Converters -{ - using System; - using System.Globalization; - using System.Windows.Data; - - /// - /// Converter to invert boolean values. - /// - public class InverseBooleanConverter : IValueConverter - { - public static readonly InverseBooleanConverter Instance = new(); - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is bool boolValue) - { - return !boolValue; - } - return true; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is bool boolValue) - { - return !boolValue; - } - return false; - } - } -} - +namespace ThreadPilot.Converters +{ + using System; + using System.Globalization; + using System.Windows.Data; + + public class InverseBooleanConverter : IValueConverter + { + public static readonly InverseBooleanConverter Instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is bool boolValue) + { + return !boolValue; + } + return true; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is bool boolValue) + { + return !boolValue; + } + return false; + } + } +} + diff --git a/Converters/ItemIndexConverter.cs b/Converters/ItemIndexConverter.cs index 657a1d6..8982551 100644 --- a/Converters/ItemIndexConverter.cs +++ b/Converters/ItemIndexConverter.cs @@ -1,51 +1,32 @@ -/* - * 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.Converters -{ - using System; - using System.Globalization; - using System.Windows; - using System.Windows.Controls; - using System.Windows.Data; - - /// - /// Converter to get the index of an item in an ItemsControl. - /// - public class ItemIndexConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is DependencyObject item) - { - var itemsControl = ItemsControl.ItemsControlFromItemContainer(item); - if (itemsControl != null) - { - int index = itemsControl.ItemContainerGenerator.IndexFromContainer(item); - return index; - } - } - - return -1; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} - +namespace ThreadPilot.Converters +{ + using System; + using System.Globalization; + using System.Windows; + using System.Windows.Controls; + using System.Windows.Data; + + public class ItemIndexConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is DependencyObject item) + { + var itemsControl = ItemsControl.ItemsControlFromItemContainer(item); + if (itemsControl != null) + { + int index = itemsControl.ItemContainerGenerator.IndexFromContainer(item); + return index; + } + } + + return -1; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} + diff --git a/Converters/NullToBoolConverter.cs b/Converters/NullToBoolConverter.cs index 60b2ca4..283c95f 100644 --- a/Converters/NullToBoolConverter.cs +++ b/Converters/NullToBoolConverter.cs @@ -1,35 +1,19 @@ -/* - * 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.Converters -{ - using System; - using System.Globalization; - using System.Windows.Data; - - public class NullToBoolConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - return value != null; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} +namespace ThreadPilot.Converters +{ + using System; + using System.Globalization; + using System.Windows.Data; + + public class NullToBoolConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value != null; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/Helpers/AffinityHelper.cs b/Helpers/AffinityHelper.cs index 3ce3ffd..8024f4a 100644 --- a/Helpers/AffinityHelper.cs +++ b/Helpers/AffinityHelper.cs @@ -1,41 +1,25 @@ -/* - * 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.Helpers -{ - using System.Collections.Generic; - using System.Linq; - using System.Windows.Controls; - - public static class AffinityHelper - { - public static long CalculateAffinityMask(IEnumerable cpuCheckboxes) - { - return cpuCheckboxes - .Where(cb => cb.IsChecked == true) - .Sum(cb => (long)cb.Tag); - } - - public static void UpdateCheckboxesFromMask(IEnumerable cpuCheckboxes, long affinityMask) - { - foreach (var checkbox in cpuCheckboxes) - { - var cpuBit = (long)checkbox.Tag; - checkbox.IsChecked = (affinityMask & cpuBit) != 0; - } - } - } -} +namespace ThreadPilot.Helpers +{ + using System.Collections.Generic; + using System.Linq; + using System.Windows.Controls; + + public static class AffinityHelper + { + public static long CalculateAffinityMask(IEnumerable cpuCheckboxes) + { + return cpuCheckboxes + .Where(cb => cb.IsChecked == true) + .Sum(cb => (long)cb.Tag); + } + + public static void UpdateCheckboxesFromMask(IEnumerable cpuCheckboxes, long affinityMask) + { + foreach (var checkbox in cpuCheckboxes) + { + var cpuBit = (long)checkbox.Tag; + checkbox.IsChecked = (affinityMask & cpuBit) != 0; + } + } + } +} diff --git a/Helpers/Converters.cs b/Helpers/Converters.cs index f9d72de..d633a12 100644 --- a/Helpers/Converters.cs +++ b/Helpers/Converters.cs @@ -1,122 +1,106 @@ -/* - * 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.Helpers -{ - using System; - using System.Globalization; - using System.Windows; - using System.Windows.Data; - - public class BytesToMbConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is long bytes) - { - return Math.Round((double)bytes / (1024 * 1024), 1); - } - return 0; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } - - public class AffinityMaskConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is long mask) - { - var selectedIndices = new System.Collections.Generic.List(); - // Dynamic core count based on system, limited to 64 (long is 64-bit) - int maxCores = Math.Min(64, Environment.ProcessorCount); - - for (int i = 0; i < maxCores; i++) - { - if ((mask & (1L << i)) != 0) - { - selectedIndices.Add(i); - } - } - - if (selectedIndices.Count == 0) - { - return "None"; - } - - // Build the display string - var indicesStr = string.Join(", ", selectedIndices); - int selectedCount = selectedIndices.Count; - - // Detect if this is likely physical cores only (every other logical processor) - // This heuristic checks if selected indices are evenly spaced by 2 (e.g., 0,2,4,6,8...) - bool isProbablyPhysicalCoresOnly = false; - if (selectedCount > 1 && selectedCount <= maxCores / 2) - { - isProbablyPhysicalCoresOnly = true; - for (int i = 1; i < selectedIndices.Count; i++) - { - if (selectedIndices[i] - selectedIndices[i - 1] != 2) - { - isProbablyPhysicalCoresOnly = false; - break; - } - } - } - - // Choose terminology based on what's selected - string label; - if (selectedCount == maxCores) - { - label = $"All threads (0-{maxCores - 1})"; - } - else if (isProbablyPhysicalCoresOnly && selectedIndices[0] == 0) - { - label = $"Physical cores ({indicesStr}) - {selectedCount} cores"; - } - else - { - label = $"Threads ({indicesStr}) - {selectedCount} threads"; - } - - return label; - } - return "Unknown"; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } - - public class BoolToFontWeightConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - return (bool)value ? FontWeights.Bold : FontWeights.Normal; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} +namespace ThreadPilot.Helpers +{ + using System; + using System.Globalization; + using System.Windows; + using System.Windows.Data; + + public class BytesToMbConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is long bytes) + { + return Math.Round((double)bytes / (1024 * 1024), 1); + } + return 0; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } + + public class AffinityMaskConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is long mask) + { + var selectedIndices = new System.Collections.Generic.List(); + // Dynamic core count based on system, limited to 64 (long is 64-bit) + int maxCores = Math.Min(64, Environment.ProcessorCount); + + for (int i = 0; i < maxCores; i++) + { + if ((mask & (1L << i)) != 0) + { + selectedIndices.Add(i); + } + } + + if (selectedIndices.Count == 0) + { + return "None"; + } + + // Build the display string + var indicesStr = string.Join(", ", selectedIndices); + int selectedCount = selectedIndices.Count; + + // Detect if this is likely physical cores only (every other logical processor) + // This heuristic checks if selected indices are evenly spaced by 2 (e.g., 0,2,4,6,8...) + bool isProbablyPhysicalCoresOnly = false; + if (selectedCount > 1 && selectedCount <= maxCores / 2) + { + isProbablyPhysicalCoresOnly = true; + for (int i = 1; i < selectedIndices.Count; i++) + { + if (selectedIndices[i] - selectedIndices[i - 1] != 2) + { + isProbablyPhysicalCoresOnly = false; + break; + } + } + } + + // Choose terminology based on what's selected + string label; + if (selectedCount == maxCores) + { + label = $"All threads (0-{maxCores - 1})"; + } + else if (isProbablyPhysicalCoresOnly && selectedIndices[0] == 0) + { + label = $"Physical cores ({indicesStr}) - {selectedCount} cores"; + } + else + { + label = $"Threads ({indicesStr}) - {selectedCount} threads"; + } + + return label; + } + return "Unknown"; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } + + public class BoolToFontWeightConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return (bool)value ? FontWeights.Bold : FontWeights.Normal; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/Helpers/DwmHelper.cs b/Helpers/DwmHelper.cs index 2552b79..acc7480 100644 --- a/Helpers/DwmHelper.cs +++ b/Helpers/DwmHelper.cs @@ -1,56 +1,34 @@ -/* - * 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.Helpers -{ - using System; - using System.Runtime.InteropServices; - using System.Windows; - using System.Windows.Interop; - - /// - /// Desktop Window Manager helper methods. - /// - public static class DwmHelper - { - private const int DwmUseImmersiveDarkMode = 20; - private const int DwmUseImmersiveDarkModeLegacy = 19; - - [DllImport("dwmapi.dll")] - private static extern int DwmSetWindowAttribute(IntPtr hwnd, int dwAttribute, ref int pvAttribute, int cbAttribute); - - /// - /// Applies dark/light title-bar styling through DWM attributes. - /// - public static void ApplyWindowCaptionTheme(Window window, bool useDarkTheme) - { - ArgumentNullException.ThrowIfNull(window); - - var windowHandle = new WindowInteropHelper(window).Handle; - if (windowHandle == IntPtr.Zero) - { - return; - } - - var darkMode = useDarkTheme ? 1 : 0; - var result = DwmSetWindowAttribute(windowHandle, DwmUseImmersiveDarkMode, ref darkMode, Marshal.SizeOf()); - if (result != 0) - { - _ = DwmSetWindowAttribute(windowHandle, DwmUseImmersiveDarkModeLegacy, ref darkMode, Marshal.SizeOf()); - } - } - } -} +namespace ThreadPilot.Helpers +{ + using System; + using System.Runtime.InteropServices; + using System.Windows; + using System.Windows.Interop; + + public static class DwmHelper + { + private const int DwmUseImmersiveDarkMode = 20; + private const int DwmUseImmersiveDarkModeLegacy = 19; + + [DllImport("dwmapi.dll")] + private static extern int DwmSetWindowAttribute(IntPtr hwnd, int dwAttribute, ref int pvAttribute, int cbAttribute); + + public static void ApplyWindowCaptionTheme(Window window, bool useDarkTheme) + { + ArgumentNullException.ThrowIfNull(window); + + var windowHandle = new WindowInteropHelper(window).Handle; + if (windowHandle == IntPtr.Zero) + { + return; + } + + var darkMode = useDarkTheme ? 1 : 0; + var result = DwmSetWindowAttribute(windowHandle, DwmUseImmersiveDarkMode, ref darkMode, Marshal.SizeOf()); + if (result != 0) + { + _ = DwmSetWindowAttribute(windowHandle, DwmUseImmersiveDarkModeLegacy, ref darkMode, Marshal.SizeOf()); + } + } + } +} diff --git a/Helpers/NavigationBehavior.cs b/Helpers/NavigationBehavior.cs index 30a347a..525d786 100644 --- a/Helpers/NavigationBehavior.cs +++ b/Helpers/NavigationBehavior.cs @@ -1,101 +1,73 @@ -/* - * 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.Helpers -{ - using System; - using System.Threading; - using System.Threading.Tasks; - using System.Windows; - using ThreadPilot.ViewModels; - - /// - /// Coordinates serialized navigation transitions and unsaved-changes policy. - /// - public sealed class NavigationBehavior : IDisposable - { - private readonly SemaphoreSlim navigationGuard = new(1, 1); - private bool isHandlingNavigation; - - /// - /// Tries entering the navigation critical section. - /// - public async Task TryEnterAsync() - { - if (this.isHandlingNavigation) - { - return false; - } - - await this.navigationGuard.WaitAsync().ConfigureAwait(false); - this.isHandlingNavigation = true; - return true; - } - - /// - /// Leaves the navigation critical section. - /// - public void Exit() - { - this.isHandlingNavigation = false; - this.navigationGuard.Release(); - } - - /// - /// Applies unsaved-settings policy before navigating away from the settings section. - /// - public static async Task EnsureCanNavigateAsync( - string targetTag, - SettingsViewModel settingsViewModel, - Func>? showUnsavedSettingsPromptAsync = null) - { - ArgumentNullException.ThrowIfNull(targetTag); - ArgumentNullException.ThrowIfNull(settingsViewModel); - - if (!settingsViewModel.HasPendingChanges || string.Equals(targetTag, "Settings", StringComparison.Ordinal)) - { - return true; - } - - var result = showUnsavedSettingsPromptAsync != null - ? await showUnsavedSettingsPromptAsync().ConfigureAwait(false) - : MessageBox.Show( - "You have unsaved changes in Settings.\n\nChoose an action:\n- Yes: Save changes\n- No: Discard changes\n- Cancel: Stay on current tab", - "Unsaved Settings", - MessageBoxButton.YesNoCancel, - MessageBoxImage.Warning); - - return result switch - { - MessageBoxResult.Cancel => false, - MessageBoxResult.Yes => await settingsViewModel.SaveIfDirtyAsync().ConfigureAwait(false), - MessageBoxResult.No => await DiscardPendingChangesAsync(settingsViewModel).ConfigureAwait(false), - _ => true, - }; - } - - public void Dispose() - { - this.navigationGuard.Dispose(); - } - - private static async Task DiscardPendingChangesAsync(SettingsViewModel settingsViewModel) - { - await settingsViewModel.DiscardPendingChangesAsync().ConfigureAwait(false); - return true; - } - } -} +namespace ThreadPilot.Helpers +{ + using System; + using System.Threading; + using System.Threading.Tasks; + using System.Windows; + using ThreadPilot.ViewModels; + + public sealed class NavigationBehavior : IDisposable + { + private readonly SemaphoreSlim navigationGuard = new(1, 1); + private bool isHandlingNavigation; + + public async Task TryEnterAsync() + { + if (this.isHandlingNavigation) + { + return false; + } + + await this.navigationGuard.WaitAsync().ConfigureAwait(false); + this.isHandlingNavigation = true; + return true; + } + + public void Exit() + { + this.isHandlingNavigation = false; + this.navigationGuard.Release(); + } + + public static async Task EnsureCanNavigateAsync( + string targetTag, + SettingsViewModel settingsViewModel, + Func>? showUnsavedSettingsPromptAsync = null) + { + ArgumentNullException.ThrowIfNull(targetTag); + ArgumentNullException.ThrowIfNull(settingsViewModel); + + if (!settingsViewModel.HasPendingChanges || string.Equals(targetTag, "Settings", StringComparison.Ordinal)) + { + return true; + } + + var result = showUnsavedSettingsPromptAsync != null + ? await showUnsavedSettingsPromptAsync().ConfigureAwait(false) + : MessageBox.Show( + "You have unsaved changes in Settings.\n\nChoose an action:\n- Yes: Save changes\n- No: Discard changes\n- Cancel: Stay on current tab", + "Unsaved Settings", + MessageBoxButton.YesNoCancel, + MessageBoxImage.Warning); + + return result switch + { + MessageBoxResult.Cancel => false, + MessageBoxResult.Yes => await settingsViewModel.SaveIfDirtyAsync().ConfigureAwait(false), + MessageBoxResult.No => await DiscardPendingChangesAsync(settingsViewModel).ConfigureAwait(false), + _ => true, + }; + } + + public void Dispose() + { + this.navigationGuard.Dispose(); + } + + private static async Task DiscardPendingChangesAsync(SettingsViewModel settingsViewModel) + { + await settingsViewModel.DiscardPendingChangesAsync().ConfigureAwait(false); + return true; + } + } +} diff --git a/Helpers/ServiceProviderExtensions.cs b/Helpers/ServiceProviderExtensions.cs index 102c923..4c17c04 100644 --- a/Helpers/ServiceProviderExtensions.cs +++ b/Helpers/ServiceProviderExtensions.cs @@ -1,32 +1,16 @@ -/* - * 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.Helpers -{ - using System; - using Microsoft.Extensions.DependencyInjection; - - public static class ServiceProviderExtensions - { - public static IServiceProvider Services => ((App)App.Current).ServiceProvider; - - public static T? GetService() - where T : class - { - return Services.GetService(typeof(T)) as T; - } - } -} +namespace ThreadPilot.Helpers +{ + using System; + using Microsoft.Extensions.DependencyInjection; + + public static class ServiceProviderExtensions + { + public static IServiceProvider Services => ((App)App.Current).ServiceProvider; + + public static T? GetService() + where T : class + { + return Services.GetService(typeof(T)) as T; + } + } +} diff --git a/Helpers/StartupMinimizedSuggestionPolicy.cs b/Helpers/StartupMinimizedSuggestionPolicy.cs index 8036392..6364070 100644 --- a/Helpers/StartupMinimizedSuggestionPolicy.cs +++ b/Helpers/StartupMinimizedSuggestionPolicy.cs @@ -1,17 +1,17 @@ -namespace ThreadPilot.Helpers -{ - using System; - using ThreadPilot.Models; - - public static class StartupMinimizedSuggestionPolicy - { - public static bool ShouldShow(ApplicationSettingsModel settings, StartupWindowBehavior behavior) - { - ArgumentNullException.ThrowIfNull(settings); - - return behavior.ShouldShowWindow - && !settings.StartMinimized - && !settings.HasSeenStartupMinimizedSuggestion; - } - } -} +namespace ThreadPilot.Helpers +{ + using System; + using ThreadPilot.Models; + + public static class StartupMinimizedSuggestionPolicy + { + public static bool ShouldShow(ApplicationSettingsModel settings, StartupWindowBehavior behavior) + { + ArgumentNullException.ThrowIfNull(settings); + + return behavior.ShouldShowWindow + && !settings.StartMinimized + && !settings.HasSeenStartupMinimizedSuggestion; + } + } +} diff --git a/Helpers/StartupWindowBehavior.cs b/Helpers/StartupWindowBehavior.cs index 642b66a..1021880 100644 --- a/Helpers/StartupWindowBehavior.cs +++ b/Helpers/StartupWindowBehavior.cs @@ -1,46 +1,46 @@ -namespace ThreadPilot.Helpers -{ - using System.Windows; - - public readonly record struct StartupWindowBehavior( - bool ShouldShowWindow, - bool ShowInTaskbar, - Visibility Visibility, - WindowState WindowState, - bool HideAfterShow, - bool ActivateAfterShow) - { - public static StartupWindowBehavior Resolve(bool isAutostart, bool startMinimized) - { - if (isAutostart && startMinimized) - { - return new StartupWindowBehavior( - ShouldShowWindow: false, - ShowInTaskbar: false, - Visibility: Visibility.Hidden, - WindowState: WindowState.Minimized, - HideAfterShow: false, - ActivateAfterShow: false); - } - - if (startMinimized) - { - return new StartupWindowBehavior( - ShouldShowWindow: false, - ShowInTaskbar: false, - Visibility: Visibility.Hidden, - WindowState: WindowState.Minimized, - HideAfterShow: false, - ActivateAfterShow: false); - } - - return new StartupWindowBehavior( - ShouldShowWindow: true, - ShowInTaskbar: true, - Visibility: Visibility.Visible, - WindowState: WindowState.Normal, - HideAfterShow: false, - ActivateAfterShow: true); - } - } -} +namespace ThreadPilot.Helpers +{ + using System.Windows; + + public readonly record struct StartupWindowBehavior( + bool ShouldShowWindow, + bool ShowInTaskbar, + Visibility Visibility, + WindowState WindowState, + bool HideAfterShow, + bool ActivateAfterShow) + { + public static StartupWindowBehavior Resolve(bool isAutostart, bool startMinimized) + { + if (isAutostart && startMinimized) + { + return new StartupWindowBehavior( + ShouldShowWindow: false, + ShowInTaskbar: false, + Visibility: Visibility.Hidden, + WindowState: WindowState.Minimized, + HideAfterShow: false, + ActivateAfterShow: false); + } + + if (startMinimized) + { + return new StartupWindowBehavior( + ShouldShowWindow: false, + ShowInTaskbar: false, + Visibility: Visibility.Hidden, + WindowState: WindowState.Minimized, + HideAfterShow: false, + ActivateAfterShow: false); + } + + return new StartupWindowBehavior( + ShouldShowWindow: true, + ShowInTaskbar: true, + Visibility: Visibility.Visible, + WindowState: WindowState.Normal, + HideAfterShow: false, + ActivateAfterShow: true); + } + } +} diff --git a/Helpers/WindowPlacementHelper.cs b/Helpers/WindowPlacementHelper.cs index 175ea49..335ed0e 100644 --- a/Helpers/WindowPlacementHelper.cs +++ b/Helpers/WindowPlacementHelper.cs @@ -1,295 +1,295 @@ -namespace ThreadPilot.Helpers -{ - using System; - using System.Collections.Generic; - using System.Linq; - using System.Windows; - using System.Windows.Forms; - using System.Windows.Media; - using DrawingRectangle = System.Drawing.Rectangle; - using WpfPoint = System.Windows.Point; - - public readonly record struct WindowBounds(double Left, double Top, double Width, double Height) - { - public double Right => this.Left + this.Width; - - public double Bottom => this.Top + this.Height; - - public double Area => Math.Max(0, this.Width) * Math.Max(0, this.Height); - } - - public readonly record struct MonitorWorkingArea(double Left, double Top, double Width, double Height, bool IsPrimary) - { - public double Right => this.Left + this.Width; - - public double Bottom => this.Top + this.Height; - - public double Area => Math.Max(0, this.Width) * Math.Max(0, this.Height); - } - - public readonly record struct WindowPlacementCorrection(WindowBounds Bounds, bool WasCorrected); - - public static class WindowPlacementHelper - { - private const double DefaultWindowWidth = 1280; - private const double DefaultWindowHeight = 864; - private const double MinimumVisibleAreaRatio = 0.25; - private const double Epsilon = 0.001; - - public static WindowPlacementCorrection CorrectWindowBounds( - WindowBounds currentBounds, - IReadOnlyCollection workingAreas) - { - var validWorkingAreas = workingAreas - .Where(IsValidWorkingArea) - .ToArray(); - - if (validWorkingAreas.Length == 0) - { - var fallbackBounds = new WindowBounds( - IsFinite(currentBounds.Left) ? currentBounds.Left : 0, - IsFinite(currentBounds.Top) ? currentBounds.Top : 0, - ResolveDimension(currentBounds.Width, DefaultWindowWidth), - ResolveDimension(currentBounds.Height, DefaultWindowHeight)); - - return new WindowPlacementCorrection(fallbackBounds, !BoundsAreEquivalent(currentBounds, fallbackBounds)); - } - - var targetArea = SelectTargetWorkingArea(currentBounds, validWorkingAreas); - var correctedWidth = Math.Min(ResolveDimension(currentBounds.Width, DefaultWindowWidth), targetArea.Width); - var correctedHeight = Math.Min(ResolveDimension(currentBounds.Height, DefaultWindowHeight), targetArea.Height); - var sanitizedBounds = new WindowBounds( - currentBounds.Left, - currentBounds.Top, - correctedWidth, - correctedHeight); - - var hasSufficientIntersection = HasSufficientIntersection(sanitizedBounds, targetArea); - double correctedLeft; - double correctedTop; - - if (!hasSufficientIntersection || !IsFinite(sanitizedBounds.Left) || !IsFinite(sanitizedBounds.Top)) - { - correctedLeft = targetArea.Left + ((targetArea.Width - correctedWidth) / 2); - correctedTop = targetArea.Top + ((targetArea.Height - correctedHeight) / 2); - } - else - { - correctedLeft = Clamp(sanitizedBounds.Left, targetArea.Left, targetArea.Right - correctedWidth); - correctedTop = Clamp(sanitizedBounds.Top, targetArea.Top, targetArea.Bottom - correctedHeight); - } - - var correctedBounds = new WindowBounds( - Math.Round(correctedLeft), - Math.Round(correctedTop), - Math.Round(correctedWidth), - Math.Round(correctedHeight)); - - return new WindowPlacementCorrection(correctedBounds, !BoundsAreEquivalent(currentBounds, correctedBounds)); - } - - public static bool TryCorrectWindowPlacement(Window window) - { - ArgumentNullException.ThrowIfNull(window); - - try - { - if (window.WindowState == WindowState.Maximized) - { - return false; - } - - var workingAreas = GetWorkingAreasInWindowDips(window); - var currentBounds = new WindowBounds( - window.Left, - window.Top, - ResolveWindowDimension(window.ActualWidth, window.Width, DefaultWindowWidth), - ResolveWindowDimension(window.ActualHeight, window.Height, DefaultWindowHeight)); - - var correction = CorrectWindowBounds(currentBounds, workingAreas); - if (!correction.WasCorrected) - { - return false; - } - - window.Width = correction.Bounds.Width; - window.Height = correction.Bounds.Height; - window.Left = correction.Bounds.Left; - window.Top = correction.Bounds.Top; - return true; - } - catch - { - return false; - } - } - - private static MonitorWorkingArea[] GetWorkingAreasInWindowDips(Window window) - { - var transformFromDevice = GetTransformFromDevice(window); - var screens = Screen.AllScreens; - if (screens.Length == 0) - { - return new[] - { - new MonitorWorkingArea( - SystemParameters.WorkArea.Left, - SystemParameters.WorkArea.Top, - SystemParameters.WorkArea.Width, - SystemParameters.WorkArea.Height, - true), - }; - } - - return screens - .Select(screen => ConvertWorkingAreaToDips(screen.WorkingArea, screen.Primary, transformFromDevice)) - .Where(IsValidWorkingArea) - .ToArray(); - } - - private static Matrix GetTransformFromDevice(Window window) - { - try - { - var source = PresentationSource.FromVisual(window); - return source?.CompositionTarget?.TransformFromDevice ?? Matrix.Identity; - } - catch - { - return Matrix.Identity; - } - } - - private static MonitorWorkingArea ConvertWorkingAreaToDips( - DrawingRectangle workingArea, - bool isPrimary, - Matrix transformFromDevice) - { - var topLeft = transformFromDevice.Transform(new WpfPoint(workingArea.Left, workingArea.Top)); - var bottomRight = transformFromDevice.Transform(new WpfPoint(workingArea.Right, workingArea.Bottom)); - - return new MonitorWorkingArea( - topLeft.X, - topLeft.Y, - bottomRight.X - topLeft.X, - bottomRight.Y - topLeft.Y, - isPrimary); - } - - private static MonitorWorkingArea SelectTargetWorkingArea( - WindowBounds currentBounds, - IReadOnlyList workingAreas) - { - var bestIntersection = workingAreas - .Select(area => new - { - Area = area, - IntersectionArea = GetIntersectionArea(currentBounds, area), - }) - .OrderByDescending(candidate => candidate.IntersectionArea) - .ThenByDescending(candidate => candidate.Area.IsPrimary) - .First(); - - if (bestIntersection.IntersectionArea > 0 && HasSufficientIntersection(currentBounds, bestIntersection.Area)) - { - return bestIntersection.Area; - } - - if (!IsFinite(currentBounds.Left) || !IsFinite(currentBounds.Top)) - { - return workingAreas.FirstOrDefault(area => area.IsPrimary, workingAreas[0]); - } - - var centerX = currentBounds.Left + (ResolveDimension(currentBounds.Width, DefaultWindowWidth) / 2); - var centerY = currentBounds.Top + (ResolveDimension(currentBounds.Height, DefaultWindowHeight) / 2); - - return workingAreas - .OrderBy(area => GetSquaredDistanceToArea(centerX, centerY, area)) - .ThenByDescending(area => area.IsPrimary) - .First(); - } - - private static double GetSquaredDistanceToArea(double x, double y, MonitorWorkingArea area) - { - var nearestX = Clamp(x, area.Left, area.Right); - var nearestY = Clamp(y, area.Top, area.Bottom); - var deltaX = x - nearestX; - var deltaY = y - nearestY; - - return (deltaX * deltaX) + (deltaY * deltaY); - } - - private static bool HasSufficientIntersection(WindowBounds bounds, MonitorWorkingArea area) - { - var intersectionArea = GetIntersectionArea(bounds, area); - var boundedWindowArea = Math.Max(1, Math.Min(bounds.Width, area.Width) * Math.Min(bounds.Height, area.Height)); - - return intersectionArea / boundedWindowArea >= MinimumVisibleAreaRatio; - } - - private static double GetIntersectionArea(WindowBounds bounds, MonitorWorkingArea area) - { - if (!IsFinite(bounds.Left) || !IsFinite(bounds.Top) || !IsFinite(bounds.Width) || !IsFinite(bounds.Height)) - { - return 0; - } - - var left = Math.Max(bounds.Left, area.Left); - var top = Math.Max(bounds.Top, area.Top); - var right = Math.Min(bounds.Right, area.Right); - var bottom = Math.Min(bounds.Bottom, area.Bottom); - var width = Math.Max(0, right - left); - var height = Math.Max(0, bottom - top); - - return width * height; - } - - private static bool IsValidWorkingArea(MonitorWorkingArea workingArea) - { - return IsFinite(workingArea.Left) - && IsFinite(workingArea.Top) - && IsFinite(workingArea.Width) - && IsFinite(workingArea.Height) - && workingArea.Width > 0 - && workingArea.Height > 0; - } - - private static double ResolveWindowDimension(double actualValue, double configuredValue, double fallback) - { - if (IsFinite(actualValue) && actualValue > 0) - { - return actualValue; - } - - return ResolveDimension(configuredValue, fallback); - } - - private static double ResolveDimension(double value, double fallback) - { - return IsFinite(value) && value > 0 ? value : fallback; - } - - private static double Clamp(double value, double minimum, double maximum) - { - if (maximum < minimum) - { - return minimum; - } - - return Math.Min(Math.Max(value, minimum), maximum); - } - - private static bool IsFinite(double value) - { - return !double.IsNaN(value) && !double.IsInfinity(value); - } - - private static bool BoundsAreEquivalent(WindowBounds left, WindowBounds right) - { - return Math.Abs(left.Left - right.Left) < Epsilon - && Math.Abs(left.Top - right.Top) < Epsilon - && Math.Abs(left.Width - right.Width) < Epsilon - && Math.Abs(left.Height - right.Height) < Epsilon; - } - } -} +namespace ThreadPilot.Helpers +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Windows; + using System.Windows.Forms; + using System.Windows.Media; + using DrawingRectangle = System.Drawing.Rectangle; + using WpfPoint = System.Windows.Point; + + public readonly record struct WindowBounds(double Left, double Top, double Width, double Height) + { + public double Right => this.Left + this.Width; + + public double Bottom => this.Top + this.Height; + + public double Area => Math.Max(0, this.Width) * Math.Max(0, this.Height); + } + + public readonly record struct MonitorWorkingArea(double Left, double Top, double Width, double Height, bool IsPrimary) + { + public double Right => this.Left + this.Width; + + public double Bottom => this.Top + this.Height; + + public double Area => Math.Max(0, this.Width) * Math.Max(0, this.Height); + } + + public readonly record struct WindowPlacementCorrection(WindowBounds Bounds, bool WasCorrected); + + public static class WindowPlacementHelper + { + private const double DefaultWindowWidth = 1280; + private const double DefaultWindowHeight = 864; + private const double MinimumVisibleAreaRatio = 0.25; + private const double Epsilon = 0.001; + + public static WindowPlacementCorrection CorrectWindowBounds( + WindowBounds currentBounds, + IReadOnlyCollection workingAreas) + { + var validWorkingAreas = workingAreas + .Where(IsValidWorkingArea) + .ToArray(); + + if (validWorkingAreas.Length == 0) + { + var fallbackBounds = new WindowBounds( + IsFinite(currentBounds.Left) ? currentBounds.Left : 0, + IsFinite(currentBounds.Top) ? currentBounds.Top : 0, + ResolveDimension(currentBounds.Width, DefaultWindowWidth), + ResolveDimension(currentBounds.Height, DefaultWindowHeight)); + + return new WindowPlacementCorrection(fallbackBounds, !BoundsAreEquivalent(currentBounds, fallbackBounds)); + } + + var targetArea = SelectTargetWorkingArea(currentBounds, validWorkingAreas); + var correctedWidth = Math.Min(ResolveDimension(currentBounds.Width, DefaultWindowWidth), targetArea.Width); + var correctedHeight = Math.Min(ResolveDimension(currentBounds.Height, DefaultWindowHeight), targetArea.Height); + var sanitizedBounds = new WindowBounds( + currentBounds.Left, + currentBounds.Top, + correctedWidth, + correctedHeight); + + var hasSufficientIntersection = HasSufficientIntersection(sanitizedBounds, targetArea); + double correctedLeft; + double correctedTop; + + if (!hasSufficientIntersection || !IsFinite(sanitizedBounds.Left) || !IsFinite(sanitizedBounds.Top)) + { + correctedLeft = targetArea.Left + ((targetArea.Width - correctedWidth) / 2); + correctedTop = targetArea.Top + ((targetArea.Height - correctedHeight) / 2); + } + else + { + correctedLeft = Clamp(sanitizedBounds.Left, targetArea.Left, targetArea.Right - correctedWidth); + correctedTop = Clamp(sanitizedBounds.Top, targetArea.Top, targetArea.Bottom - correctedHeight); + } + + var correctedBounds = new WindowBounds( + Math.Round(correctedLeft), + Math.Round(correctedTop), + Math.Round(correctedWidth), + Math.Round(correctedHeight)); + + return new WindowPlacementCorrection(correctedBounds, !BoundsAreEquivalent(currentBounds, correctedBounds)); + } + + public static bool TryCorrectWindowPlacement(Window window) + { + ArgumentNullException.ThrowIfNull(window); + + try + { + if (window.WindowState == WindowState.Maximized) + { + return false; + } + + var workingAreas = GetWorkingAreasInWindowDips(window); + var currentBounds = new WindowBounds( + window.Left, + window.Top, + ResolveWindowDimension(window.ActualWidth, window.Width, DefaultWindowWidth), + ResolveWindowDimension(window.ActualHeight, window.Height, DefaultWindowHeight)); + + var correction = CorrectWindowBounds(currentBounds, workingAreas); + if (!correction.WasCorrected) + { + return false; + } + + window.Width = correction.Bounds.Width; + window.Height = correction.Bounds.Height; + window.Left = correction.Bounds.Left; + window.Top = correction.Bounds.Top; + return true; + } + catch + { + return false; + } + } + + private static MonitorWorkingArea[] GetWorkingAreasInWindowDips(Window window) + { + var transformFromDevice = GetTransformFromDevice(window); + var screens = Screen.AllScreens; + if (screens.Length == 0) + { + return new[] + { + new MonitorWorkingArea( + SystemParameters.WorkArea.Left, + SystemParameters.WorkArea.Top, + SystemParameters.WorkArea.Width, + SystemParameters.WorkArea.Height, + true), + }; + } + + return screens + .Select(screen => ConvertWorkingAreaToDips(screen.WorkingArea, screen.Primary, transformFromDevice)) + .Where(IsValidWorkingArea) + .ToArray(); + } + + private static Matrix GetTransformFromDevice(Window window) + { + try + { + var source = PresentationSource.FromVisual(window); + return source?.CompositionTarget?.TransformFromDevice ?? Matrix.Identity; + } + catch + { + return Matrix.Identity; + } + } + + private static MonitorWorkingArea ConvertWorkingAreaToDips( + DrawingRectangle workingArea, + bool isPrimary, + Matrix transformFromDevice) + { + var topLeft = transformFromDevice.Transform(new WpfPoint(workingArea.Left, workingArea.Top)); + var bottomRight = transformFromDevice.Transform(new WpfPoint(workingArea.Right, workingArea.Bottom)); + + return new MonitorWorkingArea( + topLeft.X, + topLeft.Y, + bottomRight.X - topLeft.X, + bottomRight.Y - topLeft.Y, + isPrimary); + } + + private static MonitorWorkingArea SelectTargetWorkingArea( + WindowBounds currentBounds, + IReadOnlyList workingAreas) + { + var bestIntersection = workingAreas + .Select(area => new + { + Area = area, + IntersectionArea = GetIntersectionArea(currentBounds, area), + }) + .OrderByDescending(candidate => candidate.IntersectionArea) + .ThenByDescending(candidate => candidate.Area.IsPrimary) + .First(); + + if (bestIntersection.IntersectionArea > 0 && HasSufficientIntersection(currentBounds, bestIntersection.Area)) + { + return bestIntersection.Area; + } + + if (!IsFinite(currentBounds.Left) || !IsFinite(currentBounds.Top)) + { + return workingAreas.FirstOrDefault(area => area.IsPrimary, workingAreas[0]); + } + + var centerX = currentBounds.Left + (ResolveDimension(currentBounds.Width, DefaultWindowWidth) / 2); + var centerY = currentBounds.Top + (ResolveDimension(currentBounds.Height, DefaultWindowHeight) / 2); + + return workingAreas + .OrderBy(area => GetSquaredDistanceToArea(centerX, centerY, area)) + .ThenByDescending(area => area.IsPrimary) + .First(); + } + + private static double GetSquaredDistanceToArea(double x, double y, MonitorWorkingArea area) + { + var nearestX = Clamp(x, area.Left, area.Right); + var nearestY = Clamp(y, area.Top, area.Bottom); + var deltaX = x - nearestX; + var deltaY = y - nearestY; + + return (deltaX * deltaX) + (deltaY * deltaY); + } + + private static bool HasSufficientIntersection(WindowBounds bounds, MonitorWorkingArea area) + { + var intersectionArea = GetIntersectionArea(bounds, area); + var boundedWindowArea = Math.Max(1, Math.Min(bounds.Width, area.Width) * Math.Min(bounds.Height, area.Height)); + + return intersectionArea / boundedWindowArea >= MinimumVisibleAreaRatio; + } + + private static double GetIntersectionArea(WindowBounds bounds, MonitorWorkingArea area) + { + if (!IsFinite(bounds.Left) || !IsFinite(bounds.Top) || !IsFinite(bounds.Width) || !IsFinite(bounds.Height)) + { + return 0; + } + + var left = Math.Max(bounds.Left, area.Left); + var top = Math.Max(bounds.Top, area.Top); + var right = Math.Min(bounds.Right, area.Right); + var bottom = Math.Min(bounds.Bottom, area.Bottom); + var width = Math.Max(0, right - left); + var height = Math.Max(0, bottom - top); + + return width * height; + } + + private static bool IsValidWorkingArea(MonitorWorkingArea workingArea) + { + return IsFinite(workingArea.Left) + && IsFinite(workingArea.Top) + && IsFinite(workingArea.Width) + && IsFinite(workingArea.Height) + && workingArea.Width > 0 + && workingArea.Height > 0; + } + + private static double ResolveWindowDimension(double actualValue, double configuredValue, double fallback) + { + if (IsFinite(actualValue) && actualValue > 0) + { + return actualValue; + } + + return ResolveDimension(configuredValue, fallback); + } + + private static double ResolveDimension(double value, double fallback) + { + return IsFinite(value) && value > 0 ? value : fallback; + } + + private static double Clamp(double value, double minimum, double maximum) + { + if (maximum < minimum) + { + return minimum; + } + + return Math.Min(Math.Max(value, minimum), maximum); + } + + private static bool IsFinite(double value) + { + return !double.IsNaN(value) && !double.IsInfinity(value); + } + + private static bool BoundsAreEquivalent(WindowBounds left, WindowBounds right) + { + return Math.Abs(left.Left - right.Left) < Epsilon + && Math.Abs(left.Top - right.Top) < Epsilon + && Math.Abs(left.Width - right.Width) < Epsilon + && Math.Abs(left.Height - right.Height) < Epsilon; + } + } +} diff --git a/MainWindow.Behaviors.partial.cs b/MainWindow.Behaviors.partial.cs index 1c857ee..0ad177e 100644 --- a/MainWindow.Behaviors.partial.cs +++ b/MainWindow.Behaviors.partial.cs @@ -1,2045 +1,2025 @@ -/* - * 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 -{ - using System; - using System.Collections.Generic; - using System.IO; - using System.Linq; - using System.Threading; - using System.Threading.Tasks; - using System.Timers; - using System.Windows; - using System.Windows.Controls; - using System.Windows.Input; - using System.Windows.Media.Animation; - using System.Windows.Media.Effects; - using System.Windows.Media.Imaging; - using Microsoft.Extensions.DependencyInjection; - using ThreadPilot.Helpers; - using ThreadPilot.Models; - using ThreadPilot.Services; - using ThreadPilot.ViewModels; - using ThreadPilot.Views; - - public partial class MainWindow : Wpf.Ui.Controls.FluentWindow - { - private void SetDataContexts() - { - // Set DataContext for the main window - this.DataContext = this.mainWindowViewModel; - - // Set DataContext for the power plans view - this.PowerPlanViewControl.DataContext = this.powerPlanViewModel; - - // Set DataContext for the association view - this.AssociationView.DataContext = this.associationViewModel; - - // Set DataContext for the log viewer view - this.LogViewerViewControl.DataContext = this.logViewerViewModel; - - // Set DataContext for the system tweaks view - this.SystemTweaksView.DataContext = this.systemTweaksViewModel; - - // Set DataContext for the settings view - this.SettingsView.DataContext = this.settingsViewModel; - } - - private void InitializeLoadingOverlay() - { - try - { - var loadingOverlay = this.FindName("LoadingOverlay") as Grid; - - // Ensure overlay is visible while initialization runs - if (loadingOverlay != null) - { - loadingOverlay.Visibility = this.isSilentStartupMode ? Visibility.Collapsed : Visibility.Visible; - loadingOverlay.Opacity = this.isSilentStartupMode ? 0 : 1; - } - - if (!this.isSilentStartupMode) - { - this.ApplyUIContentBlur(15); - } - - // Start spinner animation if available - var spinnerAnimation = this.FindResource("SpinnerAnimation") as Storyboard; - if (!this.isSilentStartupMode) - { - spinnerAnimation?.Begin(); - } - - // Set a timeout guard for initialization - this.initializationTimeoutTimer = new System.Timers.Timer(15000) - { - AutoReset = false, - }; - this.initializationTimeoutTimer.Elapsed += this.OnInitializationTimeout; - this.initializationTimeoutTimer.Start(); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Failed to initialize loading overlay: {ex.Message}"); - } - } - - private async Task InitializeApplicationAsync() - { - try - { - this.LogDebug("=== Starting InitializeApplicationAsync ==="); - - await this.Dispatcher.InvokeAsync(() => this.UpdateLoadingStatus("Loading view models...", "Loading process, power plan and rules data.")); - this.LogDebug("About to call LoadViewModelsAsync..."); - await this.LoadViewModelsAsync(); - this.LogDebug("LoadViewModelsAsync completed successfully"); - this.CompleteInitializationTask("ViewModels"); - - this.LogDebug("About to initialize MainWindowViewModel..."); - await this.mainWindowViewModel.InitializeAsync(); - this.LogDebug("MainWindowViewModel initialized successfully"); - this.CompleteInitializationTask("MainWindowViewModel"); - - await this.Dispatcher.InvokeAsync(() => this.UpdateLoadingStatus("Initializing services...", "Starting monitoring, tray and notification services.")); - this.LogDebug("About to call InitializeServicesAsync..."); - await this.InitializeServicesAsync(); - this.LogDebug("InitializeServicesAsync completed successfully"); - this.CompleteInitializationTask("Services"); - this.QueueStartupUpdateCheck(); - - await this.Dispatcher.InvokeAsync(() => this.UpdateLoadingStatus("Finalizing startup...", "Applying final UI state and startup checks.")); - this.LogDebug("Finalizing startup..."); - await Task.Delay(500); // Brief delay to show final status - this.CompleteInitializationTask("Finalization"); - - // All initialization complete - this.LogDebug("All initialization complete, hiding overlay..."); - await this.Dispatcher.InvokeAsync(() => this.HideLoadingOverlay()); - this.LogDebug("=== InitializeApplicationAsync completed successfully ==="); - } - catch (Exception ex) - { - this.LogDebug($"=== ERROR in InitializeApplicationAsync: {ex} ==="); - await this.Dispatcher.InvokeAsync(() => this.ShowInitializationError(ex)); - } - } - - private void QueueStartupUpdateCheck() - { - if (Interlocked.Exchange(ref this.startupUpdateCheckStarted, 1) != 0) - { - return; - } - - TaskSafety.FireAndForget(this.CheckForUpdatesAtStartupAsync(), ex => - { - this.LogDebug($"Startup update check failed: {ex.Message}"); - }); - } - - private async Task CheckForUpdatesAtStartupAsync() - { - try - { - this.LogDebug("Startup update check started"); - var updateService = this.serviceProvider.GetRequiredService(); - var result = await updateService.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup)); - - if (result.Status == UpdateCheckStatus.Skipped) - { - this.LogDebug($"Startup update check skipped: {result.Message}"); - return; - } - - if (!result.IsUpdateAvailable || result.Release == null) - { - this.LogDebug($"Startup update check complete: {result.Message}"); - return; - } - - await this.notificationService.ShowNotificationAsync( - "Update available", - $"ThreadPilot {result.Release.Version} is available. Open Settings to download and install it.", - NotificationType.Information); - this.LogDebug($"Startup update check found update: installed {result.CurrentVersion}, latest {result.Release.Version}"); - } - catch (Exception ex) - { - this.LogDebug($"Startup update check ignored failure: {ex.Message}"); - } - } - - private static Version GetCurrentApplicationVersion() - { - var rawVersion = typeof(App).Assembly - .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false) - .OfType() - .FirstOrDefault()? - .InformationalVersion - ?? typeof(App).Assembly.GetName().Version?.ToString() - ?? "0.0.0"; - - var sanitized = rawVersion.Trim(); - if (sanitized.StartsWith("v", StringComparison.OrdinalIgnoreCase)) - { - sanitized = sanitized[1..]; - } - - sanitized = sanitized.Split('-', '+')[0]; - - return Version.TryParse(sanitized, out var version) - ? version - : new Version(0, 0, 0); - } - - private void UpdateLoadingStatus(string stage, string details = "") - { - if (this.mainWindowViewModel != null) - { - this.mainWindowViewModel.InitializationStage = stage; - this.mainWindowViewModel.InitializationDetails = details; - } - } - - private void CompleteInitializationTask(string taskName) - { - lock (this.initializationLock) - { - this.initializationTasks.Add(taskName); - System.Diagnostics.Debug.WriteLine($"Initialization task completed: {taskName}"); - } - } - - private void HideLoadingOverlay() - { - try - { - System.Diagnostics.Debug.WriteLine("=== Starting HideLoadingOverlay ==="); - this.isInitializationComplete = true; - this.initializationTimeoutTimer?.Stop(); - this.initializationTimeoutTimer?.Dispose(); - - if (this.isSilentStartupMode) - { - var silentLoadingOverlay = this.FindName("LoadingOverlay") as Grid; - if (silentLoadingOverlay != null) - { - silentLoadingOverlay.Visibility = Visibility.Collapsed; - silentLoadingOverlay.Opacity = 0; - } - - this.ClearUIContentBlur(); - this.ApplyAppRefreshPolicy(AppActivityState.TrayHidden); - return; - } - - // Stop spinner animation - var spinnerAnimation = this.FindResource("SpinnerAnimation") as Storyboard; - spinnerAnimation?.Stop(); - System.Diagnostics.Debug.WriteLine("Spinner animation stopped"); - - // Start fade-out animation - var fadeOutAnimation = this.FindResource("FadeOutAnimation") as Storyboard; - if (fadeOutAnimation != null) - { - System.Diagnostics.Debug.WriteLine("Starting fade-out animation"); - fadeOutAnimation.Completed += (s, e) => - { - System.Diagnostics.Debug.WriteLine("Fade-out animation completed, hiding overlay"); - var loadingOverlay = this.FindName("LoadingOverlay") as Grid; - if (loadingOverlay != null) - { - loadingOverlay.Visibility = Visibility.Collapsed; - System.Diagnostics.Debug.WriteLine("Loading overlay visibility set to Collapsed"); - } - - // Disable app content blur and restore style-driven behavior. - this.ClearUIContentBlur(); - System.Diagnostics.Debug.WriteLine("=== Loading overlay hidden successfully ==="); - - // Show elevation warning if needed - this.TryShowElevationWarning(); - this.TryShowStartupMinimizedSuggestion(); - }; - fadeOutAnimation.Begin(); - } - else - { - System.Diagnostics.Debug.WriteLine("WARNING: FadeOutAnimation not found, hiding overlay immediately"); - // Fallback: hide overlay immediately if animation fails - var loadingOverlay = this.FindName("LoadingOverlay") as Grid; - if (loadingOverlay != null) - { - loadingOverlay.Visibility = Visibility.Collapsed; - } - - this.ClearUIContentBlur(); - System.Diagnostics.Debug.WriteLine("=== Loading overlay hidden immediately (fallback) ==="); - - // Show elevation warning if needed - this.TryShowElevationWarning(); - this.TryShowStartupMinimizedSuggestion(); - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"=== ERROR hiding loading overlay: {ex} ==="); - // Emergency fallback: hide overlay without animation - try - { - var loadingOverlay = this.FindName("LoadingOverlay") as Grid; - if (loadingOverlay != null) - { - loadingOverlay.Visibility = Visibility.Collapsed; - } - - this.ClearUIContentBlur(); - System.Diagnostics.Debug.WriteLine("Emergency fallback: overlay hidden without animation"); - - // Show elevation warning if needed - this.TryShowElevationWarning(); - this.TryShowStartupMinimizedSuggestion(); - } - catch (Exception fallbackEx) - { - System.Diagnostics.Debug.WriteLine($"Emergency fallback also failed: {fallbackEx}"); - } - } - } - - private void ApplyUIContentBlur(double radius) - { - if (this.UIContent.Effect is not BlurEffect blur) - { - blur = new BlurEffect(); - this.UIContent.Effect = blur; - } - - blur.KernelType = KernelType.Gaussian; - blur.Radius = radius; - } - - private void ClearUIContentBlur() - { - this.UIContent.Effect = null; - } - - private void OnInitializationTimeout(object? sender, ElapsedEventArgs e) - { - this.Dispatcher.InvokeAsync(() => - { - if (!this.isInitializationComplete) - { - this.ShowInitializationError(new TimeoutException("Application initialization timed out after 15 seconds")); - } - }); - } - - private void ShowInitializationError(Exception ex) - { - try - { - this.UpdateLoadingStatus("Initialization failed", ex.Message); - - var result = System.Windows.MessageBox.Show( - $"ThreadPilot failed to initialize properly:\n\n{ex.Message}\n\nDebug log: {this.debugLogPath}\n\nWould you like to retry initialization or close the application?", - "Initialization Error", - MessageBoxButton.YesNo, - MessageBoxImage.Error); - - if (result == MessageBoxResult.Yes) - { - // Retry initialization - marshal to UI thread to prevent cross-thread access exceptions - this.isInitializationComplete = false; - this.initializationTasks.Clear(); - this.UpdateLoadingStatus("Retrying initialization...", "Restarting startup sequence."); - this.LogDebug("=== RETRYING INITIALIZATION ==="); - _ = this.Dispatcher.InvokeAsync(async () => await this.InitializeApplicationAsync()); - } - else - { - // Close application - this.LogDebug("User chose to close application"); - System.Windows.Application.Current.Shutdown(); - } - } - catch (Exception overlayEx) - { - this.LogDebug($"Error showing initialization error: {overlayEx.Message}"); - System.Windows.Application.Current.Shutdown(); - } - } - - private async Task LoadViewModelsAsync() - { - try - { - this.LogDebug("=== Starting LoadViewModelsAsync ==="); - - this.LogDebug("About to initialize ProcessViewModel (including CPU topology)..."); - try - { - // Use the full initialization method instead of just LoadProcesses - var processTask = this.processViewModel.InitializeAsync(); - var processResult = await Task.WhenAny(processTask, Task.Delay(15000)); // 15 second timeout for full initialization - if (processResult != processTask) - { - this.LogDebug("ProcessViewModel.InitializeAsync() timed out, trying fallback..."); - // Fallback: just load processes without full initialization - await this.processViewModel.LoadProcesses(); - this.LogDebug($"ProcessViewModel fallback (LoadProcesses only) completed, process count: {this.processViewModel.Processes?.Count ?? 0}, filtered count: {this.processViewModel.FilteredProcesses?.Count ?? 0}"); - } - else - { - await processTask; // Ensure we get any exceptions - this.LogDebug($"ProcessViewModel initialized successfully (including CPU topology), process count: {this.processViewModel.Processes?.Count ?? 0}, filtered count: {this.processViewModel.FilteredProcesses?.Count ?? 0}"); - } - } - catch (Exception processEx) - { - this.LogDebug($"ProcessViewModel initialization failed: {processEx.Message}, trying fallback..."); - // Fallback: just load processes without full initialization - await this.processViewModel.LoadProcesses(); - this.LogDebug($"ProcessViewModel fallback (LoadProcesses only) completed after exception, process count: {this.processViewModel.Processes?.Count ?? 0}, filtered count: {this.processViewModel.FilteredProcesses?.Count ?? 0}"); - } - - this.LogDebug("About to load PowerPlanViewModel..."); - var powerPlanTask = this.powerPlanViewModel.LoadPowerPlans(); - var powerPlanResult = await Task.WhenAny(powerPlanTask, Task.Delay(5000)); // 5 second timeout - if (powerPlanResult != powerPlanTask) - { - throw new TimeoutException("PowerPlanViewModel.LoadPowerPlans() timed out after 5 seconds"); - } - await powerPlanTask; // Ensure we get any exceptions - this.LogDebug("PowerPlanViewModel loaded successfully"); - - this.LogDebug("Skipping optional diagnostics initialization until the diagnostics page is opened."); - - this.LogDebug("About to load SystemTweaksViewModel..."); - var systemTweaksTask = this.systemTweaksViewModel.LoadCommand.ExecuteAsync(null); - var systemTweaksResult = await Task.WhenAny(systemTweaksTask, Task.Delay(5000)); // 5 second timeout - if (systemTweaksResult != systemTweaksTask) - { - throw new TimeoutException("SystemTweaksViewModel.LoadCommand.ExecuteAsync() timed out after 5 seconds"); - } - await systemTweaksTask; // Ensure we get any exceptions - this.LogDebug("SystemTweaksViewModel loaded successfully"); - - // Initialize keyboard shortcuts after window is loaded - this.Loaded += this.OnWindowLoaded; - this.LogDebug("Keyboard shortcuts event handler attached"); - - // The association view model loads its data automatically in its constructor - this.LogDebug("=== LoadViewModelsAsync completed successfully ==="); - } - catch (Exception ex) - { - this.LogDebug($"=== ERROR in LoadViewModelsAsync: {ex} ==="); - throw; // Re-throw to be handled by initialization error handler - } - } - - private async Task InitializeServicesAsync() - { - this.LogDebug("=== Starting InitializeServicesAsync ==="); - - this.LogDebug("About to initialize settings..."); - await this.InitializeSettingsAsync(); - this.LogDebug("Settings initialized successfully"); - - this.LogDebug("About to initialize system tray..."); - try - { - var systemTrayTask = this.InitializeSystemTrayAsync(); - var systemTrayResult = await Task.WhenAny(systemTrayTask, Task.Delay(5000)); // 5 second timeout - if (systemTrayResult != systemTrayTask) - { - this.LogDebug("System tray initialization timed out, continuing with basic tray setup..."); - // Initialize basic system tray without context menu updates (Initialize() is idempotent) - await this.InitializeBasicSystemTrayAsync(); - this.LogDebug("Basic system tray initialized (without context menu)"); - } - else - { - await systemTrayTask; // Ensure we get any exceptions - this.LogDebug("System tray initialized successfully"); - } - } - catch (Exception systemTrayEx) - { - this.LogDebug($"System tray initialization failed: {systemTrayEx.Message}, using basic tray..."); - // Fallback: basic system tray initialization - try - { - await this.InitializeBasicSystemTrayAsync(); - this.LogDebug("Fallback system tray initialized"); - } - catch (Exception fallbackEx) - { - this.LogDebug($"Even fallback system tray failed: {fallbackEx.Message}"); - } - } - - this.LogDebug("About to initialize notifications..."); - this.InitializeNotifications(); - this.LogDebug("Notifications initialized successfully"); - - this.LogDebug("About to initialize monitoring..."); - await this.InitializeMonitoringAsync(); - this.LogDebug("Monitoring initialized successfully"); - - if (this.skipProcessMonitoringDuringStartup) - { - this.LogDebug("Skipping process monitoring manager startup (temporary bypass enabled)"); - } - else - { - this.LogDebug("About to start process monitoring manager..."); - try - { - var monitoringTask = this.StartProcessMonitoringManagerAsync(); - var timeoutTask = Task.Delay(8000); // 8 second timeout - var completedTask = await Task.WhenAny(monitoringTask, timeoutTask); - - if (completedTask == timeoutTask) - { - this.LogDebug("Process monitoring manager startup timed out after 8 seconds, continuing without monitoring..."); - } - else - { - try - { - await monitoringTask; // Ensure we get any exceptions - this.LogDebug("Process monitoring manager started successfully"); - } - catch (Exception taskEx) - { - this.LogDebug($"Process monitoring manager task failed: {taskEx.Message}"); - } - } - } - catch (Exception monitoringEx) - { - this.LogDebug($"Process monitoring manager startup failed: {monitoringEx.Message}, continuing without monitoring..."); - } - } - - this.LogDebug("=== InitializeServicesAsync completed successfully ==="); - } - - private async Task InitializeSettingsAsync() - { - try - { - await this.settingsService.LoadSettingsAsync(); - - // Apply initial settings - var settings = this.settingsService.Settings; - var useDarkTheme = settings.HasUserThemePreference - ? settings.UseDarkTheme - : this.themeService.GetSystemUsesDarkTheme(); - - if (!settings.HasUserThemePreference && settings.UseDarkTheme != useDarkTheme) - { - settings.UseDarkTheme = useDarkTheme; - await this.settingsService.UpdateSettingsAsync(settings); - } - - this.themeService.ApplyTheme(useDarkTheme); - this.mainWindowViewModel.IsDarkTheme = useDarkTheme; - DwmHelper.ApplyWindowCaptionTheme(this, useDarkTheme); - - if (settings.StartMinimized) - { - this.WindowState = WindowState.Minimized; - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Failed to load settings: {ex.Message}"); - } - } - - private async Task InitializeSystemTrayAsync() - { - try - { - this.systemTrayService.Initialize(); - this.systemTrayService.Show(); - - // Subscribe to tray events - this.UnsubscribeSystemTrayEvents(); - this.systemTrayService.ShowMainWindowRequested += this.OnShowMainWindowRequested; - this.systemTrayService.DashboardRequested += this.OnDashboardRequested; - this.systemTrayService.ExitRequested += this.OnExitRequested; - this.systemTrayService.MonitoringToggleRequested += this.OnMonitoringToggleRequested; - this.systemTrayService.SettingsRequested += this.OnSettingsRequested; - this.systemTrayService.PowerPlanChangeRequested += this.OnPowerPlanChangeRequested; - this.systemTrayService.ProfileApplicationRequested += this.OnProfileApplicationRequested; - this.systemTrayService.PerformanceDashboardRequested += this.OnPerformanceDashboardRequested; - - // Update settings and tooltip - this.systemTrayService.UpdateSettings(this.settingsService.Settings); - this.systemTrayService.ApplyTheme(this.themeService.IsDarkTheme); - this.systemTrayService.UpdateTooltip("ThreadPilot - Process & Power Plan Manager"); - - // Initialize system tray context menu with current data - await this.UpdateSystemTrayContextMenuAsync(); - - // Start periodic system tray updates - this.StartSystemTrayUpdateTimer(); - } - catch (Exception ex) - { - // Log error but don't fail startup - System.Diagnostics.Debug.WriteLine($"Failed to initialize system tray: {ex.Message}"); - } - } - - private async Task InitializeBasicSystemTrayAsync() - { - try - { - this.LogDebug("Initializing basic system tray (without full context menu)..."); - - // Initialize basic tray icon (this is idempotent) - this.systemTrayService.Initialize(); - this.systemTrayService.Show(); - - // Subscribe to essential tray events only - this.UnsubscribeSystemTrayEvents(); - this.systemTrayService.ShowMainWindowRequested += this.OnShowMainWindowRequested; - this.systemTrayService.DashboardRequested += this.OnDashboardRequested; - this.systemTrayService.ExitRequested += this.OnExitRequested; - - // Update basic settings and tooltip - this.systemTrayService.UpdateSettings(this.settingsService.Settings); - this.systemTrayService.ApplyTheme(this.themeService.IsDarkTheme); - this.systemTrayService.UpdateTooltip("ThreadPilot - Process & Power Plan Manager (Basic Mode)"); - - this.LogDebug("Basic system tray initialization completed"); - } - catch (Exception ex) - { - this.LogDebug($"Failed to initialize basic system tray: {ex.Message}"); - throw; - } - } - - private void OnShowMainWindowRequested(object? sender, EventArgs e) - { - this.ShowWindowFromTray(); - } - - private void OnExitRequested(object? sender, EventArgs e) - { - TaskSafety.FireAndForget(this.OnExitRequestedAsync(), ex => - { - this.LogDebug($"OnExitRequested failed: {ex.Message}"); - }); - } - - private async Task OnExitRequestedAsync() - { - await this.PerformGracefulShutdownAsync(); - } - - private void OnDashboardRequested(object? sender, EventArgs e) - { - this.ShowWindowFromTray("Process"); - } - - /// - /// Performs graceful shutdown with cleanup of all applied optimizations - /// Similar to CPU Set Setter's ExitAppGracefully. - /// - private async Task PerformGracefulShutdownAsync(bool validateUnsavedChanges = true) - { - if (this.isPerformingShutdown) - { - return; - } - - if (validateUnsavedChanges && !await this.HandleUnsavedSettingsBeforeExitAsync()) - { - return; - } - - this.isPerformingShutdown = true; - - try - { - this.LogDebug("Starting graceful shutdown..."); - this.selfResourceManagementService.RestoreForegroundMode(); - - // 1. Stop monitoring services - try - { - this.LogDebug("Stopping process monitoring manager..."); - await this.processMonitorManagerService.StopAsync(); - this.LogDebug("Process monitoring manager stopped"); - } - catch (Exception ex) - { - this.LogDebug($"Error stopping process monitoring: {ex.Message}"); - } - - // 2. Cleanup applied CPU masks (like CPU Set Setter's ClearAllProcessMasksNoSave) - if (this.settingsService.Settings.ClearMasksOnClose) - { - try - { - this.LogDebug("Clearing all applied CPU masks..."); - var processService = this.serviceProvider.GetRequiredService(); - await processService.ClearAllAppliedMasksAsync(); - this.LogDebug("CPU masks cleared"); - } - catch (Exception ex) - { - this.LogDebug($"Error clearing CPU masks: {ex.Message}"); - } - - // Also reset priorities - try - { - this.LogDebug("Resetting all process priorities..."); - var processService = this.serviceProvider.GetRequiredService(); - await processService.ResetAllProcessPrioritiesAsync(); - this.LogDebug("Process priorities reset"); - } - catch (Exception ex) - { - this.LogDebug($"Error resetting priorities: {ex.Message}"); - } - } - - // 3. Restore default power plan if configured - if (this.settingsService.Settings.RestoreDefaultPowerPlanOnExit) - { - try - { - var targetDefaultPowerPlanGuid = this.settingsService.Settings.DefaultPowerPlanId; - - try - { - await this.processPowerPlanAssociationService.LoadConfigurationAsync(); - var (associationDefaultPowerPlanGuid, _) = await this.processPowerPlanAssociationService.GetDefaultPowerPlanAsync(); - if (!string.IsNullOrWhiteSpace(associationDefaultPowerPlanGuid)) - { - targetDefaultPowerPlanGuid = associationDefaultPowerPlanGuid; - } - } - catch (Exception associationEx) - { - this.LogDebug($"Could not read default power plan from association config: {associationEx.Message}"); - } - - if (string.IsNullOrWhiteSpace(targetDefaultPowerPlanGuid)) - { - this.LogDebug("No default power plan configured for restore on exit"); - } - else - { - this.LogDebug("Restoring default power plan..."); - var powerPlanService = this.serviceProvider.GetRequiredService(); - await powerPlanService.SetActivePowerPlanByGuidAsync(targetDefaultPowerPlanGuid); - this.LogDebug("Default power plan restored"); - } - } - catch (Exception ex) - { - this.LogDebug($"Error restoring power plan: {ex.Message}"); - } - } - - // 4. Save settings - try - { - this.LogDebug("Saving settings..."); - await this.settingsService.SaveSettingsAsync(); - this.LogDebug("Settings saved"); - } - catch (Exception ex) - { - this.LogDebug($"Error saving settings: {ex.Message}"); - } - - // 5. Dispose tray service - try - { - this.LogDebug("Disposing system tray..."); - this.systemTrayService.Dispose(); - this.LogDebug("System tray disposed"); - } - catch (Exception ex) - { - this.LogDebug($"Error disposing tray: {ex.Message}"); - } - - this.LogDebug("Graceful shutdown completed"); - } - catch (Exception ex) - { - this.LogDebug($"Error during graceful shutdown: {ex.Message}"); - } - finally - { - // Ensure application exits - System.Windows.Application.Current.Shutdown(); - } - } - - private async Task HandleUnsavedSettingsBeforeExitAsync() - { - if (!this.settingsViewModel.HasPendingChanges) - { - return true; - } - - var result = await this.ShowUnsavedSettingsDialogAsync( - "You have unsaved changes in Settings. Save before exiting, discard the changes, or cancel to return to ThreadPilot."); - - if (result == MessageBoxResult.Cancel) - { - return false; - } - - if (result == MessageBoxResult.Yes) - { - var saved = await this.settingsViewModel.SaveIfDirtyAsync(); - return saved; - } - - await this.settingsViewModel.DiscardPendingChangesAsync(); - return true; - } - - private async Task HandleWindowCloseAsync() - { - if (!await this.HandleUnsavedSettingsBeforeExitAsync()) - { - return; - } - - if (this.settingsService.Settings.CloseToTray) - { - this.WindowState = WindowState.Minimized; - return; - } - - await this.PerformGracefulShutdownAsync(validateUnsavedChanges: false); - } - - private void OnMonitoringToggleRequested(object? sender, MonitoringToggleEventArgs e) - { - TaskSafety.FireAndForget(this.OnMonitoringToggleRequestedAsync(e), ex => - { - this.LogDebug($"OnMonitoringToggleRequested failed: {ex.Message}"); - }); - } - - private async Task OnMonitoringToggleRequestedAsync(MonitoringToggleEventArgs e) - { - try - { - if (e.EnableMonitoring) - { - await this.processMonitorManagerService.StartAsync(); - await this.notificationService.ShowSuccessNotificationAsync( - "Automation Monitoring Enabled", - "Process rule automation and power plan management have been enabled."); - } - else - { - await this.processMonitorManagerService.StopAsync(); - await this.notificationService.ShowNotificationAsync( - "Automation Monitoring Disabled", - "Process rule automation and power plan management have been disabled.", - Models.NotificationType.Warning); - } - } - catch (Exception ex) - { - await this.notificationService.ShowErrorNotificationAsync( - "Automation Monitoring Error", - "Failed to toggle automation monitoring.", - ex); - } - } - - private void OnSettingsRequested(object? sender, EventArgs e) - { - try - { - this.ShowWindowFromTray("Settings"); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Failed to open settings: {ex.Message}"); - } - } - - private void OnPowerPlanChangeRequested(object? sender, PowerPlanChangeRequestedEventArgs e) - { - TaskSafety.FireAndForget(this.OnPowerPlanChangeRequestedAsync(e), ex => - { - this.LogDebug($"OnPowerPlanChangeRequested failed: {ex.Message}"); - }); - } - - private async Task OnPowerPlanChangeRequestedAsync(PowerPlanChangeRequestedEventArgs e) - { - try - { - var powerPlanService = this.serviceProvider.GetRequiredService(); - var success = await powerPlanService.SetActivePowerPlanByGuidAsync(e.PowerPlanGuid); - - if (success) - { - this.systemTrayService.ShowBalloonTip( - "ThreadPilot", - $"Power plan changed to {e.PowerPlanName}", 2000); - } - else - { - this.systemTrayService.ShowBalloonTip( - "ThreadPilot Error", - $"Failed to change power plan to {e.PowerPlanName}", 3000); - } - } - catch (Exception ex) - { - this.systemTrayService.ShowBalloonTip( - "ThreadPilot Error", - $"Error changing power plan: {ex.Message}", 3000); - } - } - - private void OnProfileApplicationRequested(object? sender, ProfileApplicationRequestedEventArgs e) - { - TaskSafety.FireAndForget(this.OnProfileApplicationRequestedAsync(e), ex => - { - this.LogDebug($"OnProfileApplicationRequested failed: {ex.Message}"); - }); - } - - private async Task OnProfileApplicationRequestedAsync(ProfileApplicationRequestedEventArgs e) - { - try - { - var processService = this.serviceProvider.GetRequiredService(); - var selectedProcess = this.processViewModel.SelectedProcess; - - if (selectedProcess != null) - { - var success = await processService.LoadProcessProfile(e.ProfileName, selectedProcess); - - if (success) - { - this.systemTrayService.ShowBalloonTip( - "ThreadPilot", - $"Profile '{e.ProfileName}' applied to {selectedProcess.Name}", 2000); - } - else - { - this.systemTrayService.ShowBalloonTip( - "ThreadPilot Error", - $"Failed to apply profile '{e.ProfileName}'", 3000); - } - } - else - { - this.systemTrayService.ShowBalloonTip( - "ThreadPilot", - "No process selected for profile application", 2000); - } - } - catch (Exception ex) - { - this.systemTrayService.ShowBalloonTip( - "ThreadPilot Error", - $"Error applying profile: {ex.Message}", 3000); - } - } - - private void OnPerformanceDashboardRequested(object? sender, EventArgs e) - { - try - { - this.ShowWindowFromTray("Performance"); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Failed to open performance dashboard: {ex.Message}"); - } - } - - private async Task InitializeKeyboardShortcutsAsync() - { - try - { - // Set window handle for global hotkey registration - var windowInteropHelper = new System.Windows.Interop.WindowInteropHelper(this); - var handle = windowInteropHelper.EnsureHandle(); - - if (this.keyboardShortcutService is KeyboardShortcutService service) - { - service.SetWindowHandle(handle); - } - - // Subscribe to shortcut activation events - this.keyboardShortcutService.ShortcutActivated -= this.OnShortcutActivated; - this.keyboardShortcutService.ShortcutActivated += this.OnShortcutActivated; - - // Load shortcuts from settings - with error handling - try - { - await this.keyboardShortcutService.LoadShortcutsFromSettingsAsync(); - } - catch (Exception settingsEx) - { - System.Diagnostics.Debug.WriteLine($"Failed to load shortcuts from settings, using defaults: {settingsEx.Message}"); - // Continue with default shortcuts if settings loading fails - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Failed to initialize keyboard shortcuts: {ex.Message}"); - // Don't let keyboard shortcut initialization failure prevent the app from starting - } - } - - private void OnShortcutActivated(object? sender, ShortcutActivatedEventArgs e) - { - try - { - System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => - { - await this.HandleShortcutActionAsync(e.ActionName); - }); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Error handling shortcut {e.ActionName}: {ex.Message}"); - } - } - - private async Task HandleShortcutActionAsync(string actionName) - { - switch (actionName) - { - case ShortcutActions.ShowMainWindow: - if (this.IsVisible && this.WindowState != WindowState.Minimized) - { - this.ShowInTaskbar = false; - this.Hide(); - this.ApplyAppRefreshPolicy(AppActivityState.TrayHidden); - } - else - { - this.ShowWindowFromTray(); - } - break; - - case ShortcutActions.ToggleMonitoring: - // Toggle monitoring - implementation can be added later - await this.notificationService.ShowNotificationAsync("Keyboard Shortcut", "Toggle monitoring shortcut activated"); - break; - - case ShortcutActions.PowerPlanHighPerformance: - // Switch to high performance power plan - implementation can be added later - await this.notificationService.ShowNotificationAsync("Keyboard Shortcut", "High Performance power plan shortcut activated"); - break; - - case ShortcutActions.OpenTweaks: - this.ShowWindowFromTray("Tweaks"); - break; - - case ShortcutActions.OpenSettings: - this.ShowWindowFromTray("Settings"); - break; - - case ShortcutActions.RefreshProcessList: - // Refresh process list - implementation can be added later - await this.notificationService.ShowNotificationAsync("Keyboard Shortcut", "Refresh process list shortcut activated"); - break; - - case ShortcutActions.ExitApplication: - this.Close(); - break; - } - } - - private async Task UpdateSystemTrayContextMenuAsync() - { - try - { - await this.systemTrayStatusUpdater.UpdateContextMenuAsync(this.systemTrayService); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Failed to update system tray context menu: {ex.Message}"); - } - } - - private void StartSystemTrayUpdateTimer() - { - try - { - this.systemTrayUpdateTimer?.Stop(); - this.systemTrayUpdateTimer?.Dispose(); - this.systemTrayUpdateTimer = null; - - if (!this.systemTrayStatusUpdater.ShouldRunPerformanceStatusUpdates) - { - return; - } - - this.systemTrayUpdateFailureStreak = 0; - this.systemTrayUpdateTimer = new System.Timers.Timer(SystemTrayUpdateBaseIntervalMs); - this.systemTrayUpdateTimer.Elapsed += async (s, e) => - { - if (this.isSystemTrayUpdatesSuspended) - { - return; - } - - if (Interlocked.Exchange(ref this.isSystemTrayUpdateInProgress, 1) == 1) - { - return; - } - - try - { - var updateSucceeded = await this.UpdateSystemTrayStatusAsync(); - this.ApplySystemTrayUpdateBackoff(updateSucceeded); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Error in system tray update timer: {ex.Message}"); - this.ApplySystemTrayUpdateBackoff(updateSucceeded: false); - } - finally - { - Interlocked.Exchange(ref this.isSystemTrayUpdateInProgress, 0); - } - }; - this.systemTrayUpdateTimer.AutoReset = true; - - if (!this.isSystemTrayUpdatesSuspended && - this.IsVisible && - this.WindowState != WindowState.Minimized) - { - this.systemTrayUpdateTimer.Start(); - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Failed to start system tray update timer: {ex.Message}"); - } - } - - private void ApplySystemTrayUpdateBackoff(bool updateSucceeded) - { - if (this.systemTrayUpdateTimer == null) - { - return; - } - - if (updateSucceeded) - { - this.systemTrayUpdateFailureStreak = 0; - if (Math.Abs(this.systemTrayUpdateTimer.Interval - SystemTrayUpdateBaseIntervalMs) > 1) - { - this.systemTrayUpdateTimer.Interval = SystemTrayUpdateBaseIntervalMs; - } - - return; - } - - this.systemTrayUpdateFailureStreak = Math.Min(4, this.systemTrayUpdateFailureStreak + 1); - var exponentialDelay = SystemTrayUpdateBaseIntervalMs * Math.Pow(2, this.systemTrayUpdateFailureStreak); - var nextIntervalMs = Math.Min(SystemTrayUpdateMaxIntervalMs, exponentialDelay); - - if (Math.Abs(this.systemTrayUpdateTimer.Interval - nextIntervalMs) > 1) - { - this.systemTrayUpdateTimer.Interval = nextIntervalMs; - } - } - - private async Task UpdateSystemTrayStatusAsync() - { - try - { - return await this.systemTrayStatusUpdater.UpdateStatusAsync( - this.systemTrayService, - action => this.Dispatcher.InvokeAsync(action).Task); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Failed to update system tray status: {ex.Message}"); - return false; - } - } - - private void InitializeNotifications() - { - try - { - // Subscribe to settings changes to update notification service - this.settingsService.SettingsChanged += this.OnSettingsChanged; - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Failed to initialize notifications: {ex.Message}"); - } - } - - private async Task InitializeMonitoringAsync() - { - try - { - // Subscribe to monitoring status changes - this.processMonitorService.MonitoringStatusChanged += this.OnMonitoringStatusChanged; - - // Update tray with initial monitoring status - this.systemTrayService.UpdateMonitoringStatus( - this.processMonitorService.IsMonitoring, - this.processMonitorService.IsWmiAvailable); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Failed to initialize monitoring: {ex.Message}"); - } - } - - private async Task StartProcessMonitoringManagerAsync() - { - try - { - this.LogDebug("Subscribing to process monitor manager events..."); - // Subscribe to process monitor manager events - this.processMonitorManagerService.ServiceStatusChanged += this.OnProcessMonitorManagerStatusChanged; - - this.LogDebug("Starting process monitoring manager service..."); - // Start the process monitoring manager service with internal timeout - var startTask = this.processMonitorManagerService.StartAsync(); - var timeoutTask = Task.Delay(6000); // 6 second internal timeout - var completedTask = await Task.WhenAny(startTask, timeoutTask); - - if (completedTask == timeoutTask) - { - this.LogDebug("ProcessMonitorManagerService.StartAsync() timed out internally"); - throw new TimeoutException("Process monitoring manager service startup timed out"); - } - - await startTask; // Get any exceptions - this.LogDebug("Process monitoring manager service started, showing notification..."); - - if (!this.isSilentStartupMode) - { - await this.notificationService.ShowSuccessNotificationAsync( - "ThreadPilot Started", - "Process monitoring and power plan management is now active"); - } - - this.LogDebug(this.isSilentStartupMode - ? "Startup success notification skipped for silent startup" - : "Success notification shown"); - } - catch (Exception ex) - { - this.LogDebug($"Failed to start process monitoring manager: {ex.Message}"); - try - { - await this.notificationService.ShowErrorNotificationAsync( - "Startup Error", - "Failed to start process monitoring manager", - ex); - } - catch (Exception notificationEx) - { - this.LogDebug($"Failed to show error notification: {notificationEx.Message}"); - } - throw; // Re-throw to be caught by outer handler - } - } - - private void OnSettingsChanged(object? sender, ApplicationSettingsChangedEventArgs e) - { - // Update tray service with new settings - this.systemTrayService.UpdateSettings(e.NewSettings); - - var useDarkTheme = e.NewSettings.HasUserThemePreference - ? e.NewSettings.UseDarkTheme - : this.themeService.GetSystemUsesDarkTheme(); - - this.themeService.ApplyTheme(useDarkTheme); - this.mainWindowViewModel.IsDarkTheme = useDarkTheme; - this.systemTrayService.ApplyTheme(useDarkTheme); - DwmHelper.ApplyWindowCaptionTheme(this, useDarkTheme); - this.ApplySelfResourcePolicy(this.lastAppliedRefreshState ?? this.GetForegroundActivityState(), e.NewSettings); - } - - private void OnMonitoringStatusChanged(object? sender, MonitoringStatusEventArgs e) - { - // Update tray icon and status - this.systemTrayService.UpdateMonitoringStatus(e.IsMonitoring, e.IsWmiAvailable); - - // Show notification if there's an error - if (e.Error != null && this.settingsService.Settings.EnableErrorNotifications) - { - this.notificationService.ShowErrorNotificationAsync( - "Automation Monitoring Error", - e.StatusMessage ?? "An error occurred with automation monitoring.", - e.Error); - } - } - - private void OnProcessMonitorManagerStatusChanged(object? sender, ServiceStatusEventArgs e) - { - // Update main window status - this.mainWindowViewModel.UpdateProcessMonitoringStatus(e.IsRunning, e.Status); - - // Show notification for critical status changes - if (!e.IsRunning && e.Error != null && this.settingsService.Settings.EnableErrorNotifications) - { - this.notificationService.ShowErrorNotificationAsync( - "Automation Monitoring Error", - e.Details ?? "Automation monitoring encountered an error.", - e.Error); - } - } - - protected override void OnStateChanged(EventArgs e) - { - try - { - 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.ApplyAppRefreshPolicy(activityState); - } - else if (this.WindowState == WindowState.Normal || this.WindowState == WindowState.Maximized) - { - this.ShowInTaskbar = true; - this.EnsureDashboardVisibleOnScreen(); - - this.ApplyAppRefreshPolicy(this.GetForegroundActivityState()); - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Error handling window state change: {ex.Message}"); - } - - base.OnStateChanged(e); - } - - private void SuspendHiddenModeRefreshes() - { - this.isSystemTrayUpdatesSuspended = true; - this.systemTrayUpdateTimer?.Stop(); - Interlocked.Exchange(ref this.isSystemTrayUpdateInProgress, 0); - this.powerPlanViewModel.PauseAutoRefresh(); - } - - private void ResumeForegroundRefreshes() - { - this.isSystemTrayUpdatesSuspended = false; - this.systemTrayUpdateFailureStreak = 0; - this.systemTrayUpdateTimer?.Stop(); - - if (!this.systemTrayStatusUpdater.ShouldRunPerformanceStatusUpdates) - { - return; - } - - if (this.systemTrayUpdateTimer != null) - { - this.systemTrayUpdateTimer.Interval = SystemTrayUpdateBaseIntervalMs; - } - this.systemTrayUpdateTimer?.Start(); - - _ = this.Dispatcher.InvokeAsync(async () => - { - try - { - var updateSucceeded = await this.UpdateSystemTrayStatusAsync(); - this.ApplySystemTrayUpdateBackoff(updateSucceeded); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Failed to refresh tray status after resume: {ex.Message}"); - } - }); - } - - private AppActivityState GetForegroundActivityState() - { - if (this.ProcessManagementTab.Visibility == Visibility.Visible) - { - return AppActivityState.ForegroundProcessView; - } - - return this.PerformanceViewControl.Visibility == Visibility.Visible - ? AppActivityState.ForegroundDiagnosticsView - : AppActivityState.ForegroundOtherTab; - } - - private void ApplyAppRefreshPolicy(AppActivityState state) - { - if (!AppRefreshPolicy.ShouldApplyTransition(this.lastAppliedRefreshState, state)) - { - return; - } - - this.lastAppliedRefreshState = 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.GetPerformanceViewModel().ActivateDiagnosticsAsync(); - } - else if (this.performanceViewModel != null) - { - _ = this.performanceViewModel.SuspendBackgroundMonitoringAsync(); - } - - this.ApplySelfResourcePolicy(state); - } - - private void ApplySelfResourcePolicy(AppActivityState state, ApplicationSettingsModel? settings = null) - { - var currentSettings = settings ?? this.settingsService.Settings; - var isHiddenState = state is AppActivityState.Minimized or AppActivityState.TrayHidden; - - if (SelfResourcePolicy.ShouldApplyLowImpactMode(isHiddenState, currentSettings.EnableSelfLowImpactMode)) - { - this.selfResourceManagementService.ApplyLowImpactMode(SelfResourcePolicy.ShouldLimitAffinity( - isHiddenState, - currentSettings.EnableSelfLowImpactMode, - currentSettings.EnableSelfAffinityLimit)); - return; - } - - this.selfResourceManagementService.RestoreForegroundMode(); - } - - protected override void OnSourceInitialized(EventArgs e) - { - base.OnSourceInitialized(e); - try - { - DwmHelper.ApplyWindowCaptionTheme(this, this.themeService.IsDarkTheme); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Failed to apply window caption theme: {ex.Message}"); - } - - this.EnsureDashboardVisibleOnScreen(); - } - - [System.Diagnostics.Conditional("DEBUG")] - private void LogDebug(string message) - { - try - { - var timestampedMessage = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [MainWindow] {message}"; - System.Diagnostics.Debug.WriteLine(timestampedMessage); - File.AppendAllText(this.debugLogPath, timestampedMessage + Environment.NewLine); - } - catch - { - // Swallow logging failures to avoid impacting runtime behavior - } - } - - private void OnWindowLoaded(object? sender, RoutedEventArgs e) - { - TaskSafety.FireAndForget(this.OnWindowLoadedAsync(), ex => - { - this.LogDebug($"OnWindowLoaded failed: {ex.Message}"); - }); - } - - private async Task OnWindowLoadedAsync() - { - this.Loaded -= this.OnWindowLoaded; - this.EnsureDashboardVisibleOnScreen(); - await this.InitializeKeyboardShortcutsAsync(); - } - - private void OnOpenRulesRequested(object? sender, EventArgs e) - { - this.SelectMainTab("Rules"); - } - - private void ShowWindowFromTray(string? tabTag = null) - { - this.ShowInTaskbar = true; - this.EnsureDashboardVisibleOnScreen(); - - if (!this.IsVisible) - { - this.Show(); - } - else - { - this.Visibility = Visibility.Visible; - } - - if (this.WindowState == WindowState.Minimized) - { - this.WindowState = WindowState.Normal; - } - - this.EnsureDashboardVisibleOnScreen(); - this.ShowInTaskbar = true; - - if (tabTag != null) - { - this.SelectMainTab(tabTag); - } - - // Force foreground restoration when invoked from tray context menu. - this.Topmost = true; - 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.ApplyAppRefreshPolicy(processViewWillBeActive - ? AppActivityState.ForegroundProcessView - : AppActivityState.ForegroundOtherTab); - } - - internal bool EnsureDashboardVisibleOnScreen() - { - return WindowPlacementHelper.TryCorrectWindowPlacement(this); - } - - private void SelectMainTab(string tag) - { - if (string.IsNullOrEmpty(tag)) - { - return; - } - - if (string.Equals(tag, "Performance", StringComparison.Ordinal)) - { - this.GetPerformanceViewModel(); - } - - this.ApplySectionVisibility(tag); - - if (string.Equals(tag, "Performance", StringComparison.Ordinal)) - { - this.TryShowPerformanceIntro(); - } - } - - private void TryShowPerformanceIntro() - { - if (this.isPerformanceIntroVisible || !this.isInitializationComplete) - { - return; - } - - try - { - var settings = this.settingsService.Settings; - if (settings.HasSeenPerformanceIntro) - { - return; - } - - this.isPerformanceIntroVisible = true; - this.PerformanceIntroOverlay.Visibility = Visibility.Visible; - } - catch (Exception ex) - { - this.LogDebug($"Failed to show Performance intro overlay: {ex.Message}"); - } - } - - private void TryShowStartupMinimizedSuggestion() - { - if (!this.showStartupMinimizedSuggestionOnReady - || this.isSilentStartupMode - || !this.isInitializationComplete - || this.isElevationWarningVisible) - { - return; - } - - try - { - if (!StartupMinimizedSuggestionPolicy.ShouldShow( - this.settingsService.Settings, - StartupWindowBehavior.Resolve(isAutostart: false, startMinimized: false))) - { - return; - } - - this.StartupMinimizedSuggestionOverlay.Visibility = Visibility.Visible; - } - catch (Exception ex) - { - this.LogDebug($"Failed to show startup minimized suggestion: {ex.Message}"); - } - } - - private async Task PersistStartupMinimizedSuggestionSeenAsync() - { - try - { - var settings = this.settingsService.Settings; - if (settings.HasSeenStartupMinimizedSuggestion) - { - return; - } - - settings.HasSeenStartupMinimizedSuggestion = true; - await this.settingsService.UpdateSettingsAsync(settings); - } - catch (Exception ex) - { - this.LogDebug($"Failed to persist startup minimized suggestion state: {ex.Message}"); - } - } - - private void HideStartupMinimizedSuggestion() - { - this.showStartupMinimizedSuggestionOnReady = false; - this.StartupMinimizedSuggestionOverlay.Visibility = Visibility.Collapsed; - } - - private async void StartupSuggestionOpenSettings_Click(object sender, RoutedEventArgs e) - { - await this.PersistStartupMinimizedSuggestionSeenAsync(); - this.HideStartupMinimizedSuggestion(); - this.SelectMainTab("Settings"); - } - - private async void StartupSuggestionDontShowAgain_Click(object sender, RoutedEventArgs e) - { - await this.PersistStartupMinimizedSuggestionSeenAsync(); - this.HideStartupMinimizedSuggestion(); - } - - private void HidePerformanceIntro() - { - this.isPerformanceIntroVisible = false; - this.PerformanceIntroOverlay.Visibility = Visibility.Collapsed; - } - - private Task ShowUnsavedSettingsDialogAsync(string message) - { - if (!this.Dispatcher.CheckAccess()) - { - return this.Dispatcher.InvokeAsync(() => this.ShowUnsavedSettingsDialogAsync(message)).Task.Unwrap(); - } - - if (this.unsavedSettingsDialogCompletionSource != null) - { - return Task.FromResult(MessageBoxResult.Cancel); - } - - this.UnsavedSettingsDialogMessage.Text = message; - this.unsavedSettingsDialogCompletionSource = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - this.UnsavedSettingsOverlay.Visibility = Visibility.Visible; - return this.unsavedSettingsDialogCompletionSource.Task; - } - - private void CompleteUnsavedSettingsDialog(MessageBoxResult result) - { - var completionSource = this.unsavedSettingsDialogCompletionSource; - if (completionSource == null) - { - return; - } - - this.unsavedSettingsDialogCompletionSource = null; - this.UnsavedSettingsOverlay.Visibility = Visibility.Collapsed; - completionSource.TrySetResult(result); - } - - private void UnsavedSettingsSave_Click(object sender, RoutedEventArgs e) - { - this.CompleteUnsavedSettingsDialog(MessageBoxResult.Yes); - } - - private void UnsavedSettingsDiscard_Click(object sender, RoutedEventArgs e) - { - this.CompleteUnsavedSettingsDialog(MessageBoxResult.No); - } - - private void UnsavedSettingsCancel_Click(object sender, RoutedEventArgs e) - { - this.CompleteUnsavedSettingsDialog(MessageBoxResult.Cancel); - } - - private async void PerformanceIntroContinue_Click(object sender, RoutedEventArgs e) - { - try - { - var settings = this.settingsService.Settings; - if (!settings.HasSeenPerformanceIntro) - { - settings.HasSeenPerformanceIntro = true; - await this.settingsService.UpdateSettingsAsync(settings); - } - } - catch (Exception ex) - { - this.LogDebug($"Failed to persist Performance intro state: {ex.Message}"); - } - finally - { - this.HidePerformanceIntro(); - } - } - - // Elevation Warning Modal Management - private bool isElevationWarningVisible = false; - private double previousElevationAppContentOpacity = 1; - private double previousElevationBackdropBlurRadius = 0; - - private void TryShowElevationWarning() - { - if (this.isElevationWarningVisible || !this.isInitializationComplete) - { - return; - } - - try - { - var settings = this.settingsService.Settings; - - // Only show if user is not admin AND hasn't dismissed the warning yet - if (this.elevationService?.IsRunningAsAdministrator() == true || settings.HasSeenElevationWarning) - { - return; - } - - this.isElevationWarningVisible = true; - var elevationOverlay = this.FindName("ElevationWarningOverlay") as Grid; - if (elevationOverlay != null) - { - elevationOverlay.Visibility = Visibility.Visible; - } - - // Apply blur and disable interaction - this.previousElevationAppContentOpacity = this.UIContent.Opacity; - this.UIContent.IsHitTestVisible = false; - this.UIContent.Opacity = 0.74; - - var elevationBlur = this.FindName("ElevationWarningBlur") as BlurEffect; - if (elevationBlur != null) - { - this.previousElevationBackdropBlurRadius = elevationBlur.Radius; - elevationBlur.Radius = 16; - } - } - catch (Exception ex) - { - this.LogDebug($"Failed to show elevation warning overlay: {ex.Message}"); - } - } - - private void HideElevationWarning() - { - this.isElevationWarningVisible = false; - var elevationOverlay = this.FindName("ElevationWarningOverlay") as Grid; - if (elevationOverlay != null) - { - elevationOverlay.Visibility = Visibility.Collapsed; - } - - // Restore interaction and remove blur - this.UIContent.IsHitTestVisible = true; - this.UIContent.Opacity = this.previousElevationAppContentOpacity; - - var elevationBlur = this.FindName("ElevationWarningBlur") as BlurEffect; - if (elevationBlur != null) - { - elevationBlur.Radius = this.previousElevationBackdropBlurRadius; - } - - this.TryShowStartupMinimizedSuggestion(); - } - - private void ElevationWarningDismiss_Click(object sender, RoutedEventArgs e) - { - try - { - var settings = this.settingsService.Settings; - if (!settings.HasSeenElevationWarning) - { - settings.HasSeenElevationWarning = true; - _ = this.settingsService.UpdateSettingsAsync(settings); - } - } - catch (Exception ex) - { - this.LogDebug($"Failed to persist elevation warning dismiss state: {ex.Message}"); - } - finally - { - this.HideElevationWarning(); - } - } - - private async void ElevationWarningRequestElevation_Click(object sender, RoutedEventArgs e) - { - try - { - if (this.elevationService != null) - { - var success = await this.elevationService.RequestElevationIfNeeded(); - if (success) - { - System.Diagnostics.Debug.WriteLine("Elevation requested successfully from warning dialog"); - } - } - } - catch (Exception ex) - { - this.LogDebug($"Failed to request elevation from warning dialog: {ex.Message}"); - } - finally - { - // Hide the warning after attempting elevation (regardless of success) - this.HideElevationWarning(); - } - } - - private void ApplySectionVisibility(string tag) - { - this.ProcessManagementTab.Visibility = tag == "Process" ? Visibility.Visible : Visibility.Collapsed; - this.CoreMasksTab.Visibility = tag == "Masks" ? Visibility.Visible : Visibility.Collapsed; - this.PowerPlanViewControl.Visibility = tag == "Power" ? Visibility.Visible : Visibility.Collapsed; - this.AssociationView.Visibility = tag == "Rules" ? Visibility.Visible : Visibility.Collapsed; - this.PerformanceViewControl.Visibility = tag == "Performance" ? Visibility.Visible : Visibility.Collapsed; - this.LogViewerViewControl.Visibility = tag == "Logs" ? Visibility.Visible : Visibility.Collapsed; - this.SystemTweaksView.Visibility = tag == "Tweaks" ? Visibility.Visible : Visibility.Collapsed; - this.SettingsView.Visibility = tag == "Settings" ? Visibility.Visible : Visibility.Collapsed; - - this.NavProcess.IsActive = tag == "Process"; - this.NavMasks.IsActive = tag == "Masks"; - this.NavPower.IsActive = tag == "Power"; - this.NavRules.IsActive = tag == "Rules"; - this.NavPerf.IsActive = tag == "Performance"; - this.NavLogs.IsActive = tag == "Logs"; - this.NavTweaks.IsActive = tag == "Tweaks"; - this.NavSettings.IsActive = tag == "Settings"; - - if (!this.IsVisible) - { - this.ApplyAppRefreshPolicy(AppActivityState.TrayHidden); - return; - } - - if (this.WindowState == WindowState.Minimized) - { - this.ApplyAppRefreshPolicy(AppActivityState.Minimized); - return; - } - - var activityState = tag switch - { - "Process" => AppActivityState.ForegroundProcessView, - "Performance" => AppActivityState.ForegroundDiagnosticsView, - _ => AppActivityState.ForegroundOtherTab, - }; - - this.ApplyAppRefreshPolicy(activityState); - } - - private void NavMenuItem_Click(object sender, RoutedEventArgs e) - { - TaskSafety.FireAndForget(this.NavMenuItem_ClickAsync(sender, e), ex => - { - this.LogDebug($"NavMenuItem_Click failed: {ex.Message}"); - }); - } - - private async Task NavMenuItem_ClickAsync(object sender, RoutedEventArgs e) - { - if (!await this.navigationBehavior.TryEnterAsync()) - { - return; - } - - try - { - var invokedItem = sender as Wpf.Ui.Controls.NavigationViewItem; - if (invokedItem == null) - { - return; - } - - var tag = invokedItem.Tag?.ToString(); - if (string.IsNullOrEmpty(tag)) - { - return; - } - - if (!this.IsLoaded) - { - return; - } - - var canNavigate = await NavigationBehavior.EnsureCanNavigateAsync( - tag, - this.settingsViewModel, - () => this.ShowUnsavedSettingsDialogAsync( - "You have unsaved changes in Settings. Save before switching tabs, discard the changes, or cancel to stay on Settings.")); - if (!canNavigate) - { - return; - } - - if (string.Equals(tag, "Performance", StringComparison.Ordinal)) - { - this.GetPerformanceViewModel(); - } - - this.ApplySectionVisibility(tag); - - if (string.Equals(tag, "Performance", StringComparison.Ordinal)) - { - this.TryShowPerformanceIntro(); - } - } - finally - { - this.navigationBehavior.Exit(); - } - } - - protected override void OnClosing(System.ComponentModel.CancelEventArgs e) - { - if (this.isPerformingShutdown) - { - base.OnClosing(e); - return; - } - - if (this.isPerformanceIntroVisible) - { - e.Cancel = true; - System.Windows.MessageBox.Show( - "Please complete the Performance introduction before closing the application.\n\nClick 'Continue to Performance' to proceed.", - "Performance Introduction Required", - MessageBoxButton.OK, - MessageBoxImage.Information); - return; - } - - e.Cancel = true; - _ = this.HandleWindowCloseAsync(); - } - - protected override void OnClosed(EventArgs e) - { - try - { - this.Loaded -= this.OnWindowLoaded; - this.processViewModel.OpenRulesRequested -= this.OnOpenRulesRequested; - - this.settingsService.SettingsChanged -= this.OnSettingsChanged; - this.processMonitorService.MonitoringStatusChanged -= this.OnMonitoringStatusChanged; - this.processMonitorManagerService.ServiceStatusChanged -= this.OnProcessMonitorManagerStatusChanged; - this.keyboardShortcutService.ShortcutActivated -= this.OnShortcutActivated; - - this.UnsubscribeSystemTrayEvents(); - - this.systemTrayUpdateTimer?.Stop(); - this.systemTrayUpdateTimer?.Dispose(); - - this.initializationTimeoutTimer?.Stop(); - this.initializationTimeoutTimer?.Dispose(); - this.performanceViewModel?.Dispose(); - - this.selfResourceManagementService.RestoreForegroundMode(); - this.navigationBehavior.Dispose(); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Error disposing timers: {ex.Message}"); - } - - base.OnClosed(e); - } - - private void UnsubscribeSystemTrayEvents() - { - this.systemTrayService.ShowMainWindowRequested -= this.OnShowMainWindowRequested; - this.systemTrayService.DashboardRequested -= this.OnDashboardRequested; - this.systemTrayService.ExitRequested -= this.OnExitRequested; - this.systemTrayService.MonitoringToggleRequested -= this.OnMonitoringToggleRequested; - this.systemTrayService.SettingsRequested -= this.OnSettingsRequested; - this.systemTrayService.PowerPlanChangeRequested -= this.OnPowerPlanChangeRequested; - this.systemTrayService.ProfileApplicationRequested -= this.OnProfileApplicationRequested; - this.systemTrayService.PerformanceDashboardRequested -= this.OnPerformanceDashboardRequested; - } - } -} +namespace ThreadPilot +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using System.Timers; + using System.Windows; + using System.Windows.Controls; + using System.Windows.Input; + using System.Windows.Media.Animation; + using System.Windows.Media.Effects; + using System.Windows.Media.Imaging; + using Microsoft.Extensions.DependencyInjection; + using ThreadPilot.Helpers; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + using ThreadPilot.Views; + + public partial class MainWindow : Wpf.Ui.Controls.FluentWindow + { + private void SetDataContexts() + { + // Set DataContext for the main window + this.DataContext = this.mainWindowViewModel; + + // Set DataContext for the power plans view + this.PowerPlanViewControl.DataContext = this.powerPlanViewModel; + + // Set DataContext for the association view + this.AssociationView.DataContext = this.associationViewModel; + + // Set DataContext for the log viewer view + this.LogViewerViewControl.DataContext = this.logViewerViewModel; + + // Set DataContext for the system tweaks view + this.SystemTweaksView.DataContext = this.systemTweaksViewModel; + + // Set DataContext for the settings view + this.SettingsView.DataContext = this.settingsViewModel; + } + + private void InitializeLoadingOverlay() + { + try + { + var loadingOverlay = this.FindName("LoadingOverlay") as Grid; + + // Ensure overlay is visible while initialization runs + if (loadingOverlay != null) + { + loadingOverlay.Visibility = this.isSilentStartupMode ? Visibility.Collapsed : Visibility.Visible; + loadingOverlay.Opacity = this.isSilentStartupMode ? 0 : 1; + } + + if (!this.isSilentStartupMode) + { + this.ApplyUIContentBlur(15); + } + + // Start spinner animation if available + var spinnerAnimation = this.FindResource("SpinnerAnimation") as Storyboard; + if (!this.isSilentStartupMode) + { + spinnerAnimation?.Begin(); + } + + // Set a timeout guard for initialization + this.initializationTimeoutTimer = new System.Timers.Timer(15000) + { + AutoReset = false, + }; + this.initializationTimeoutTimer.Elapsed += this.OnInitializationTimeout; + this.initializationTimeoutTimer.Start(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to initialize loading overlay: {ex.Message}"); + } + } + + private async Task InitializeApplicationAsync() + { + try + { + this.LogDebug("=== Starting InitializeApplicationAsync ==="); + + await this.Dispatcher.InvokeAsync(() => this.UpdateLoadingStatus("Loading view models...", "Loading process, power plan and rules data.")); + this.LogDebug("About to call LoadViewModelsAsync..."); + await this.LoadViewModelsAsync(); + this.LogDebug("LoadViewModelsAsync completed successfully"); + this.CompleteInitializationTask("ViewModels"); + + this.LogDebug("About to initialize MainWindowViewModel..."); + await this.mainWindowViewModel.InitializeAsync(); + this.LogDebug("MainWindowViewModel initialized successfully"); + this.CompleteInitializationTask("MainWindowViewModel"); + + await this.Dispatcher.InvokeAsync(() => this.UpdateLoadingStatus("Initializing services...", "Starting monitoring, tray and notification services.")); + this.LogDebug("About to call InitializeServicesAsync..."); + await this.InitializeServicesAsync(); + this.LogDebug("InitializeServicesAsync completed successfully"); + this.CompleteInitializationTask("Services"); + this.QueueStartupUpdateCheck(); + + await this.Dispatcher.InvokeAsync(() => this.UpdateLoadingStatus("Finalizing startup...", "Applying final UI state and startup checks.")); + this.LogDebug("Finalizing startup..."); + await Task.Delay(500); // Brief delay to show final status + this.CompleteInitializationTask("Finalization"); + + // All initialization complete + this.LogDebug("All initialization complete, hiding overlay..."); + await this.Dispatcher.InvokeAsync(() => this.HideLoadingOverlay()); + this.LogDebug("=== InitializeApplicationAsync completed successfully ==="); + } + catch (Exception ex) + { + this.LogDebug($"=== ERROR in InitializeApplicationAsync: {ex} ==="); + await this.Dispatcher.InvokeAsync(() => this.ShowInitializationError(ex)); + } + } + + private void QueueStartupUpdateCheck() + { + if (Interlocked.Exchange(ref this.startupUpdateCheckStarted, 1) != 0) + { + return; + } + + TaskSafety.FireAndForget(this.CheckForUpdatesAtStartupAsync(), ex => + { + this.LogDebug($"Startup update check failed: {ex.Message}"); + }); + } + + private async Task CheckForUpdatesAtStartupAsync() + { + try + { + this.LogDebug("Startup update check started"); + var updateService = this.serviceProvider.GetRequiredService(); + var result = await updateService.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup)); + + if (result.Status == UpdateCheckStatus.Skipped) + { + this.LogDebug($"Startup update check skipped: {result.Message}"); + return; + } + + if (!result.IsUpdateAvailable || result.Release == null) + { + this.LogDebug($"Startup update check complete: {result.Message}"); + return; + } + + await this.notificationService.ShowNotificationAsync( + "Update available", + $"ThreadPilot {result.Release.Version} is available. Open Settings to download and install it.", + NotificationType.Information); + this.LogDebug($"Startup update check found update: installed {result.CurrentVersion}, latest {result.Release.Version}"); + } + catch (Exception ex) + { + this.LogDebug($"Startup update check ignored failure: {ex.Message}"); + } + } + + private static Version GetCurrentApplicationVersion() + { + var rawVersion = typeof(App).Assembly + .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false) + .OfType() + .FirstOrDefault()? + .InformationalVersion + ?? typeof(App).Assembly.GetName().Version?.ToString() + ?? "0.0.0"; + + var sanitized = rawVersion.Trim(); + if (sanitized.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + { + sanitized = sanitized[1..]; + } + + sanitized = sanitized.Split('-', '+')[0]; + + return Version.TryParse(sanitized, out var version) + ? version + : new Version(0, 0, 0); + } + + private void UpdateLoadingStatus(string stage, string details = "") + { + if (this.mainWindowViewModel != null) + { + this.mainWindowViewModel.InitializationStage = stage; + this.mainWindowViewModel.InitializationDetails = details; + } + } + + private void CompleteInitializationTask(string taskName) + { + lock (this.initializationLock) + { + this.initializationTasks.Add(taskName); + System.Diagnostics.Debug.WriteLine($"Initialization task completed: {taskName}"); + } + } + + private void HideLoadingOverlay() + { + try + { + System.Diagnostics.Debug.WriteLine("=== Starting HideLoadingOverlay ==="); + this.isInitializationComplete = true; + this.initializationTimeoutTimer?.Stop(); + this.initializationTimeoutTimer?.Dispose(); + + if (this.isSilentStartupMode) + { + var silentLoadingOverlay = this.FindName("LoadingOverlay") as Grid; + if (silentLoadingOverlay != null) + { + silentLoadingOverlay.Visibility = Visibility.Collapsed; + silentLoadingOverlay.Opacity = 0; + } + + this.ClearUIContentBlur(); + this.ApplyAppRefreshPolicy(AppActivityState.TrayHidden); + return; + } + + // Stop spinner animation + var spinnerAnimation = this.FindResource("SpinnerAnimation") as Storyboard; + spinnerAnimation?.Stop(); + System.Diagnostics.Debug.WriteLine("Spinner animation stopped"); + + // Start fade-out animation + var fadeOutAnimation = this.FindResource("FadeOutAnimation") as Storyboard; + if (fadeOutAnimation != null) + { + System.Diagnostics.Debug.WriteLine("Starting fade-out animation"); + fadeOutAnimation.Completed += (s, e) => + { + System.Diagnostics.Debug.WriteLine("Fade-out animation completed, hiding overlay"); + var loadingOverlay = this.FindName("LoadingOverlay") as Grid; + if (loadingOverlay != null) + { + loadingOverlay.Visibility = Visibility.Collapsed; + System.Diagnostics.Debug.WriteLine("Loading overlay visibility set to Collapsed"); + } + + // Disable app content blur and restore style-driven behavior. + this.ClearUIContentBlur(); + System.Diagnostics.Debug.WriteLine("=== Loading overlay hidden successfully ==="); + + // Show elevation warning if needed + this.TryShowElevationWarning(); + this.TryShowStartupMinimizedSuggestion(); + }; + fadeOutAnimation.Begin(); + } + else + { + System.Diagnostics.Debug.WriteLine("WARNING: FadeOutAnimation not found, hiding overlay immediately"); + // Fallback: hide overlay immediately if animation fails + var loadingOverlay = this.FindName("LoadingOverlay") as Grid; + if (loadingOverlay != null) + { + loadingOverlay.Visibility = Visibility.Collapsed; + } + + this.ClearUIContentBlur(); + System.Diagnostics.Debug.WriteLine("=== Loading overlay hidden immediately (fallback) ==="); + + // Show elevation warning if needed + this.TryShowElevationWarning(); + this.TryShowStartupMinimizedSuggestion(); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"=== ERROR hiding loading overlay: {ex} ==="); + // Emergency fallback: hide overlay without animation + try + { + var loadingOverlay = this.FindName("LoadingOverlay") as Grid; + if (loadingOverlay != null) + { + loadingOverlay.Visibility = Visibility.Collapsed; + } + + this.ClearUIContentBlur(); + System.Diagnostics.Debug.WriteLine("Emergency fallback: overlay hidden without animation"); + + // Show elevation warning if needed + this.TryShowElevationWarning(); + this.TryShowStartupMinimizedSuggestion(); + } + catch (Exception fallbackEx) + { + System.Diagnostics.Debug.WriteLine($"Emergency fallback also failed: {fallbackEx}"); + } + } + } + + private void ApplyUIContentBlur(double radius) + { + if (this.UIContent.Effect is not BlurEffect blur) + { + blur = new BlurEffect(); + this.UIContent.Effect = blur; + } + + blur.KernelType = KernelType.Gaussian; + blur.Radius = radius; + } + + private void ClearUIContentBlur() + { + this.UIContent.Effect = null; + } + + private void OnInitializationTimeout(object? sender, ElapsedEventArgs e) + { + this.Dispatcher.InvokeAsync(() => + { + if (!this.isInitializationComplete) + { + this.ShowInitializationError(new TimeoutException("Application initialization timed out after 15 seconds")); + } + }); + } + + private void ShowInitializationError(Exception ex) + { + try + { + this.UpdateLoadingStatus("Initialization failed", ex.Message); + + var result = System.Windows.MessageBox.Show( + $"ThreadPilot failed to initialize properly:\n\n{ex.Message}\n\nDebug log: {this.debugLogPath}\n\nWould you like to retry initialization or close the application?", + "Initialization Error", + MessageBoxButton.YesNo, + MessageBoxImage.Error); + + if (result == MessageBoxResult.Yes) + { + // Retry initialization - marshal to UI thread to prevent cross-thread access exceptions + this.isInitializationComplete = false; + this.initializationTasks.Clear(); + this.UpdateLoadingStatus("Retrying initialization...", "Restarting startup sequence."); + this.LogDebug("=== RETRYING INITIALIZATION ==="); + _ = this.Dispatcher.InvokeAsync(async () => await this.InitializeApplicationAsync()); + } + else + { + // Close application + this.LogDebug("User chose to close application"); + System.Windows.Application.Current.Shutdown(); + } + } + catch (Exception overlayEx) + { + this.LogDebug($"Error showing initialization error: {overlayEx.Message}"); + System.Windows.Application.Current.Shutdown(); + } + } + + private async Task LoadViewModelsAsync() + { + try + { + this.LogDebug("=== Starting LoadViewModelsAsync ==="); + + this.LogDebug("About to initialize ProcessViewModel (including CPU topology)..."); + try + { + // Use the full initialization method instead of just LoadProcesses + var processTask = this.processViewModel.InitializeAsync(); + var processResult = await Task.WhenAny(processTask, Task.Delay(15000)); // 15 second timeout for full initialization + if (processResult != processTask) + { + this.LogDebug("ProcessViewModel.InitializeAsync() timed out, trying fallback..."); + // Fallback: just load processes without full initialization + await this.processViewModel.LoadProcesses(); + this.LogDebug($"ProcessViewModel fallback (LoadProcesses only) completed, process count: {this.processViewModel.Processes?.Count ?? 0}, filtered count: {this.processViewModel.FilteredProcesses?.Count ?? 0}"); + } + else + { + await processTask; // Ensure we get any exceptions + this.LogDebug($"ProcessViewModel initialized successfully (including CPU topology), process count: {this.processViewModel.Processes?.Count ?? 0}, filtered count: {this.processViewModel.FilteredProcesses?.Count ?? 0}"); + } + } + catch (Exception processEx) + { + this.LogDebug($"ProcessViewModel initialization failed: {processEx.Message}, trying fallback..."); + // Fallback: just load processes without full initialization + await this.processViewModel.LoadProcesses(); + this.LogDebug($"ProcessViewModel fallback (LoadProcesses only) completed after exception, process count: {this.processViewModel.Processes?.Count ?? 0}, filtered count: {this.processViewModel.FilteredProcesses?.Count ?? 0}"); + } + + this.LogDebug("About to load PowerPlanViewModel..."); + var powerPlanTask = this.powerPlanViewModel.LoadPowerPlans(); + var powerPlanResult = await Task.WhenAny(powerPlanTask, Task.Delay(5000)); // 5 second timeout + if (powerPlanResult != powerPlanTask) + { + throw new TimeoutException("PowerPlanViewModel.LoadPowerPlans() timed out after 5 seconds"); + } + await powerPlanTask; // Ensure we get any exceptions + this.LogDebug("PowerPlanViewModel loaded successfully"); + + this.LogDebug("Skipping optional diagnostics initialization until the diagnostics page is opened."); + + this.LogDebug("About to load SystemTweaksViewModel..."); + var systemTweaksTask = this.systemTweaksViewModel.LoadCommand.ExecuteAsync(null); + var systemTweaksResult = await Task.WhenAny(systemTweaksTask, Task.Delay(5000)); // 5 second timeout + if (systemTweaksResult != systemTweaksTask) + { + throw new TimeoutException("SystemTweaksViewModel.LoadCommand.ExecuteAsync() timed out after 5 seconds"); + } + await systemTweaksTask; // Ensure we get any exceptions + this.LogDebug("SystemTweaksViewModel loaded successfully"); + + // Initialize keyboard shortcuts after window is loaded + this.Loaded += this.OnWindowLoaded; + this.LogDebug("Keyboard shortcuts event handler attached"); + + // The association view model loads its data automatically in its constructor + this.LogDebug("=== LoadViewModelsAsync completed successfully ==="); + } + catch (Exception ex) + { + this.LogDebug($"=== ERROR in LoadViewModelsAsync: {ex} ==="); + throw; // Re-throw to be handled by initialization error handler + } + } + + private async Task InitializeServicesAsync() + { + this.LogDebug("=== Starting InitializeServicesAsync ==="); + + this.LogDebug("About to initialize settings..."); + await this.InitializeSettingsAsync(); + this.LogDebug("Settings initialized successfully"); + + this.LogDebug("About to initialize system tray..."); + try + { + var systemTrayTask = this.InitializeSystemTrayAsync(); + var systemTrayResult = await Task.WhenAny(systemTrayTask, Task.Delay(5000)); // 5 second timeout + if (systemTrayResult != systemTrayTask) + { + this.LogDebug("System tray initialization timed out, continuing with basic tray setup..."); + // Initialize basic system tray without context menu updates (Initialize() is idempotent) + await this.InitializeBasicSystemTrayAsync(); + this.LogDebug("Basic system tray initialized (without context menu)"); + } + else + { + await systemTrayTask; // Ensure we get any exceptions + this.LogDebug("System tray initialized successfully"); + } + } + catch (Exception systemTrayEx) + { + this.LogDebug($"System tray initialization failed: {systemTrayEx.Message}, using basic tray..."); + // Fallback: basic system tray initialization + try + { + await this.InitializeBasicSystemTrayAsync(); + this.LogDebug("Fallback system tray initialized"); + } + catch (Exception fallbackEx) + { + this.LogDebug($"Even fallback system tray failed: {fallbackEx.Message}"); + } + } + + this.LogDebug("About to initialize notifications..."); + this.InitializeNotifications(); + this.LogDebug("Notifications initialized successfully"); + + this.LogDebug("About to initialize monitoring..."); + await this.InitializeMonitoringAsync(); + this.LogDebug("Monitoring initialized successfully"); + + if (this.skipProcessMonitoringDuringStartup) + { + this.LogDebug("Skipping process monitoring manager startup (temporary bypass enabled)"); + } + else + { + this.LogDebug("About to start process monitoring manager..."); + try + { + var monitoringTask = this.StartProcessMonitoringManagerAsync(); + var timeoutTask = Task.Delay(8000); // 8 second timeout + var completedTask = await Task.WhenAny(monitoringTask, timeoutTask); + + if (completedTask == timeoutTask) + { + this.LogDebug("Process monitoring manager startup timed out after 8 seconds, continuing without monitoring..."); + } + else + { + try + { + await monitoringTask; // Ensure we get any exceptions + this.LogDebug("Process monitoring manager started successfully"); + } + catch (Exception taskEx) + { + this.LogDebug($"Process monitoring manager task failed: {taskEx.Message}"); + } + } + } + catch (Exception monitoringEx) + { + this.LogDebug($"Process monitoring manager startup failed: {monitoringEx.Message}, continuing without monitoring..."); + } + } + + this.LogDebug("=== InitializeServicesAsync completed successfully ==="); + } + + private async Task InitializeSettingsAsync() + { + try + { + await this.settingsService.LoadSettingsAsync(); + + // Apply initial settings + var settings = this.settingsService.Settings; + var useDarkTheme = settings.HasUserThemePreference + ? settings.UseDarkTheme + : this.themeService.GetSystemUsesDarkTheme(); + + if (!settings.HasUserThemePreference && settings.UseDarkTheme != useDarkTheme) + { + settings.UseDarkTheme = useDarkTheme; + await this.settingsService.UpdateSettingsAsync(settings); + } + + this.themeService.ApplyTheme(useDarkTheme); + this.mainWindowViewModel.IsDarkTheme = useDarkTheme; + DwmHelper.ApplyWindowCaptionTheme(this, useDarkTheme); + + if (settings.StartMinimized) + { + this.WindowState = WindowState.Minimized; + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to load settings: {ex.Message}"); + } + } + + private async Task InitializeSystemTrayAsync() + { + try + { + this.systemTrayService.Initialize(); + this.systemTrayService.Show(); + + // Subscribe to tray events + this.UnsubscribeSystemTrayEvents(); + this.systemTrayService.ShowMainWindowRequested += this.OnShowMainWindowRequested; + this.systemTrayService.DashboardRequested += this.OnDashboardRequested; + this.systemTrayService.ExitRequested += this.OnExitRequested; + this.systemTrayService.MonitoringToggleRequested += this.OnMonitoringToggleRequested; + this.systemTrayService.SettingsRequested += this.OnSettingsRequested; + this.systemTrayService.PowerPlanChangeRequested += this.OnPowerPlanChangeRequested; + this.systemTrayService.ProfileApplicationRequested += this.OnProfileApplicationRequested; + this.systemTrayService.PerformanceDashboardRequested += this.OnPerformanceDashboardRequested; + + // Update settings and tooltip + this.systemTrayService.UpdateSettings(this.settingsService.Settings); + this.systemTrayService.ApplyTheme(this.themeService.IsDarkTheme); + this.systemTrayService.UpdateTooltip("ThreadPilot - Process & Power Plan Manager"); + + // Initialize system tray context menu with current data + await this.UpdateSystemTrayContextMenuAsync(); + + // Start periodic system tray updates + this.StartSystemTrayUpdateTimer(); + } + catch (Exception ex) + { + // Log error but don't fail startup + System.Diagnostics.Debug.WriteLine($"Failed to initialize system tray: {ex.Message}"); + } + } + + private async Task InitializeBasicSystemTrayAsync() + { + try + { + this.LogDebug("Initializing basic system tray (without full context menu)..."); + + // Initialize basic tray icon (this is idempotent) + this.systemTrayService.Initialize(); + this.systemTrayService.Show(); + + // Subscribe to essential tray events only + this.UnsubscribeSystemTrayEvents(); + this.systemTrayService.ShowMainWindowRequested += this.OnShowMainWindowRequested; + this.systemTrayService.DashboardRequested += this.OnDashboardRequested; + this.systemTrayService.ExitRequested += this.OnExitRequested; + + // Update basic settings and tooltip + this.systemTrayService.UpdateSettings(this.settingsService.Settings); + this.systemTrayService.ApplyTheme(this.themeService.IsDarkTheme); + this.systemTrayService.UpdateTooltip("ThreadPilot - Process & Power Plan Manager (Basic Mode)"); + + this.LogDebug("Basic system tray initialization completed"); + } + catch (Exception ex) + { + this.LogDebug($"Failed to initialize basic system tray: {ex.Message}"); + throw; + } + } + + private void OnShowMainWindowRequested(object? sender, EventArgs e) + { + this.ShowWindowFromTray(); + } + + private void OnExitRequested(object? sender, EventArgs e) + { + TaskSafety.FireAndForget(this.OnExitRequestedAsync(), ex => + { + this.LogDebug($"OnExitRequested failed: {ex.Message}"); + }); + } + + private async Task OnExitRequestedAsync() + { + await this.PerformGracefulShutdownAsync(); + } + + private void OnDashboardRequested(object? sender, EventArgs e) + { + this.ShowWindowFromTray("Process"); + } + + private async Task PerformGracefulShutdownAsync(bool validateUnsavedChanges = true) + { + if (this.isPerformingShutdown) + { + return; + } + + if (validateUnsavedChanges && !await this.HandleUnsavedSettingsBeforeExitAsync()) + { + return; + } + + this.isPerformingShutdown = true; + + try + { + this.LogDebug("Starting graceful shutdown..."); + this.selfResourceManagementService.RestoreForegroundMode(); + + // 1. Stop monitoring services + try + { + this.LogDebug("Stopping process monitoring manager..."); + await this.processMonitorManagerService.StopAsync(); + this.LogDebug("Process monitoring manager stopped"); + } + catch (Exception ex) + { + this.LogDebug($"Error stopping process monitoring: {ex.Message}"); + } + + // 2. Cleanup applied CPU masks (like CPU Set Setter's ClearAllProcessMasksNoSave) + if (this.settingsService.Settings.ClearMasksOnClose) + { + try + { + this.LogDebug("Clearing all applied CPU masks..."); + var processService = this.serviceProvider.GetRequiredService(); + await processService.ClearAllAppliedMasksAsync(); + this.LogDebug("CPU masks cleared"); + } + catch (Exception ex) + { + this.LogDebug($"Error clearing CPU masks: {ex.Message}"); + } + + // Also reset priorities + try + { + this.LogDebug("Resetting all process priorities..."); + var processService = this.serviceProvider.GetRequiredService(); + await processService.ResetAllProcessPrioritiesAsync(); + this.LogDebug("Process priorities reset"); + } + catch (Exception ex) + { + this.LogDebug($"Error resetting priorities: {ex.Message}"); + } + } + + // 3. Restore default power plan if configured + if (this.settingsService.Settings.RestoreDefaultPowerPlanOnExit) + { + try + { + var targetDefaultPowerPlanGuid = this.settingsService.Settings.DefaultPowerPlanId; + + try + { + await this.processPowerPlanAssociationService.LoadConfigurationAsync(); + var (associationDefaultPowerPlanGuid, _) = await this.processPowerPlanAssociationService.GetDefaultPowerPlanAsync(); + if (!string.IsNullOrWhiteSpace(associationDefaultPowerPlanGuid)) + { + targetDefaultPowerPlanGuid = associationDefaultPowerPlanGuid; + } + } + catch (Exception associationEx) + { + this.LogDebug($"Could not read default power plan from association config: {associationEx.Message}"); + } + + if (string.IsNullOrWhiteSpace(targetDefaultPowerPlanGuid)) + { + this.LogDebug("No default power plan configured for restore on exit"); + } + else + { + this.LogDebug("Restoring default power plan..."); + var powerPlanService = this.serviceProvider.GetRequiredService(); + await powerPlanService.SetActivePowerPlanByGuidAsync(targetDefaultPowerPlanGuid); + this.LogDebug("Default power plan restored"); + } + } + catch (Exception ex) + { + this.LogDebug($"Error restoring power plan: {ex.Message}"); + } + } + + // 4. Save settings + try + { + this.LogDebug("Saving settings..."); + await this.settingsService.SaveSettingsAsync(); + this.LogDebug("Settings saved"); + } + catch (Exception ex) + { + this.LogDebug($"Error saving settings: {ex.Message}"); + } + + // 5. Dispose tray service + try + { + this.LogDebug("Disposing system tray..."); + this.systemTrayService.Dispose(); + this.LogDebug("System tray disposed"); + } + catch (Exception ex) + { + this.LogDebug($"Error disposing tray: {ex.Message}"); + } + + this.LogDebug("Graceful shutdown completed"); + } + catch (Exception ex) + { + this.LogDebug($"Error during graceful shutdown: {ex.Message}"); + } + finally + { + // Ensure application exits + System.Windows.Application.Current.Shutdown(); + } + } + + private async Task HandleUnsavedSettingsBeforeExitAsync() + { + if (!this.settingsViewModel.HasPendingChanges) + { + return true; + } + + var result = await this.ShowUnsavedSettingsDialogAsync( + "You have unsaved changes in Settings. Save before exiting, discard the changes, or cancel to return to ThreadPilot."); + + if (result == MessageBoxResult.Cancel) + { + return false; + } + + if (result == MessageBoxResult.Yes) + { + var saved = await this.settingsViewModel.SaveIfDirtyAsync(); + return saved; + } + + await this.settingsViewModel.DiscardPendingChangesAsync(); + return true; + } + + private async Task HandleWindowCloseAsync() + { + if (!await this.HandleUnsavedSettingsBeforeExitAsync()) + { + return; + } + + if (this.settingsService.Settings.CloseToTray) + { + this.WindowState = WindowState.Minimized; + return; + } + + await this.PerformGracefulShutdownAsync(validateUnsavedChanges: false); + } + + private void OnMonitoringToggleRequested(object? sender, MonitoringToggleEventArgs e) + { + TaskSafety.FireAndForget(this.OnMonitoringToggleRequestedAsync(e), ex => + { + this.LogDebug($"OnMonitoringToggleRequested failed: {ex.Message}"); + }); + } + + private async Task OnMonitoringToggleRequestedAsync(MonitoringToggleEventArgs e) + { + try + { + if (e.EnableMonitoring) + { + await this.processMonitorManagerService.StartAsync(); + await this.notificationService.ShowSuccessNotificationAsync( + "Automation Monitoring Enabled", + "Process rule automation and power plan management have been enabled."); + } + else + { + await this.processMonitorManagerService.StopAsync(); + await this.notificationService.ShowNotificationAsync( + "Automation Monitoring Disabled", + "Process rule automation and power plan management have been disabled.", + Models.NotificationType.Warning); + } + } + catch (Exception ex) + { + await this.notificationService.ShowErrorNotificationAsync( + "Automation Monitoring Error", + "Failed to toggle automation monitoring.", + ex); + } + } + + private void OnSettingsRequested(object? sender, EventArgs e) + { + try + { + this.ShowWindowFromTray("Settings"); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to open settings: {ex.Message}"); + } + } + + private void OnPowerPlanChangeRequested(object? sender, PowerPlanChangeRequestedEventArgs e) + { + TaskSafety.FireAndForget(this.OnPowerPlanChangeRequestedAsync(e), ex => + { + this.LogDebug($"OnPowerPlanChangeRequested failed: {ex.Message}"); + }); + } + + private async Task OnPowerPlanChangeRequestedAsync(PowerPlanChangeRequestedEventArgs e) + { + try + { + var powerPlanService = this.serviceProvider.GetRequiredService(); + var success = await powerPlanService.SetActivePowerPlanByGuidAsync(e.PowerPlanGuid); + + if (success) + { + this.systemTrayService.ShowBalloonTip( + "ThreadPilot", + $"Power plan changed to {e.PowerPlanName}", 2000); + } + else + { + this.systemTrayService.ShowBalloonTip( + "ThreadPilot Error", + $"Failed to change power plan to {e.PowerPlanName}", 3000); + } + } + catch (Exception ex) + { + this.systemTrayService.ShowBalloonTip( + "ThreadPilot Error", + $"Error changing power plan: {ex.Message}", 3000); + } + } + + private void OnProfileApplicationRequested(object? sender, ProfileApplicationRequestedEventArgs e) + { + TaskSafety.FireAndForget(this.OnProfileApplicationRequestedAsync(e), ex => + { + this.LogDebug($"OnProfileApplicationRequested failed: {ex.Message}"); + }); + } + + private async Task OnProfileApplicationRequestedAsync(ProfileApplicationRequestedEventArgs e) + { + try + { + var processService = this.serviceProvider.GetRequiredService(); + var selectedProcess = this.processViewModel.SelectedProcess; + + if (selectedProcess != null) + { + var success = await processService.LoadProcessProfile(e.ProfileName, selectedProcess); + + if (success) + { + this.systemTrayService.ShowBalloonTip( + "ThreadPilot", + $"Profile '{e.ProfileName}' applied to {selectedProcess.Name}", 2000); + } + else + { + this.systemTrayService.ShowBalloonTip( + "ThreadPilot Error", + $"Failed to apply profile '{e.ProfileName}'", 3000); + } + } + else + { + this.systemTrayService.ShowBalloonTip( + "ThreadPilot", + "No process selected for profile application", 2000); + } + } + catch (Exception ex) + { + this.systemTrayService.ShowBalloonTip( + "ThreadPilot Error", + $"Error applying profile: {ex.Message}", 3000); + } + } + + private void OnPerformanceDashboardRequested(object? sender, EventArgs e) + { + try + { + this.ShowWindowFromTray("Performance"); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to open performance dashboard: {ex.Message}"); + } + } + + private async Task InitializeKeyboardShortcutsAsync() + { + try + { + // Set window handle for global hotkey registration + var windowInteropHelper = new System.Windows.Interop.WindowInteropHelper(this); + var handle = windowInteropHelper.EnsureHandle(); + + if (this.keyboardShortcutService is KeyboardShortcutService service) + { + service.SetWindowHandle(handle); + } + + // Subscribe to shortcut activation events + this.keyboardShortcutService.ShortcutActivated -= this.OnShortcutActivated; + this.keyboardShortcutService.ShortcutActivated += this.OnShortcutActivated; + + // Load shortcuts from settings - with error handling + try + { + await this.keyboardShortcutService.LoadShortcutsFromSettingsAsync(); + } + catch (Exception settingsEx) + { + System.Diagnostics.Debug.WriteLine($"Failed to load shortcuts from settings, using defaults: {settingsEx.Message}"); + // Continue with default shortcuts if settings loading fails + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to initialize keyboard shortcuts: {ex.Message}"); + // Don't let keyboard shortcut initialization failure prevent the app from starting + } + } + + private void OnShortcutActivated(object? sender, ShortcutActivatedEventArgs e) + { + try + { + System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => + { + await this.HandleShortcutActionAsync(e.ActionName); + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error handling shortcut {e.ActionName}: {ex.Message}"); + } + } + + private async Task HandleShortcutActionAsync(string actionName) + { + switch (actionName) + { + case ShortcutActions.ShowMainWindow: + if (this.IsVisible && this.WindowState != WindowState.Minimized) + { + this.ShowInTaskbar = false; + this.Hide(); + this.ApplyAppRefreshPolicy(AppActivityState.TrayHidden); + } + else + { + this.ShowWindowFromTray(); + } + break; + + case ShortcutActions.ToggleMonitoring: + // Toggle monitoring - implementation can be added later + await this.notificationService.ShowNotificationAsync("Keyboard Shortcut", "Toggle monitoring shortcut activated"); + break; + + case ShortcutActions.PowerPlanHighPerformance: + // Switch to high performance power plan - implementation can be added later + await this.notificationService.ShowNotificationAsync("Keyboard Shortcut", "High Performance power plan shortcut activated"); + break; + + case ShortcutActions.OpenTweaks: + this.ShowWindowFromTray("Tweaks"); + break; + + case ShortcutActions.OpenSettings: + this.ShowWindowFromTray("Settings"); + break; + + case ShortcutActions.RefreshProcessList: + // Refresh process list - implementation can be added later + await this.notificationService.ShowNotificationAsync("Keyboard Shortcut", "Refresh process list shortcut activated"); + break; + + case ShortcutActions.ExitApplication: + this.Close(); + break; + } + } + + private async Task UpdateSystemTrayContextMenuAsync() + { + try + { + await this.systemTrayStatusUpdater.UpdateContextMenuAsync(this.systemTrayService); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to update system tray context menu: {ex.Message}"); + } + } + + private void StartSystemTrayUpdateTimer() + { + try + { + this.systemTrayUpdateTimer?.Stop(); + this.systemTrayUpdateTimer?.Dispose(); + this.systemTrayUpdateTimer = null; + + if (!this.systemTrayStatusUpdater.ShouldRunPerformanceStatusUpdates) + { + return; + } + + this.systemTrayUpdateFailureStreak = 0; + this.systemTrayUpdateTimer = new System.Timers.Timer(SystemTrayUpdateBaseIntervalMs); + this.systemTrayUpdateTimer.Elapsed += async (s, e) => + { + if (this.isSystemTrayUpdatesSuspended) + { + return; + } + + if (Interlocked.Exchange(ref this.isSystemTrayUpdateInProgress, 1) == 1) + { + return; + } + + try + { + var updateSucceeded = await this.UpdateSystemTrayStatusAsync(); + this.ApplySystemTrayUpdateBackoff(updateSucceeded); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error in system tray update timer: {ex.Message}"); + this.ApplySystemTrayUpdateBackoff(updateSucceeded: false); + } + finally + { + Interlocked.Exchange(ref this.isSystemTrayUpdateInProgress, 0); + } + }; + this.systemTrayUpdateTimer.AutoReset = true; + + if (!this.isSystemTrayUpdatesSuspended && + this.IsVisible && + this.WindowState != WindowState.Minimized) + { + this.systemTrayUpdateTimer.Start(); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to start system tray update timer: {ex.Message}"); + } + } + + private void ApplySystemTrayUpdateBackoff(bool updateSucceeded) + { + if (this.systemTrayUpdateTimer == null) + { + return; + } + + if (updateSucceeded) + { + this.systemTrayUpdateFailureStreak = 0; + if (Math.Abs(this.systemTrayUpdateTimer.Interval - SystemTrayUpdateBaseIntervalMs) > 1) + { + this.systemTrayUpdateTimer.Interval = SystemTrayUpdateBaseIntervalMs; + } + + return; + } + + this.systemTrayUpdateFailureStreak = Math.Min(4, this.systemTrayUpdateFailureStreak + 1); + var exponentialDelay = SystemTrayUpdateBaseIntervalMs * Math.Pow(2, this.systemTrayUpdateFailureStreak); + var nextIntervalMs = Math.Min(SystemTrayUpdateMaxIntervalMs, exponentialDelay); + + if (Math.Abs(this.systemTrayUpdateTimer.Interval - nextIntervalMs) > 1) + { + this.systemTrayUpdateTimer.Interval = nextIntervalMs; + } + } + + private async Task UpdateSystemTrayStatusAsync() + { + try + { + return await this.systemTrayStatusUpdater.UpdateStatusAsync( + this.systemTrayService, + action => this.Dispatcher.InvokeAsync(action).Task); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to update system tray status: {ex.Message}"); + return false; + } + } + + private void InitializeNotifications() + { + try + { + // Subscribe to settings changes to update notification service + this.settingsService.SettingsChanged += this.OnSettingsChanged; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to initialize notifications: {ex.Message}"); + } + } + + private async Task InitializeMonitoringAsync() + { + try + { + // Subscribe to monitoring status changes + this.processMonitorService.MonitoringStatusChanged += this.OnMonitoringStatusChanged; + + // Update tray with initial monitoring status + this.systemTrayService.UpdateMonitoringStatus( + this.processMonitorService.IsMonitoring, + this.processMonitorService.IsWmiAvailable); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to initialize monitoring: {ex.Message}"); + } + } + + private async Task StartProcessMonitoringManagerAsync() + { + try + { + this.LogDebug("Subscribing to process monitor manager events..."); + // Subscribe to process monitor manager events + this.processMonitorManagerService.ServiceStatusChanged += this.OnProcessMonitorManagerStatusChanged; + + this.LogDebug("Starting process monitoring manager service..."); + // Start the process monitoring manager service with internal timeout + var startTask = this.processMonitorManagerService.StartAsync(); + var timeoutTask = Task.Delay(6000); // 6 second internal timeout + var completedTask = await Task.WhenAny(startTask, timeoutTask); + + if (completedTask == timeoutTask) + { + this.LogDebug("ProcessMonitorManagerService.StartAsync() timed out internally"); + throw new TimeoutException("Process monitoring manager service startup timed out"); + } + + await startTask; // Get any exceptions + this.LogDebug("Process monitoring manager service started, showing notification..."); + + if (!this.isSilentStartupMode) + { + await this.notificationService.ShowSuccessNotificationAsync( + "ThreadPilot Started", + "Process monitoring and power plan management is now active"); + } + + this.LogDebug(this.isSilentStartupMode + ? "Startup success notification skipped for silent startup" + : "Success notification shown"); + } + catch (Exception ex) + { + this.LogDebug($"Failed to start process monitoring manager: {ex.Message}"); + try + { + await this.notificationService.ShowErrorNotificationAsync( + "Startup Error", + "Failed to start process monitoring manager", + ex); + } + catch (Exception notificationEx) + { + this.LogDebug($"Failed to show error notification: {notificationEx.Message}"); + } + throw; // Re-throw to be caught by outer handler + } + } + + private void OnSettingsChanged(object? sender, ApplicationSettingsChangedEventArgs e) + { + // Update tray service with new settings + this.systemTrayService.UpdateSettings(e.NewSettings); + + var useDarkTheme = e.NewSettings.HasUserThemePreference + ? e.NewSettings.UseDarkTheme + : this.themeService.GetSystemUsesDarkTheme(); + + this.themeService.ApplyTheme(useDarkTheme); + this.mainWindowViewModel.IsDarkTheme = useDarkTheme; + this.systemTrayService.ApplyTheme(useDarkTheme); + DwmHelper.ApplyWindowCaptionTheme(this, useDarkTheme); + this.ApplySelfResourcePolicy(this.lastAppliedRefreshState ?? this.GetForegroundActivityState(), e.NewSettings); + } + + private void OnMonitoringStatusChanged(object? sender, MonitoringStatusEventArgs e) + { + // Update tray icon and status + this.systemTrayService.UpdateMonitoringStatus(e.IsMonitoring, e.IsWmiAvailable); + + // Show notification if there's an error + if (e.Error != null && this.settingsService.Settings.EnableErrorNotifications) + { + this.notificationService.ShowErrorNotificationAsync( + "Automation Monitoring Error", + e.StatusMessage ?? "An error occurred with automation monitoring.", + e.Error); + } + } + + private void OnProcessMonitorManagerStatusChanged(object? sender, ServiceStatusEventArgs e) + { + // Update main window status + this.mainWindowViewModel.UpdateProcessMonitoringStatus(e.IsRunning, e.Status); + + // Show notification for critical status changes + if (!e.IsRunning && e.Error != null && this.settingsService.Settings.EnableErrorNotifications) + { + this.notificationService.ShowErrorNotificationAsync( + "Automation Monitoring Error", + e.Details ?? "Automation monitoring encountered an error.", + e.Error); + } + } + + protected override void OnStateChanged(EventArgs e) + { + try + { + 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.ApplyAppRefreshPolicy(activityState); + } + else if (this.WindowState == WindowState.Normal || this.WindowState == WindowState.Maximized) + { + this.ShowInTaskbar = true; + this.EnsureDashboardVisibleOnScreen(); + + this.ApplyAppRefreshPolicy(this.GetForegroundActivityState()); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error handling window state change: {ex.Message}"); + } + + base.OnStateChanged(e); + } + + private void SuspendHiddenModeRefreshes() + { + this.isSystemTrayUpdatesSuspended = true; + this.systemTrayUpdateTimer?.Stop(); + Interlocked.Exchange(ref this.isSystemTrayUpdateInProgress, 0); + this.powerPlanViewModel.PauseAutoRefresh(); + } + + private void ResumeForegroundRefreshes() + { + this.isSystemTrayUpdatesSuspended = false; + this.systemTrayUpdateFailureStreak = 0; + this.systemTrayUpdateTimer?.Stop(); + + if (!this.systemTrayStatusUpdater.ShouldRunPerformanceStatusUpdates) + { + return; + } + + if (this.systemTrayUpdateTimer != null) + { + this.systemTrayUpdateTimer.Interval = SystemTrayUpdateBaseIntervalMs; + } + this.systemTrayUpdateTimer?.Start(); + + _ = this.Dispatcher.InvokeAsync(async () => + { + try + { + var updateSucceeded = await this.UpdateSystemTrayStatusAsync(); + this.ApplySystemTrayUpdateBackoff(updateSucceeded); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to refresh tray status after resume: {ex.Message}"); + } + }); + } + + private AppActivityState GetForegroundActivityState() + { + if (this.ProcessManagementTab.Visibility == Visibility.Visible) + { + return AppActivityState.ForegroundProcessView; + } + + return this.PerformanceViewControl.Visibility == Visibility.Visible + ? AppActivityState.ForegroundDiagnosticsView + : AppActivityState.ForegroundOtherTab; + } + + private void ApplyAppRefreshPolicy(AppActivityState state) + { + if (!AppRefreshPolicy.ShouldApplyTransition(this.lastAppliedRefreshState, state)) + { + return; + } + + this.lastAppliedRefreshState = 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.GetPerformanceViewModel().ActivateDiagnosticsAsync(); + } + else if (this.performanceViewModel != null) + { + _ = this.performanceViewModel.SuspendBackgroundMonitoringAsync(); + } + + this.ApplySelfResourcePolicy(state); + } + + private void ApplySelfResourcePolicy(AppActivityState state, ApplicationSettingsModel? settings = null) + { + var currentSettings = settings ?? this.settingsService.Settings; + var isHiddenState = state is AppActivityState.Minimized or AppActivityState.TrayHidden; + + if (SelfResourcePolicy.ShouldApplyLowImpactMode(isHiddenState, currentSettings.EnableSelfLowImpactMode)) + { + this.selfResourceManagementService.ApplyLowImpactMode(SelfResourcePolicy.ShouldLimitAffinity( + isHiddenState, + currentSettings.EnableSelfLowImpactMode, + currentSettings.EnableSelfAffinityLimit)); + return; + } + + this.selfResourceManagementService.RestoreForegroundMode(); + } + + protected override void OnSourceInitialized(EventArgs e) + { + base.OnSourceInitialized(e); + try + { + DwmHelper.ApplyWindowCaptionTheme(this, this.themeService.IsDarkTheme); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to apply window caption theme: {ex.Message}"); + } + + this.EnsureDashboardVisibleOnScreen(); + } + + [System.Diagnostics.Conditional("DEBUG")] + private void LogDebug(string message) + { + try + { + var timestampedMessage = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [MainWindow] {message}"; + System.Diagnostics.Debug.WriteLine(timestampedMessage); + File.AppendAllText(this.debugLogPath, timestampedMessage + Environment.NewLine); + } + catch + { + // Swallow logging failures to avoid impacting runtime behavior + } + } + + private void OnWindowLoaded(object? sender, RoutedEventArgs e) + { + TaskSafety.FireAndForget(this.OnWindowLoadedAsync(), ex => + { + this.LogDebug($"OnWindowLoaded failed: {ex.Message}"); + }); + } + + private async Task OnWindowLoadedAsync() + { + this.Loaded -= this.OnWindowLoaded; + this.EnsureDashboardVisibleOnScreen(); + await this.InitializeKeyboardShortcutsAsync(); + } + + private void OnOpenRulesRequested(object? sender, EventArgs e) + { + this.SelectMainTab("Rules"); + } + + private void ShowWindowFromTray(string? tabTag = null) + { + this.ShowInTaskbar = true; + this.EnsureDashboardVisibleOnScreen(); + + if (!this.IsVisible) + { + this.Show(); + } + else + { + this.Visibility = Visibility.Visible; + } + + if (this.WindowState == WindowState.Minimized) + { + this.WindowState = WindowState.Normal; + } + + this.EnsureDashboardVisibleOnScreen(); + this.ShowInTaskbar = true; + + if (tabTag != null) + { + this.SelectMainTab(tabTag); + } + + // Force foreground restoration when invoked from tray context menu. + this.Topmost = true; + 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.ApplyAppRefreshPolicy(processViewWillBeActive + ? AppActivityState.ForegroundProcessView + : AppActivityState.ForegroundOtherTab); + } + + internal bool EnsureDashboardVisibleOnScreen() + { + return WindowPlacementHelper.TryCorrectWindowPlacement(this); + } + + private void SelectMainTab(string tag) + { + if (string.IsNullOrEmpty(tag)) + { + return; + } + + if (string.Equals(tag, "Performance", StringComparison.Ordinal)) + { + this.GetPerformanceViewModel(); + } + + this.ApplySectionVisibility(tag); + + if (string.Equals(tag, "Performance", StringComparison.Ordinal)) + { + this.TryShowPerformanceIntro(); + } + } + + private void TryShowPerformanceIntro() + { + if (this.isPerformanceIntroVisible || !this.isInitializationComplete) + { + return; + } + + try + { + var settings = this.settingsService.Settings; + if (settings.HasSeenPerformanceIntro) + { + return; + } + + this.isPerformanceIntroVisible = true; + this.PerformanceIntroOverlay.Visibility = Visibility.Visible; + } + catch (Exception ex) + { + this.LogDebug($"Failed to show Performance intro overlay: {ex.Message}"); + } + } + + private void TryShowStartupMinimizedSuggestion() + { + if (!this.showStartupMinimizedSuggestionOnReady + || this.isSilentStartupMode + || !this.isInitializationComplete + || this.isElevationWarningVisible) + { + return; + } + + try + { + if (!StartupMinimizedSuggestionPolicy.ShouldShow( + this.settingsService.Settings, + StartupWindowBehavior.Resolve(isAutostart: false, startMinimized: false))) + { + return; + } + + this.StartupMinimizedSuggestionOverlay.Visibility = Visibility.Visible; + } + catch (Exception ex) + { + this.LogDebug($"Failed to show startup minimized suggestion: {ex.Message}"); + } + } + + private async Task PersistStartupMinimizedSuggestionSeenAsync() + { + try + { + var settings = this.settingsService.Settings; + if (settings.HasSeenStartupMinimizedSuggestion) + { + return; + } + + settings.HasSeenStartupMinimizedSuggestion = true; + await this.settingsService.UpdateSettingsAsync(settings); + } + catch (Exception ex) + { + this.LogDebug($"Failed to persist startup minimized suggestion state: {ex.Message}"); + } + } + + private void HideStartupMinimizedSuggestion() + { + this.showStartupMinimizedSuggestionOnReady = false; + this.StartupMinimizedSuggestionOverlay.Visibility = Visibility.Collapsed; + } + + private async void StartupSuggestionOpenSettings_Click(object sender, RoutedEventArgs e) + { + await this.PersistStartupMinimizedSuggestionSeenAsync(); + this.HideStartupMinimizedSuggestion(); + this.SelectMainTab("Settings"); + } + + private async void StartupSuggestionDontShowAgain_Click(object sender, RoutedEventArgs e) + { + await this.PersistStartupMinimizedSuggestionSeenAsync(); + this.HideStartupMinimizedSuggestion(); + } + + private void HidePerformanceIntro() + { + this.isPerformanceIntroVisible = false; + this.PerformanceIntroOverlay.Visibility = Visibility.Collapsed; + } + + private Task ShowUnsavedSettingsDialogAsync(string message) + { + if (!this.Dispatcher.CheckAccess()) + { + return this.Dispatcher.InvokeAsync(() => this.ShowUnsavedSettingsDialogAsync(message)).Task.Unwrap(); + } + + if (this.unsavedSettingsDialogCompletionSource != null) + { + return Task.FromResult(MessageBoxResult.Cancel); + } + + this.UnsavedSettingsDialogMessage.Text = message; + this.unsavedSettingsDialogCompletionSource = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + this.UnsavedSettingsOverlay.Visibility = Visibility.Visible; + return this.unsavedSettingsDialogCompletionSource.Task; + } + + private void CompleteUnsavedSettingsDialog(MessageBoxResult result) + { + var completionSource = this.unsavedSettingsDialogCompletionSource; + if (completionSource == null) + { + return; + } + + this.unsavedSettingsDialogCompletionSource = null; + this.UnsavedSettingsOverlay.Visibility = Visibility.Collapsed; + completionSource.TrySetResult(result); + } + + private void UnsavedSettingsSave_Click(object sender, RoutedEventArgs e) + { + this.CompleteUnsavedSettingsDialog(MessageBoxResult.Yes); + } + + private void UnsavedSettingsDiscard_Click(object sender, RoutedEventArgs e) + { + this.CompleteUnsavedSettingsDialog(MessageBoxResult.No); + } + + private void UnsavedSettingsCancel_Click(object sender, RoutedEventArgs e) + { + this.CompleteUnsavedSettingsDialog(MessageBoxResult.Cancel); + } + + private async void PerformanceIntroContinue_Click(object sender, RoutedEventArgs e) + { + try + { + var settings = this.settingsService.Settings; + if (!settings.HasSeenPerformanceIntro) + { + settings.HasSeenPerformanceIntro = true; + await this.settingsService.UpdateSettingsAsync(settings); + } + } + catch (Exception ex) + { + this.LogDebug($"Failed to persist Performance intro state: {ex.Message}"); + } + finally + { + this.HidePerformanceIntro(); + } + } + + // Elevation Warning Modal Management + private bool isElevationWarningVisible = false; + private double previousElevationAppContentOpacity = 1; + private double previousElevationBackdropBlurRadius = 0; + + private void TryShowElevationWarning() + { + if (this.isElevationWarningVisible || !this.isInitializationComplete) + { + return; + } + + try + { + var settings = this.settingsService.Settings; + + // Only show if user is not admin AND hasn't dismissed the warning yet + if (this.elevationService?.IsRunningAsAdministrator() == true || settings.HasSeenElevationWarning) + { + return; + } + + this.isElevationWarningVisible = true; + var elevationOverlay = this.FindName("ElevationWarningOverlay") as Grid; + if (elevationOverlay != null) + { + elevationOverlay.Visibility = Visibility.Visible; + } + + // Apply blur and disable interaction + this.previousElevationAppContentOpacity = this.UIContent.Opacity; + this.UIContent.IsHitTestVisible = false; + this.UIContent.Opacity = 0.74; + + var elevationBlur = this.FindName("ElevationWarningBlur") as BlurEffect; + if (elevationBlur != null) + { + this.previousElevationBackdropBlurRadius = elevationBlur.Radius; + elevationBlur.Radius = 16; + } + } + catch (Exception ex) + { + this.LogDebug($"Failed to show elevation warning overlay: {ex.Message}"); + } + } + + private void HideElevationWarning() + { + this.isElevationWarningVisible = false; + var elevationOverlay = this.FindName("ElevationWarningOverlay") as Grid; + if (elevationOverlay != null) + { + elevationOverlay.Visibility = Visibility.Collapsed; + } + + // Restore interaction and remove blur + this.UIContent.IsHitTestVisible = true; + this.UIContent.Opacity = this.previousElevationAppContentOpacity; + + var elevationBlur = this.FindName("ElevationWarningBlur") as BlurEffect; + if (elevationBlur != null) + { + elevationBlur.Radius = this.previousElevationBackdropBlurRadius; + } + + this.TryShowStartupMinimizedSuggestion(); + } + + private void ElevationWarningDismiss_Click(object sender, RoutedEventArgs e) + { + try + { + var settings = this.settingsService.Settings; + if (!settings.HasSeenElevationWarning) + { + settings.HasSeenElevationWarning = true; + _ = this.settingsService.UpdateSettingsAsync(settings); + } + } + catch (Exception ex) + { + this.LogDebug($"Failed to persist elevation warning dismiss state: {ex.Message}"); + } + finally + { + this.HideElevationWarning(); + } + } + + private async void ElevationWarningRequestElevation_Click(object sender, RoutedEventArgs e) + { + try + { + if (this.elevationService != null) + { + var success = await this.elevationService.RequestElevationIfNeeded(); + if (success) + { + System.Diagnostics.Debug.WriteLine("Elevation requested successfully from warning dialog"); + } + } + } + catch (Exception ex) + { + this.LogDebug($"Failed to request elevation from warning dialog: {ex.Message}"); + } + finally + { + // Hide the warning after attempting elevation (regardless of success) + this.HideElevationWarning(); + } + } + + private void ApplySectionVisibility(string tag) + { + this.ProcessManagementTab.Visibility = tag == "Process" ? Visibility.Visible : Visibility.Collapsed; + this.CoreMasksTab.Visibility = tag == "Masks" ? Visibility.Visible : Visibility.Collapsed; + this.PowerPlanViewControl.Visibility = tag == "Power" ? Visibility.Visible : Visibility.Collapsed; + this.AssociationView.Visibility = tag == "Rules" ? Visibility.Visible : Visibility.Collapsed; + this.PerformanceViewControl.Visibility = tag == "Performance" ? Visibility.Visible : Visibility.Collapsed; + this.LogViewerViewControl.Visibility = tag == "Logs" ? Visibility.Visible : Visibility.Collapsed; + this.SystemTweaksView.Visibility = tag == "Tweaks" ? Visibility.Visible : Visibility.Collapsed; + this.SettingsView.Visibility = tag == "Settings" ? Visibility.Visible : Visibility.Collapsed; + + this.NavProcess.IsActive = tag == "Process"; + this.NavMasks.IsActive = tag == "Masks"; + this.NavPower.IsActive = tag == "Power"; + this.NavRules.IsActive = tag == "Rules"; + this.NavPerf.IsActive = tag == "Performance"; + this.NavLogs.IsActive = tag == "Logs"; + this.NavTweaks.IsActive = tag == "Tweaks"; + this.NavSettings.IsActive = tag == "Settings"; + + if (!this.IsVisible) + { + this.ApplyAppRefreshPolicy(AppActivityState.TrayHidden); + return; + } + + if (this.WindowState == WindowState.Minimized) + { + this.ApplyAppRefreshPolicy(AppActivityState.Minimized); + return; + } + + var activityState = tag switch + { + "Process" => AppActivityState.ForegroundProcessView, + "Performance" => AppActivityState.ForegroundDiagnosticsView, + _ => AppActivityState.ForegroundOtherTab, + }; + + this.ApplyAppRefreshPolicy(activityState); + } + + private void NavMenuItem_Click(object sender, RoutedEventArgs e) + { + TaskSafety.FireAndForget(this.NavMenuItem_ClickAsync(sender, e), ex => + { + this.LogDebug($"NavMenuItem_Click failed: {ex.Message}"); + }); + } + + private async Task NavMenuItem_ClickAsync(object sender, RoutedEventArgs e) + { + if (!await this.navigationBehavior.TryEnterAsync()) + { + return; + } + + try + { + var invokedItem = sender as Wpf.Ui.Controls.NavigationViewItem; + if (invokedItem == null) + { + return; + } + + var tag = invokedItem.Tag?.ToString(); + if (string.IsNullOrEmpty(tag)) + { + return; + } + + if (!this.IsLoaded) + { + return; + } + + var canNavigate = await NavigationBehavior.EnsureCanNavigateAsync( + tag, + this.settingsViewModel, + () => this.ShowUnsavedSettingsDialogAsync( + "You have unsaved changes in Settings. Save before switching tabs, discard the changes, or cancel to stay on Settings.")); + if (!canNavigate) + { + return; + } + + if (string.Equals(tag, "Performance", StringComparison.Ordinal)) + { + this.GetPerformanceViewModel(); + } + + this.ApplySectionVisibility(tag); + + if (string.Equals(tag, "Performance", StringComparison.Ordinal)) + { + this.TryShowPerformanceIntro(); + } + } + finally + { + this.navigationBehavior.Exit(); + } + } + + protected override void OnClosing(System.ComponentModel.CancelEventArgs e) + { + if (this.isPerformingShutdown) + { + base.OnClosing(e); + return; + } + + if (this.isPerformanceIntroVisible) + { + e.Cancel = true; + System.Windows.MessageBox.Show( + "Please complete the Performance introduction before closing the application.\n\nClick 'Continue to Performance' to proceed.", + "Performance Introduction Required", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + e.Cancel = true; + _ = this.HandleWindowCloseAsync(); + } + + protected override void OnClosed(EventArgs e) + { + try + { + this.Loaded -= this.OnWindowLoaded; + this.processViewModel.OpenRulesRequested -= this.OnOpenRulesRequested; + + this.settingsService.SettingsChanged -= this.OnSettingsChanged; + this.processMonitorService.MonitoringStatusChanged -= this.OnMonitoringStatusChanged; + this.processMonitorManagerService.ServiceStatusChanged -= this.OnProcessMonitorManagerStatusChanged; + this.keyboardShortcutService.ShortcutActivated -= this.OnShortcutActivated; + + this.UnsubscribeSystemTrayEvents(); + + this.systemTrayUpdateTimer?.Stop(); + this.systemTrayUpdateTimer?.Dispose(); + + this.initializationTimeoutTimer?.Stop(); + this.initializationTimeoutTimer?.Dispose(); + this.performanceViewModel?.Dispose(); + + this.selfResourceManagementService.RestoreForegroundMode(); + this.navigationBehavior.Dispose(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error disposing timers: {ex.Message}"); + } + + base.OnClosed(e); + } + + private void UnsubscribeSystemTrayEvents() + { + this.systemTrayService.ShowMainWindowRequested -= this.OnShowMainWindowRequested; + this.systemTrayService.DashboardRequested -= this.OnDashboardRequested; + this.systemTrayService.ExitRequested -= this.OnExitRequested; + this.systemTrayService.MonitoringToggleRequested -= this.OnMonitoringToggleRequested; + this.systemTrayService.SettingsRequested -= this.OnSettingsRequested; + this.systemTrayService.PowerPlanChangeRequested -= this.OnPowerPlanChangeRequested; + this.systemTrayService.ProfileApplicationRequested -= this.OnProfileApplicationRequested; + this.systemTrayService.PerformanceDashboardRequested -= this.OnPerformanceDashboardRequested; + } + } +} diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 22b9c91..eec3de9 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1,216 +1,200 @@ -/* - * 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 -{ - using System; - using System.Collections.Generic; - using System.IO; - using System.Linq; - using System.Threading; - using System.Threading.Tasks; - using System.Timers; - using System.Windows; - using System.Windows.Controls; - using System.Windows.Input; - using System.Windows.Media.Animation; - using System.Windows.Media.Effects; - using System.Windows.Media.Imaging; - using Microsoft.Extensions.DependencyInjection; - using ThreadPilot.Helpers; - using ThreadPilot.Services; - using ThreadPilot.ViewModels; - using ThreadPilot.Views; - - public partial class MainWindow : Wpf.Ui.Controls.FluentWindow - { - private const int SystemTrayUpdateBaseIntervalMs = 10000; - private const int SystemTrayUpdateMaxIntervalMs = 60000; - - private readonly ProcessViewModel processViewModel; - private readonly PowerPlanViewModel powerPlanViewModel; - private readonly IDiagnosticsViewModelProvider diagnosticsViewModelProvider; - private readonly ProcessPowerPlanAssociationViewModel associationViewModel; - private readonly LogViewerViewModel logViewerViewModel; - private readonly ISystemTrayService systemTrayService; - private readonly ISystemTrayStatusUpdater systemTrayStatusUpdater; - private readonly IApplicationSettingsService settingsService; - private readonly INotificationService notificationService; - private readonly IProcessMonitorService processMonitorService; - private readonly IProcessMonitorManagerService processMonitorManagerService; - private readonly IProcessPowerPlanAssociationService processPowerPlanAssociationService; - private readonly SettingsViewModel settingsViewModel; - private readonly MainWindowViewModel mainWindowViewModel; - private readonly SystemTweaksViewModel systemTweaksViewModel; - private readonly ISelfResourceManagementService selfResourceManagementService; - private readonly IKeyboardShortcutService keyboardShortcutService; - private readonly IServiceProvider serviceProvider; - private readonly IThemeService themeService; - private System.Timers.Timer? systemTrayUpdateTimer; - private PerformanceViewModel? performanceViewModel; - private bool isSystemTrayUpdatesSuspended; - private int isSystemTrayUpdateInProgress; - private int systemTrayUpdateFailureStreak; - private int startupUpdateCheckStarted; - private AppActivityState? lastAppliedRefreshState; - private readonly IElevationService elevationService; - private readonly ISecurityService securityService; - - // Loading overlay management - private bool isInitializationComplete = false; - private readonly List initializationTasks = new(); - private readonly object initializationLock = new(); - private System.Timers.Timer? initializationTimeoutTimer; - private readonly string debugLogPath = Path.Combine(Path.GetTempPath(), "ThreadPilot_Debug.log"); - - // Flag to skip process monitoring during startup if it causes issues - private readonly bool skipProcessMonitoringDuringStartup = false; - private bool isPerformingShutdown = false; - private readonly NavigationBehavior navigationBehavior = new(); - private bool isPerformanceIntroVisible = false; - private double previousAppContentOpacity = 1; - private TaskCompletionSource? unsavedSettingsDialogCompletionSource; - private bool isSilentStartupMode; - private bool showStartupMinimizedSuggestionOnReady; - - public MainWindow( - ProcessViewModel processViewModel, - PowerPlanViewModel powerPlanViewModel, - IDiagnosticsViewModelProvider diagnosticsViewModelProvider, - ProcessPowerPlanAssociationViewModel associationViewModel, - LogViewerViewModel logViewerViewModel, - ISystemTrayService systemTrayService, - ISystemTrayStatusUpdater systemTrayStatusUpdater, - IApplicationSettingsService settingsService, - INotificationService notificationService, - IProcessMonitorService processMonitorService, - IProcessMonitorManagerService processMonitorManagerService, - IProcessPowerPlanAssociationService processPowerPlanAssociationService, - SettingsViewModel settingsViewModel, - MainWindowViewModel mainWindowViewModel, - SystemTweaksViewModel systemTweaksViewModel, - ISelfResourceManagementService selfResourceManagementService, - IKeyboardShortcutService keyboardShortcutService, - IThemeService themeService, - IServiceProvider serviceProvider, - IElevationService elevationService, - ISecurityService securityService) - { - try - { - System.Diagnostics.Debug.WriteLine("MainWindow constructor starting..."); - - this.InitializeComponent(); - System.Diagnostics.Debug.WriteLine("InitializeComponent completed"); - this.ConfigureDiagnosticsNavigation(); - - // Initialize loading overlay - this.InitializeLoadingOverlay(); - this.LogDebug("Loading overlay initialized"); - this.LogDebug($"Debug log file: {this.debugLogPath}"); - - this.processViewModel = processViewModel; - this.powerPlanViewModel = powerPlanViewModel; - this.diagnosticsViewModelProvider = diagnosticsViewModelProvider; - this.associationViewModel = associationViewModel; - this.logViewerViewModel = logViewerViewModel; - this.systemTrayService = systemTrayService; - this.systemTrayStatusUpdater = systemTrayStatusUpdater; - this.settingsService = settingsService; - this.notificationService = notificationService; - this.processMonitorService = processMonitorService; - this.processMonitorManagerService = processMonitorManagerService; - this.processPowerPlanAssociationService = processPowerPlanAssociationService; - this.settingsViewModel = settingsViewModel; - this.mainWindowViewModel = mainWindowViewModel; - this.systemTweaksViewModel = systemTweaksViewModel; - this.selfResourceManagementService = selfResourceManagementService; - this.keyboardShortcutService = keyboardShortcutService; - this.themeService = themeService; - this.serviceProvider = serviceProvider; - this.elevationService = elevationService; - this.securityService = securityService; - - this.processViewModel.OpenRulesRequested += this.OnOpenRulesRequested; - - System.Diagnostics.Debug.WriteLine("Dependencies assigned"); - - this.SetDataContexts(); - System.Diagnostics.Debug.WriteLine("DataContexts set"); - - this.UpdateLoadingStatus("Starting ThreadPilot...", "Preparing startup sequence."); - - // Start async initialization - marshal to UI thread to prevent cross-thread access exceptions - _ = this.Dispatcher.InvokeAsync(async () => await this.InitializeApplicationAsync()); - System.Diagnostics.Debug.WriteLine("Async initialization started"); - System.Diagnostics.Debug.WriteLine("MainWindow constructor completed successfully"); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Error in MainWindow constructor: {ex}"); - System.Windows.MessageBox.Show( - $"Error initializing MainWindow:\n\n{ex.Message}\n\nStack Trace:\n{ex.StackTrace}", - "MainWindow Initialization Error", MessageBoxButton.OK, MessageBoxImage.Error); - throw; - } - } - - public void ConfigureStartupMode(bool isSilentStartupMode, bool showStartupMinimizedSuggestionOnReady) - { - this.isSilentStartupMode = isSilentStartupMode; - this.showStartupMinimizedSuggestionOnReady = showStartupMinimizedSuggestionOnReady; - - if (!isSilentStartupMode) - { - return; - } - - this.showStartupMinimizedSuggestionOnReady = false; - this.LoadingOverlay.Visibility = Visibility.Collapsed; - this.ClearUIContentBlur(); - - if (this.FindResource("SpinnerAnimation") is Storyboard spinnerAnimation) - { - spinnerAnimation.Stop(); - } - - this.isSystemTrayUpdatesSuspended = true; - this.lastAppliedRefreshState = AppActivityState.TrayHidden; - this.processViewModel.SetProcessViewActive(false); - this.processViewModel.ApplyRefreshDecision(AppRefreshPolicy.Evaluate(AppActivityState.TrayHidden)); - this.powerPlanViewModel.PauseAutoRefresh(); - } - - private void ConfigureDiagnosticsNavigation() - { - this.NavPerf.Visibility = AppNavigationOptions.ShowAdvancedDiagnostics - ? Visibility.Visible - : Visibility.Collapsed; - } - - private PerformanceViewModel GetPerformanceViewModel() - { - if (this.performanceViewModel != null) - { - return this.performanceViewModel; - } - - this.performanceViewModel = this.diagnosticsViewModelProvider.GetOrCreate(); - this.PerformanceViewControl.DataContext = this.performanceViewModel; - return this.performanceViewModel; - } - } -} +namespace ThreadPilot +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using System.Timers; + using System.Windows; + using System.Windows.Controls; + using System.Windows.Input; + using System.Windows.Media.Animation; + using System.Windows.Media.Effects; + using System.Windows.Media.Imaging; + using Microsoft.Extensions.DependencyInjection; + using ThreadPilot.Helpers; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + using ThreadPilot.Views; + + public partial class MainWindow : Wpf.Ui.Controls.FluentWindow + { + private const int SystemTrayUpdateBaseIntervalMs = 10000; + private const int SystemTrayUpdateMaxIntervalMs = 60000; + + private readonly ProcessViewModel processViewModel; + private readonly PowerPlanViewModel powerPlanViewModel; + private readonly Lazy performanceViewModelFactory; + private readonly ProcessPowerPlanAssociationViewModel associationViewModel; + private readonly LogViewerViewModel logViewerViewModel; + private readonly ISystemTrayService systemTrayService; + private readonly ISystemTrayStatusUpdater systemTrayStatusUpdater; + private readonly IApplicationSettingsService settingsService; + private readonly INotificationService notificationService; + private readonly IProcessMonitorService processMonitorService; + private readonly IProcessMonitorManagerService processMonitorManagerService; + private readonly IProcessPowerPlanAssociationService processPowerPlanAssociationService; + private readonly SettingsViewModel settingsViewModel; + private readonly MainWindowViewModel mainWindowViewModel; + private readonly SystemTweaksViewModel systemTweaksViewModel; + private readonly ISelfResourceManagementService selfResourceManagementService; + private readonly IKeyboardShortcutService keyboardShortcutService; + private readonly IServiceProvider serviceProvider; + private readonly IThemeService themeService; + private System.Timers.Timer? systemTrayUpdateTimer; + private PerformanceViewModel? performanceViewModel; + private bool isSystemTrayUpdatesSuspended; + private int isSystemTrayUpdateInProgress; + private int systemTrayUpdateFailureStreak; + private int startupUpdateCheckStarted; + private AppActivityState? lastAppliedRefreshState; + private readonly IElevationService elevationService; + private readonly ISecurityService securityService; + + // Loading overlay management + private bool isInitializationComplete = false; + private readonly List initializationTasks = new(); + private readonly object initializationLock = new(); + private System.Timers.Timer? initializationTimeoutTimer; + private readonly string debugLogPath = Path.Combine(Path.GetTempPath(), "ThreadPilot_Debug.log"); + + // Flag to skip process monitoring during startup if it causes issues + private readonly bool skipProcessMonitoringDuringStartup = false; + private bool isPerformingShutdown = false; + private readonly NavigationBehavior navigationBehavior = new(); + private bool isPerformanceIntroVisible = false; + private double previousAppContentOpacity = 1; + private TaskCompletionSource? unsavedSettingsDialogCompletionSource; + private bool isSilentStartupMode; + private bool showStartupMinimizedSuggestionOnReady; + + public MainWindow( + ProcessViewModel processViewModel, + PowerPlanViewModel powerPlanViewModel, + Lazy performanceViewModelFactory, + ProcessPowerPlanAssociationViewModel associationViewModel, + LogViewerViewModel logViewerViewModel, + ISystemTrayService systemTrayService, + ISystemTrayStatusUpdater systemTrayStatusUpdater, + IApplicationSettingsService settingsService, + INotificationService notificationService, + IProcessMonitorService processMonitorService, + IProcessMonitorManagerService processMonitorManagerService, + IProcessPowerPlanAssociationService processPowerPlanAssociationService, + SettingsViewModel settingsViewModel, + MainWindowViewModel mainWindowViewModel, + SystemTweaksViewModel systemTweaksViewModel, + ISelfResourceManagementService selfResourceManagementService, + IKeyboardShortcutService keyboardShortcutService, + IThemeService themeService, + IServiceProvider serviceProvider, + IElevationService elevationService, + ISecurityService securityService) + { + try + { + System.Diagnostics.Debug.WriteLine("MainWindow constructor starting..."); + + this.InitializeComponent(); + System.Diagnostics.Debug.WriteLine("InitializeComponent completed"); + this.ConfigureDiagnosticsNavigation(); + + // Initialize loading overlay + this.InitializeLoadingOverlay(); + this.LogDebug("Loading overlay initialized"); + this.LogDebug($"Debug log file: {this.debugLogPath}"); + + this.processViewModel = processViewModel; + this.powerPlanViewModel = powerPlanViewModel; + this.performanceViewModelFactory = performanceViewModelFactory; + this.associationViewModel = associationViewModel; + this.logViewerViewModel = logViewerViewModel; + this.systemTrayService = systemTrayService; + this.systemTrayStatusUpdater = systemTrayStatusUpdater; + this.settingsService = settingsService; + this.notificationService = notificationService; + this.processMonitorService = processMonitorService; + this.processMonitorManagerService = processMonitorManagerService; + this.processPowerPlanAssociationService = processPowerPlanAssociationService; + this.settingsViewModel = settingsViewModel; + this.mainWindowViewModel = mainWindowViewModel; + this.systemTweaksViewModel = systemTweaksViewModel; + this.selfResourceManagementService = selfResourceManagementService; + this.keyboardShortcutService = keyboardShortcutService; + this.themeService = themeService; + this.serviceProvider = serviceProvider; + this.elevationService = elevationService; + this.securityService = securityService; + + this.processViewModel.OpenRulesRequested += this.OnOpenRulesRequested; + + System.Diagnostics.Debug.WriteLine("Dependencies assigned"); + + this.SetDataContexts(); + System.Diagnostics.Debug.WriteLine("DataContexts set"); + + this.UpdateLoadingStatus("Starting ThreadPilot...", "Preparing startup sequence."); + + // Start async initialization - marshal to UI thread to prevent cross-thread access exceptions + _ = this.Dispatcher.InvokeAsync(async () => await this.InitializeApplicationAsync()); + System.Diagnostics.Debug.WriteLine("Async initialization started"); + System.Diagnostics.Debug.WriteLine("MainWindow constructor completed successfully"); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error in MainWindow constructor: {ex}"); + System.Windows.MessageBox.Show( + $"Error initializing MainWindow:\n\n{ex.Message}\n\nStack Trace:\n{ex.StackTrace}", + "MainWindow Initialization Error", MessageBoxButton.OK, MessageBoxImage.Error); + throw; + } + } + + public void ConfigureStartupMode(bool isSilentStartupMode, bool showStartupMinimizedSuggestionOnReady) + { + this.isSilentStartupMode = isSilentStartupMode; + this.showStartupMinimizedSuggestionOnReady = showStartupMinimizedSuggestionOnReady; + + if (!isSilentStartupMode) + { + return; + } + + this.showStartupMinimizedSuggestionOnReady = false; + this.LoadingOverlay.Visibility = Visibility.Collapsed; + this.ClearUIContentBlur(); + + if (this.FindResource("SpinnerAnimation") is Storyboard spinnerAnimation) + { + spinnerAnimation.Stop(); + } + + this.isSystemTrayUpdatesSuspended = true; + this.lastAppliedRefreshState = AppActivityState.TrayHidden; + this.processViewModel.SetProcessViewActive(false); + this.processViewModel.ApplyRefreshDecision(AppRefreshPolicy.Evaluate(AppActivityState.TrayHidden)); + this.powerPlanViewModel.PauseAutoRefresh(); + } + + private void ConfigureDiagnosticsNavigation() + { + this.NavPerf.Visibility = AppNavigationOptions.ShowAdvancedDiagnostics + ? Visibility.Visible + : Visibility.Collapsed; + } + + private PerformanceViewModel GetPerformanceViewModel() + { + if (this.performanceViewModel != null) + { + return this.performanceViewModel; + } + + this.performanceViewModel = this.performanceViewModelFactory.Value; + this.PerformanceViewControl.DataContext = this.performanceViewModel; + return this.performanceViewModel; + } + } +} diff --git a/Models/ApplicationSettingsModel.cs b/Models/ApplicationSettingsModel.cs index 3219d8a..c3fb5d0 100644 --- a/Models/ApplicationSettingsModel.cs +++ b/Models/ApplicationSettingsModel.cs @@ -1,400 +1,359 @@ -/* - * 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.Models -{ - using System; - using System.Collections.Generic; - using System.ComponentModel; - using System.Text.Json; - using CommunityToolkit.Mvvm.ComponentModel; - using ThreadPilot.Models.Core; - using ThreadPilot.Services; - - /// - /// Model for application settings including notifications and tray preferences. - /// - public partial class ApplicationSettingsModel : ObservableObject, IModel - { - private static readonly JsonSerializerOptions UserSettingsComparisonJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - }; - - [ObservableProperty] - private string id = "ApplicationSettings"; // Singleton settings - - [ObservableProperty] - private DateTime createdAt = DateTime.UtcNow; - - [ObservableProperty] - private DateTime updatedAt = DateTime.UtcNow; - - [ObservableProperty] - private bool enableNotifications = true; - - [ObservableProperty] - private NotificationLevelProfile notificationLevel = NotificationLevelProfile.All; - - [ObservableProperty] - private bool enableBalloonNotifications = true; - - [ObservableProperty] - private bool enableToastNotifications = true; - - [ObservableProperty] - private bool enablePowerPlanChangeNotifications = true; - - [ObservableProperty] - private bool enableProcessMonitoringNotifications = true; - - [ObservableProperty] - private bool enableErrorNotifications = true; - - [ObservableProperty] - private bool enableSuccessNotifications = true; - - [ObservableProperty] - private bool minimizeToTray = true; - - [ObservableProperty] - private bool closeToTray = true; // Default true: close to tray like CPU Set Setter - - [ObservableProperty] - private bool startMinimized = false; - - [ObservableProperty] - private bool showTrayIcon = true; - - [ObservableProperty] - private bool enableQuickApplyFromTray = true; - - [ObservableProperty] - private bool enableMonitoringControlFromTray = true; - - [ObservableProperty] - private int notificationDisplayDurationMs = 3000; - - [ObservableProperty] - private int balloonNotificationTimeoutMs = 5000; - - [ObservableProperty] - private NotificationPosition notificationPosition = NotificationPosition.BottomRight; - - [ObservableProperty] - private NotificationSound notificationSound = NotificationSound.Default; - - [ObservableProperty] - private bool enableNotificationSound = false; - - [ObservableProperty] - private string customTrayIconPath = string.Empty; - - [ObservableProperty] - private bool useCustomTrayIcon = false; - - [ObservableProperty] - private TrayIconStyle trayIconStyle = TrayIconStyle.Default; - - [ObservableProperty] - private bool showDetailedTooltips = true; - - [ObservableProperty] - private bool enableContextMenuAnimations = true; - - [ObservableProperty] - private bool autoHideNotifications = true; - - [ObservableProperty] - private bool enableNotificationHistory = true; - - [ObservableProperty] - private int maxNotificationHistoryItems = 50; - - // Autostart Settings - [ObservableProperty] - private bool autostartWithWindows = true; - - // Power Plan Settings - [ObservableProperty] - private string defaultPowerPlanId = string.Empty; - - [ObservableProperty] - private string defaultPowerPlanName = "Balanced"; - - [ObservableProperty] - private bool restoreDefaultPowerPlanOnExit = true; - - /// - /// When true, all applied CPU masks are cleared when exiting the application - /// (processes return to using all cores). - /// - [ObservableProperty] - private bool clearMasksOnClose = true; - - [ObservableProperty] - private bool useDarkTheme = false; - - [ObservableProperty] - private bool hasUserThemePreference = false; - - [ObservableProperty] - private string language = LocalizationService.DefaultLanguage; - - [ObservableProperty] - private bool enableAutomaticUpdateChecks = true; - - [ObservableProperty] - private DateTimeOffset? lastUpdateCheckUtc = null; - - [ObservableProperty] - private int updateCheckIntervalDays = 7; - - [ObservableProperty] - private bool includePrereleaseUpdates = false; - - // Monitoring Settings - [ObservableProperty] - private int pollingIntervalMs = 5000; - - [ObservableProperty] - private int fallbackPollingIntervalMs = 10000; - - [ObservableProperty] - private bool enableWmiMonitoring = true; - - [ObservableProperty] - private bool enableFallbackPolling = true; - - [ObservableProperty] - private bool applyPersistentRulesOnProcessStart = true; - - // Advanced Settings - [ObservableProperty] - private bool enableDebugLogging = false; - - [ObservableProperty] - private bool enablePerformanceCounters = false; - - [ObservableProperty] - private bool hasSeenPerformanceIntro = false; - - [ObservableProperty] - private bool hasSeenElevationWarning = false; - - [ObservableProperty] - private bool hasSeenStartupMinimizedSuggestion = false; - - [ObservableProperty] - private bool enableSelfLowImpactMode = true; - - [ObservableProperty] - private bool enableSelfAffinityLimit = false; - - [ObservableProperty] - private int maxLogFileSizeMb = 10; - - [ObservableProperty] - private int logRetentionDays = 7; - - /// - /// Keyboard shortcuts configuration. - /// - [ObservableProperty] - private List keyboardShortcuts = new(); - - /// - /// Copies settings from another instance. - /// - public void CopyFrom(ApplicationSettingsModel other) - { - if (other == null) - { - return; - } - - this.EnableNotifications = other.EnableNotifications; - this.NotificationLevel = other.NotificationLevel; - this.EnableBalloonNotifications = other.EnableBalloonNotifications; - this.EnableToastNotifications = other.EnableToastNotifications; - this.EnablePowerPlanChangeNotifications = other.EnablePowerPlanChangeNotifications; - this.EnableProcessMonitoringNotifications = other.EnableProcessMonitoringNotifications; - this.EnableErrorNotifications = other.EnableErrorNotifications; - this.EnableSuccessNotifications = other.EnableSuccessNotifications; - this.MinimizeToTray = other.MinimizeToTray; - this.CloseToTray = other.CloseToTray; - this.StartMinimized = other.StartMinimized; - this.ShowTrayIcon = other.ShowTrayIcon; - this.EnableQuickApplyFromTray = other.EnableQuickApplyFromTray; - this.EnableMonitoringControlFromTray = other.EnableMonitoringControlFromTray; - this.NotificationDisplayDurationMs = other.NotificationDisplayDurationMs; - this.BalloonNotificationTimeoutMs = other.BalloonNotificationTimeoutMs; - this.NotificationPosition = other.NotificationPosition; - this.NotificationSound = other.NotificationSound; - this.EnableNotificationSound = other.EnableNotificationSound; - this.CustomTrayIconPath = other.CustomTrayIconPath; - this.UseCustomTrayIcon = other.UseCustomTrayIcon; - this.TrayIconStyle = other.TrayIconStyle; - this.ShowDetailedTooltips = other.ShowDetailedTooltips; - this.EnableContextMenuAnimations = other.EnableContextMenuAnimations; - this.AutoHideNotifications = other.AutoHideNotifications; - this.EnableNotificationHistory = other.EnableNotificationHistory; - this.MaxNotificationHistoryItems = other.MaxNotificationHistoryItems; - - // Autostart Settings - this.AutostartWithWindows = other.AutostartWithWindows; - - // Power Plan Settings - this.DefaultPowerPlanId = other.DefaultPowerPlanId; - this.DefaultPowerPlanName = other.DefaultPowerPlanName; - this.RestoreDefaultPowerPlanOnExit = other.RestoreDefaultPowerPlanOnExit; - this.ClearMasksOnClose = other.ClearMasksOnClose; - this.UseDarkTheme = other.UseDarkTheme; - this.HasUserThemePreference = other.HasUserThemePreference; - this.Language = LocalizationService.NormalizeLanguage(other.Language); - this.EnableAutomaticUpdateChecks = other.EnableAutomaticUpdateChecks; - this.LastUpdateCheckUtc = other.LastUpdateCheckUtc; - this.UpdateCheckIntervalDays = other.UpdateCheckIntervalDays; - this.IncludePrereleaseUpdates = other.IncludePrereleaseUpdates; - - // Monitoring Settings - this.PollingIntervalMs = other.PollingIntervalMs; - this.FallbackPollingIntervalMs = other.FallbackPollingIntervalMs; - this.EnableWmiMonitoring = other.EnableWmiMonitoring; - this.EnableFallbackPolling = other.EnableFallbackPolling; - this.ApplyPersistentRulesOnProcessStart = other.ApplyPersistentRulesOnProcessStart; - - // Advanced Settings - this.EnableDebugLogging = other.EnableDebugLogging; - this.EnablePerformanceCounters = other.EnablePerformanceCounters; - this.HasSeenPerformanceIntro = other.HasSeenPerformanceIntro; - this.HasSeenElevationWarning = other.HasSeenElevationWarning; - this.HasSeenStartupMinimizedSuggestion = other.HasSeenStartupMinimizedSuggestion; - this.EnableSelfLowImpactMode = other.EnableSelfLowImpactMode; - this.EnableSelfAffinityLimit = other.EnableSelfAffinityLimit; - this.MaxLogFileSizeMb = other.MaxLogFileSizeMb; - this.LogRetentionDays = other.LogRetentionDays; - - // Keyboard Shortcuts - this.KeyboardShortcuts = other.KeyboardShortcuts != null - ? new List(other.KeyboardShortcuts) - : new List(); - } - - // IModel implementation - properties are auto-generated by ObservableProperty - public ValidationResult Validate() - { - var errors = new List(); - - if (this.NotificationDisplayDurationMs < 1000 || this.NotificationDisplayDurationMs > 30000) - { - errors.Add("Notification display duration must be between 1 and 30 seconds"); - } - - if (this.PollingIntervalMs < 1000 || this.PollingIntervalMs > 60000) - { - errors.Add("Process polling interval must be between 1 and 60 seconds"); - } - - if (this.FallbackPollingIntervalMs < 1000 || this.FallbackPollingIntervalMs > 60000) - { - errors.Add("Fallback polling interval must be between 1 and 60 seconds"); - } - - if (this.UpdateCheckIntervalDays < 1 || this.UpdateCheckIntervalDays > 365) - { - errors.Add("Update check interval must be between 1 and 365 days"); - } - - return errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors.ToArray()); - } - - public IModel Clone() - { - var clone = new ApplicationSettingsModel(); - clone.CopyFrom(this); - clone.Id = this.Id; - clone.CreatedAt = this.CreatedAt; - clone.UpdatedAt = this.UpdatedAt; - return clone; - } - - public bool HasSameUserSettingsAs(ApplicationSettingsModel? other) - { - if (other == null) - { - return false; - } - - var currentSnapshot = (ApplicationSettingsModel)this.Clone(); - var otherSnapshot = (ApplicationSettingsModel)other.Clone(); - - currentSnapshot.Id = otherSnapshot.Id; - currentSnapshot.CreatedAt = otherSnapshot.CreatedAt; - currentSnapshot.UpdatedAt = otherSnapshot.UpdatedAt; - - var currentJson = JsonSerializer.Serialize(currentSnapshot, UserSettingsComparisonJsonOptions); - var otherJson = JsonSerializer.Serialize(otherSnapshot, UserSettingsComparisonJsonOptions); - return string.Equals(currentJson, otherJson, StringComparison.Ordinal); - } - } - - /// - /// Notification level profile options. - /// - public enum NotificationLevelProfile - { - All, - WarningsAndErrorsOnly, - Silent, - } - - /// - /// Notification position options. - /// - public enum NotificationPosition - { - TopLeft, - TopRight, - BottomLeft, - BottomRight, - Center, - } - - /// - /// Notification sound options. - /// - public enum NotificationSound - { - None, - Default, - Information, - Warning, - Error, - Custom, - } - - /// - /// Tray icon style options. - /// - public enum TrayIconStyle - { - Default, - Monochrome, - Colored, - Custom, - } -} +namespace ThreadPilot.Models +{ + using System; + using System.Collections.Generic; + using System.ComponentModel; + using System.Text.Json; + using CommunityToolkit.Mvvm.ComponentModel; + using ThreadPilot.Models.Core; + using ThreadPilot.Services; + + public partial class ApplicationSettingsModel : ObservableObject, IModel + { + private static readonly JsonSerializerOptions UserSettingsComparisonJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + [ObservableProperty] + private string id = "ApplicationSettings"; // Singleton settings + + [ObservableProperty] + private DateTime createdAt = DateTime.UtcNow; + + [ObservableProperty] + private DateTime updatedAt = DateTime.UtcNow; + + [ObservableProperty] + private bool enableNotifications = true; + + [ObservableProperty] + private NotificationLevelProfile notificationLevel = NotificationLevelProfile.All; + + [ObservableProperty] + private bool enableBalloonNotifications = true; + + [ObservableProperty] + private bool enableToastNotifications = true; + + [ObservableProperty] + private bool enablePowerPlanChangeNotifications = true; + + [ObservableProperty] + private bool enableProcessMonitoringNotifications = true; + + [ObservableProperty] + private bool enableErrorNotifications = true; + + [ObservableProperty] + private bool enableSuccessNotifications = true; + + [ObservableProperty] + private bool minimizeToTray = true; + + [ObservableProperty] + private bool closeToTray = true; // Default true: close to tray like CPU Set Setter + + [ObservableProperty] + private bool startMinimized = false; + + [ObservableProperty] + private bool showTrayIcon = true; + + [ObservableProperty] + private bool enableQuickApplyFromTray = true; + + [ObservableProperty] + private bool enableMonitoringControlFromTray = true; + + [ObservableProperty] + private int notificationDisplayDurationMs = 3000; + + [ObservableProperty] + private int balloonNotificationTimeoutMs = 5000; + + [ObservableProperty] + private NotificationPosition notificationPosition = NotificationPosition.BottomRight; + + [ObservableProperty] + private NotificationSound notificationSound = NotificationSound.Default; + + [ObservableProperty] + private bool enableNotificationSound = false; + + [ObservableProperty] + private string customTrayIconPath = string.Empty; + + [ObservableProperty] + private bool useCustomTrayIcon = false; + + [ObservableProperty] + private TrayIconStyle trayIconStyle = TrayIconStyle.Default; + + [ObservableProperty] + private bool showDetailedTooltips = true; + + [ObservableProperty] + private bool enableContextMenuAnimations = true; + + [ObservableProperty] + private bool autoHideNotifications = true; + + [ObservableProperty] + private bool enableNotificationHistory = true; + + [ObservableProperty] + private int maxNotificationHistoryItems = 50; + + // Autostart Settings + [ObservableProperty] + private bool autostartWithWindows = true; + + // Power Plan Settings + [ObservableProperty] + private string defaultPowerPlanId = string.Empty; + + [ObservableProperty] + private string defaultPowerPlanName = "Balanced"; + + [ObservableProperty] + private bool restoreDefaultPowerPlanOnExit = true; + + [ObservableProperty] + private bool clearMasksOnClose = true; + + [ObservableProperty] + private bool useDarkTheme = false; + + [ObservableProperty] + private bool hasUserThemePreference = false; + + [ObservableProperty] + private string language = LocalizationService.DefaultLanguage; + + [ObservableProperty] + private bool enableAutomaticUpdateChecks = true; + + [ObservableProperty] + private DateTimeOffset? lastUpdateCheckUtc = null; + + [ObservableProperty] + private int updateCheckIntervalDays = 7; + + [ObservableProperty] + private bool includePrereleaseUpdates = false; + + // Monitoring Settings + [ObservableProperty] + private int pollingIntervalMs = 5000; + + [ObservableProperty] + private int fallbackPollingIntervalMs = 10000; + + [ObservableProperty] + private bool enableWmiMonitoring = true; + + [ObservableProperty] + private bool enableFallbackPolling = true; + + [ObservableProperty] + private bool applyPersistentRulesOnProcessStart = true; + + // Advanced Settings + [ObservableProperty] + private bool enableDebugLogging = false; + + [ObservableProperty] + private bool enablePerformanceCounters = false; + + [ObservableProperty] + private bool hasSeenPerformanceIntro = false; + + [ObservableProperty] + private bool hasSeenElevationWarning = false; + + [ObservableProperty] + private bool hasSeenStartupMinimizedSuggestion = false; + + [ObservableProperty] + private bool enableSelfLowImpactMode = true; + + [ObservableProperty] + private bool enableSelfAffinityLimit = false; + + [ObservableProperty] + private int maxLogFileSizeMb = 10; + + [ObservableProperty] + private int logRetentionDays = 7; + + [ObservableProperty] + private List keyboardShortcuts = new(); + + public void CopyFrom(ApplicationSettingsModel other) + { + if (other == null) + { + return; + } + + this.EnableNotifications = other.EnableNotifications; + this.NotificationLevel = other.NotificationLevel; + this.EnableBalloonNotifications = other.EnableBalloonNotifications; + this.EnableToastNotifications = other.EnableToastNotifications; + this.EnablePowerPlanChangeNotifications = other.EnablePowerPlanChangeNotifications; + this.EnableProcessMonitoringNotifications = other.EnableProcessMonitoringNotifications; + this.EnableErrorNotifications = other.EnableErrorNotifications; + this.EnableSuccessNotifications = other.EnableSuccessNotifications; + this.MinimizeToTray = other.MinimizeToTray; + this.CloseToTray = other.CloseToTray; + this.StartMinimized = other.StartMinimized; + this.ShowTrayIcon = other.ShowTrayIcon; + this.EnableQuickApplyFromTray = other.EnableQuickApplyFromTray; + this.EnableMonitoringControlFromTray = other.EnableMonitoringControlFromTray; + this.NotificationDisplayDurationMs = other.NotificationDisplayDurationMs; + this.BalloonNotificationTimeoutMs = other.BalloonNotificationTimeoutMs; + this.NotificationPosition = other.NotificationPosition; + this.NotificationSound = other.NotificationSound; + this.EnableNotificationSound = other.EnableNotificationSound; + this.CustomTrayIconPath = other.CustomTrayIconPath; + this.UseCustomTrayIcon = other.UseCustomTrayIcon; + this.TrayIconStyle = other.TrayIconStyle; + this.ShowDetailedTooltips = other.ShowDetailedTooltips; + this.EnableContextMenuAnimations = other.EnableContextMenuAnimations; + this.AutoHideNotifications = other.AutoHideNotifications; + this.EnableNotificationHistory = other.EnableNotificationHistory; + this.MaxNotificationHistoryItems = other.MaxNotificationHistoryItems; + + // Autostart Settings + this.AutostartWithWindows = other.AutostartWithWindows; + + // Power Plan Settings + this.DefaultPowerPlanId = other.DefaultPowerPlanId; + this.DefaultPowerPlanName = other.DefaultPowerPlanName; + this.RestoreDefaultPowerPlanOnExit = other.RestoreDefaultPowerPlanOnExit; + this.ClearMasksOnClose = other.ClearMasksOnClose; + this.UseDarkTheme = other.UseDarkTheme; + this.HasUserThemePreference = other.HasUserThemePreference; + this.Language = LocalizationService.NormalizeLanguage(other.Language); + this.EnableAutomaticUpdateChecks = other.EnableAutomaticUpdateChecks; + this.LastUpdateCheckUtc = other.LastUpdateCheckUtc; + this.UpdateCheckIntervalDays = other.UpdateCheckIntervalDays; + this.IncludePrereleaseUpdates = other.IncludePrereleaseUpdates; + + // Monitoring Settings + this.PollingIntervalMs = other.PollingIntervalMs; + this.FallbackPollingIntervalMs = other.FallbackPollingIntervalMs; + this.EnableWmiMonitoring = other.EnableWmiMonitoring; + this.EnableFallbackPolling = other.EnableFallbackPolling; + this.ApplyPersistentRulesOnProcessStart = other.ApplyPersistentRulesOnProcessStart; + + // Advanced Settings + this.EnableDebugLogging = other.EnableDebugLogging; + this.EnablePerformanceCounters = other.EnablePerformanceCounters; + this.HasSeenPerformanceIntro = other.HasSeenPerformanceIntro; + this.HasSeenElevationWarning = other.HasSeenElevationWarning; + this.HasSeenStartupMinimizedSuggestion = other.HasSeenStartupMinimizedSuggestion; + this.EnableSelfLowImpactMode = other.EnableSelfLowImpactMode; + this.EnableSelfAffinityLimit = other.EnableSelfAffinityLimit; + this.MaxLogFileSizeMb = other.MaxLogFileSizeMb; + this.LogRetentionDays = other.LogRetentionDays; + + // Keyboard Shortcuts + this.KeyboardShortcuts = other.KeyboardShortcuts != null + ? new List(other.KeyboardShortcuts) + : new List(); + } + + // IModel implementation - properties are auto-generated by ObservableProperty + public ValidationResult Validate() + { + var errors = new List(); + + if (this.NotificationDisplayDurationMs < 1000 || this.NotificationDisplayDurationMs > 30000) + { + errors.Add("Notification display duration must be between 1 and 30 seconds"); + } + + if (this.PollingIntervalMs < 1000 || this.PollingIntervalMs > 60000) + { + errors.Add("Process polling interval must be between 1 and 60 seconds"); + } + + if (this.FallbackPollingIntervalMs < 1000 || this.FallbackPollingIntervalMs > 60000) + { + errors.Add("Fallback polling interval must be between 1 and 60 seconds"); + } + + if (this.UpdateCheckIntervalDays < 1 || this.UpdateCheckIntervalDays > 365) + { + errors.Add("Update check interval must be between 1 and 365 days"); + } + + return errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors.ToArray()); + } + + public IModel Clone() + { + var clone = new ApplicationSettingsModel(); + clone.CopyFrom(this); + clone.Id = this.Id; + clone.CreatedAt = this.CreatedAt; + clone.UpdatedAt = this.UpdatedAt; + return clone; + } + + public bool HasSameUserSettingsAs(ApplicationSettingsModel? other) + { + if (other == null) + { + return false; + } + + var currentSnapshot = (ApplicationSettingsModel)this.Clone(); + var otherSnapshot = (ApplicationSettingsModel)other.Clone(); + + currentSnapshot.Id = otherSnapshot.Id; + currentSnapshot.CreatedAt = otherSnapshot.CreatedAt; + currentSnapshot.UpdatedAt = otherSnapshot.UpdatedAt; + + var currentJson = JsonSerializer.Serialize(currentSnapshot, UserSettingsComparisonJsonOptions); + var otherJson = JsonSerializer.Serialize(otherSnapshot, UserSettingsComparisonJsonOptions); + return string.Equals(currentJson, otherJson, StringComparison.Ordinal); + } + } + + public enum NotificationLevelProfile + { + All, + WarningsAndErrorsOnly, + Silent, + } + + public enum NotificationPosition + { + TopLeft, + TopRight, + BottomLeft, + BottomRight, + Center, + } + + public enum NotificationSound + { + None, + Default, + Information, + Warning, + Error, + Custom, + } + + public enum TrayIconStyle + { + Default, + Monochrome, + Colored, + Custom, + } +} diff --git a/Models/ConditionalProcessProfile.cs b/Models/ConditionalProcessProfile.cs index da5c406..21e2ead 100644 --- a/Models/ConditionalProcessProfile.cs +++ b/Models/ConditionalProcessProfile.cs @@ -1,325 +1,273 @@ -/* - * 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.Models -{ - using System; - using System.Collections.Generic; - using System.Linq; - using CommunityToolkit.Mvvm.ComponentModel; - - /// - /// Condition types for profile triggers. - /// - public enum ProfileConditionType - { - SystemLoad, - TimeOfDay, - PowerState, - ProcessCount, - MemoryUsage, - CpuTemperature, - BatteryLevel, - NetworkActivity, - UserIdle, - Custom, - } - - /// - /// Comparison operators for conditions. - /// - public enum ComparisonOperator - { - Equals, - NotEquals, - GreaterThan, - LessThan, - GreaterThanOrEqual, - LessThanOrEqual, - Contains, - NotContains, - Between, - NotBetween, - } - - /// - /// Logical operators for combining conditions. - /// - public enum LogicalOperator - { - And, - Or, - Not, - } - - /// - /// System state information for condition evaluation. - /// - public class SystemState - { - public double CpuUsage { get; set; } - - public double MemoryUsage { get; set; } - - public int ProcessCount { get; set; } - - public DateTime CurrentTime { get; set; } = DateTime.Now; - - public bool IsOnBattery { get; set; } - - public int BatteryLevel { get; set; } - - public double CpuTemperature { get; set; } - - public bool IsUserIdle { get; set; } - - public TimeSpan UserIdleTime { get; set; } - - public double NetworkActivity { get; set; } - - public Dictionary CustomProperties { get; set; } = new(); - } - - /// - /// Individual condition for profile evaluation. - /// - public partial class ProfileCondition : ObservableObject - { - [ObservableProperty] - private string name = string.Empty; - - [ObservableProperty] - private ProfileConditionType conditionType; - - [ObservableProperty] - private ComparisonOperator comparisonOperator; - - [ObservableProperty] - private object? value; - - [ObservableProperty] - private object? secondaryValue; // For Between/NotBetween operations - - [ObservableProperty] - private bool isEnabled = true; - - [ObservableProperty] - private string description = string.Empty; - - /// - /// Evaluate this condition against the current system state. - /// - public bool Evaluate(ProcessModel process, SystemState systemState) - { - if (!this.IsEnabled) - { - return true; // Disabled conditions are considered true - } - - try - { - var actualValue = this.GetActualValue(process, systemState); - return CompareValues(actualValue, this.Value, this.SecondaryValue, this.ComparisonOperator); - } - catch (Exception) - { - return false; // Failed conditions are considered false - } - } - - private object? GetActualValue(ProcessModel process, SystemState systemState) - { - return this.ConditionType switch - { - ProfileConditionType.SystemLoad => systemState.CpuUsage, - ProfileConditionType.TimeOfDay => systemState.CurrentTime.TimeOfDay.TotalHours, - ProfileConditionType.PowerState => systemState.IsOnBattery, - ProfileConditionType.ProcessCount => systemState.ProcessCount, - ProfileConditionType.MemoryUsage => systemState.MemoryUsage, - ProfileConditionType.CpuTemperature => systemState.CpuTemperature, - ProfileConditionType.BatteryLevel => systemState.BatteryLevel, - ProfileConditionType.NetworkActivity => systemState.NetworkActivity, - ProfileConditionType.UserIdle => systemState.IsUserIdle, - ProfileConditionType.Custom => systemState.CustomProperties.GetValueOrDefault(this.Name), - _ => null, - }; - } - - private static bool CompareValues(object? actual, object? expected, object? secondary, ComparisonOperator op) - { - if (actual == null || expected == null) - { - return false; - } - - return op switch - { - ComparisonOperator.Equals => actual.Equals(expected), - ComparisonOperator.NotEquals => !actual.Equals(expected), - ComparisonOperator.GreaterThan => Comparer.Default.Compare(actual, expected) > 0, - ComparisonOperator.LessThan => Comparer.Default.Compare(actual, expected) < 0, - ComparisonOperator.GreaterThanOrEqual => Comparer.Default.Compare(actual, expected) >= 0, - ComparisonOperator.LessThanOrEqual => Comparer.Default.Compare(actual, expected) <= 0, - ComparisonOperator.Contains => actual.ToString()?.Contains(expected.ToString() ?? string.Empty) ?? false, - ComparisonOperator.NotContains => !(actual.ToString()?.Contains(expected.ToString() ?? string.Empty) ?? false), - ComparisonOperator.Between => secondary != null && - Comparer.Default.Compare(actual, expected) >= 0 && - Comparer.Default.Compare(actual, secondary) <= 0, - ComparisonOperator.NotBetween => secondary != null && - !(Comparer.Default.Compare(actual, expected) >= 0 && - Comparer.Default.Compare(actual, secondary) <= 0), - _ => false, - }; - } - } - - /// - /// Group of conditions with logical operators. - /// - public partial class ConditionGroup : ObservableObject - { - [ObservableProperty] - private string name = string.Empty; - - [ObservableProperty] - private LogicalOperator logicalOperator = LogicalOperator.And; - - [ObservableProperty] - private List conditions = new(); - - [ObservableProperty] - private List subGroups = new(); - - [ObservableProperty] - private bool isEnabled = true; - - /// - /// Evaluate this condition group. - /// - public bool Evaluate(ProcessModel process, SystemState systemState) - { - if (!this.IsEnabled) - { - return true; - } - - var conditionResults = this.Conditions.Select(c => c.Evaluate(process, systemState)).ToList(); - var subGroupResults = this.SubGroups.Select(g => g.Evaluate(process, systemState)).ToList(); - var allResults = conditionResults.Concat(subGroupResults).ToList(); - - if (!allResults.Any()) - { - return true; // No conditions means always true - } - - return this.LogicalOperator switch - { - LogicalOperator.And => allResults.All(r => r), - LogicalOperator.Or => allResults.Any(r => r), - LogicalOperator.Not => !allResults.All(r => r), - _ => false, - }; - } - } - - /// - /// Extended ProfileModel with conditional triggers. - /// - public partial class ConditionalProcessProfile : ProfileModel - { - [ObservableProperty] - private List conditionGroups = new(); - - [ObservableProperty] - private TimeSpan autoApplyDelay = TimeSpan.FromSeconds(5); - - [ObservableProperty] - private int priority = 0; // Higher priority profiles are applied first - - [ObservableProperty] - private bool isAutoApplyEnabled = true; - - [ObservableProperty] - private DateTime lastEvaluated = DateTime.MinValue; - - [ObservableProperty] - private DateTime lastApplied = DateTime.MinValue; - - [ObservableProperty] - private bool wasLastEvaluationTrue = false; - - [ObservableProperty] - private string lastEvaluationReason = string.Empty; - - /// - /// Check if this profile should be applied based on conditions. - /// - public bool ShouldApply(ProcessModel process, SystemState systemState) - { - if (!this.IsAutoApplyEnabled) - { - return false; - } - - this.LastEvaluated = DateTime.UtcNow; - - try - { - // If no condition groups, always apply (like regular profile) - if (!this.ConditionGroups.Any()) - { - this.WasLastEvaluationTrue = true; - this.LastEvaluationReason = "No conditions defined"; - return true; - } - - // Evaluate all condition groups (AND logic between groups) - var results = this.ConditionGroups.Select(g => g.Evaluate(process, systemState)).ToList(); - var shouldApply = results.All(r => r); - - this.WasLastEvaluationTrue = shouldApply; - this.LastEvaluationReason = shouldApply - ? "All condition groups satisfied" - : $"Failed conditions: {string.Join(", ", this.ConditionGroups.Where((g, i) => !results[i]).Select(g => g.Name))}"; - - return shouldApply; - } - catch (Exception ex) - { - this.WasLastEvaluationTrue = false; - this.LastEvaluationReason = $"Evaluation error: {ex.Message}"; - return false; - } - } - - /// - /// Check if enough time has passed since last application. - /// - public bool CanApplyNow() - { - return DateTime.UtcNow - this.LastApplied >= this.AutoApplyDelay; - } - - /// - /// Mark this profile as applied. - /// - public void MarkAsApplied() - { - this.LastApplied = DateTime.UtcNow; - } - } -} - +namespace ThreadPilot.Models +{ + using System; + using System.Collections.Generic; + using System.Linq; + using CommunityToolkit.Mvvm.ComponentModel; + + public enum ProfileConditionType + { + SystemLoad, + TimeOfDay, + PowerState, + ProcessCount, + MemoryUsage, + CpuTemperature, + BatteryLevel, + NetworkActivity, + UserIdle, + Custom, + } + + public enum ComparisonOperator + { + Equals, + NotEquals, + GreaterThan, + LessThan, + GreaterThanOrEqual, + LessThanOrEqual, + Contains, + NotContains, + Between, + NotBetween, + } + + public enum LogicalOperator + { + And, + Or, + Not, + } + + public class SystemState + { + public double CpuUsage { get; set; } + + public double MemoryUsage { get; set; } + + public int ProcessCount { get; set; } + + public DateTime CurrentTime { get; set; } = DateTime.Now; + + public bool IsOnBattery { get; set; } + + public int BatteryLevel { get; set; } + + public double CpuTemperature { get; set; } + + public bool IsUserIdle { get; set; } + + public TimeSpan UserIdleTime { get; set; } + + public double NetworkActivity { get; set; } + + public Dictionary CustomProperties { get; set; } = new(); + } + + public partial class ProfileCondition : ObservableObject + { + [ObservableProperty] + private string name = string.Empty; + + [ObservableProperty] + private ProfileConditionType conditionType; + + [ObservableProperty] + private ComparisonOperator comparisonOperator; + + [ObservableProperty] + private object? value; + + [ObservableProperty] + private object? secondaryValue; // For Between/NotBetween operations + + [ObservableProperty] + private bool isEnabled = true; + + [ObservableProperty] + private string description = string.Empty; + + public bool Evaluate(ProcessModel process, SystemState systemState) + { + if (!this.IsEnabled) + { + return true; // Disabled conditions are considered true + } + + try + { + var actualValue = this.GetActualValue(process, systemState); + return CompareValues(actualValue, this.Value, this.SecondaryValue, this.ComparisonOperator); + } + catch (Exception) + { + return false; // Failed conditions are considered false + } + } + + private object? GetActualValue(ProcessModel process, SystemState systemState) + { + return this.ConditionType switch + { + ProfileConditionType.SystemLoad => systemState.CpuUsage, + ProfileConditionType.TimeOfDay => systemState.CurrentTime.TimeOfDay.TotalHours, + ProfileConditionType.PowerState => systemState.IsOnBattery, + ProfileConditionType.ProcessCount => systemState.ProcessCount, + ProfileConditionType.MemoryUsage => systemState.MemoryUsage, + ProfileConditionType.CpuTemperature => systemState.CpuTemperature, + ProfileConditionType.BatteryLevel => systemState.BatteryLevel, + ProfileConditionType.NetworkActivity => systemState.NetworkActivity, + ProfileConditionType.UserIdle => systemState.IsUserIdle, + ProfileConditionType.Custom => systemState.CustomProperties.GetValueOrDefault(this.Name), + _ => null, + }; + } + + private static bool CompareValues(object? actual, object? expected, object? secondary, ComparisonOperator op) + { + if (actual == null || expected == null) + { + return false; + } + + return op switch + { + ComparisonOperator.Equals => actual.Equals(expected), + ComparisonOperator.NotEquals => !actual.Equals(expected), + ComparisonOperator.GreaterThan => Comparer.Default.Compare(actual, expected) > 0, + ComparisonOperator.LessThan => Comparer.Default.Compare(actual, expected) < 0, + ComparisonOperator.GreaterThanOrEqual => Comparer.Default.Compare(actual, expected) >= 0, + ComparisonOperator.LessThanOrEqual => Comparer.Default.Compare(actual, expected) <= 0, + ComparisonOperator.Contains => actual.ToString()?.Contains(expected.ToString() ?? string.Empty) ?? false, + ComparisonOperator.NotContains => !(actual.ToString()?.Contains(expected.ToString() ?? string.Empty) ?? false), + ComparisonOperator.Between => secondary != null && + Comparer.Default.Compare(actual, expected) >= 0 && + Comparer.Default.Compare(actual, secondary) <= 0, + ComparisonOperator.NotBetween => secondary != null && + !(Comparer.Default.Compare(actual, expected) >= 0 && + Comparer.Default.Compare(actual, secondary) <= 0), + _ => false, + }; + } + } + + public partial class ConditionGroup : ObservableObject + { + [ObservableProperty] + private string name = string.Empty; + + [ObservableProperty] + private LogicalOperator logicalOperator = LogicalOperator.And; + + [ObservableProperty] + private List conditions = new(); + + [ObservableProperty] + private List subGroups = new(); + + [ObservableProperty] + private bool isEnabled = true; + + public bool Evaluate(ProcessModel process, SystemState systemState) + { + if (!this.IsEnabled) + { + return true; + } + + var conditionResults = this.Conditions.Select(c => c.Evaluate(process, systemState)).ToList(); + var subGroupResults = this.SubGroups.Select(g => g.Evaluate(process, systemState)).ToList(); + var allResults = conditionResults.Concat(subGroupResults).ToList(); + + if (!allResults.Any()) + { + return true; // No conditions means always true + } + + return this.LogicalOperator switch + { + LogicalOperator.And => allResults.All(r => r), + LogicalOperator.Or => allResults.Any(r => r), + LogicalOperator.Not => !allResults.All(r => r), + _ => false, + }; + } + } + + public partial class ConditionalProcessProfile : ProfileModel + { + [ObservableProperty] + private List conditionGroups = new(); + + [ObservableProperty] + private TimeSpan autoApplyDelay = TimeSpan.FromSeconds(5); + + [ObservableProperty] + private int priority = 0; // Higher priority profiles are applied first + + [ObservableProperty] + private bool isAutoApplyEnabled = true; + + [ObservableProperty] + private DateTime lastEvaluated = DateTime.MinValue; + + [ObservableProperty] + private DateTime lastApplied = DateTime.MinValue; + + [ObservableProperty] + private bool wasLastEvaluationTrue = false; + + [ObservableProperty] + private string lastEvaluationReason = string.Empty; + + public bool ShouldApply(ProcessModel process, SystemState systemState) + { + if (!this.IsAutoApplyEnabled) + { + return false; + } + + this.LastEvaluated = DateTime.UtcNow; + + try + { + // If no condition groups, always apply (like regular profile) + if (!this.ConditionGroups.Any()) + { + this.WasLastEvaluationTrue = true; + this.LastEvaluationReason = "No conditions defined"; + return true; + } + + // Evaluate all condition groups (AND logic between groups) + var results = this.ConditionGroups.Select(g => g.Evaluate(process, systemState)).ToList(); + var shouldApply = results.All(r => r); + + this.WasLastEvaluationTrue = shouldApply; + this.LastEvaluationReason = shouldApply + ? "All condition groups satisfied" + : $"Failed conditions: {string.Join(", ", this.ConditionGroups.Where((g, i) => !results[i]).Select(g => g.Name))}"; + + return shouldApply; + } + catch (Exception ex) + { + this.WasLastEvaluationTrue = false; + this.LastEvaluationReason = $"Evaluation error: {ex.Message}"; + return false; + } + } + + public bool CanApplyNow() + { + return DateTime.UtcNow - this.LastApplied >= this.AutoApplyDelay; + } + + public void MarkAsApplied() + { + this.LastApplied = DateTime.UtcNow; + } + } +} + diff --git a/Models/Core/IModel.cs b/Models/Core/IModel.cs index 65b486a..d74a6ea 100644 --- a/Models/Core/IModel.cs +++ b/Models/Core/IModel.cs @@ -1,123 +1,83 @@ -/* - * 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.Models.Core -{ - using System; - using System.ComponentModel; - - /// - /// Base interface for all domain models. - /// - public interface IModel : INotifyPropertyChanged - { - /// - /// Gets unique identifier for the model instance. - /// - string Id { get; } - - /// - /// Gets timestamp when the model was created. - /// - DateTime CreatedAt { get; } - - /// - /// Gets timestamp when the model was last updated. - /// - DateTime UpdatedAt { get; } - - /// - /// Validate the model state. - /// - ValidationResult Validate(); - - /// - /// Create a copy of the model. - /// - IModel Clone(); - } - - /// - /// Validation result for model validation. - /// - public class ValidationResult - { - public bool IsValid { get; } - - public string[] Errors { get; } - - public ValidationResult(bool isValid, params string[] errors) - { - this.IsValid = isValid; - this.Errors = errors ?? Array.Empty(); - } - - public static ValidationResult Success() => new(true); - - public static ValidationResult Failure(params string[] errors) => new(false, errors); - } - - /// - /// Base implementation for domain models. - /// - public abstract class BaseModel : IModel - { - public string Id { get; protected set; } - - public DateTime CreatedAt { get; protected set; } - - public DateTime UpdatedAt { get; protected set; } - - public event PropertyChangedEventHandler? PropertyChanged; - - protected BaseModel() - { - this.Id = Guid.NewGuid().ToString(); - this.CreatedAt = DateTime.UtcNow; - this.UpdatedAt = DateTime.UtcNow; - } - - protected BaseModel(string id) - { - this.Id = id ?? throw new ArgumentNullException(nameof(id)); - this.CreatedAt = DateTime.UtcNow; - this.UpdatedAt = DateTime.UtcNow; - } - - protected virtual void OnPropertyChanged(string propertyName) - { - this.UpdatedAt = DateTime.UtcNow; - this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - - protected bool SetProperty(ref T field, T value, string propertyName) - { - if (Equals(field, value)) - { - return false; - } - - field = value; - this.OnPropertyChanged(propertyName); - return true; - } - - public abstract ValidationResult Validate(); - - public abstract IModel Clone(); - } -} - +namespace ThreadPilot.Models.Core +{ + using System; + using System.ComponentModel; + + public interface IModel : INotifyPropertyChanged + { + string Id { get; } + + DateTime CreatedAt { get; } + + DateTime UpdatedAt { get; } + + ValidationResult Validate(); + + IModel Clone(); + } + + public class ValidationResult + { + public bool IsValid { get; } + + public string[] Errors { get; } + + public ValidationResult(bool isValid, params string[] errors) + { + this.IsValid = isValid; + this.Errors = errors ?? Array.Empty(); + } + + public static ValidationResult Success() => new(true); + + public static ValidationResult Failure(params string[] errors) => new(false, errors); + } + + public abstract class BaseModel : IModel + { + public string Id { get; protected set; } + + public DateTime CreatedAt { get; protected set; } + + public DateTime UpdatedAt { get; protected set; } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected BaseModel() + { + this.Id = Guid.NewGuid().ToString(); + this.CreatedAt = DateTime.UtcNow; + this.UpdatedAt = DateTime.UtcNow; + } + + protected BaseModel(string id) + { + this.Id = id ?? throw new ArgumentNullException(nameof(id)); + this.CreatedAt = DateTime.UtcNow; + this.UpdatedAt = DateTime.UtcNow; + } + + protected virtual void OnPropertyChanged(string propertyName) + { + this.UpdatedAt = DateTime.UtcNow; + this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + protected bool SetProperty(ref T field, T value, string propertyName) + { + if (Equals(field, value)) + { + return false; + } + + field = value; + this.OnPropertyChanged(propertyName); + return true; + } + + public abstract ValidationResult Validate(); + + public abstract IModel Clone(); + } +} + diff --git a/Models/CoreMask.cs b/Models/CoreMask.cs index b61cfdc..0f25e31 100644 --- a/Models/CoreMask.cs +++ b/Models/CoreMask.cs @@ -1,166 +1,123 @@ -/* - * 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.Models -{ - using System; - using System.Collections.Generic; - using System.Collections.ObjectModel; - using System.Linq; - using CommunityToolkit.Mvvm.ComponentModel; - - /// - /// Represents a reusable CPU core affinity mask - /// Based on CPUSetSetter's LogicalProcessorMask. - /// - public partial class CoreMask : ObservableObject - { - [ObservableProperty] - private string id = Guid.NewGuid().ToString(); - - [ObservableProperty] - private string name = string.Empty; - - [ObservableProperty] - private string description = string.Empty; - - /// - /// Gets or sets array of boolean values, one per logical core. - /// - public ObservableCollection BoolMask { get; set; } = new(); - - public int ProfileSchemaVersion { get; set; } = CpuAffinityProfileSchemaVersions.Legacy; - - public CpuSelection? CpuSelection { get; set; } - - public CpuSelectionMigrationMetadata? CpuSelectionMigration { get; set; } - - [ObservableProperty] - private bool isDefault = false; - - [ObservableProperty] - private bool isEnabled = true; - - [ObservableProperty] - private DateTime createdAt = DateTime.UtcNow; - - [ObservableProperty] - private DateTime updatedAt = DateTime.UtcNow; - - /// - /// Gets a value indicating whether special mask that allows all cores (no restrictions). - /// - public bool IsNoMask => this.BoolMask.All(b => b); - - /// - /// Gets the count of selected cores. - /// - public int SelectedCoreCount => this.BoolMask.Count(b => b); - - /// - /// Converts the boolean mask to a legacy 64-bit processor affinity value. - /// This is only safe for single processor-group selections below CPU 64; - /// topology-aware apply paths must prefer . - /// - public long ToProcessorAffinity() - { - long affinity = 0; - for (int i = 0; i < this.BoolMask.Count; i++) - { - if (this.BoolMask[i]) - { - affinity |= 1L << i; - } - } - return affinity; - } - - /// - /// Creates a CoreMask from a processor affinity value. - /// - public static CoreMask FromProcessorAffinity(long affinity, int coreCount, string name = "Custom") - { - var mask = new CoreMask { Name = name }; - for (int i = 0; i < coreCount; i++) - { - mask.BoolMask.Add(((affinity >> i) & 1) == 1); - } - return mask; - } - - /// - /// Creates a mask with all cores enabled. - /// - public static CoreMask CreateAllCoresMask(int coreCount) - { - var mask = new CoreMask - { - Name = "All Cores", - Description = "Use all available CPU cores", - IsDefault = true, - }; - - for (int i = 0; i < coreCount; i++) - { - mask.BoolMask.Add(true); - } - - return mask; - } - - /// - /// Creates a mask with no cores (empty mask, for deletion purposes). - /// - public static CoreMask CreateNoMask() - { - return new CoreMask - { - Name = "No Restriction", - Description = "Process can use all cores", - IsDefault = false, - }; - } - - public CoreMask Clone() - { - var cloned = new CoreMask - { - Id = Guid.NewGuid().ToString(), - Name = this.Name + " (Copy)", - Description = this.Description, - IsEnabled = this.IsEnabled, - IsDefault = false, - ProfileSchemaVersion = this.ProfileSchemaVersion, - CpuSelection = this.CpuSelection, - CpuSelectionMigration = this.CpuSelectionMigration, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - }; - - foreach (var bit in this.BoolMask) - { - cloned.BoolMask.Add(bit); - } - - return cloned; - } - - public override string ToString() - { - return $"{this.Name} ({this.SelectedCoreCount}/{this.BoolMask.Count} cores)"; - } - } -} +namespace ThreadPilot.Models +{ + using System; + using System.Collections.Generic; + using System.Collections.ObjectModel; + using System.Linq; + using CommunityToolkit.Mvvm.ComponentModel; + + public partial class CoreMask : ObservableObject + { + [ObservableProperty] + private string id = Guid.NewGuid().ToString(); + + [ObservableProperty] + private string name = string.Empty; + + [ObservableProperty] + private string description = string.Empty; + + public ObservableCollection BoolMask { get; set; } = new(); + + public int ProfileSchemaVersion { get; set; } = CpuAffinityProfileSchemaVersions.Legacy; + + public CpuSelection? CpuSelection { get; set; } + + public CpuSelectionMigrationMetadata? CpuSelectionMigration { get; set; } + + [ObservableProperty] + private bool isDefault = false; + + [ObservableProperty] + private bool isEnabled = true; + + [ObservableProperty] + private DateTime createdAt = DateTime.UtcNow; + + [ObservableProperty] + private DateTime updatedAt = DateTime.UtcNow; + + public bool IsNoMask => this.BoolMask.All(b => b); + + public int SelectedCoreCount => this.BoolMask.Count(b => b); + + public long ToProcessorAffinity() + { + long affinity = 0; + for (int i = 0; i < this.BoolMask.Count; i++) + { + if (this.BoolMask[i]) + { + affinity |= 1L << i; + } + } + return affinity; + } + + public static CoreMask FromProcessorAffinity(long affinity, int coreCount, string name = "Custom") + { + var mask = new CoreMask { Name = name }; + for (int i = 0; i < coreCount; i++) + { + mask.BoolMask.Add(((affinity >> i) & 1) == 1); + } + return mask; + } + + public static CoreMask CreateAllCoresMask(int coreCount) + { + var mask = new CoreMask + { + Name = "All Cores", + Description = "Use all available CPU cores", + IsDefault = true, + }; + + for (int i = 0; i < coreCount; i++) + { + mask.BoolMask.Add(true); + } + + return mask; + } + + public static CoreMask CreateNoMask() + { + return new CoreMask + { + Name = "No Restriction", + Description = "Process can use all cores", + IsDefault = false, + }; + } + + public CoreMask Clone() + { + var cloned = new CoreMask + { + Id = Guid.NewGuid().ToString(), + Name = this.Name + " (Copy)", + Description = this.Description, + IsEnabled = this.IsEnabled, + IsDefault = false, + ProfileSchemaVersion = this.ProfileSchemaVersion, + CpuSelection = this.CpuSelection, + CpuSelectionMigration = this.CpuSelectionMigration, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + foreach (var bit in this.BoolMask) + { + cloned.BoolMask.Add(bit); + } + + return cloned; + } + + public override string ToString() + { + return $"{this.Name} ({this.SelectedCoreCount}/{this.BoolMask.Count} cores)"; + } + } +} diff --git a/Models/CpuAffinityProfileSchemaVersions.cs b/Models/CpuAffinityProfileSchemaVersions.cs index 86c71bf..8daf212 100644 --- a/Models/CpuAffinityProfileSchemaVersions.cs +++ b/Models/CpuAffinityProfileSchemaVersions.cs @@ -1,25 +1,9 @@ -/* - * 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.Models -{ - public static class CpuAffinityProfileSchemaVersions - { - public const int Legacy = 1; - - public const int CpuSelection = 2; - } -} +namespace ThreadPilot.Models +{ + public static class CpuAffinityProfileSchemaVersions + { + public const int Legacy = 1; + + public const int CpuSelection = 2; + } +} diff --git a/Models/CpuPreset.cs b/Models/CpuPreset.cs index e5dc17e..45ac309 100644 --- a/Models/CpuPreset.cs +++ b/Models/CpuPreset.cs @@ -1,46 +1,27 @@ -/* - * 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.Models -{ - /// - /// Topology-aware CPU affinity preset generated from a CPU topology snapshot. - /// - public sealed record CpuPreset - { - public string PresetId { get; init; } = string.Empty; - - public string Name { get; init; } = string.Empty; - - public string Description { get; init; } = string.Empty; - - public CpuSelection Selection { get; init; } = new(); - - public string Reason { get; init; } = string.Empty; - - public string? SourcePresetId { get; init; } - - public string? Warning { get; init; } - - public CpuTopologySignature? GeneratedByTopologySignature { get; init; } - - public bool IsUserEditable { get; init; } = true; - - public bool IsGenerated { get; init; } = true; - - public bool ReviewRequired { get; init; } - } -} +namespace ThreadPilot.Models +{ + public sealed record CpuPreset + { + public string PresetId { get; init; } = string.Empty; + + public string Name { get; init; } = string.Empty; + + public string Description { get; init; } = string.Empty; + + public CpuSelection Selection { get; init; } = new(); + + public string Reason { get; init; } = string.Empty; + + public string? SourcePresetId { get; init; } + + public string? Warning { get; init; } + + public CpuTopologySignature? GeneratedByTopologySignature { get; init; } + + public bool IsUserEditable { get; init; } = true; + + public bool IsGenerated { get; init; } = true; + + public bool ReviewRequired { get; init; } + } +} diff --git a/Models/CpuSelection.cs b/Models/CpuSelection.cs index c8d63ab..f16985c 100644 --- a/Models/CpuSelection.cs +++ b/Models/CpuSelection.cs @@ -1,378 +1,346 @@ -/* - * 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.Models -{ - using System; - using System.Collections.Generic; - using System.Linq; - - /// - /// Identifies a logical processor without relying on a legacy 64-bit affinity mask. - /// - public readonly record struct ProcessorRef(ushort Group, byte LogicalProcessorNumber, int GlobalIndex); - - /// - /// Stable signature used to determine whether a persisted CPU selection was created for the current topology. - /// - public sealed record CpuTopologySignature - { - public string CpuBrand { get; init; } = "Unknown"; - - public int LogicalProcessorCount { get; init; } - - public int PhysicalCoreCount { get; init; } - - public int ProcessorGroupCount { get; init; } = 1; - - public int NumaNodeCount { get; init; } - - public int LastLevelCacheGroupCount { get; init; } - - public int PackageCount { get; init; } - - public string Source { get; init; } = "Unknown"; - } - - /// - /// Metadata that explains how a CPU selection was built and whether it can be represented by legacy APIs. - /// - public sealed record CpuSelectionMetadata - { - public CpuTopologySignature? TopologySignature { get; init; } - - public bool CreatedFromLegacyAffinityMask { get; init; } - - public bool ContainsLogicalProcessorsBeyondLegacyMask { get; init; } - - public bool HasMultipleProcessorGroups { get; init; } - - public int ProcessorGroupCount { get; init; } - - public int MaxGlobalLogicalProcessorIndex { get; init; } = -1; - - public string SelectionReason { get; init; } = string.Empty; - } - - /// - /// Lightweight topology snapshot used by the CpuSelection migration layer. - /// Runtime topology detection will populate this in a later phase. - /// - public sealed class CpuTopologySnapshot - { - private readonly IReadOnlyDictionary cpuSetIdsByProcessor; - private readonly IReadOnlyDictionary efficiencyClassesByProcessor; - private readonly IReadOnlyDictionary coreIndexesByProcessor; - private readonly IReadOnlyDictionary numaNodeIndexesByProcessor; - private readonly IReadOnlyDictionary lastLevelCacheIndexesByProcessor; - private readonly IReadOnlyDictionary packageIndexesByProcessor; - private readonly IReadOnlyDictionary> smtSiblingGlobalIndexesByProcessor; - - private CpuTopologySnapshot( - IReadOnlyList logicalProcessors, - IReadOnlyDictionary cpuSetIdsByProcessor, - IReadOnlyDictionary efficiencyClassesByProcessor, - IReadOnlyDictionary coreIndexesByProcessor, - IReadOnlyDictionary numaNodeIndexesByProcessor, - IReadOnlyDictionary lastLevelCacheIndexesByProcessor, - IReadOnlyDictionary packageIndexesByProcessor, - IReadOnlyDictionary> smtSiblingGlobalIndexesByProcessor, - CpuTopologySignature signature) - { - this.LogicalProcessors = logicalProcessors; - this.cpuSetIdsByProcessor = cpuSetIdsByProcessor; - this.efficiencyClassesByProcessor = efficiencyClassesByProcessor; - this.coreIndexesByProcessor = coreIndexesByProcessor; - this.numaNodeIndexesByProcessor = numaNodeIndexesByProcessor; - this.lastLevelCacheIndexesByProcessor = lastLevelCacheIndexesByProcessor; - this.packageIndexesByProcessor = packageIndexesByProcessor; - this.smtSiblingGlobalIndexesByProcessor = smtSiblingGlobalIndexesByProcessor; - this.Signature = signature; - } - - public IReadOnlyList LogicalProcessors { get; } - - public CpuTopologySignature Signature { get; } - - public static CpuTopologySnapshot Create( - IEnumerable logicalProcessors, - IReadOnlyDictionary? cpuSetIds = null, - IReadOnlyDictionary? efficiencyClasses = null, - CpuTopologySignature? signature = null, - IReadOnlyDictionary? coreIndexes = null, - IReadOnlyDictionary? numaNodeIndexes = null, - IReadOnlyDictionary? lastLevelCacheIndexes = null, - IReadOnlyDictionary? packageIndexes = null, - IReadOnlyDictionary>? smtSiblingGlobalIndexes = null) - { - ArgumentNullException.ThrowIfNull(logicalProcessors); - - var processors = logicalProcessors - .Distinct() - .OrderBy(processor => processor.GlobalIndex) - .ThenBy(processor => processor.Group) - .ThenBy(processor => processor.LogicalProcessorNumber) - .ToList(); - - var duplicatedGlobalIndexes = processors - .GroupBy(processor => processor.GlobalIndex) - .Where(group => group.Count() > 1) - .Select(group => group.Key) - .ToList(); - if (duplicatedGlobalIndexes.Count > 0) - { - throw new ArgumentException( - $"GlobalIndex must be unique in a CPU topology snapshot. Duplicates: {string.Join(", ", duplicatedGlobalIndexes)}.", - nameof(logicalProcessors)); - } - - var processorSet = processors.ToHashSet(); - var cpuSetMap = cpuSetIds? - .Where(kvp => processorSet.Contains(kvp.Key)) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value) - ?? new Dictionary(); - - var efficiencyClassMap = efficiencyClasses? - .Where(kvp => processorSet.Contains(kvp.Key)) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value) - ?? new Dictionary(); - - var coreIndexMap = FilterKnownProcessorMap(coreIndexes, processorSet); - var numaNodeIndexMap = FilterKnownProcessorMap(numaNodeIndexes, processorSet); - var lastLevelCacheIndexMap = FilterKnownProcessorMap(lastLevelCacheIndexes, processorSet); - var packageIndexMap = FilterKnownProcessorMap(packageIndexes, processorSet); - var knownGlobalIndexes = processors.Select(processor => processor.GlobalIndex).ToHashSet(); - var smtSiblingMap = smtSiblingGlobalIndexes? - .Where(kvp => processorSet.Contains(kvp.Key)) - .ToDictionary( - kvp => kvp.Key, - kvp => (IReadOnlyList)kvp.Value - .Where(knownGlobalIndexes.Contains) - .Distinct() - .OrderBy(index => index) - .ToList()) - ?? new Dictionary>(); - - var resolvedSignature = signature ?? new CpuTopologySignature - { - LogicalProcessorCount = processors.Count, - PhysicalCoreCount = coreIndexMap.Count == 0 - ? processors.Count - : coreIndexMap.Values.Distinct().Count(), - ProcessorGroupCount = processors.Select(processor => processor.Group).Distinct().Count(), - NumaNodeCount = numaNodeIndexMap.Values.Distinct().Count(), - LastLevelCacheGroupCount = lastLevelCacheIndexMap.Values.Distinct().Count(), - PackageCount = packageIndexMap.Values.Distinct().Count(), - Source = "Snapshot", - }; - - return new CpuTopologySnapshot( - processors, - cpuSetMap, - efficiencyClassMap, - coreIndexMap, - numaNodeIndexMap, - lastLevelCacheIndexMap, - packageIndexMap, - smtSiblingMap, - resolvedSignature); - } - - public bool TryGetCpuSetId(ProcessorRef processor, out uint cpuSetId) => - this.cpuSetIdsByProcessor.TryGetValue(processor, out cpuSetId); - - public bool TryGetEfficiencyClass(ProcessorRef processor, out byte efficiencyClass) => - this.efficiencyClassesByProcessor.TryGetValue(processor, out efficiencyClass); - - public bool TryGetCoreIndex(ProcessorRef processor, out int coreIndex) => - this.coreIndexesByProcessor.TryGetValue(processor, out coreIndex); - - public bool TryGetNumaNodeIndex(ProcessorRef processor, out int numaNodeIndex) => - this.numaNodeIndexesByProcessor.TryGetValue(processor, out numaNodeIndex); - - public bool TryGetLastLevelCacheIndex(ProcessorRef processor, out int lastLevelCacheIndex) => - this.lastLevelCacheIndexesByProcessor.TryGetValue(processor, out lastLevelCacheIndex); - - public bool TryGetPackageIndex(ProcessorRef processor, out int packageIndex) => - this.packageIndexesByProcessor.TryGetValue(processor, out packageIndex); - - public IReadOnlyList GetSmtSiblingGlobalIndexes(ProcessorRef processor) => - this.smtSiblingGlobalIndexesByProcessor.TryGetValue(processor, out var siblings) - ? siblings - : []; - - public byte? GetPerformanceEfficiencyClass() - { - if (this.efficiencyClassesByProcessor.Count == 0) - { - return null; - } - - return this.efficiencyClassesByProcessor.Values.Max(); - } - - private static Dictionary FilterKnownProcessorMap( - IReadOnlyDictionary? source, - HashSet processorSet) - { - return source? - .Where(kvp => processorSet.Contains(kvp.Key)) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value) - ?? new Dictionary(); - } - } - - /// - /// Group-aware CPU selection model used by new persistence and migration code. - /// - public sealed record CpuSelection - { - public List CpuSetIds { get; init; } = new(); - - public List LogicalProcessors { get; init; } = new(); - - public List GlobalLogicalProcessorIndexes { get; init; } = new(); - - public CpuSelectionMetadata Metadata { get; init; } = new(); - - public static CpuSelection FromProcessors( - IEnumerable processors, - CpuTopologySnapshot topology, - string selectionReason = "") - { - ArgumentNullException.ThrowIfNull(processors); - ArgumentNullException.ThrowIfNull(topology); - - var selectedProcessors = processors - .Distinct() - .OrderBy(processor => processor.GlobalIndex) - .ThenBy(processor => processor.Group) - .ThenBy(processor => processor.LogicalProcessorNumber) - .ToList(); - - var topologyProcessors = topology.LogicalProcessors.ToHashSet(); - var missingProcessors = selectedProcessors - .Where(processor => !topologyProcessors.Contains(processor)) - .ToList(); - if (missingProcessors.Count > 0) - { - throw new ArgumentException( - $"CPU selection contains processor(s) not present in the topology: {string.Join(", ", missingProcessors)}.", - nameof(processors)); - } - - var cpuSetIds = selectedProcessors - .Select(processor => topology.TryGetCpuSetId(processor, out var cpuSetId) ? (uint?)cpuSetId : null) - .Where(cpuSetId => cpuSetId.HasValue) - .Select(cpuSetId => cpuSetId!.Value) - .Distinct() - .OrderBy(cpuSetId => cpuSetId) - .ToList(); - - return new CpuSelection - { - CpuSetIds = cpuSetIds, - LogicalProcessors = selectedProcessors, - GlobalLogicalProcessorIndexes = selectedProcessors - .Select(processor => processor.GlobalIndex) - .Distinct() - .OrderBy(index => index) - .ToList(), - Metadata = CreateMetadata(selectedProcessors, topology.Signature, createdFromLegacyAffinityMask: false, selectionReason), - }; - } - - public static CpuSelection FromLegacyAffinityMask(long mask, CpuTopologySnapshot topology) - { - ArgumentNullException.ThrowIfNull(topology); - - var unsignedMask = unchecked((ulong)mask); - var selectedIndexes = new HashSet(); - for (var bit = 0; bit < 64; bit++) - { - if ((unsignedMask & (1UL << bit)) != 0) - { - selectedIndexes.Add(bit); - } - } - - var selectedProcessors = topology.LogicalProcessors - .Where(processor => selectedIndexes.Contains(processor.GlobalIndex)) - .ToList(); - - var selection = FromProcessors(selectedProcessors, topology, "Migrated from legacy affinity mask"); - return selection with - { - Metadata = CreateMetadata( - selection.LogicalProcessors, - topology.Signature, - createdFromLegacyAffinityMask: true, - "Migrated from legacy affinity mask"), - }; - } - - public static long? ToLegacyAffinityMaskOrNull(CpuSelection selection) - { - ArgumentNullException.ThrowIfNull(selection); - - if (selection.LogicalProcessors.Any(processor => processor.GlobalIndex >= 64)) - { - return null; - } - - if (selection.LogicalProcessors.Select(processor => processor.Group).Distinct().Count() > 1) - { - return null; - } - - long mask = 0; - foreach (var processor in selection.LogicalProcessors) - { - if (processor.GlobalIndex < 0) - { - return null; - } - - mask |= 1L << processor.GlobalIndex; - } - - return mask; - } - - private static CpuSelectionMetadata CreateMetadata( - IReadOnlyCollection processors, - CpuTopologySignature signature, - bool createdFromLegacyAffinityMask, - string selectionReason) - { - var groups = processors.Select(processor => processor.Group).Distinct().ToList(); - var maxGlobalIndex = processors.Count == 0 - ? -1 - : processors.Max(processor => processor.GlobalIndex); - - return new CpuSelectionMetadata - { - TopologySignature = signature, - CreatedFromLegacyAffinityMask = createdFromLegacyAffinityMask, - ContainsLogicalProcessorsBeyondLegacyMask = maxGlobalIndex >= 64, - HasMultipleProcessorGroups = groups.Count > 1, - ProcessorGroupCount = groups.Count, - MaxGlobalLogicalProcessorIndex = maxGlobalIndex, - SelectionReason = selectionReason, - }; - } - } -} +namespace ThreadPilot.Models +{ + using System; + using System.Collections.Generic; + using System.Linq; + + public readonly record struct ProcessorRef(ushort Group, byte LogicalProcessorNumber, int GlobalIndex); + + public sealed record CpuTopologySignature + { + public string CpuBrand { get; init; } = "Unknown"; + + public int LogicalProcessorCount { get; init; } + + public int PhysicalCoreCount { get; init; } + + public int ProcessorGroupCount { get; init; } = 1; + + public int NumaNodeCount { get; init; } + + public int LastLevelCacheGroupCount { get; init; } + + public int PackageCount { get; init; } + + public string Source { get; init; } = "Unknown"; + } + + public sealed record CpuSelectionMetadata + { + public CpuTopologySignature? TopologySignature { get; init; } + + public bool CreatedFromLegacyAffinityMask { get; init; } + + public bool ContainsLogicalProcessorsBeyondLegacyMask { get; init; } + + public bool HasMultipleProcessorGroups { get; init; } + + public int ProcessorGroupCount { get; init; } + + public int MaxGlobalLogicalProcessorIndex { get; init; } = -1; + + public string SelectionReason { get; init; } = string.Empty; + } + + public sealed class CpuTopologySnapshot + { + private readonly IReadOnlyDictionary cpuSetIdsByProcessor; + private readonly IReadOnlyDictionary efficiencyClassesByProcessor; + private readonly IReadOnlyDictionary coreIndexesByProcessor; + private readonly IReadOnlyDictionary numaNodeIndexesByProcessor; + private readonly IReadOnlyDictionary lastLevelCacheIndexesByProcessor; + private readonly IReadOnlyDictionary packageIndexesByProcessor; + private readonly IReadOnlyDictionary> smtSiblingGlobalIndexesByProcessor; + + private CpuTopologySnapshot( + IReadOnlyList logicalProcessors, + IReadOnlyDictionary cpuSetIdsByProcessor, + IReadOnlyDictionary efficiencyClassesByProcessor, + IReadOnlyDictionary coreIndexesByProcessor, + IReadOnlyDictionary numaNodeIndexesByProcessor, + IReadOnlyDictionary lastLevelCacheIndexesByProcessor, + IReadOnlyDictionary packageIndexesByProcessor, + IReadOnlyDictionary> smtSiblingGlobalIndexesByProcessor, + CpuTopologySignature signature) + { + this.LogicalProcessors = logicalProcessors; + this.cpuSetIdsByProcessor = cpuSetIdsByProcessor; + this.efficiencyClassesByProcessor = efficiencyClassesByProcessor; + this.coreIndexesByProcessor = coreIndexesByProcessor; + this.numaNodeIndexesByProcessor = numaNodeIndexesByProcessor; + this.lastLevelCacheIndexesByProcessor = lastLevelCacheIndexesByProcessor; + this.packageIndexesByProcessor = packageIndexesByProcessor; + this.smtSiblingGlobalIndexesByProcessor = smtSiblingGlobalIndexesByProcessor; + this.Signature = signature; + } + + public IReadOnlyList LogicalProcessors { get; } + + public CpuTopologySignature Signature { get; } + + public static CpuTopologySnapshot Create( + IEnumerable logicalProcessors, + IReadOnlyDictionary? cpuSetIds = null, + IReadOnlyDictionary? efficiencyClasses = null, + CpuTopologySignature? signature = null, + IReadOnlyDictionary? coreIndexes = null, + IReadOnlyDictionary? numaNodeIndexes = null, + IReadOnlyDictionary? lastLevelCacheIndexes = null, + IReadOnlyDictionary? packageIndexes = null, + IReadOnlyDictionary>? smtSiblingGlobalIndexes = null) + { + ArgumentNullException.ThrowIfNull(logicalProcessors); + + var processors = logicalProcessors + .Distinct() + .OrderBy(processor => processor.GlobalIndex) + .ThenBy(processor => processor.Group) + .ThenBy(processor => processor.LogicalProcessorNumber) + .ToList(); + + var duplicatedGlobalIndexes = processors + .GroupBy(processor => processor.GlobalIndex) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToList(); + if (duplicatedGlobalIndexes.Count > 0) + { + throw new ArgumentException( + $"GlobalIndex must be unique in a CPU topology snapshot. Duplicates: {string.Join(", ", duplicatedGlobalIndexes)}.", + nameof(logicalProcessors)); + } + + var processorSet = processors.ToHashSet(); + var cpuSetMap = cpuSetIds? + .Where(kvp => processorSet.Contains(kvp.Key)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value) + ?? new Dictionary(); + + var efficiencyClassMap = efficiencyClasses? + .Where(kvp => processorSet.Contains(kvp.Key)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value) + ?? new Dictionary(); + + var coreIndexMap = FilterKnownProcessorMap(coreIndexes, processorSet); + var numaNodeIndexMap = FilterKnownProcessorMap(numaNodeIndexes, processorSet); + var lastLevelCacheIndexMap = FilterKnownProcessorMap(lastLevelCacheIndexes, processorSet); + var packageIndexMap = FilterKnownProcessorMap(packageIndexes, processorSet); + var knownGlobalIndexes = processors.Select(processor => processor.GlobalIndex).ToHashSet(); + var smtSiblingMap = smtSiblingGlobalIndexes? + .Where(kvp => processorSet.Contains(kvp.Key)) + .ToDictionary( + kvp => kvp.Key, + kvp => (IReadOnlyList)kvp.Value + .Where(knownGlobalIndexes.Contains) + .Distinct() + .OrderBy(index => index) + .ToList()) + ?? new Dictionary>(); + + var resolvedSignature = signature ?? new CpuTopologySignature + { + LogicalProcessorCount = processors.Count, + PhysicalCoreCount = coreIndexMap.Count == 0 + ? processors.Count + : coreIndexMap.Values.Distinct().Count(), + ProcessorGroupCount = processors.Select(processor => processor.Group).Distinct().Count(), + NumaNodeCount = numaNodeIndexMap.Values.Distinct().Count(), + LastLevelCacheGroupCount = lastLevelCacheIndexMap.Values.Distinct().Count(), + PackageCount = packageIndexMap.Values.Distinct().Count(), + Source = "Snapshot", + }; + + return new CpuTopologySnapshot( + processors, + cpuSetMap, + efficiencyClassMap, + coreIndexMap, + numaNodeIndexMap, + lastLevelCacheIndexMap, + packageIndexMap, + smtSiblingMap, + resolvedSignature); + } + + public bool TryGetCpuSetId(ProcessorRef processor, out uint cpuSetId) => + this.cpuSetIdsByProcessor.TryGetValue(processor, out cpuSetId); + + public bool TryGetEfficiencyClass(ProcessorRef processor, out byte efficiencyClass) => + this.efficiencyClassesByProcessor.TryGetValue(processor, out efficiencyClass); + + public bool TryGetCoreIndex(ProcessorRef processor, out int coreIndex) => + this.coreIndexesByProcessor.TryGetValue(processor, out coreIndex); + + public bool TryGetNumaNodeIndex(ProcessorRef processor, out int numaNodeIndex) => + this.numaNodeIndexesByProcessor.TryGetValue(processor, out numaNodeIndex); + + public bool TryGetLastLevelCacheIndex(ProcessorRef processor, out int lastLevelCacheIndex) => + this.lastLevelCacheIndexesByProcessor.TryGetValue(processor, out lastLevelCacheIndex); + + public bool TryGetPackageIndex(ProcessorRef processor, out int packageIndex) => + this.packageIndexesByProcessor.TryGetValue(processor, out packageIndex); + + public IReadOnlyList GetSmtSiblingGlobalIndexes(ProcessorRef processor) => + this.smtSiblingGlobalIndexesByProcessor.TryGetValue(processor, out var siblings) + ? siblings + : []; + + public byte? GetPerformanceEfficiencyClass() + { + if (this.efficiencyClassesByProcessor.Count == 0) + { + return null; + } + + return this.efficiencyClassesByProcessor.Values.Max(); + } + + private static Dictionary FilterKnownProcessorMap( + IReadOnlyDictionary? source, + HashSet processorSet) + { + return source? + .Where(kvp => processorSet.Contains(kvp.Key)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value) + ?? new Dictionary(); + } + } + + public sealed record CpuSelection + { + public List CpuSetIds { get; init; } = new(); + + public List LogicalProcessors { get; init; } = new(); + + public List GlobalLogicalProcessorIndexes { get; init; } = new(); + + public CpuSelectionMetadata Metadata { get; init; } = new(); + + public static CpuSelection FromProcessors( + IEnumerable processors, + CpuTopologySnapshot topology, + string selectionReason = "") + { + ArgumentNullException.ThrowIfNull(processors); + ArgumentNullException.ThrowIfNull(topology); + + var selectedProcessors = processors + .Distinct() + .OrderBy(processor => processor.GlobalIndex) + .ThenBy(processor => processor.Group) + .ThenBy(processor => processor.LogicalProcessorNumber) + .ToList(); + + var topologyProcessors = topology.LogicalProcessors.ToHashSet(); + var missingProcessors = selectedProcessors + .Where(processor => !topologyProcessors.Contains(processor)) + .ToList(); + if (missingProcessors.Count > 0) + { + throw new ArgumentException( + $"CPU selection contains processor(s) not present in the topology: {string.Join(", ", missingProcessors)}.", + nameof(processors)); + } + + var cpuSetIds = selectedProcessors + .Select(processor => topology.TryGetCpuSetId(processor, out var cpuSetId) ? (uint?)cpuSetId : null) + .Where(cpuSetId => cpuSetId.HasValue) + .Select(cpuSetId => cpuSetId!.Value) + .Distinct() + .OrderBy(cpuSetId => cpuSetId) + .ToList(); + + return new CpuSelection + { + CpuSetIds = cpuSetIds, + LogicalProcessors = selectedProcessors, + GlobalLogicalProcessorIndexes = selectedProcessors + .Select(processor => processor.GlobalIndex) + .Distinct() + .OrderBy(index => index) + .ToList(), + Metadata = CreateMetadata(selectedProcessors, topology.Signature, createdFromLegacyAffinityMask: false, selectionReason), + }; + } + + public static CpuSelection FromLegacyAffinityMask(long mask, CpuTopologySnapshot topology) + { + ArgumentNullException.ThrowIfNull(topology); + + var unsignedMask = unchecked((ulong)mask); + var selectedIndexes = new HashSet(); + for (var bit = 0; bit < 64; bit++) + { + if ((unsignedMask & (1UL << bit)) != 0) + { + selectedIndexes.Add(bit); + } + } + + var selectedProcessors = topology.LogicalProcessors + .Where(processor => selectedIndexes.Contains(processor.GlobalIndex)) + .ToList(); + + var selection = FromProcessors(selectedProcessors, topology, "Migrated from legacy affinity mask"); + return selection with + { + Metadata = CreateMetadata( + selection.LogicalProcessors, + topology.Signature, + createdFromLegacyAffinityMask: true, + "Migrated from legacy affinity mask"), + }; + } + + public static long? ToLegacyAffinityMaskOrNull(CpuSelection selection) + { + ArgumentNullException.ThrowIfNull(selection); + + if (selection.LogicalProcessors.Any(processor => processor.GlobalIndex >= 64)) + { + return null; + } + + if (selection.LogicalProcessors.Select(processor => processor.Group).Distinct().Count() > 1) + { + return null; + } + + long mask = 0; + foreach (var processor in selection.LogicalProcessors) + { + if (processor.GlobalIndex < 0) + { + return null; + } + + mask |= 1L << processor.GlobalIndex; + } + + return mask; + } + + private static CpuSelectionMetadata CreateMetadata( + IReadOnlyCollection processors, + CpuTopologySignature signature, + bool createdFromLegacyAffinityMask, + string selectionReason) + { + var groups = processors.Select(processor => processor.Group).Distinct().ToList(); + var maxGlobalIndex = processors.Count == 0 + ? -1 + : processors.Max(processor => processor.GlobalIndex); + + return new CpuSelectionMetadata + { + TopologySignature = signature, + CreatedFromLegacyAffinityMask = createdFromLegacyAffinityMask, + ContainsLogicalProcessorsBeyondLegacyMask = maxGlobalIndex >= 64, + HasMultipleProcessorGroups = groups.Count > 1, + ProcessorGroupCount = groups.Count, + MaxGlobalLogicalProcessorIndex = maxGlobalIndex, + SelectionReason = selectionReason, + }; + } + } +} diff --git a/Models/CpuSelectionMigrationMetadata.cs b/Models/CpuSelectionMigrationMetadata.cs index a3c47e3..80bc905 100644 --- a/Models/CpuSelectionMigrationMetadata.cs +++ b/Models/CpuSelectionMigrationMetadata.cs @@ -1,35 +1,19 @@ -/* - * 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.Models -{ - public sealed record CpuSelectionMigrationMetadata - { - public bool CreatedFromLegacyAffinityMask { get; init; } - - public bool CreatedFromLegacyCoreMask { get; init; } - - public bool ReviewRequired { get; init; } - - public string MigrationConfidence { get; init; } = string.Empty; - - public string Reason { get; init; } = string.Empty; - - public CpuTopologySignature? TopologySignature { get; init; } - - public long? SourceLegacyAffinityMask { get; init; } - } -} +namespace ThreadPilot.Models +{ + public sealed record CpuSelectionMigrationMetadata + { + public bool CreatedFromLegacyAffinityMask { get; init; } + + public bool CreatedFromLegacyCoreMask { get; init; } + + public bool ReviewRequired { get; init; } + + public string MigrationConfidence { get; init; } = string.Empty; + + public string Reason { get; init; } = string.Empty; + + public CpuTopologySignature? TopologySignature { get; init; } + + public long? SourceLegacyAffinityMask { get; init; } + } +} diff --git a/Models/CpuTopologyModel.cs b/Models/CpuTopologyModel.cs index e0fc3c3..cdca1f5 100644 --- a/Models/CpuTopologyModel.cs +++ b/Models/CpuTopologyModel.cs @@ -1,197 +1,133 @@ -/* - * 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.Models -{ - using System; - using System.Collections.Generic; - using System.Linq; - using CommunityToolkit.Mvvm.ComponentModel; - - /// - /// Represents a logical CPU core with topology information. - /// - public partial class CpuCoreModel : ObservableObject - { - public int LogicalCoreId { get; set; } - - public int PhysicalCoreId { get; set; } - - public int SocketId { get; set; } - - public int? CcdId { get; set; } // Core Complex Die (AMD) - - public int? ClusterId { get; set; } // Intel Cluster - - public CpuCoreType CoreType { get; set; } = CpuCoreType.Unknown; - - public bool IsHyperThreaded { get; set; } - - public int? HyperThreadSibling { get; set; } - - public string Label { get; set; } = string.Empty; - - public string LogicalProcessorName { get; set; } = string.Empty; // e.g., "Core0_T0", "Core0_T1" (T0 = physical, T1+ = SMT) - - [ObservableProperty] - private bool isEnabled = true; - - [ObservableProperty] - private bool isSelected = false; - - /// - /// Gets the affinity mask bit for this logical core. - /// - public long AffinityMask => 1L << this.LogicalCoreId; - } - - /// - /// Types of CPU cores. - /// - public enum CpuCoreType - { - Unknown, - Standard, - PerformanceCore, // Intel P-cores - EfficiencyCore, // Intel E-cores - Zen, // AMD Zen cores - ZenPlus, // AMD Zen+ cores - Zen2, // AMD Zen2 cores - Zen3, // AMD Zen3 cores - Zen4, // AMD Zen4 cores - } - - /// - /// Represents CPU topology information. - /// - public class CpuTopologyModel - { - public List LogicalCores { get; set; } = new(); - - public int TotalLogicalCores => this.LogicalCores.Count; - - public int TotalPhysicalCores => this.LogicalCores.GroupBy(c => c.PhysicalCoreId).Count(); - - public int TotalSockets => this.LogicalCores.GroupBy(c => c.SocketId).Count(); - - public int SocketCount => this.TotalSockets; // Alias for TotalSockets - - public bool HasHyperThreading => this.LogicalCores.Any(c => c.IsHyperThreaded); - - public bool HasSmt => this.HasHyperThreading; // SMT is AMD's term for HyperThreading - - public bool HasIntelHybrid => this.LogicalCores.Any(c => c.CoreType == CpuCoreType.PerformanceCore || c.CoreType == CpuCoreType.EfficiencyCore); - - public bool HasHybridArchitecture => this.HasIntelHybrid; // Alias for HasIntelHybrid - - public bool HasAmdCcd => this.LogicalCores.Any(c => c.CcdId.HasValue); - - public int CcdCount => this.LogicalCores.Where(c => c.CcdId.HasValue).Select(c => c.CcdId!.Value).Distinct().Count(); - - public string Architecture => this.CpuArchitecture; // Alias for CpuArchitecture - - public string CpuArchitecture { get; set; } = "Unknown"; - - public string CpuBrand { get; set; } = "Unknown"; - - public bool TopologyDetectionSuccessful { get; set; } = false; - - /// - /// Gets all CCDs (Core Complex Dies) available. - /// - public IEnumerable AvailableCcds => this.LogicalCores - .Where(c => c.CcdId.HasValue) - .Select(c => c.CcdId!.Value) - .Distinct() - .OrderBy(id => id); - - /// - /// Gets all performance cores (Intel P-cores). - /// - public IEnumerable PerformanceCores => this.LogicalCores - .Where(c => c.CoreType == CpuCoreType.PerformanceCore); - - /// - /// Gets all efficiency cores (Intel E-cores). - /// - public IEnumerable EfficiencyCores => this.LogicalCores - .Where(c => c.CoreType == CpuCoreType.EfficiencyCore); - - /// - /// Gets all physical cores (one logical core per physical core, excluding HT siblings). - /// - public IEnumerable PhysicalCores => this.LogicalCores - .GroupBy(c => c.PhysicalCoreId) - .Select(g => g.OrderBy(c => c.LogicalCoreId).First()); - - /// - /// Gets cores by CCD ID. - /// - public IEnumerable GetCoresByCcd(int ccdId) => this.LogicalCores - .Where(c => c.CcdId == ccdId); - - /// - /// Gets cores by socket ID. - /// - public IEnumerable GetCoresBySocket(int socketId) => this.LogicalCores - .Where(c => c.SocketId == socketId); - - /// - /// Calculates affinity mask for selected cores. - /// - public long CalculateAffinityMask(IEnumerable cores) - { - return cores.Aggregate(0L, (mask, core) => mask | core.AffinityMask); - } - - /// - /// Gets affinity mask for all physical cores (excluding HT siblings). - /// - public long GetPhysicalCoresAffinityMask() => this.CalculateAffinityMask(this.PhysicalCores); - - /// - /// Gets affinity mask for performance cores. - /// - public long GetPerformanceCoresAffinityMask() => this.CalculateAffinityMask(this.PerformanceCores); - - /// - /// Gets affinity mask for efficiency cores. - /// - public long GetEfficiencyCoresAffinityMask() => this.CalculateAffinityMask(this.EfficiencyCores); - - /// - /// Gets affinity mask for a specific CCD. - /// - public long GetCcdAffinityMask(int ccdId) => this.CalculateAffinityMask(this.GetCoresByCcd(ccdId)); - } - - /// - /// Quick selection preset for CPU affinity. - /// - public class CpuAffinityPreset - { - public string Name { get; set; } = string.Empty; - - public string Description { get; set; } = string.Empty; - - public long AffinityMask { get; set; } - - public bool IsAvailable { get; set; } = true; - - public string UnavailableReason { get; set; } = string.Empty; - } -} - +namespace ThreadPilot.Models +{ + using System; + using System.Collections.Generic; + using System.Linq; + using CommunityToolkit.Mvvm.ComponentModel; + + public partial class CpuCoreModel : ObservableObject + { + public int LogicalCoreId { get; set; } + + public int PhysicalCoreId { get; set; } + + public int SocketId { get; set; } + + public int? CcdId { get; set; } // Core Complex Die (AMD) + + public int? ClusterId { get; set; } // Intel Cluster + + public CpuCoreType CoreType { get; set; } = CpuCoreType.Unknown; + + public bool IsHyperThreaded { get; set; } + + public int? HyperThreadSibling { get; set; } + + public string Label { get; set; } = string.Empty; + + public string LogicalProcessorName { get; set; } = string.Empty; // e.g., "Core0_T0", "Core0_T1" (T0 = physical, T1+ = SMT) + + [ObservableProperty] + private bool isEnabled = true; + + [ObservableProperty] + private bool isSelected = false; + + public long AffinityMask => 1L << this.LogicalCoreId; + } + + public enum CpuCoreType + { + Unknown, + Standard, + PerformanceCore, // Intel P-cores + EfficiencyCore, // Intel E-cores + Zen, // AMD Zen cores + ZenPlus, // AMD Zen+ cores + Zen2, // AMD Zen2 cores + Zen3, // AMD Zen3 cores + Zen4, // AMD Zen4 cores + } + + public class CpuTopologyModel + { + public List LogicalCores { get; set; } = new(); + + public int TotalLogicalCores => this.LogicalCores.Count; + + public int TotalPhysicalCores => this.LogicalCores.GroupBy(c => c.PhysicalCoreId).Count(); + + public int TotalSockets => this.LogicalCores.GroupBy(c => c.SocketId).Count(); + + public int SocketCount => this.TotalSockets; // Alias for TotalSockets + + public bool HasHyperThreading => this.LogicalCores.Any(c => c.IsHyperThreaded); + + public bool HasSmt => this.HasHyperThreading; // SMT is AMD's term for HyperThreading + + public bool HasIntelHybrid => this.LogicalCores.Any(c => c.CoreType == CpuCoreType.PerformanceCore || c.CoreType == CpuCoreType.EfficiencyCore); + + public bool HasHybridArchitecture => this.HasIntelHybrid; // Alias for HasIntelHybrid + + public bool HasAmdCcd => this.LogicalCores.Any(c => c.CcdId.HasValue); + + public int CcdCount => this.LogicalCores.Where(c => c.CcdId.HasValue).Select(c => c.CcdId!.Value).Distinct().Count(); + + public string Architecture => this.CpuArchitecture; // Alias for CpuArchitecture + + public string CpuArchitecture { get; set; } = "Unknown"; + + public string CpuBrand { get; set; } = "Unknown"; + + public bool TopologyDetectionSuccessful { get; set; } = false; + + public IEnumerable AvailableCcds => this.LogicalCores + .Where(c => c.CcdId.HasValue) + .Select(c => c.CcdId!.Value) + .Distinct() + .OrderBy(id => id); + + public IEnumerable PerformanceCores => this.LogicalCores + .Where(c => c.CoreType == CpuCoreType.PerformanceCore); + + public IEnumerable EfficiencyCores => this.LogicalCores + .Where(c => c.CoreType == CpuCoreType.EfficiencyCore); + + public IEnumerable PhysicalCores => this.LogicalCores + .GroupBy(c => c.PhysicalCoreId) + .Select(g => g.OrderBy(c => c.LogicalCoreId).First()); + + public IEnumerable GetCoresByCcd(int ccdId) => this.LogicalCores + .Where(c => c.CcdId == ccdId); + + public IEnumerable GetCoresBySocket(int socketId) => this.LogicalCores + .Where(c => c.SocketId == socketId); + + public long CalculateAffinityMask(IEnumerable cores) + { + return cores.Aggregate(0L, (mask, core) => mask | core.AffinityMask); + } + + public long GetPhysicalCoresAffinityMask() => this.CalculateAffinityMask(this.PhysicalCores); + + public long GetPerformanceCoresAffinityMask() => this.CalculateAffinityMask(this.PerformanceCores); + + public long GetEfficiencyCoresAffinityMask() => this.CalculateAffinityMask(this.EfficiencyCores); + + public long GetCcdAffinityMask(int ccdId) => this.CalculateAffinityMask(this.GetCoresByCcd(ccdId)); + } + + public class CpuAffinityPreset + { + public string Name { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; + + public long AffinityMask { get; set; } + + public bool IsAvailable { get; set; } = true; + + public string UnavailableReason { get; set; } = string.Empty; + } +} + diff --git a/Models/LogEventTypes.cs b/Models/LogEventTypes.cs index 98cf8ff..87e2b75 100644 --- a/Models/LogEventTypes.cs +++ b/Models/LogEventTypes.cs @@ -1,233 +1,187 @@ -/* - * 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.Models -{ - /// - /// Defines structured log event types and categories for consistent logging. - /// - public static class LogEventTypes - { - /// - /// Power plan related events. - /// - public static class PowerPlan - { - public const string Changed = "PowerPlanChanged"; - public const string ChangeRequested = "PowerPlanChangeRequested"; - public const string ChangeFailed = "PowerPlanChangeFailed"; - public const string Restored = "PowerPlanRestored"; - public const string DefaultSet = "DefaultPowerPlanSet"; - public const string EnumerationFailed = "PowerPlanEnumerationFailed"; - } - - /// - /// Process monitoring events. - /// - public static class ProcessMonitoring - { - public const string Started = "ProcessStarted"; - public const string Stopped = "ProcessStopped"; - public const string MonitoringStarted = "MonitoringStarted"; - public const string MonitoringStopped = "MonitoringStopped"; - public const string WmiEventReceived = "WmiEventReceived"; - public const string WmiConnectionFailed = "WmiConnectionFailed"; - public const string PollingFallback = "PollingFallbackActivated"; - public const string ProcessDetected = "ProcessDetected"; - public const string ProcessLost = "ProcessLost"; - public const string AssociationTriggered = "AssociationTriggered"; - } - - /// - /// User action events. - /// - public static class UserActions - { - public const string SettingsChanged = "SettingsChanged"; - public const string ProcessAffinityChanged = "ProcessAffinityChanged"; - public const string ProcessPriorityChanged = "ProcessPriorityChanged"; - public const string AssociationAdded = "AssociationAdded"; - public const string AssociationRemoved = "AssociationRemoved"; - public const string MonitoringToggled = "MonitoringToggled"; - public const string GameBoostToggled = "GameBoostToggled"; - public const string AutostartToggled = "AutostartToggled"; - public const string NotificationSettingsChanged = "NotificationSettingsChanged"; - public const string LogsExported = "LogsExported"; - public const string LogsCleared = "LogsCleared"; - } - - /// - /// System events. - /// - public static class System - { - public const string ApplicationStarted = "ApplicationStarted"; - public const string ApplicationShutdown = "ApplicationShutdown"; - public const string ServiceInitialized = "ServiceInitialized"; - public const string ServiceStarted = "ServiceStarted"; - public const string ServiceStopped = "ServiceStopped"; - public const string ConfigurationLoaded = "ConfigurationLoaded"; - public const string ConfigurationSaved = "ConfigurationSaved"; - public const string CpuTopologyDetected = "CpuTopologyDetected"; - public const string SystemTrayInitialized = "SystemTrayInitialized"; - public const string NotificationSent = "NotificationSent"; - public const string ErrorRecovered = "ErrorRecovered"; - } - - /// - /// Error categories. - /// - public static class Errors - { - public const string ServiceFailure = "ServiceFailure"; - public const string ConfigurationError = "ConfigurationError"; - public const string FileSystemError = "FileSystemError"; - public const string PermissionError = "PermissionError"; - public const string NetworkError = "NetworkError"; - public const string WmiError = "WmiError"; - public const string UnhandledException = "UnhandledException"; - public const string ValidationError = "ValidationError"; - } - - /// - /// Performance events. - /// - public static class Performance - { - public const string HighMemoryUsage = "HighMemoryUsage"; - public const string HighCpuUsage = "HighCpuUsage"; - public const string SlowOperation = "SlowOperation"; - public const string LargeLogFile = "LargeLogFile"; - public const string ProcessCountHigh = "ProcessCountHigh"; - public const string Gen2PauseAlert = "Gen2PauseAlert"; - } - } - - /// - /// Log categories for organizing log entries. - /// - public static class LogCategories - { - public const string PowerPlan = "PowerPlan"; - public const string ProcessMonitoring = "ProcessMonitoring"; - public const string UserAction = "UserAction"; - public const string System = "System"; - public const string Error = "Error"; - public const string Performance = "Performance"; - public const string Security = "Security"; - public const string Configuration = "Configuration"; - public const string Lifecycle = "Lifecycle"; - } - - /// - /// Common log properties for structured logging. - /// - public static class LogProperties - { - public const string ProcessName = "ProcessName"; - public const string ProcessId = "ProcessId"; - public const string PowerPlanId = "PowerPlanId"; - public const string PowerPlanName = "PowerPlanName"; - public const string GameName = "GameName"; - public const string UserId = "UserId"; - public const string SessionId = "SessionId"; - public const string CorrelationId = "CorrelationId"; - public const string Duration = "Duration"; - public const string ErrorCode = "ErrorCode"; - public const string StackTrace = "StackTrace"; - public const string MemoryUsage = "MemoryUsage"; - public const string CpuUsage = "CpuUsage"; - public const string ThreadId = "ThreadId"; - public const string Version = "Version"; - public const string Environment = "Environment"; - } - - /// - /// Helper class for creating structured log data. - /// - public static class LogDataBuilder - { - public static Dictionary CreateProcessData(string processName, int processId) - { - return new Dictionary - { - [LogProperties.ProcessName] = processName, - [LogProperties.ProcessId] = processId, - }; - } - - public static Dictionary CreatePowerPlanData(string planId, string planName) - { - return new Dictionary - { - [LogProperties.PowerPlanId] = planId, - [LogProperties.PowerPlanName] = planName, - }; - } - - public static Dictionary CreateGameData(string gameName) - { - return new Dictionary - { - [LogProperties.GameName] = gameName, - }; - } - - public static Dictionary CreatePerformanceData(long memoryUsage, double cpuUsage) - { - return new Dictionary - { - [LogProperties.MemoryUsage] = memoryUsage, - [LogProperties.CpuUsage] = cpuUsage, - }; - } - - public static Dictionary CreateErrorData(Exception exception) - { - var stackTrace = exception.StackTrace ?? "N/A"; - stackTrace = stackTrace[..Math.Min(2000, stackTrace.Length)]; - - return new Dictionary - { - [LogProperties.ErrorCode] = exception.HResult, - [LogProperties.StackTrace] = stackTrace, - ["ExceptionType"] = exception.GetType().Name, - ["InnerException"] = exception.InnerException?.Message ?? "N/A", - }; - } - - public static Dictionary CreateTimingData(TimeSpan duration) - { - return new Dictionary - { - [LogProperties.Duration] = duration.TotalMilliseconds, - }; - } - - public static Dictionary CreateSystemData() - { - return new Dictionary - { - [LogProperties.Version] = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown", - [LogProperties.Environment] = Environment.OSVersion.ToString(), - [LogProperties.ThreadId] = Thread.CurrentThread.ManagedThreadId, - ["MachineName"] = Environment.MachineName, - ["UserName"] = Environment.UserName, - }; - } - } -} - +namespace ThreadPilot.Models +{ + public static class LogEventTypes + { + public static class PowerPlan + { + public const string Changed = "PowerPlanChanged"; + public const string ChangeRequested = "PowerPlanChangeRequested"; + public const string ChangeFailed = "PowerPlanChangeFailed"; + public const string Restored = "PowerPlanRestored"; + public const string DefaultSet = "DefaultPowerPlanSet"; + public const string EnumerationFailed = "PowerPlanEnumerationFailed"; + } + + public static class ProcessMonitoring + { + public const string Started = "ProcessStarted"; + public const string Stopped = "ProcessStopped"; + public const string MonitoringStarted = "MonitoringStarted"; + public const string MonitoringStopped = "MonitoringStopped"; + public const string WmiEventReceived = "WmiEventReceived"; + public const string WmiConnectionFailed = "WmiConnectionFailed"; + public const string PollingFallback = "PollingFallbackActivated"; + public const string ProcessDetected = "ProcessDetected"; + public const string ProcessLost = "ProcessLost"; + public const string AssociationTriggered = "AssociationTriggered"; + } + + public static class UserActions + { + public const string SettingsChanged = "SettingsChanged"; + public const string ProcessAffinityChanged = "ProcessAffinityChanged"; + public const string ProcessPriorityChanged = "ProcessPriorityChanged"; + public const string AssociationAdded = "AssociationAdded"; + public const string AssociationRemoved = "AssociationRemoved"; + public const string MonitoringToggled = "MonitoringToggled"; + public const string GameBoostToggled = "GameBoostToggled"; + public const string AutostartToggled = "AutostartToggled"; + public const string NotificationSettingsChanged = "NotificationSettingsChanged"; + public const string LogsExported = "LogsExported"; + public const string LogsCleared = "LogsCleared"; + } + + public static class System + { + public const string ApplicationStarted = "ApplicationStarted"; + public const string ApplicationShutdown = "ApplicationShutdown"; + public const string ServiceInitialized = "ServiceInitialized"; + public const string ServiceStarted = "ServiceStarted"; + public const string ServiceStopped = "ServiceStopped"; + public const string ConfigurationLoaded = "ConfigurationLoaded"; + public const string ConfigurationSaved = "ConfigurationSaved"; + public const string CpuTopologyDetected = "CpuTopologyDetected"; + public const string SystemTrayInitialized = "SystemTrayInitialized"; + public const string NotificationSent = "NotificationSent"; + public const string ErrorRecovered = "ErrorRecovered"; + } + + public static class Errors + { + public const string ServiceFailure = "ServiceFailure"; + public const string ConfigurationError = "ConfigurationError"; + public const string FileSystemError = "FileSystemError"; + public const string PermissionError = "PermissionError"; + public const string NetworkError = "NetworkError"; + public const string WmiError = "WmiError"; + public const string UnhandledException = "UnhandledException"; + public const string ValidationError = "ValidationError"; + } + + public static class Performance + { + public const string HighMemoryUsage = "HighMemoryUsage"; + public const string HighCpuUsage = "HighCpuUsage"; + public const string SlowOperation = "SlowOperation"; + public const string LargeLogFile = "LargeLogFile"; + public const string ProcessCountHigh = "ProcessCountHigh"; + public const string Gen2PauseAlert = "Gen2PauseAlert"; + } + } + + public static class LogCategories + { + public const string PowerPlan = "PowerPlan"; + public const string ProcessMonitoring = "ProcessMonitoring"; + public const string UserAction = "UserAction"; + public const string System = "System"; + public const string Error = "Error"; + public const string Performance = "Performance"; + public const string Security = "Security"; + public const string Configuration = "Configuration"; + public const string Lifecycle = "Lifecycle"; + } + + public static class LogProperties + { + public const string ProcessName = "ProcessName"; + public const string ProcessId = "ProcessId"; + public const string PowerPlanId = "PowerPlanId"; + public const string PowerPlanName = "PowerPlanName"; + public const string GameName = "GameName"; + public const string UserId = "UserId"; + public const string SessionId = "SessionId"; + public const string CorrelationId = "CorrelationId"; + public const string Duration = "Duration"; + public const string ErrorCode = "ErrorCode"; + public const string StackTrace = "StackTrace"; + public const string MemoryUsage = "MemoryUsage"; + public const string CpuUsage = "CpuUsage"; + public const string ThreadId = "ThreadId"; + public const string Version = "Version"; + public const string Environment = "Environment"; + } + + public static class LogDataBuilder + { + public static Dictionary CreateProcessData(string processName, int processId) + { + return new Dictionary + { + [LogProperties.ProcessName] = processName, + [LogProperties.ProcessId] = processId, + }; + } + + public static Dictionary CreatePowerPlanData(string planId, string planName) + { + return new Dictionary + { + [LogProperties.PowerPlanId] = planId, + [LogProperties.PowerPlanName] = planName, + }; + } + + public static Dictionary CreateGameData(string gameName) + { + return new Dictionary + { + [LogProperties.GameName] = gameName, + }; + } + + public static Dictionary CreatePerformanceData(long memoryUsage, double cpuUsage) + { + return new Dictionary + { + [LogProperties.MemoryUsage] = memoryUsage, + [LogProperties.CpuUsage] = cpuUsage, + }; + } + + public static Dictionary CreateErrorData(Exception exception) + { + var stackTrace = exception.StackTrace ?? "N/A"; + stackTrace = stackTrace[..Math.Min(2000, stackTrace.Length)]; + + return new Dictionary + { + [LogProperties.ErrorCode] = exception.HResult, + [LogProperties.StackTrace] = stackTrace, + ["ExceptionType"] = exception.GetType().Name, + ["InnerException"] = exception.InnerException?.Message ?? "N/A", + }; + } + + public static Dictionary CreateTimingData(TimeSpan duration) + { + return new Dictionary + { + [LogProperties.Duration] = duration.TotalMilliseconds, + }; + } + + public static Dictionary CreateSystemData() + { + return new Dictionary + { + [LogProperties.Version] = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown", + [LogProperties.Environment] = Environment.OSVersion.ToString(), + [LogProperties.ThreadId] = Thread.CurrentThread.ManagedThreadId, + ["MachineName"] = Environment.MachineName, + ["UserName"] = Environment.UserName, + }; + } + } +} + diff --git a/Models/NotificationModel.cs b/Models/NotificationModel.cs index 1ca441f..b6a8c91 100644 --- a/Models/NotificationModel.cs +++ b/Models/NotificationModel.cs @@ -1,160 +1,111 @@ -/* - * 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.Models -{ - using System; - using CommunityToolkit.Mvvm.ComponentModel; - - /// - /// Model representing a notification. - /// - public partial class NotificationModel : ObservableObject - { - [ObservableProperty] - private string id = Guid.NewGuid().ToString(); - - [ObservableProperty] - private string title = string.Empty; - - [ObservableProperty] - private string message = string.Empty; - - [ObservableProperty] - private NotificationType type = NotificationType.Information; - - [ObservableProperty] - private DateTime timestamp = DateTime.Now; - - [ObservableProperty] - private int durationMs = 3000; - - [ObservableProperty] - private bool isRead = false; - - [ObservableProperty] - private bool isPersistent = false; - - [ObservableProperty] - private string? actionText; - - [ObservableProperty] - private string? actionCommand; - - [ObservableProperty] - private string? iconPath; - - [ObservableProperty] - private NotificationPriority priority = NotificationPriority.Normal; - - [ObservableProperty] - private string? category; - - [ObservableProperty] - private string? sourceService; - - /// - /// Initializes a new instance of the class. - /// Creates a new notification. - /// - public NotificationModel() - { - } - - /// - /// Initializes a new instance of the class. - /// Creates a new notification with basic information. - /// - public NotificationModel(string title, string message, NotificationType type = NotificationType.Information) - { - this.Title = title; - this.Message = message; - this.Type = type; - } - - /// - /// Initializes a new instance of the class. - /// Creates a new notification with full information. - /// - public NotificationModel(string title, string message, NotificationType type, int durationMs, bool isPersistent = false) - { - this.Title = title; - this.Message = message; - this.Type = type; - this.DurationMs = durationMs; - this.IsPersistent = isPersistent; - } - - /// - /// Marks the notification as read. - /// - public void MarkAsRead() - { - this.IsRead = true; - } - - /// - /// Gets the display text for the notification type. - /// - public string TypeDisplayText => this.Type switch - { - NotificationType.Information => "Info", - NotificationType.Success => "Success", - NotificationType.Warning => "Warning", - NotificationType.Error => "Error", - NotificationType.PowerPlanChange => "Power Plan", - NotificationType.ProcessMonitoring => "Process Monitor", - NotificationType.CpuAffinity => "CPU Affinity", - _ => "Unknown", - }; - - /// - /// Gets the formatted timestamp. - /// - public string FormattedTimestamp => this.Timestamp.ToString("HH:mm:ss"); - - /// - /// Gets the formatted date and time. - /// - public string FormattedDateTime => this.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"); - } - - /// - /// Types of notifications. - /// - public enum NotificationType - { - Information, - Success, - Warning, - Error, - PowerPlanChange, - ProcessMonitoring, - CpuAffinity, - } - - /// - /// Notification priority levels. - /// - public enum NotificationPriority - { - Low, - Normal, - High, - Critical, - } -} - +namespace ThreadPilot.Models +{ + using System; + using CommunityToolkit.Mvvm.ComponentModel; + + public partial class NotificationModel : ObservableObject + { + [ObservableProperty] + private string id = Guid.NewGuid().ToString(); + + [ObservableProperty] + private string title = string.Empty; + + [ObservableProperty] + private string message = string.Empty; + + [ObservableProperty] + private NotificationType type = NotificationType.Information; + + [ObservableProperty] + private DateTime timestamp = DateTime.Now; + + [ObservableProperty] + private int durationMs = 3000; + + [ObservableProperty] + private bool isRead = false; + + [ObservableProperty] + private bool isPersistent = false; + + [ObservableProperty] + private string? actionText; + + [ObservableProperty] + private string? actionCommand; + + [ObservableProperty] + private string? iconPath; + + [ObservableProperty] + private NotificationPriority priority = NotificationPriority.Normal; + + [ObservableProperty] + private string? category; + + [ObservableProperty] + private string? sourceService; + + public NotificationModel() + { + } + + public NotificationModel(string title, string message, NotificationType type = NotificationType.Information) + { + this.Title = title; + this.Message = message; + this.Type = type; + } + + public NotificationModel(string title, string message, NotificationType type, int durationMs, bool isPersistent = false) + { + this.Title = title; + this.Message = message; + this.Type = type; + this.DurationMs = durationMs; + this.IsPersistent = isPersistent; + } + + public void MarkAsRead() + { + this.IsRead = true; + } + + public string TypeDisplayText => this.Type switch + { + NotificationType.Information => "Info", + NotificationType.Success => "Success", + NotificationType.Warning => "Warning", + NotificationType.Error => "Error", + NotificationType.PowerPlanChange => "Power Plan", + NotificationType.ProcessMonitoring => "Process Monitor", + NotificationType.CpuAffinity => "CPU Affinity", + _ => "Unknown", + }; + + public string FormattedTimestamp => this.Timestamp.ToString("HH:mm:ss"); + + public string FormattedDateTime => this.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"); + } + + public enum NotificationType + { + Information, + Success, + Warning, + Error, + PowerPlanChange, + ProcessMonitoring, + CpuAffinity, + } + + public enum NotificationPriority + { + Low, + Normal, + High, + Critical, + } +} + diff --git a/Models/PersistentProcessRule.cs b/Models/PersistentProcessRule.cs index c56048c..238e46a 100644 --- a/Models/PersistentProcessRule.cs +++ b/Models/PersistentProcessRule.cs @@ -1,70 +1,70 @@ -/* - * ThreadPilot - persistent process rule models. - */ -namespace ThreadPilot.Models -{ - using System; - using System.Diagnostics; - - public sealed record PersistentProcessRule - { - public string Id { get; init; } = Guid.NewGuid().ToString("N"); - - public string Name { get; init; } = string.Empty; - - public bool IsEnabled { get; init; } - - public string? ProcessName { get; init; } - - public string? ExecutablePath { get; init; } - - public CpuSelection? CpuSelection { get; init; } - - public long? LegacyAffinityMask { get; init; } - - public ProcessPriorityClass? Priority { get; init; } - - public ProcessMemoryPriority? MemoryPriority { get; init; } - - public bool ApplyAffinityOnStart { get; init; } - - public bool ApplyPriorityOnStart { get; init; } - - public bool ApplyMemoryPriorityOnStart { get; init; } - - public DateTime CreatedAt { get; init; } = DateTime.UtcNow; - - public DateTime UpdatedAt { get; init; } = DateTime.UtcNow; - - public string? Description { get; init; } - } - - public sealed record PersistentRuleApplyResult - { - public bool Success { get; init; } - - public string RuleId { get; init; } = string.Empty; - - public int ProcessId { get; init; } - - public string ProcessName { get; init; } = string.Empty; - - public bool AffinityApplied { get; init; } - - public bool PriorityApplied { get; init; } - - public bool MemoryPriorityApplied { get; init; } - - public string? ErrorCode { get; init; } - - public string UserMessage { get; init; } = string.Empty; - - public string TechnicalMessage { get; init; } = string.Empty; - - public bool IsAccessDenied { get; init; } - - public bool IsAntiCheatLikely { get; init; } - - public bool IsProcessExited { get; init; } - } -} +/* + * ThreadPilot - persistent process rule models. + */ +namespace ThreadPilot.Models +{ + using System; + using System.Diagnostics; + + public sealed record PersistentProcessRule + { + public string Id { get; init; } = Guid.NewGuid().ToString("N"); + + public string Name { get; init; } = string.Empty; + + public bool IsEnabled { get; init; } + + public string? ProcessName { get; init; } + + public string? ExecutablePath { get; init; } + + public CpuSelection? CpuSelection { get; init; } + + public long? LegacyAffinityMask { get; init; } + + public ProcessPriorityClass? Priority { get; init; } + + public ProcessMemoryPriority? MemoryPriority { get; init; } + + public bool ApplyAffinityOnStart { get; init; } + + public bool ApplyPriorityOnStart { get; init; } + + public bool ApplyMemoryPriorityOnStart { get; init; } + + public DateTime CreatedAt { get; init; } = DateTime.UtcNow; + + public DateTime UpdatedAt { get; init; } = DateTime.UtcNow; + + public string? Description { get; init; } + } + + public sealed record PersistentRuleApplyResult + { + public bool Success { get; init; } + + public string RuleId { get; init; } = string.Empty; + + public int ProcessId { get; init; } + + public string ProcessName { get; init; } = string.Empty; + + public bool AffinityApplied { get; init; } + + public bool PriorityApplied { get; init; } + + public bool MemoryPriorityApplied { get; init; } + + public string? ErrorCode { get; init; } + + public string UserMessage { get; init; } = string.Empty; + + public string TechnicalMessage { get; init; } = string.Empty; + + public bool IsAccessDenied { get; init; } + + public bool IsAntiCheatLikely { get; init; } + + public bool IsProcessExited { get; init; } + } +} diff --git a/Models/PowerPlanModel.cs b/Models/PowerPlanModel.cs index fc6082f..5d319a9 100644 --- a/Models/PowerPlanModel.cs +++ b/Models/PowerPlanModel.cs @@ -1,41 +1,25 @@ -/* - * 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.Models -{ - using CommunityToolkit.Mvvm.ComponentModel; - - public partial class PowerPlanModel : ObservableObject - { - [ObservableProperty] - private string guid = string.Empty; - - [ObservableProperty] - private string name = string.Empty; - - [ObservableProperty] - private string description = string.Empty; - - [ObservableProperty] - private bool isActive; - - [ObservableProperty] - private bool isCustomPlan; - - [ObservableProperty] - private string filePath = string.Empty; - } -} +namespace ThreadPilot.Models +{ + using CommunityToolkit.Mvvm.ComponentModel; + + public partial class PowerPlanModel : ObservableObject + { + [ObservableProperty] + private string guid = string.Empty; + + [ObservableProperty] + private string name = string.Empty; + + [ObservableProperty] + private string description = string.Empty; + + [ObservableProperty] + private bool isActive; + + [ObservableProperty] + private bool isCustomPlan; + + [ObservableProperty] + private string filePath = string.Empty; + } +} diff --git a/Models/ProcessMemoryPriority.cs b/Models/ProcessMemoryPriority.cs index 7306868..6c83902 100644 --- a/Models/ProcessMemoryPriority.cs +++ b/Models/ProcessMemoryPriority.cs @@ -1,19 +1,14 @@ -/* - * ThreadPilot - process memory priority model. - */ -namespace ThreadPilot.Models -{ - /// - /// Documented Windows process memory priority levels. - /// CPU priority influences CPU scheduling; memory priority influences how aggressively - /// Windows may reclaim or page a process's memory under pressure. - /// - public enum ProcessMemoryPriority - { - VeryLow = 1, - Low = 2, - Medium = 3, - BelowNormal = 4, - Normal = 5, - } -} +/* + * ThreadPilot - process memory priority model. + */ +namespace ThreadPilot.Models +{ + public enum ProcessMemoryPriority + { + VeryLow = 1, + Low = 2, + Medium = 3, + BelowNormal = 4, + Normal = 5, + } +} diff --git a/Models/ProcessModel.cs b/Models/ProcessModel.cs index 301650f..3b54b21 100644 --- a/Models/ProcessModel.cs +++ b/Models/ProcessModel.cs @@ -1,87 +1,67 @@ -/* - * 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.Models -{ - using System; - using System.Diagnostics; - using CommunityToolkit.Mvvm.ComponentModel; - - public enum ProcessClassification - { - ForegroundApp, - VisibleWindowApp, - BackgroundUser, - System, - ProtectedOrAccessDenied, - Terminated, - Unknown, - } - - public partial class ProcessModel : ObservableObject - { - [ObservableProperty] - private int processId; - - [ObservableProperty] - private string name = string.Empty; - - [ObservableProperty] - private string executablePath = string.Empty; - - [ObservableProperty] - private double cpuUsage; - - [ObservableProperty] - private long memoryUsage; - - [ObservableProperty] - private ProcessPriorityClass priority; - - [ObservableProperty] - private long processorAffinity; - - [ObservableProperty] - private IntPtr mainWindowHandle; - - [ObservableProperty] - private string mainWindowTitle = string.Empty; - - [ObservableProperty] - private bool hasVisibleWindow; - - [ObservableProperty] - private bool isForeground; - - [ObservableProperty] - private ProcessClassification classification = ProcessClassification.Unknown; - - [ObservableProperty] - private bool isIdleServerDisabled; - - [ObservableProperty] - private bool isRegistryPriorityEnabled; - - /// - /// Forces PropertyChanged notification for ProcessorAffinity. - /// Used to update DataGrid binding when affinity changes from background thread. - /// - public void ForceNotifyProcessorAffinityChanged() - { - this.OnPropertyChanged(nameof(this.ProcessorAffinity)); - } - } -} +namespace ThreadPilot.Models +{ + using System; + using System.Diagnostics; + using CommunityToolkit.Mvvm.ComponentModel; + + public enum ProcessClassification + { + ForegroundApp, + VisibleWindowApp, + BackgroundUser, + System, + ProtectedOrAccessDenied, + Terminated, + Unknown, + } + + public partial class ProcessModel : ObservableObject + { + [ObservableProperty] + private int processId; + + [ObservableProperty] + private string name = string.Empty; + + [ObservableProperty] + private string executablePath = string.Empty; + + [ObservableProperty] + private double cpuUsage; + + [ObservableProperty] + private long memoryUsage; + + [ObservableProperty] + private ProcessPriorityClass priority; + + [ObservableProperty] + private long processorAffinity; + + [ObservableProperty] + private IntPtr mainWindowHandle; + + [ObservableProperty] + private string mainWindowTitle = string.Empty; + + [ObservableProperty] + private bool hasVisibleWindow; + + [ObservableProperty] + private bool isForeground; + + [ObservableProperty] + private ProcessClassification classification = ProcessClassification.Unknown; + + [ObservableProperty] + private bool isIdleServerDisabled; + + [ObservableProperty] + private bool isRegistryPriorityEnabled; + + public void ForceNotifyProcessorAffinityChanged() + { + this.OnPropertyChanged(nameof(this.ProcessorAffinity)); + } + } +} diff --git a/Models/ProcessMonitorConfiguration.cs b/Models/ProcessMonitorConfiguration.cs index 180b193..84afe74 100644 --- a/Models/ProcessMonitorConfiguration.cs +++ b/Models/ProcessMonitorConfiguration.cs @@ -1,160 +1,123 @@ -/* - * 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.Models -{ - using System; - using System.Collections.Generic; - using CommunityToolkit.Mvvm.ComponentModel; - - /// - /// Configuration model for process monitoring and power plan associations. - /// - public partial class ProcessMonitorConfiguration : ObservableObject - { - [ObservableProperty] - private string defaultPowerPlanGuid = string.Empty; - - [ObservableProperty] - private string defaultPowerPlanName = string.Empty; - - [ObservableProperty] - private bool isEventBasedMonitoringEnabled = true; - - [ObservableProperty] - private bool isFallbackPollingEnabled = true; - - [ObservableProperty] - private int pollingIntervalSeconds = 5; - - [ObservableProperty] - private bool preventDuplicatePowerPlanChanges = true; - - [ObservableProperty] - private int powerPlanChangeDelayMs = 250; - - [ObservableProperty] - private bool enableLogging = true; - - [ObservableProperty] - private List associations = new(); - - [ObservableProperty] - private DateTime lastSavedDate = DateTime.Now; - - [ObservableProperty] - private string configurationVersion = "1.0"; - - public ProcessMonitorConfiguration() - { - this.Associations = new List(); - } - - /// - /// Gets all enabled associations sorted by priority (descending). - /// - public IEnumerable GetEnabledAssociations() - { - return this.Associations - .Where(a => a.IsEnabled) - .OrderByDescending(a => a.Priority) - .ThenBy(a => a.ExecutableName); - } - - /// - /// Finds the best matching association for a process. - /// - public ProcessPowerPlanAssociation? FindMatchingAssociation(ProcessModel process) - { - return this.GetEnabledAssociations() - .FirstOrDefault(a => a.MatchesProcess(process)); - } - - /// - /// Finds association by executable name. - /// - public ProcessPowerPlanAssociation? FindAssociationByExecutable(string executableName) - { - return this.Associations - .FirstOrDefault(a => a.MatchesExecutable(executableName)); - } - - /// - /// Adds or updates an association. - /// - public void AddOrUpdateAssociation(ProcessPowerPlanAssociation association) - { - var existing = this.Associations.FirstOrDefault(a => a.Id == association.Id); - if (existing != null) - { - var index = this.Associations.IndexOf(existing); - this.Associations[index] = association; - } - else - { - this.Associations.Add(association); - } - this.LastSavedDate = DateTime.Now; - } - - /// - /// Removes an association. - /// - public bool RemoveAssociation(string associationId) - { - var association = this.Associations.FirstOrDefault(a => a.Id == associationId); - if (association != null) - { - this.Associations.Remove(association); - this.LastSavedDate = DateTime.Now; - return true; - } - return false; - } - - /// - /// Validates the configuration. - /// - public List Validate() - { - var errors = new List(); - - if (this.PollingIntervalSeconds < 1) - { - errors.Add("Polling interval must be at least 1 second"); - } - - if (this.PowerPlanChangeDelayMs < 0) - { - errors.Add("Power plan change delay cannot be negative"); - } - - // Check for duplicate associations - var duplicates = this.Associations - .GroupBy(a => new { a.ExecutableName, a.MatchByPath }) - .Where(g => g.Count() > 1) - .Select(g => g.Key.ExecutableName); - - foreach (var duplicate in duplicates) - { - errors.Add($"Duplicate association found for executable: {duplicate}"); - } - - return errors; - } - } -} - +namespace ThreadPilot.Models +{ + using System; + using System.Collections.Generic; + using CommunityToolkit.Mvvm.ComponentModel; + + public partial class ProcessMonitorConfiguration : ObservableObject + { + [ObservableProperty] + private string defaultPowerPlanGuid = string.Empty; + + [ObservableProperty] + private string defaultPowerPlanName = string.Empty; + + [ObservableProperty] + private bool isEventBasedMonitoringEnabled = true; + + [ObservableProperty] + private bool isFallbackPollingEnabled = true; + + [ObservableProperty] + private int pollingIntervalSeconds = 5; + + [ObservableProperty] + private bool preventDuplicatePowerPlanChanges = true; + + [ObservableProperty] + private int powerPlanChangeDelayMs = 250; + + [ObservableProperty] + private bool enableLogging = true; + + [ObservableProperty] + private List associations = new(); + + [ObservableProperty] + private DateTime lastSavedDate = DateTime.Now; + + [ObservableProperty] + private string configurationVersion = "1.0"; + + public ProcessMonitorConfiguration() + { + this.Associations = new List(); + } + + public IEnumerable GetEnabledAssociations() + { + return this.Associations + .Where(a => a.IsEnabled) + .OrderByDescending(a => a.Priority) + .ThenBy(a => a.ExecutableName); + } + + public ProcessPowerPlanAssociation? FindMatchingAssociation(ProcessModel process) + { + return this.GetEnabledAssociations() + .FirstOrDefault(a => a.MatchesProcess(process)); + } + + public ProcessPowerPlanAssociation? FindAssociationByExecutable(string executableName) + { + return this.Associations + .FirstOrDefault(a => a.MatchesExecutable(executableName)); + } + + public void AddOrUpdateAssociation(ProcessPowerPlanAssociation association) + { + var existing = this.Associations.FirstOrDefault(a => a.Id == association.Id); + if (existing != null) + { + var index = this.Associations.IndexOf(existing); + this.Associations[index] = association; + } + else + { + this.Associations.Add(association); + } + this.LastSavedDate = DateTime.Now; + } + + public bool RemoveAssociation(string associationId) + { + var association = this.Associations.FirstOrDefault(a => a.Id == associationId); + if (association != null) + { + this.Associations.Remove(association); + this.LastSavedDate = DateTime.Now; + return true; + } + return false; + } + + public List Validate() + { + var errors = new List(); + + if (this.PollingIntervalSeconds < 1) + { + errors.Add("Polling interval must be at least 1 second"); + } + + if (this.PowerPlanChangeDelayMs < 0) + { + errors.Add("Power plan change delay cannot be negative"); + } + + // Check for duplicate associations + var duplicates = this.Associations + .GroupBy(a => new { a.ExecutableName, a.MatchByPath }) + .Where(g => g.Count() > 1) + .Select(g => g.Key.ExecutableName); + + foreach (var duplicate in duplicates) + { + errors.Add($"Duplicate association found for executable: {duplicate}"); + } + + return errors; + } + } +} + diff --git a/Models/ProcessPowerPlanAssociation.cs b/Models/ProcessPowerPlanAssociation.cs index b59705c..24394e3 100644 --- a/Models/ProcessPowerPlanAssociation.cs +++ b/Models/ProcessPowerPlanAssociation.cs @@ -1,193 +1,152 @@ -/* - * 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.Models -{ - using System; - using System.Collections.Generic; - using System.IO; - using CommunityToolkit.Mvvm.ComponentModel; - using ThreadPilot.Models.Core; - - /// - /// Represents an association between an executable and a power plan. - /// - public partial class ProcessPowerPlanAssociation : ObservableObject, IModel - { - [ObservableProperty] - private string id = Guid.NewGuid().ToString(); - - [ObservableProperty] - private string executableName = string.Empty; - - [ObservableProperty] - private string executablePath = string.Empty; - - [ObservableProperty] - private string powerPlanGuid = string.Empty; - - [ObservableProperty] - private string powerPlanName = string.Empty; - - /// - /// Core mask ID to apply to this process (optional). - /// - [ObservableProperty] - private string? coreMaskId = null; - - /// - /// Core mask name for display (optional). - /// - [ObservableProperty] - private string? coreMaskName = null; - - /// - /// Process priority to apply (optional). - /// - [ObservableProperty] - private string? processPriority = null; - - [ObservableProperty] - private bool isEnabled = true; - - [ObservableProperty] - private DateTime createdAt = DateTime.UtcNow; - - [ObservableProperty] - private DateTime updatedAt = DateTime.UtcNow; - - // IModel implementation - properties are auto-generated by ObservableProperty - - [ObservableProperty] - private string description = string.Empty; - - /// - /// Whether to match by exact executable name or path. - /// - [ObservableProperty] - private bool matchByPath = false; - - /// - /// Priority for this association (higher number = higher priority) - /// Used when multiple associations could match the same process. - /// - [ObservableProperty] - private int priority = 0; - - public ProcessPowerPlanAssociation() - { - } - - public ProcessPowerPlanAssociation(string executableName, string powerPlanGuid, string powerPlanName) - { - this.ExecutableName = executableName; - this.PowerPlanGuid = powerPlanGuid; - this.PowerPlanName = powerPlanName; - } - - /// - /// Checks if this association matches the given process. - /// - public bool MatchesProcess(ProcessModel process) - { - if (!this.IsEnabled) - { - return false; - } - - if (this.MatchByPath && !string.IsNullOrEmpty(this.ExecutablePath)) - { - return string.Equals(process.ExecutablePath, this.ExecutablePath, StringComparison.OrdinalIgnoreCase); - } - else - { - var processName = NormalizeExecutableName(process.Name); - var associationName = NormalizeExecutableName(this.ExecutableName); - return string.Equals(processName, associationName, StringComparison.OrdinalIgnoreCase); - } - } - - /// - /// Checks if this association matches the given executable name. - /// - public bool MatchesExecutable(string executableName) - { - if (!this.IsEnabled) - { - return false; - } - - var associationName = NormalizeExecutableName(this.ExecutableName); - var inputName = NormalizeExecutableName(executableName); - return string.Equals(associationName, inputName, StringComparison.OrdinalIgnoreCase); - } - - private static string NormalizeExecutableName(string? executableName) - { - if (string.IsNullOrWhiteSpace(executableName)) - { - return string.Empty; - } - - return Path.GetFileNameWithoutExtension(executableName.Trim()); - } - - - - public ValidationResult Validate() - { - var errors = new List(); - - if (string.IsNullOrWhiteSpace(this.ExecutableName)) - { - errors.Add("Executable name is required"); - } - - if (string.IsNullOrWhiteSpace(this.PowerPlanGuid)) - { - errors.Add("Power plan GUID is required"); - } - - if (string.IsNullOrWhiteSpace(this.PowerPlanName)) - { - errors.Add("Power plan name is required"); - } - - return errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors.ToArray()); - } - - public IModel Clone() - { - return new ProcessPowerPlanAssociation - { - id = Guid.NewGuid().ToString(), // New ID for clone - ExecutableName = this.ExecutableName, - ExecutablePath = this.ExecutablePath, - PowerPlanGuid = this.PowerPlanGuid, - PowerPlanName = this.PowerPlanName, - CoreMaskId = this.CoreMaskId, - CoreMaskName = this.CoreMaskName, - ProcessPriority = this.ProcessPriority, - IsEnabled = this.IsEnabled, - Description = this.Description, - MatchByPath = this.MatchByPath, - createdAt = DateTime.UtcNow, - updatedAt = DateTime.UtcNow, - }; - } - } -} - +namespace ThreadPilot.Models +{ + using System; + using System.Collections.Generic; + using System.IO; + using CommunityToolkit.Mvvm.ComponentModel; + using ThreadPilot.Models.Core; + + public partial class ProcessPowerPlanAssociation : ObservableObject, IModel + { + [ObservableProperty] + private string id = Guid.NewGuid().ToString(); + + [ObservableProperty] + private string executableName = string.Empty; + + [ObservableProperty] + private string executablePath = string.Empty; + + [ObservableProperty] + private string powerPlanGuid = string.Empty; + + [ObservableProperty] + private string powerPlanName = string.Empty; + + [ObservableProperty] + private string? coreMaskId = null; + + [ObservableProperty] + private string? coreMaskName = null; + + [ObservableProperty] + private string? processPriority = null; + + [ObservableProperty] + private bool isEnabled = true; + + [ObservableProperty] + private DateTime createdAt = DateTime.UtcNow; + + [ObservableProperty] + private DateTime updatedAt = DateTime.UtcNow; + + // IModel implementation - properties are auto-generated by ObservableProperty + + [ObservableProperty] + private string description = string.Empty; + + [ObservableProperty] + private bool matchByPath = false; + + [ObservableProperty] + private int priority = 0; + + public ProcessPowerPlanAssociation() + { + } + + public ProcessPowerPlanAssociation(string executableName, string powerPlanGuid, string powerPlanName) + { + this.ExecutableName = executableName; + this.PowerPlanGuid = powerPlanGuid; + this.PowerPlanName = powerPlanName; + } + + public bool MatchesProcess(ProcessModel process) + { + if (!this.IsEnabled) + { + return false; + } + + if (this.MatchByPath && !string.IsNullOrEmpty(this.ExecutablePath)) + { + return string.Equals(process.ExecutablePath, this.ExecutablePath, StringComparison.OrdinalIgnoreCase); + } + else + { + var processName = NormalizeExecutableName(process.Name); + var associationName = NormalizeExecutableName(this.ExecutableName); + return string.Equals(processName, associationName, StringComparison.OrdinalIgnoreCase); + } + } + + public bool MatchesExecutable(string executableName) + { + if (!this.IsEnabled) + { + return false; + } + + var associationName = NormalizeExecutableName(this.ExecutableName); + var inputName = NormalizeExecutableName(executableName); + return string.Equals(associationName, inputName, StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeExecutableName(string? executableName) + { + if (string.IsNullOrWhiteSpace(executableName)) + { + return string.Empty; + } + + return Path.GetFileNameWithoutExtension(executableName.Trim()); + } + + + + public ValidationResult Validate() + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(this.ExecutableName)) + { + errors.Add("Executable name is required"); + } + + if (string.IsNullOrWhiteSpace(this.PowerPlanGuid)) + { + errors.Add("Power plan GUID is required"); + } + + if (string.IsNullOrWhiteSpace(this.PowerPlanName)) + { + errors.Add("Power plan name is required"); + } + + return errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors.ToArray()); + } + + public IModel Clone() + { + return new ProcessPowerPlanAssociation + { + id = Guid.NewGuid().ToString(), // New ID for clone + ExecutableName = this.ExecutableName, + ExecutablePath = this.ExecutablePath, + PowerPlanGuid = this.PowerPlanGuid, + PowerPlanName = this.PowerPlanName, + CoreMaskId = this.CoreMaskId, + CoreMaskName = this.CoreMaskName, + ProcessPriority = this.ProcessPriority, + IsEnabled = this.IsEnabled, + Description = this.Description, + MatchByPath = this.MatchByPath, + createdAt = DateTime.UtcNow, + updatedAt = DateTime.UtcNow, + }; + } + } +} + diff --git a/Models/ProcessProfileSnapshot.cs b/Models/ProcessProfileSnapshot.cs index b8959f2..44b568f 100644 --- a/Models/ProcessProfileSnapshot.cs +++ b/Models/ProcessProfileSnapshot.cs @@ -1,35 +1,19 @@ -/* - * 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.Models -{ - using System.Diagnostics; - - public sealed class ProcessProfileSnapshot - { - public int ProfileSchemaVersion { get; set; } = CpuAffinityProfileSchemaVersions.Legacy; - - public string ProcessName { get; set; } = string.Empty; - - public ProcessPriorityClass Priority { get; set; } - - public long ProcessorAffinity { get; set; } - - public CpuSelection? CpuSelection { get; set; } - - public CpuSelectionMigrationMetadata? CpuSelectionMigration { get; set; } - } -} +namespace ThreadPilot.Models +{ + using System.Diagnostics; + + public sealed class ProcessProfileSnapshot + { + public int ProfileSchemaVersion { get; set; } = CpuAffinityProfileSchemaVersions.Legacy; + + public string ProcessName { get; set; } = string.Empty; + + public ProcessPriorityClass Priority { get; set; } + + public long ProcessorAffinity { get; set; } + + public CpuSelection? CpuSelection { get; set; } + + public CpuSelectionMigrationMetadata? CpuSelectionMigration { get; set; } + } +} diff --git a/Models/ProfileModel.cs b/Models/ProfileModel.cs index 4eafee9..8bc73d0 100644 --- a/Models/ProfileModel.cs +++ b/Models/ProfileModel.cs @@ -1,107 +1,91 @@ -/* - * 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.Models -{ - using System; - using System.Collections.Generic; - using System.ComponentModel; - using System.Diagnostics; - using CommunityToolkit.Mvvm.ComponentModel; - using ThreadPilot.Models.Core; - - public partial class ProfileModel : ObservableObject, IModel - { - [ObservableProperty] - private string id = Guid.NewGuid().ToString(); - - [ObservableProperty] - private DateTime createdAt = DateTime.UtcNow; - - [ObservableProperty] - private DateTime updatedAt = DateTime.UtcNow; - - [ObservableProperty] - private string name = string.Empty; - - [ObservableProperty] - private string processName = string.Empty; - - [ObservableProperty] - private ProcessPriorityClass priority = ProcessPriorityClass.Normal; - - [ObservableProperty] - private long processorAffinity = -1; // All cores - - [ObservableProperty] - private int profileSchemaVersion = CpuAffinityProfileSchemaVersions.Legacy; - - [ObservableProperty] - private CpuSelection? cpuSelection = null; - - [ObservableProperty] - private CpuSelectionMigrationMetadata? cpuSelectionMigration = null; - - [ObservableProperty] - private string description = string.Empty; - - [ObservableProperty] - private bool isEnabled = true; - - // IModel implementation - properties are auto-generated by ObservableProperty - - public ValidationResult Validate() - { - var errors = new List(); - - if (string.IsNullOrWhiteSpace(this.Name)) - { - errors.Add("Profile name is required"); - } - - if (string.IsNullOrWhiteSpace(this.ProcessName)) - { - errors.Add("Process name is required"); - } - - return errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors.ToArray()); - } - - public IModel Clone() - { - return new ProfileModel - { - id = Guid.NewGuid().ToString(), // New ID for clone - Name = this.Name, - ProcessName = this.ProcessName, - Priority = this.Priority, - ProcessorAffinity = this.ProcessorAffinity, - ProfileSchemaVersion = this.ProfileSchemaVersion, - CpuSelection = this.CpuSelection, - CpuSelectionMigration = this.CpuSelectionMigration, - Description = this.Description, - IsEnabled = this.IsEnabled, - createdAt = DateTime.UtcNow, - updatedAt = DateTime.UtcNow, - }; - } - - partial void OnUpdatedAtChanged(DateTime value) - { - updatedAt = DateTime.UtcNow; - } - } -} +namespace ThreadPilot.Models +{ + using System; + using System.Collections.Generic; + using System.ComponentModel; + using System.Diagnostics; + using CommunityToolkit.Mvvm.ComponentModel; + using ThreadPilot.Models.Core; + + public partial class ProfileModel : ObservableObject, IModel + { + [ObservableProperty] + private string id = Guid.NewGuid().ToString(); + + [ObservableProperty] + private DateTime createdAt = DateTime.UtcNow; + + [ObservableProperty] + private DateTime updatedAt = DateTime.UtcNow; + + [ObservableProperty] + private string name = string.Empty; + + [ObservableProperty] + private string processName = string.Empty; + + [ObservableProperty] + private ProcessPriorityClass priority = ProcessPriorityClass.Normal; + + [ObservableProperty] + private long processorAffinity = -1; // All cores + + [ObservableProperty] + private int profileSchemaVersion = CpuAffinityProfileSchemaVersions.Legacy; + + [ObservableProperty] + private CpuSelection? cpuSelection = null; + + [ObservableProperty] + private CpuSelectionMigrationMetadata? cpuSelectionMigration = null; + + [ObservableProperty] + private string description = string.Empty; + + [ObservableProperty] + private bool isEnabled = true; + + // IModel implementation - properties are auto-generated by ObservableProperty + + public ValidationResult Validate() + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(this.Name)) + { + errors.Add("Profile name is required"); + } + + if (string.IsNullOrWhiteSpace(this.ProcessName)) + { + errors.Add("Process name is required"); + } + + return errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors.ToArray()); + } + + public IModel Clone() + { + return new ProfileModel + { + id = Guid.NewGuid().ToString(), // New ID for clone + Name = this.Name, + ProcessName = this.ProcessName, + Priority = this.Priority, + ProcessorAffinity = this.ProcessorAffinity, + ProfileSchemaVersion = this.ProfileSchemaVersion, + CpuSelection = this.CpuSelection, + CpuSelectionMigration = this.CpuSelectionMigration, + Description = this.Description, + IsEnabled = this.IsEnabled, + createdAt = DateTime.UtcNow, + updatedAt = DateTime.UtcNow, + }; + } + + partial void OnUpdatedAtChanged(DateTime value) + { + updatedAt = DateTime.UtcNow; + } + } +} diff --git a/Models/ThreadPilotException.cs b/Models/ThreadPilotException.cs index f700166..9ff88cb 100644 --- a/Models/ThreadPilotException.cs +++ b/Models/ThreadPilotException.cs @@ -1,162 +1,115 @@ -/* - * ThreadPilot - exception hierarchy and error code registry. - */ -namespace ThreadPilot.Models -{ - using System; - - /// - /// Defines stable error codes used in diagnostics and incident reporting. - /// - public enum ErrorCode - { - Unknown = 0, - ProcessManagement = 1000, - Privilege = 2000, - RuleEngine = 3000, - ResourceOptimization = 4000, - Persistence = 5000, - Unhandled = 9000, - } - - /// - /// Base exception type for domain-level ThreadPilot failures. - /// - public class ThreadPilotException : Exception - { - /// - /// Initializes a new instance of the class. - /// - public ThreadPilotException() - : this("A ThreadPilot error has occurred.", ErrorCode.Unknown) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// Error message. - public ThreadPilotException(string message) - : this(message, ErrorCode.Unknown) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// Error message. - /// Inner exception. - public ThreadPilotException(string message, Exception innerException) - : this(message, ErrorCode.Unknown, innerException) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// Error message. - /// Domain error code. - public ThreadPilotException(string message, ErrorCode errorCode) - : base(message) - { - this.ErrorCode = errorCode; - } - - /// - /// Initializes a new instance of the class. - /// - /// Error message. - /// Domain error code. - /// Inner exception. - public ThreadPilotException(string message, ErrorCode errorCode, Exception innerException) - : base(message, innerException) - { - this.ErrorCode = errorCode; - } - - /// - /// Gets the domain error code associated with this exception. - /// - public ErrorCode ErrorCode { get; } - } - - /// - /// Exception for process monitoring and process-control failures. - /// - public sealed class ProcessManagementException : ThreadPilotException - { - public ProcessManagementException(string message) - : base(message, ErrorCode.ProcessManagement) - { - } - - public ProcessManagementException(string message, Exception innerException) - : base(message, ErrorCode.ProcessManagement, innerException) - { - } - } - - /// - /// Exception for privilege and elevation failures. - /// - public sealed class PrivilegeException : ThreadPilotException - { - public PrivilegeException(string message) - : base(message, ErrorCode.Privilege) - { - } - - public PrivilegeException(string message, Exception innerException) - : base(message, ErrorCode.Privilege, innerException) - { - } - } - - /// - /// Exception for rule parsing and rule-matching failures. - /// - public sealed class RuleEngineException : ThreadPilotException - { - public RuleEngineException(string message) - : base(message, ErrorCode.RuleEngine) - { - } - - public RuleEngineException(string message, Exception innerException) - : base(message, ErrorCode.RuleEngine, innerException) - { - } - } - - /// - /// Exception for performance/resource optimization failures. - /// - public sealed class ResourceOptimizationException : ThreadPilotException - { - public ResourceOptimizationException(string message) - : base(message, ErrorCode.ResourceOptimization) - { - } - - public ResourceOptimizationException(string message, Exception innerException) - : base(message, ErrorCode.ResourceOptimization, innerException) - { - } - } - - /// - /// Exception for persistence/configuration I/O failures. - /// - public sealed class PersistenceException : ThreadPilotException - { - public PersistenceException(string message) - : base(message, ErrorCode.Persistence) - { - } - - public PersistenceException(string message, Exception innerException) - : base(message, ErrorCode.Persistence, innerException) - { - } - } -} +/* + * ThreadPilot - exception hierarchy and error code registry. + */ +namespace ThreadPilot.Models +{ + using System; + + public enum ErrorCode + { + Unknown = 0, + ProcessManagement = 1000, + Privilege = 2000, + RuleEngine = 3000, + ResourceOptimization = 4000, + Persistence = 5000, + Unhandled = 9000, + } + + public class ThreadPilotException : Exception + { + public ThreadPilotException() + : this("A ThreadPilot error has occurred.", ErrorCode.Unknown) + { + } + + public ThreadPilotException(string message) + : this(message, ErrorCode.Unknown) + { + } + + public ThreadPilotException(string message, Exception innerException) + : this(message, ErrorCode.Unknown, innerException) + { + } + + public ThreadPilotException(string message, ErrorCode errorCode) + : base(message) + { + this.ErrorCode = errorCode; + } + + public ThreadPilotException(string message, ErrorCode errorCode, Exception innerException) + : base(message, innerException) + { + this.ErrorCode = errorCode; + } + + public ErrorCode ErrorCode { get; } + } + + public sealed class ProcessManagementException : ThreadPilotException + { + public ProcessManagementException(string message) + : base(message, ErrorCode.ProcessManagement) + { + } + + public ProcessManagementException(string message, Exception innerException) + : base(message, ErrorCode.ProcessManagement, innerException) + { + } + } + + public sealed class PrivilegeException : ThreadPilotException + { + public PrivilegeException(string message) + : base(message, ErrorCode.Privilege) + { + } + + public PrivilegeException(string message, Exception innerException) + : base(message, ErrorCode.Privilege, innerException) + { + } + } + + public sealed class RuleEngineException : ThreadPilotException + { + public RuleEngineException(string message) + : base(message, ErrorCode.RuleEngine) + { + } + + public RuleEngineException(string message, Exception innerException) + : base(message, ErrorCode.RuleEngine, innerException) + { + } + } + + public sealed class ResourceOptimizationException : ThreadPilotException + { + public ResourceOptimizationException(string message) + : base(message, ErrorCode.ResourceOptimization) + { + } + + public ResourceOptimizationException(string message, Exception innerException) + : base(message, ErrorCode.ResourceOptimization, innerException) + { + } + } + + public sealed class PersistenceException : ThreadPilotException + { + public PersistenceException(string message) + : base(message, ErrorCode.Persistence) + { + } + + public PersistenceException(string message, Exception innerException) + : base(message, ErrorCode.Persistence, innerException) + { + } + } +} diff --git a/Platforms/Windows/CpuSetApplyResult.cs b/Platforms/Windows/CpuSetApplyResult.cs index e312fd5..533e759 100644 --- a/Platforms/Windows/CpuSetApplyResult.cs +++ b/Platforms/Windows/CpuSetApplyResult.cs @@ -1,46 +1,46 @@ -namespace ThreadPilot.Platforms.Windows -{ - using ThreadPilot.Services; - - public sealed record CpuSetApplyResult - { - public bool Success { get; init; } - - public string ErrorCode { get; init; } = AffinityApplyErrorCodes.None; - - public int Win32ErrorCode { get; init; } - - public string UserMessage { get; init; } = string.Empty; - - public string TechnicalMessage { get; init; } = string.Empty; - - public bool IsAccessDenied { get; init; } - - public bool IsAntiCheatLikely { get; init; } - - public static CpuSetApplyResult Succeeded(string technicalMessage) => - new() - { - Success = true, - TechnicalMessage = technicalMessage, - }; - - public static CpuSetApplyResult Failed( - string errorCode, - string userMessage, - string technicalMessage, - int win32ErrorCode = 0, - bool isAccessDenied = false, - bool isAntiCheatLikely = false) => - new() - { - Success = false, - ErrorCode = errorCode, - UserMessage = userMessage, - TechnicalMessage = technicalMessage, - Win32ErrorCode = win32ErrorCode, - IsAccessDenied = isAccessDenied, - IsAntiCheatLikely = isAntiCheatLikely, - }; - } -} +namespace ThreadPilot.Platforms.Windows +{ + using ThreadPilot.Services; + + public sealed record CpuSetApplyResult + { + public bool Success { get; init; } + + public string ErrorCode { get; init; } = AffinityApplyErrorCodes.None; + + public int Win32ErrorCode { get; init; } + + public string UserMessage { get; init; } = string.Empty; + + public string TechnicalMessage { get; init; } = string.Empty; + + public bool IsAccessDenied { get; init; } + + public bool IsAntiCheatLikely { get; init; } + + public static CpuSetApplyResult Succeeded(string technicalMessage) => + new() + { + Success = true, + TechnicalMessage = technicalMessage, + }; + + public static CpuSetApplyResult Failed( + string errorCode, + string userMessage, + string technicalMessage, + int win32ErrorCode = 0, + bool isAccessDenied = false, + bool isAntiCheatLikely = false) => + new() + { + Success = false, + ErrorCode = errorCode, + UserMessage = userMessage, + TechnicalMessage = technicalMessage, + Win32ErrorCode = win32ErrorCode, + IsAccessDenied = isAccessDenied, + IsAntiCheatLikely = isAntiCheatLikely, + }; + } +} diff --git a/Platforms/Windows/CpuSetMapping.cs b/Platforms/Windows/CpuSetMapping.cs index 6fc8019..73926f5 100644 --- a/Platforms/Windows/CpuSetMapping.cs +++ b/Platforms/Windows/CpuSetMapping.cs @@ -1,129 +1,113 @@ -/* - * 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.Platforms.Windows -{ - using System; - using System.Collections.Generic; - using System.Linq; - using ThreadPilot.Models; - - internal sealed class CpuSetMapping - { - private readonly IReadOnlyDictionary cpuSetIdsByProcessor; - private readonly IReadOnlyDictionary processorsByCpuSetId; - - private CpuSetMapping( - IReadOnlyDictionary cpuSetIdsByProcessor, - IReadOnlyDictionary processorsByCpuSetId) - { - this.cpuSetIdsByProcessor = cpuSetIdsByProcessor; - this.processorsByCpuSetId = processorsByCpuSetId; - } - - public static CpuSetMapping Empty { get; } = new( - new Dictionary(), - new Dictionary()); - - public bool IsEmpty => this.cpuSetIdsByProcessor.Count == 0; - - public static CpuSetMapping Create(IReadOnlyDictionary cpuSetIdsByProcessor) - { - ArgumentNullException.ThrowIfNull(cpuSetIdsByProcessor); - - var forwardMap = cpuSetIdsByProcessor - .OrderBy(kvp => kvp.Key.GlobalIndex) - .ThenBy(kvp => kvp.Key.Group) - .ThenBy(kvp => kvp.Key.LogicalProcessorNumber) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - - var inverseMap = forwardMap - .GroupBy(kvp => kvp.Value) - .ToDictionary( - group => group.Key, - group => group - .Select(kvp => kvp.Key) - .OrderBy(processor => processor.GlobalIndex) - .ThenBy(processor => processor.Group) - .ThenBy(processor => processor.LogicalProcessorNumber) - .First()); - - return new CpuSetMapping(forwardMap, inverseMap); - } - - public static ProcessorRef CreateProcessorRef(ushort group, byte logicalProcessorNumber) - { - return new ProcessorRef(group, logicalProcessorNumber, (group * 64) + logicalProcessorNumber); - } - - public bool TryGetCpuSetId(ProcessorRef processor, out uint cpuSetId) - { - return this.cpuSetIdsByProcessor.TryGetValue(processor, out cpuSetId); - } - - public bool TryGetProcessorRef(uint cpuSetId, out ProcessorRef processor) - { - return this.processorsByCpuSetId.TryGetValue(cpuSetId, out processor); - } - - public IReadOnlyList ResolveCpuSetIds(CpuSelection selection) - { - ArgumentNullException.ThrowIfNull(selection); - - if (selection.CpuSetIds.Count > 0) - { - return selection.CpuSetIds - .Distinct() - .OrderBy(cpuSetId => cpuSetId) - .ToList(); - } - - return selection.LogicalProcessors - .Select(processor => this.TryGetCpuSetId(processor, out var cpuSetId) ? (uint?)cpuSetId : null) - .Where(cpuSetId => cpuSetId.HasValue) - .Select(cpuSetId => cpuSetId!.Value) - .Distinct() - .OrderBy(cpuSetId => cpuSetId) - .ToList(); - } - - public IReadOnlyList ResolveLegacyAffinityMask(long affinityMask, int logicalProcessorCount) - { - var unsignedMask = unchecked((ulong)affinityMask); - var maxLegacyBits = Math.Min(Math.Max(logicalProcessorCount, 0), 64); - var cpuSetIds = new List(); - - for (var bit = 0; bit < maxLegacyBits; bit++) - { - if ((unsignedMask & (1UL << bit)) == 0) - { - continue; - } - - var processor = CreateProcessorRef(0, (byte)bit); - if (this.TryGetCpuSetId(processor, out var cpuSetId)) - { - cpuSetIds.Add(cpuSetId); - } - } - - return cpuSetIds - .Distinct() - .OrderBy(cpuSetId => cpuSetId) - .ToList(); - } - } -} +namespace ThreadPilot.Platforms.Windows +{ + using System; + using System.Collections.Generic; + using System.Linq; + using ThreadPilot.Models; + + internal sealed class CpuSetMapping + { + private readonly IReadOnlyDictionary cpuSetIdsByProcessor; + private readonly IReadOnlyDictionary processorsByCpuSetId; + + private CpuSetMapping( + IReadOnlyDictionary cpuSetIdsByProcessor, + IReadOnlyDictionary processorsByCpuSetId) + { + this.cpuSetIdsByProcessor = cpuSetIdsByProcessor; + this.processorsByCpuSetId = processorsByCpuSetId; + } + + public static CpuSetMapping Empty { get; } = new( + new Dictionary(), + new Dictionary()); + + public bool IsEmpty => this.cpuSetIdsByProcessor.Count == 0; + + public static CpuSetMapping Create(IReadOnlyDictionary cpuSetIdsByProcessor) + { + ArgumentNullException.ThrowIfNull(cpuSetIdsByProcessor); + + var forwardMap = cpuSetIdsByProcessor + .OrderBy(kvp => kvp.Key.GlobalIndex) + .ThenBy(kvp => kvp.Key.Group) + .ThenBy(kvp => kvp.Key.LogicalProcessorNumber) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + var inverseMap = forwardMap + .GroupBy(kvp => kvp.Value) + .ToDictionary( + group => group.Key, + group => group + .Select(kvp => kvp.Key) + .OrderBy(processor => processor.GlobalIndex) + .ThenBy(processor => processor.Group) + .ThenBy(processor => processor.LogicalProcessorNumber) + .First()); + + return new CpuSetMapping(forwardMap, inverseMap); + } + + public static ProcessorRef CreateProcessorRef(ushort group, byte logicalProcessorNumber) + { + return new ProcessorRef(group, logicalProcessorNumber, (group * 64) + logicalProcessorNumber); + } + + public bool TryGetCpuSetId(ProcessorRef processor, out uint cpuSetId) + { + return this.cpuSetIdsByProcessor.TryGetValue(processor, out cpuSetId); + } + + public bool TryGetProcessorRef(uint cpuSetId, out ProcessorRef processor) + { + return this.processorsByCpuSetId.TryGetValue(cpuSetId, out processor); + } + + public IReadOnlyList ResolveCpuSetIds(CpuSelection selection) + { + ArgumentNullException.ThrowIfNull(selection); + + if (selection.CpuSetIds.Count > 0) + { + return selection.CpuSetIds + .Distinct() + .OrderBy(cpuSetId => cpuSetId) + .ToList(); + } + + return selection.LogicalProcessors + .Select(processor => this.TryGetCpuSetId(processor, out var cpuSetId) ? (uint?)cpuSetId : null) + .Where(cpuSetId => cpuSetId.HasValue) + .Select(cpuSetId => cpuSetId!.Value) + .Distinct() + .OrderBy(cpuSetId => cpuSetId) + .ToList(); + } + + public IReadOnlyList ResolveLegacyAffinityMask(long affinityMask, int logicalProcessorCount) + { + var unsignedMask = unchecked((ulong)affinityMask); + var maxLegacyBits = Math.Min(Math.Max(logicalProcessorCount, 0), 64); + var cpuSetIds = new List(); + + for (var bit = 0; bit < maxLegacyBits; bit++) + { + if ((unsignedMask & (1UL << bit)) == 0) + { + continue; + } + + var processor = CreateProcessorRef(0, (byte)bit); + if (this.TryGetCpuSetId(processor, out var cpuSetId)) + { + cpuSetIds.Add(cpuSetId); + } + } + + return cpuSetIds + .Distinct() + .OrderBy(cpuSetId => cpuSetId) + .ToList(); + } + } +} diff --git a/Platforms/Windows/CpuSetNativeMethods.cs b/Platforms/Windows/CpuSetNativeMethods.cs index 06ee816..0251668 100644 --- a/Platforms/Windows/CpuSetNativeMethods.cs +++ b/Platforms/Windows/CpuSetNativeMethods.cs @@ -1,83 +1,64 @@ -/* - * 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.Platforms.Windows -{ - using System; - using System.Runtime.InteropServices; - using Microsoft.Win32.SafeHandles; - - /// - /// P/Invoke declarations for Windows CPU Set APIs. - /// - internal static partial class CpuSetNativeMethods - { - [LibraryImport("kernel32.dll", SetLastError = true)] - public static partial SafeProcessHandle OpenProcess(ProcessAccessFlags access, [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, uint processId); - - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static partial bool GetSystemCpuSetInformation(IntPtr Information, uint BufferLength, ref uint ReturnedLength, SafeProcessHandle Process, uint Flags); - - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static partial bool SetProcessDefaultCpuSets(SafeProcessHandle Process, uint[]? CpuSetIds, uint CpuSetIdCount); - - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static partial bool GetProcessTimes(SafeProcessHandle hProcess, out FILETIME lpCreationTime, out FILETIME lpExitTime, out FILETIME lpKernelTime, out FILETIME lpUserTime); - } - - [Flags] - public enum ProcessAccessFlags : uint - { - PROCESS_SET_INFORMATION = 0x00000200, - PROCESS_QUERY_LIMITED_INFORMATION = 0x00001000, - PROCESS_SET_LIMITED_INFORMATION = 0x00002000, - } - - [StructLayout(LayoutKind.Sequential)] - public struct FILETIME - { - public uint DwLowDateTime; - public uint DwHighDateTime; - - public readonly ulong ULong => (((ulong)this.DwHighDateTime) << 32) + this.DwLowDateTime; - } - - [StructLayout(LayoutKind.Sequential)] - public struct SYSTEM_CPU_SET_INFORMATION - { - public uint Size; - public CPU_SET_INFORMATION_TYPE Type; - public uint Id; - public ushort Group; - public byte LogicalProcessorIndex; - public byte CoreIndex; - public byte LastLevelCacheIndex; - public byte NumaNodeIndex; - public byte EfficiencyClass; - public byte AllFlags; - public uint Reserved; - public ulong AllocationTag; - } - - public enum CPU_SET_INFORMATION_TYPE : int - { - CpuSetInformation = 0, - } -} - +namespace ThreadPilot.Platforms.Windows +{ + using System; + using System.Runtime.InteropServices; + using Microsoft.Win32.SafeHandles; + + internal static partial class CpuSetNativeMethods + { + [LibraryImport("kernel32.dll", SetLastError = true)] + public static partial SafeProcessHandle OpenProcess(ProcessAccessFlags access, [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, uint processId); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool GetSystemCpuSetInformation(IntPtr Information, uint BufferLength, ref uint ReturnedLength, SafeProcessHandle Process, uint Flags); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool SetProcessDefaultCpuSets(SafeProcessHandle Process, uint[]? CpuSetIds, uint CpuSetIdCount); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool GetProcessTimes(SafeProcessHandle hProcess, out FILETIME lpCreationTime, out FILETIME lpExitTime, out FILETIME lpKernelTime, out FILETIME lpUserTime); + } + + [Flags] + public enum ProcessAccessFlags : uint + { + PROCESS_SET_INFORMATION = 0x00000200, + PROCESS_QUERY_LIMITED_INFORMATION = 0x00001000, + PROCESS_SET_LIMITED_INFORMATION = 0x00002000, + } + + [StructLayout(LayoutKind.Sequential)] + public struct FILETIME + { + public uint DwLowDateTime; + public uint DwHighDateTime; + + public readonly ulong ULong => (((ulong)this.DwHighDateTime) << 32) + this.DwLowDateTime; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SYSTEM_CPU_SET_INFORMATION + { + public uint Size; + public CPU_SET_INFORMATION_TYPE Type; + public uint Id; + public ushort Group; + public byte LogicalProcessorIndex; + public byte CoreIndex; + public byte LastLevelCacheIndex; + public byte NumaNodeIndex; + public byte EfficiencyClass; + public byte AllFlags; + public uint Reserved; + public ulong AllocationTag; + } + + public enum CPU_SET_INFORMATION_TYPE : int + { + CpuSetInformation = 0, + } +} + diff --git a/Platforms/Windows/IProcessCpuSetHandler.cs b/Platforms/Windows/IProcessCpuSetHandler.cs index ac06b56..094e13c 100644 --- a/Platforms/Windows/IProcessCpuSetHandler.cs +++ b/Platforms/Windows/IProcessCpuSetHandler.cs @@ -1,83 +1,24 @@ -/* - * 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.Platforms.Windows -{ - using System; - using ThreadPilot.Models; - - /// - /// Interface for handling CPU Set operations on a specific process. - /// - public interface IProcessCpuSetHandler : IDisposable - { - /// - /// Gets the process ID this handler manages. - /// - uint ProcessId { get; } - - /// - /// Gets the executable name. - /// - string ExecutableName { get; } - - /// - /// Applies a CPU affinity mask to the process using CPU Sets. - /// This legacy path is valid only for single-processor-group systems with up to - /// 64 logical processors. It will be superseded by - /// for topology-aware CPU Set selection. - /// - /// The affinity mask where each bit represents a logical processor. - /// If true, clears the CPU Set (allows all cores); if false, applies the mask. - /// True if the operation succeeded, false otherwise. - bool ApplyCpuSetMask(long affinityMask, bool clearMask = false); - - /// - /// Applies a CPU affinity mask to the process using CPU Sets and returns detailed failure information. - /// - /// The affinity mask where each bit represents a logical processor. - /// If true, clears the CPU Set (allows all cores); if false, applies the mask. - /// Detailed CPU Set apply result. - CpuSetApplyResult ApplyCpuSetMaskDetailed(long affinityMask, bool clearMask = false); - - /// - /// Applies a topology-aware CPU selection to the process using CPU Sets. - /// - /// The CPU selection to apply. Ignored and allowed to be null when is true. - /// If true, clears the CPU Set selection and ignores . - /// True if the operation succeeded, false otherwise. - bool ApplyCpuSelection(CpuSelection? selection, bool clearSelection = false); - - /// - /// Applies a topology-aware CPU selection to the process using CPU Sets and returns detailed failure information. - /// - /// The CPU selection to apply. Ignored and allowed to be null when is true. - /// If true, clears the CPU Set selection and ignores . - /// Detailed CPU Set apply result. - CpuSetApplyResult ApplyCpuSelectionDetailed(CpuSelection? selection, bool clearSelection = false); - - /// - /// Gets the average CPU usage for this process. - /// - /// CPU usage percentage (0-1 range), or -1 if unavailable. - double GetAverageCpuUsage(); - - /// - /// Gets a value indicating whether checks if the handler has valid handles to the process. - /// - bool IsValid { get; } - } -} +namespace ThreadPilot.Platforms.Windows +{ + using System; + using ThreadPilot.Models; + + public interface IProcessCpuSetHandler : IDisposable + { + uint ProcessId { get; } + + string ExecutableName { get; } + + bool ApplyCpuSetMask(long affinityMask, bool clearMask = false); + + CpuSetApplyResult ApplyCpuSetMaskDetailed(long affinityMask, bool clearMask = false); + + bool ApplyCpuSelection(CpuSelection? selection, bool clearSelection = false); + + CpuSetApplyResult ApplyCpuSelectionDetailed(CpuSelection? selection, bool clearSelection = false); + + double GetAverageCpuUsage(); + + bool IsValid { get; } + } +} diff --git a/Platforms/Windows/IProcessCpuSetNativeApi.cs b/Platforms/Windows/IProcessCpuSetNativeApi.cs index 43d2b84..bfd74da 100644 --- a/Platforms/Windows/IProcessCpuSetNativeApi.cs +++ b/Platforms/Windows/IProcessCpuSetNativeApi.cs @@ -1,89 +1,73 @@ -/* - * 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.Platforms.Windows -{ - using System; - using System.Runtime.InteropServices; - using Microsoft.Win32.SafeHandles; - - internal interface IProcessCpuSetNativeApi - { - SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId); - - bool SetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetIds, uint cpuSetIdCount); - - bool GetProcessTimes( - SafeProcessHandle process, - out FILETIME creationTime, - out FILETIME exitTime, - out FILETIME kernelTime, - out FILETIME userTime); - - bool GetSystemCpuSetInformation( - IntPtr information, - uint bufferLength, - ref uint returnedLength, - SafeProcessHandle process, - uint flags); - - int GetLastWin32Error(); - } - - internal sealed class ProcessCpuSetNativeApi : IProcessCpuSetNativeApi - { - public static ProcessCpuSetNativeApi Instance { get; } = new(); - - private ProcessCpuSetNativeApi() - { - } - - public SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId) - { - return CpuSetNativeMethods.OpenProcess(access, inheritHandle, processId); - } - - public bool SetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetIds, uint cpuSetIdCount) - { - return CpuSetNativeMethods.SetProcessDefaultCpuSets(process, cpuSetIds, cpuSetIdCount); - } - - public bool GetProcessTimes( - SafeProcessHandle process, - out FILETIME creationTime, - out FILETIME exitTime, - out FILETIME kernelTime, - out FILETIME userTime) - { - return CpuSetNativeMethods.GetProcessTimes(process, out creationTime, out exitTime, out kernelTime, out userTime); - } - - public bool GetSystemCpuSetInformation( - IntPtr information, - uint bufferLength, - ref uint returnedLength, - SafeProcessHandle process, - uint flags) - { - return CpuSetNativeMethods.GetSystemCpuSetInformation(information, bufferLength, ref returnedLength, process, flags); - } - - public int GetLastWin32Error() - { - return Marshal.GetLastWin32Error(); - } - } -} +namespace ThreadPilot.Platforms.Windows +{ + using System; + using System.Runtime.InteropServices; + using Microsoft.Win32.SafeHandles; + + internal interface IProcessCpuSetNativeApi + { + SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId); + + bool SetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetIds, uint cpuSetIdCount); + + bool GetProcessTimes( + SafeProcessHandle process, + out FILETIME creationTime, + out FILETIME exitTime, + out FILETIME kernelTime, + out FILETIME userTime); + + bool GetSystemCpuSetInformation( + IntPtr information, + uint bufferLength, + ref uint returnedLength, + SafeProcessHandle process, + uint flags); + + int GetLastWin32Error(); + } + + internal sealed class ProcessCpuSetNativeApi : IProcessCpuSetNativeApi + { + public static ProcessCpuSetNativeApi Instance { get; } = new(); + + private ProcessCpuSetNativeApi() + { + } + + public SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId) + { + return CpuSetNativeMethods.OpenProcess(access, inheritHandle, processId); + } + + public bool SetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetIds, uint cpuSetIdCount) + { + return CpuSetNativeMethods.SetProcessDefaultCpuSets(process, cpuSetIds, cpuSetIdCount); + } + + public bool GetProcessTimes( + SafeProcessHandle process, + out FILETIME creationTime, + out FILETIME exitTime, + out FILETIME kernelTime, + out FILETIME userTime) + { + return CpuSetNativeMethods.GetProcessTimes(process, out creationTime, out exitTime, out kernelTime, out userTime); + } + + public bool GetSystemCpuSetInformation( + IntPtr information, + uint bufferLength, + ref uint returnedLength, + SafeProcessHandle process, + uint flags) + { + return CpuSetNativeMethods.GetSystemCpuSetInformation(information, bufferLength, ref returnedLength, process, flags); + } + + public int GetLastWin32Error() + { + return Marshal.GetLastWin32Error(); + } + } +} diff --git a/Platforms/Windows/IProcessMemoryPriorityNativeApi.cs b/Platforms/Windows/IProcessMemoryPriorityNativeApi.cs index 3bd8164..d22b2b8 100644 --- a/Platforms/Windows/IProcessMemoryPriorityNativeApi.cs +++ b/Platforms/Windows/IProcessMemoryPriorityNativeApi.cs @@ -1,88 +1,88 @@ -/* - * ThreadPilot - Windows process memory priority native API abstraction. - */ -namespace ThreadPilot.Platforms.Windows -{ - using System; - using System.Runtime.InteropServices; - using Microsoft.Win32.SafeHandles; - - public interface IProcessMemoryPriorityNativeApi - { - bool IsSupported { get; } - - SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId); - - bool GetProcessInformation( - SafeProcessHandle process, - ProcessInformationClass processInformationClass, - ref MemoryPriorityInformation processInformation, - uint processInformationSize); - - bool SetProcessInformation( - SafeProcessHandle process, - ProcessInformationClass processInformationClass, - ref MemoryPriorityInformation processInformation, - uint processInformationSize); - - int GetLastWin32Error(); - } - - public sealed class ProcessMemoryPriorityNativeApi : IProcessMemoryPriorityNativeApi - { - public static ProcessMemoryPriorityNativeApi Instance { get; } = new(); - - private ProcessMemoryPriorityNativeApi() - { - } - - public bool IsSupported => OperatingSystem.IsWindowsVersionAtLeast(6, 2); - - public SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId) - { - return ProcessMemoryPriorityNativeMethods.OpenProcess(access, inheritHandle, processId); - } - - public bool GetProcessInformation( - SafeProcessHandle process, - ProcessInformationClass processInformationClass, - ref MemoryPriorityInformation processInformation, - uint processInformationSize) - { - return ProcessMemoryPriorityNativeMethods.GetProcessInformation( - process, - processInformationClass, - ref processInformation, - processInformationSize); - } - - public bool SetProcessInformation( - SafeProcessHandle process, - ProcessInformationClass processInformationClass, - ref MemoryPriorityInformation processInformation, - uint processInformationSize) - { - return ProcessMemoryPriorityNativeMethods.SetProcessInformation( - process, - processInformationClass, - ref processInformation, - processInformationSize); - } - - public int GetLastWin32Error() - { - return Marshal.GetLastWin32Error(); - } - } - - public enum ProcessInformationClass - { - ProcessMemoryPriority = 0, - } - - [StructLayout(LayoutKind.Sequential)] - public struct MemoryPriorityInformation - { - public uint MemoryPriority; - } -} +/* + * ThreadPilot - Windows process memory priority native API abstraction. + */ +namespace ThreadPilot.Platforms.Windows +{ + using System; + using System.Runtime.InteropServices; + using Microsoft.Win32.SafeHandles; + + public interface IProcessMemoryPriorityNativeApi + { + bool IsSupported { get; } + + SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId); + + bool GetProcessInformation( + SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref MemoryPriorityInformation processInformation, + uint processInformationSize); + + bool SetProcessInformation( + SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref MemoryPriorityInformation processInformation, + uint processInformationSize); + + int GetLastWin32Error(); + } + + public sealed class ProcessMemoryPriorityNativeApi : IProcessMemoryPriorityNativeApi + { + public static ProcessMemoryPriorityNativeApi Instance { get; } = new(); + + private ProcessMemoryPriorityNativeApi() + { + } + + public bool IsSupported => OperatingSystem.IsWindowsVersionAtLeast(6, 2); + + public SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId) + { + return ProcessMemoryPriorityNativeMethods.OpenProcess(access, inheritHandle, processId); + } + + public bool GetProcessInformation( + SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref MemoryPriorityInformation processInformation, + uint processInformationSize) + { + return ProcessMemoryPriorityNativeMethods.GetProcessInformation( + process, + processInformationClass, + ref processInformation, + processInformationSize); + } + + public bool SetProcessInformation( + SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref MemoryPriorityInformation processInformation, + uint processInformationSize) + { + return ProcessMemoryPriorityNativeMethods.SetProcessInformation( + process, + processInformationClass, + ref processInformation, + processInformationSize); + } + + public int GetLastWin32Error() + { + return Marshal.GetLastWin32Error(); + } + } + + public enum ProcessInformationClass + { + ProcessMemoryPriority = 0, + } + + [StructLayout(LayoutKind.Sequential)] + public struct MemoryPriorityInformation + { + public uint MemoryPriority; + } +} diff --git a/Platforms/Windows/ProcessCpuSetHandler.cs b/Platforms/Windows/ProcessCpuSetHandler.cs index 4c4e41b..4cf6e53 100644 --- a/Platforms/Windows/ProcessCpuSetHandler.cs +++ b/Platforms/Windows/ProcessCpuSetHandler.cs @@ -1,447 +1,424 @@ -/* - * 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.Platforms.Windows -{ - using System; - using System.Collections.Generic; - using System.ComponentModel; - using System.Runtime.InteropServices; - using Microsoft.Extensions.Logging; - using Microsoft.Win32.SafeHandles; - using ThreadPilot.Models; - using ThreadPilot.Services; - - /// - /// Handles CPU Set operations for a specific process using Windows APIs - /// Based on CPUSetSetter's ProcessHandlerWindows implementation. - /// - public class ProcessCpuSetHandler : IProcessCpuSetHandler - { - private static CpuSetMapping staticCpuSetMapping = CpuSetMapping.Empty; - private static readonly object staticInitLock = new object(); - private static bool staticInitialized = false; - - private readonly Queue cpuTimeMovingAverageBuffer = new(); - private readonly string executableName; - private readonly uint pid; - private readonly IProcessCpuSetNativeApi nativeApi; - private readonly CpuSetMapping cpuSetMapping; - private readonly ILogger? logger; - - private SafeProcessHandle? queryLimitedInfoHandle; - private SafeProcessHandle? setLimitedInfoHandle; - private bool disposed = false; - - public ProcessCpuSetHandler(uint processId, string executableName, ILogger? logger = null) - : this(processId, executableName, ProcessCpuSetNativeApi.Instance, EnsureStaticInitialization(ProcessCpuSetNativeApi.Instance), logger) - { - } - - internal ProcessCpuSetHandler( - uint processId, - string executableName, - IProcessCpuSetNativeApi nativeApi, - CpuSetMapping cpuSetMapping, - ILogger? logger = null) - { - this.pid = processId; - this.executableName = executableName ?? $"PID_{processId}"; - this.nativeApi = nativeApi ?? throw new ArgumentNullException(nameof(nativeApi)); - this.cpuSetMapping = cpuSetMapping ?? throw new ArgumentNullException(nameof(cpuSetMapping)); - this.logger = logger; - - // Open handle for querying process information - this.queryLimitedInfoHandle = this.nativeApi.OpenProcess( - ProcessAccessFlags.PROCESS_QUERY_LIMITED_INFORMATION, - false, - processId); - - if (this.queryLimitedInfoHandle == null || this.queryLimitedInfoHandle.IsInvalid) - { - var error = this.nativeApi.GetLastWin32Error(); - this.logger?.LogWarning("Failed to open process {ProcessId} for querying: {Error}", processId, new Win32Exception(error).Message); - } - } - - public uint ProcessId => this.pid; - - public string ExecutableName => this.executableName; - - public bool IsValid => this.queryLimitedInfoHandle != null && !this.queryLimitedInfoHandle.IsInvalid; - - private static CpuSetMapping EnsureStaticInitialization(IProcessCpuSetNativeApi nativeApi) - { - if (staticInitialized) - { - return staticCpuSetMapping; - } - - lock (staticInitLock) - { - if (staticInitialized) - { - return staticCpuSetMapping; - } - - try - { - staticCpuSetMapping = GetCpuSetMapping(nativeApi); - } - catch (Exception) - { - // If we can't get CPU Set mapping, CPU Sets won't be available - // The handler will still work but ApplyCpuSetMask will return false - staticCpuSetMapping = CpuSetMapping.Empty; - } - - staticInitialized = true; - return staticCpuSetMapping; - } - } - - public double GetAverageCpuUsage() - { - if (this.queryLimitedInfoHandle == null || this.queryLimitedInfoHandle.IsInvalid) - { - return -1; - } - - try - { - DateTime now = DateTime.Now; - - // Remove datapoints older than 30 seconds from the moving average buffer - while (this.cpuTimeMovingAverageBuffer.Count > 0) - { - TimeSpan datapointAge = now - this.cpuTimeMovingAverageBuffer.Peek().Timestamp; - if (datapointAge.TotalSeconds > 30) - { - this.cpuTimeMovingAverageBuffer.Dequeue(); - } - else - { - break; - } - } - - // Get the current total CPU time of the process - bool success = this.nativeApi.GetProcessTimes( - this.queryLimitedInfoHandle, - out _, - out _, - out FILETIME kernelTime, - out FILETIME userTime); - - if (!success) - { - return -1; - } - - TimeSpan totalCpuTime = TimeSpan.FromTicks((long)(kernelTime.ULong + userTime.ULong)); - this.cpuTimeMovingAverageBuffer.Enqueue(new CpuTimeTimestamp - { - Timestamp = now, - TotalCpuTime = totalCpuTime, - }); - - // Need at least 2 samples to calculate usage - if (this.cpuTimeMovingAverageBuffer.Count < 2) - { - return 0; - } - - // Take the CPU time from now and (up to) a minute ago, and get the average usage % - CpuTimeTimestamp startDatapoint = this.cpuTimeMovingAverageBuffer.Peek(); - TimeSpan deltaTime = now - startDatapoint.Timestamp; - TimeSpan deltaCpuTime = totalCpuTime - startDatapoint.TotalCpuTime; - - if (deltaCpuTime.Ticks == 0 || deltaTime.Ticks == 0) - { - return 0; - } - - return (double)deltaCpuTime.Ticks / deltaTime.Ticks / Environment.ProcessorCount; - } - catch - { - return -1; - } - } - - public bool ApplyCpuSetMask(long affinityMask, bool clearMask = false) => - this.ApplyCpuSetMaskDetailed(affinityMask, clearMask).Success; - - public CpuSetApplyResult ApplyCpuSetMaskDetailed(long affinityMask, bool clearMask = false) - { - if (this.disposed) - { - throw new ObjectDisposedException(nameof(ProcessCpuSetHandler)); - } - - // Legacy mask support is intentionally limited to single-group systems where - // logical processors 0-63 map to processor group 0. CpuSelection will replace - // this path for group-aware selections in a later phase. - if (this.cpuSetMapping.IsEmpty) - { - this.logger?.LogWarning("CPU Set mapping not available. Cannot apply CPU Sets to process {ProcessId}", this.pid); - return CpuSetApplyResult.Failed( - AffinityApplyErrorCodes.CpuSetsUnavailable, - ProcessOperationUserMessages.CpuSetsUnavailable, - $"CPU Set mapping is not available for process '{this.executableName}' (PID: {this.pid})."); - } - - var handleResult = this.EnsureSetHandleDetailed(); - if (!handleResult.Success) - { - return handleResult; - } - - if (clearMask) - { - return this.ApplyCpuSetIdsDetailed(null, 0, "clear CPU Set"); - } - - var cpuSetIds = this.cpuSetMapping.ResolveLegacyAffinityMask(affinityMask, Environment.ProcessorCount); - - if (cpuSetIds.Count == 0) - { - this.logger?.LogWarning( - "No valid CPU Set IDs found for affinity mask 0x{AffinityMask:X} on process '{ExecutableName}'", - affinityMask, this.executableName); - return CpuSetApplyResult.Failed( - AffinityApplyErrorCodes.InvalidTopology, - ProcessOperationUserMessages.InvalidTopology, - $"No valid CPU Set IDs found for affinity mask 0x{affinityMask:X} on process '{this.executableName}'."); - } - - var cpuSetIdsArray = cpuSetIds.ToArray(); - var result = this.ApplyCpuSetIdsDetailed(cpuSetIdsArray, (uint)cpuSetIdsArray.Length, "apply CPU Set"); - - if (result.Success) - { - this.logger?.LogInformation( - "Applied CPU Set (affinity mask 0x{AffinityMask:X}) to '{ExecutableName}' (PID: {ProcessId})", - affinityMask, this.executableName, this.pid); - } - - return result; - } - - public bool ApplyCpuSelection(CpuSelection? selection, bool clearSelection = false) => - this.ApplyCpuSelectionDetailed(selection, clearSelection).Success; - - public CpuSetApplyResult ApplyCpuSelectionDetailed(CpuSelection? selection, bool clearSelection = false) - { - if (this.disposed) - { - throw new ObjectDisposedException(nameof(ProcessCpuSetHandler)); - } - - var handleResult = this.EnsureSetHandleDetailed(); - if (!handleResult.Success) - { - return handleResult; - } - - if (clearSelection) - { - return this.ApplyCpuSetIdsDetailed(null, 0, "clear CPU Set selection"); - } - - ArgumentNullException.ThrowIfNull(selection); - - var cpuSetIds = this.cpuSetMapping.ResolveCpuSetIds(selection); - if (cpuSetIds.Count == 0) - { - this.logger?.LogWarning( - "No valid CPU Set IDs resolved for CPU selection on process '{ExecutableName}' (PID: {ProcessId})", - this.executableName, - this.pid); - return CpuSetApplyResult.Failed( - AffinityApplyErrorCodes.InvalidTopology, - ProcessOperationUserMessages.InvalidTopology, - $"No valid CPU Set IDs resolved for CPU selection on process '{this.executableName}' (PID: {this.pid})."); - } - - var cpuSetIdsArray = cpuSetIds.ToArray(); - return this.ApplyCpuSetIdsDetailed(cpuSetIdsArray, (uint)cpuSetIdsArray.Length, "apply CPU Set selection"); - } - - private bool EnsureSetHandle() - { - return this.EnsureSetHandleDetailed().Success; - } - - private CpuSetApplyResult EnsureSetHandleDetailed() - { - if (this.setLimitedInfoHandle == null) - { - this.setLimitedInfoHandle = this.nativeApi.OpenProcess( - ProcessAccessFlags.PROCESS_SET_LIMITED_INFORMATION, - false, - this.pid); - - if (this.setLimitedInfoHandle == null || this.setLimitedInfoHandle.IsInvalid) - { - int openError = this.nativeApi.GetLastWin32Error(); - string extraHelpString = (openError == 5) - ? $" {ProcessOperationUserMessages.AdminClarification}" - : string.Empty; - this.logger?.LogWarning( - "Could not open process '{ExecutableName}' (PID: {ProcessId}) for setting affinity: {Error}{Help}", - this.executableName, this.pid, new Win32Exception(openError).Message, extraHelpString); - return this.CreateNativeFailureResult( - "open process for CPU Set changes", - openError); - } - } - else if (this.setLimitedInfoHandle.IsInvalid) - { - // The handle was already made previously and failed, don't bother trying again - return CpuSetApplyResult.Failed( - AffinityApplyErrorCodes.CpuSetsUnavailable, - ProcessOperationUserMessages.CpuSetsUnavailable, - $"The cached CPU Set handle for '{this.executableName}' (PID: {this.pid}) is invalid."); - } - - return CpuSetApplyResult.Succeeded($"CPU Set handle is available for '{this.executableName}' (PID: {this.pid})."); - } - - private bool ApplyCpuSetIds(uint[]? cpuSetIds, uint cpuSetIdCount, string operationName) - { - return this.ApplyCpuSetIdsDetailed(cpuSetIds, cpuSetIdCount, operationName).Success; - } - - private CpuSetApplyResult ApplyCpuSetIdsDetailed(uint[]? cpuSetIds, uint cpuSetIdCount, string operationName) - { - bool success = this.nativeApi.SetProcessDefaultCpuSets(this.setLimitedInfoHandle!, cpuSetIds, cpuSetIdCount); - if (success) - { - this.logger?.LogInformation( - "Completed {OperationName} for '{ExecutableName}' (PID: {ProcessId})", - operationName, - this.executableName, - this.pid); - return CpuSetApplyResult.Succeeded( - $"Completed {operationName} for '{this.executableName}' (PID: {this.pid})."); - } - - int error = this.nativeApi.GetLastWin32Error(); - string errorMessage = $"Could not {operationName} for '{this.executableName}' (PID: {this.pid}): {new Win32Exception(error).Message}"; - if (error == 5) - { - errorMessage += $" {ProcessOperationUserMessages.AdminClarification}"; - } - - this.logger?.LogWarning(errorMessage); - return this.CreateNativeFailureResult(operationName, error, errorMessage); - } - - private CpuSetApplyResult CreateNativeFailureResult( - string operationName, - int win32ErrorCode, - string? technicalMessage = null) - { - var message = technicalMessage ?? - $"Could not {operationName} for '{this.executableName}' (PID: {this.pid}): {new Win32Exception(win32ErrorCode).Message}"; - var accessDenied = win32ErrorCode == 5; - - return CpuSetApplyResult.Failed( - accessDenied ? AffinityApplyErrorCodes.AccessDenied : AffinityApplyErrorCodes.NativeApplyFailed, - accessDenied ? ProcessOperationUserMessages.AccessDenied : ProcessOperationUserMessages.CpuSetsUnavailable, - message, - win32ErrorCode, - isAccessDenied: accessDenied); - } - - /// - /// Gets the CPU Set ID of each logical processor keyed by processor group and group-relative logical processor number. - /// - private static CpuSetMapping GetCpuSetMapping(IProcessCpuSetNativeApi nativeApi) - { - uint bufferLength = 0; - - // First call to get buffer size - if (!nativeApi.GetSystemCpuSetInformation(IntPtr.Zero, 0, ref bufferLength, new SafeProcessHandle(), 0)) - { - int error = nativeApi.GetLastWin32Error(); - if (error != 0x7A) // ERROR_INSUFFICIENT_BUFFER - { - throw new Win32Exception(error, "Failed to query CPU Set information buffer size"); - } - } - - Dictionary cpuSets = new Dictionary(); - IntPtr buffer = Marshal.AllocHGlobal((int)bufferLength); - - try - { - // Second call to get actual data - if (!nativeApi.GetSystemCpuSetInformation(buffer, bufferLength, ref bufferLength, new SafeProcessHandle(), 0)) - { - throw new Win32Exception(nativeApi.GetLastWin32Error(), "Failed to get CPU Set information"); - } - - IntPtr current = buffer; - IntPtr bufferEnd = buffer + (int)bufferLength; - - while (current.ToInt64() < bufferEnd.ToInt64()) - { - SYSTEM_CPU_SET_INFORMATION item = Marshal.PtrToStructure(current); - - if (item.Type != CPU_SET_INFORMATION_TYPE.CpuSetInformation) - { - throw new InvalidCastException("Invalid CPU Set information type encountered"); - } - - var processor = CpuSetMapping.CreateProcessorRef(item.Group, item.LogicalProcessorIndex); - cpuSets[processor] = item.Id; - - current = IntPtr.Add(current, (int)item.Size); - } - - return CpuSetMapping.Create(cpuSets); - } - finally - { - Marshal.FreeHGlobal(buffer); - } - } - - public void Dispose() - { - if (this.disposed) - { - return; - } - - this.queryLimitedInfoHandle?.Dispose(); - this.setLimitedInfoHandle?.Dispose(); - this.cpuTimeMovingAverageBuffer.Clear(); - - this.disposed = true; - GC.SuppressFinalize(this); - } - - private class CpuTimeTimestamp - { - public DateTime Timestamp { get; init; } - - public TimeSpan TotalCpuTime { get; init; } - } - } -} +namespace ThreadPilot.Platforms.Windows +{ + using System; + using System.Collections.Generic; + using System.ComponentModel; + using System.Runtime.InteropServices; + using Microsoft.Extensions.Logging; + using Microsoft.Win32.SafeHandles; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public class ProcessCpuSetHandler : IProcessCpuSetHandler + { + private static CpuSetMapping staticCpuSetMapping = CpuSetMapping.Empty; + private static readonly object staticInitLock = new object(); + private static bool staticInitialized = false; + + private readonly Queue cpuTimeMovingAverageBuffer = new(); + private readonly string executableName; + private readonly uint pid; + private readonly IProcessCpuSetNativeApi nativeApi; + private readonly CpuSetMapping cpuSetMapping; + private readonly ILogger? logger; + + private SafeProcessHandle? queryLimitedInfoHandle; + private SafeProcessHandle? setLimitedInfoHandle; + private bool disposed = false; + + public ProcessCpuSetHandler(uint processId, string executableName, ILogger? logger = null) + : this(processId, executableName, ProcessCpuSetNativeApi.Instance, EnsureStaticInitialization(ProcessCpuSetNativeApi.Instance), logger) + { + } + + internal ProcessCpuSetHandler( + uint processId, + string executableName, + IProcessCpuSetNativeApi nativeApi, + CpuSetMapping cpuSetMapping, + ILogger? logger = null) + { + this.pid = processId; + this.executableName = executableName ?? $"PID_{processId}"; + this.nativeApi = nativeApi ?? throw new ArgumentNullException(nameof(nativeApi)); + this.cpuSetMapping = cpuSetMapping ?? throw new ArgumentNullException(nameof(cpuSetMapping)); + this.logger = logger; + + // Open handle for querying process information + this.queryLimitedInfoHandle = this.nativeApi.OpenProcess( + ProcessAccessFlags.PROCESS_QUERY_LIMITED_INFORMATION, + false, + processId); + + if (this.queryLimitedInfoHandle == null || this.queryLimitedInfoHandle.IsInvalid) + { + var error = this.nativeApi.GetLastWin32Error(); + this.logger?.LogWarning("Failed to open process {ProcessId} for querying: {Error}", processId, new Win32Exception(error).Message); + } + } + + public uint ProcessId => this.pid; + + public string ExecutableName => this.executableName; + + public bool IsValid => this.queryLimitedInfoHandle != null && !this.queryLimitedInfoHandle.IsInvalid; + + private static CpuSetMapping EnsureStaticInitialization(IProcessCpuSetNativeApi nativeApi) + { + if (staticInitialized) + { + return staticCpuSetMapping; + } + + lock (staticInitLock) + { + if (staticInitialized) + { + return staticCpuSetMapping; + } + + try + { + staticCpuSetMapping = GetCpuSetMapping(nativeApi); + } + catch (Exception) + { + // If we can't get CPU Set mapping, CPU Sets won't be available + // The handler will still work but ApplyCpuSetMask will return false + staticCpuSetMapping = CpuSetMapping.Empty; + } + + staticInitialized = true; + return staticCpuSetMapping; + } + } + + public double GetAverageCpuUsage() + { + if (this.queryLimitedInfoHandle == null || this.queryLimitedInfoHandle.IsInvalid) + { + return -1; + } + + try + { + DateTime now = DateTime.Now; + + // Remove datapoints older than 30 seconds from the moving average buffer + while (this.cpuTimeMovingAverageBuffer.Count > 0) + { + TimeSpan datapointAge = now - this.cpuTimeMovingAverageBuffer.Peek().Timestamp; + if (datapointAge.TotalSeconds > 30) + { + this.cpuTimeMovingAverageBuffer.Dequeue(); + } + else + { + break; + } + } + + // Get the current total CPU time of the process + bool success = this.nativeApi.GetProcessTimes( + this.queryLimitedInfoHandle, + out _, + out _, + out FILETIME kernelTime, + out FILETIME userTime); + + if (!success) + { + return -1; + } + + TimeSpan totalCpuTime = TimeSpan.FromTicks((long)(kernelTime.ULong + userTime.ULong)); + this.cpuTimeMovingAverageBuffer.Enqueue(new CpuTimeTimestamp + { + Timestamp = now, + TotalCpuTime = totalCpuTime, + }); + + // Need at least 2 samples to calculate usage + if (this.cpuTimeMovingAverageBuffer.Count < 2) + { + return 0; + } + + // Take the CPU time from now and (up to) a minute ago, and get the average usage % + CpuTimeTimestamp startDatapoint = this.cpuTimeMovingAverageBuffer.Peek(); + TimeSpan deltaTime = now - startDatapoint.Timestamp; + TimeSpan deltaCpuTime = totalCpuTime - startDatapoint.TotalCpuTime; + + if (deltaCpuTime.Ticks == 0 || deltaTime.Ticks == 0) + { + return 0; + } + + return (double)deltaCpuTime.Ticks / deltaTime.Ticks / Environment.ProcessorCount; + } + catch + { + return -1; + } + } + + public bool ApplyCpuSetMask(long affinityMask, bool clearMask = false) => + this.ApplyCpuSetMaskDetailed(affinityMask, clearMask).Success; + + public CpuSetApplyResult ApplyCpuSetMaskDetailed(long affinityMask, bool clearMask = false) + { + if (this.disposed) + { + throw new ObjectDisposedException(nameof(ProcessCpuSetHandler)); + } + + // Legacy mask support is intentionally limited to single-group systems where + // logical processors 0-63 map to processor group 0. CpuSelection will replace + // this path for group-aware selections in a later phase. + if (this.cpuSetMapping.IsEmpty) + { + this.logger?.LogWarning("CPU Set mapping not available. Cannot apply CPU Sets to process {ProcessId}", this.pid); + return CpuSetApplyResult.Failed( + AffinityApplyErrorCodes.CpuSetsUnavailable, + ProcessOperationUserMessages.CpuSetsUnavailable, + $"CPU Set mapping is not available for process '{this.executableName}' (PID: {this.pid})."); + } + + var handleResult = this.EnsureSetHandleDetailed(); + if (!handleResult.Success) + { + return handleResult; + } + + if (clearMask) + { + return this.ApplyCpuSetIdsDetailed(null, 0, "clear CPU Set"); + } + + var cpuSetIds = this.cpuSetMapping.ResolveLegacyAffinityMask(affinityMask, Environment.ProcessorCount); + + if (cpuSetIds.Count == 0) + { + this.logger?.LogWarning( + "No valid CPU Set IDs found for affinity mask 0x{AffinityMask:X} on process '{ExecutableName}'", + affinityMask, this.executableName); + return CpuSetApplyResult.Failed( + AffinityApplyErrorCodes.InvalidTopology, + ProcessOperationUserMessages.InvalidTopology, + $"No valid CPU Set IDs found for affinity mask 0x{affinityMask:X} on process '{this.executableName}'."); + } + + var cpuSetIdsArray = cpuSetIds.ToArray(); + var result = this.ApplyCpuSetIdsDetailed(cpuSetIdsArray, (uint)cpuSetIdsArray.Length, "apply CPU Set"); + + if (result.Success) + { + this.logger?.LogInformation( + "Applied CPU Set (affinity mask 0x{AffinityMask:X}) to '{ExecutableName}' (PID: {ProcessId})", + affinityMask, this.executableName, this.pid); + } + + return result; + } + + public bool ApplyCpuSelection(CpuSelection? selection, bool clearSelection = false) => + this.ApplyCpuSelectionDetailed(selection, clearSelection).Success; + + public CpuSetApplyResult ApplyCpuSelectionDetailed(CpuSelection? selection, bool clearSelection = false) + { + if (this.disposed) + { + throw new ObjectDisposedException(nameof(ProcessCpuSetHandler)); + } + + var handleResult = this.EnsureSetHandleDetailed(); + if (!handleResult.Success) + { + return handleResult; + } + + if (clearSelection) + { + return this.ApplyCpuSetIdsDetailed(null, 0, "clear CPU Set selection"); + } + + ArgumentNullException.ThrowIfNull(selection); + + var cpuSetIds = this.cpuSetMapping.ResolveCpuSetIds(selection); + if (cpuSetIds.Count == 0) + { + this.logger?.LogWarning( + "No valid CPU Set IDs resolved for CPU selection on process '{ExecutableName}' (PID: {ProcessId})", + this.executableName, + this.pid); + return CpuSetApplyResult.Failed( + AffinityApplyErrorCodes.InvalidTopology, + ProcessOperationUserMessages.InvalidTopology, + $"No valid CPU Set IDs resolved for CPU selection on process '{this.executableName}' (PID: {this.pid})."); + } + + var cpuSetIdsArray = cpuSetIds.ToArray(); + return this.ApplyCpuSetIdsDetailed(cpuSetIdsArray, (uint)cpuSetIdsArray.Length, "apply CPU Set selection"); + } + + private bool EnsureSetHandle() + { + return this.EnsureSetHandleDetailed().Success; + } + + private CpuSetApplyResult EnsureSetHandleDetailed() + { + if (this.setLimitedInfoHandle == null) + { + this.setLimitedInfoHandle = this.nativeApi.OpenProcess( + ProcessAccessFlags.PROCESS_SET_LIMITED_INFORMATION, + false, + this.pid); + + if (this.setLimitedInfoHandle == null || this.setLimitedInfoHandle.IsInvalid) + { + int openError = this.nativeApi.GetLastWin32Error(); + string extraHelpString = (openError == 5) + ? $" {ProcessOperationUserMessages.AdminClarification}" + : string.Empty; + this.logger?.LogWarning( + "Could not open process '{ExecutableName}' (PID: {ProcessId}) for setting affinity: {Error}{Help}", + this.executableName, this.pid, new Win32Exception(openError).Message, extraHelpString); + return this.CreateNativeFailureResult( + "open process for CPU Set changes", + openError); + } + } + else if (this.setLimitedInfoHandle.IsInvalid) + { + // The handle was already made previously and failed, don't bother trying again + return CpuSetApplyResult.Failed( + AffinityApplyErrorCodes.CpuSetsUnavailable, + ProcessOperationUserMessages.CpuSetsUnavailable, + $"The cached CPU Set handle for '{this.executableName}' (PID: {this.pid}) is invalid."); + } + + return CpuSetApplyResult.Succeeded($"CPU Set handle is available for '{this.executableName}' (PID: {this.pid})."); + } + + private bool ApplyCpuSetIds(uint[]? cpuSetIds, uint cpuSetIdCount, string operationName) + { + return this.ApplyCpuSetIdsDetailed(cpuSetIds, cpuSetIdCount, operationName).Success; + } + + private CpuSetApplyResult ApplyCpuSetIdsDetailed(uint[]? cpuSetIds, uint cpuSetIdCount, string operationName) + { + bool success = this.nativeApi.SetProcessDefaultCpuSets(this.setLimitedInfoHandle!, cpuSetIds, cpuSetIdCount); + if (success) + { + this.logger?.LogInformation( + "Completed {OperationName} for '{ExecutableName}' (PID: {ProcessId})", + operationName, + this.executableName, + this.pid); + return CpuSetApplyResult.Succeeded( + $"Completed {operationName} for '{this.executableName}' (PID: {this.pid})."); + } + + int error = this.nativeApi.GetLastWin32Error(); + string errorMessage = $"Could not {operationName} for '{this.executableName}' (PID: {this.pid}): {new Win32Exception(error).Message}"; + if (error == 5) + { + errorMessage += $" {ProcessOperationUserMessages.AdminClarification}"; + } + + this.logger?.LogWarning(errorMessage); + return this.CreateNativeFailureResult(operationName, error, errorMessage); + } + + private CpuSetApplyResult CreateNativeFailureResult( + string operationName, + int win32ErrorCode, + string? technicalMessage = null) + { + var message = technicalMessage ?? + $"Could not {operationName} for '{this.executableName}' (PID: {this.pid}): {new Win32Exception(win32ErrorCode).Message}"; + var accessDenied = win32ErrorCode == 5; + + return CpuSetApplyResult.Failed( + accessDenied ? AffinityApplyErrorCodes.AccessDenied : AffinityApplyErrorCodes.NativeApplyFailed, + accessDenied ? ProcessOperationUserMessages.AccessDenied : ProcessOperationUserMessages.CpuSetsUnavailable, + message, + win32ErrorCode, + isAccessDenied: accessDenied); + } + + private static CpuSetMapping GetCpuSetMapping(IProcessCpuSetNativeApi nativeApi) + { + uint bufferLength = 0; + + // First call to get buffer size + if (!nativeApi.GetSystemCpuSetInformation(IntPtr.Zero, 0, ref bufferLength, new SafeProcessHandle(), 0)) + { + int error = nativeApi.GetLastWin32Error(); + if (error != 0x7A) // ERROR_INSUFFICIENT_BUFFER + { + throw new Win32Exception(error, "Failed to query CPU Set information buffer size"); + } + } + + Dictionary cpuSets = new Dictionary(); + IntPtr buffer = Marshal.AllocHGlobal((int)bufferLength); + + try + { + // Second call to get actual data + if (!nativeApi.GetSystemCpuSetInformation(buffer, bufferLength, ref bufferLength, new SafeProcessHandle(), 0)) + { + throw new Win32Exception(nativeApi.GetLastWin32Error(), "Failed to get CPU Set information"); + } + + IntPtr current = buffer; + IntPtr bufferEnd = buffer + (int)bufferLength; + + while (current.ToInt64() < bufferEnd.ToInt64()) + { + SYSTEM_CPU_SET_INFORMATION item = Marshal.PtrToStructure(current); + + if (item.Type != CPU_SET_INFORMATION_TYPE.CpuSetInformation) + { + throw new InvalidCastException("Invalid CPU Set information type encountered"); + } + + var processor = CpuSetMapping.CreateProcessorRef(item.Group, item.LogicalProcessorIndex); + cpuSets[processor] = item.Id; + + current = IntPtr.Add(current, (int)item.Size); + } + + return CpuSetMapping.Create(cpuSets); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + public void Dispose() + { + if (this.disposed) + { + return; + } + + this.queryLimitedInfoHandle?.Dispose(); + this.setLimitedInfoHandle?.Dispose(); + this.cpuTimeMovingAverageBuffer.Clear(); + + this.disposed = true; + GC.SuppressFinalize(this); + } + + private class CpuTimeTimestamp + { + public DateTime Timestamp { get; init; } + + public TimeSpan TotalCpuTime { get; init; } + } + } +} diff --git a/Platforms/Windows/ProcessMemoryPriorityNativeMethods.cs b/Platforms/Windows/ProcessMemoryPriorityNativeMethods.cs index cc48cfc..1535bfa 100644 --- a/Platforms/Windows/ProcessMemoryPriorityNativeMethods.cs +++ b/Platforms/Windows/ProcessMemoryPriorityNativeMethods.cs @@ -1,33 +1,33 @@ -/* - * ThreadPilot - Windows process memory priority P/Invoke declarations. - */ -namespace ThreadPilot.Platforms.Windows -{ - using System.Runtime.InteropServices; - using Microsoft.Win32.SafeHandles; - - internal static partial class ProcessMemoryPriorityNativeMethods - { - [LibraryImport("kernel32.dll", SetLastError = true)] - public static partial SafeProcessHandle OpenProcess( - ProcessAccessFlags access, - [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, - uint processId); - - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static partial bool GetProcessInformation( - SafeProcessHandle process, - ProcessInformationClass processInformationClass, - ref MemoryPriorityInformation processInformation, - uint processInformationSize); - - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static partial bool SetProcessInformation( - SafeProcessHandle process, - ProcessInformationClass processInformationClass, - ref MemoryPriorityInformation processInformation, - uint processInformationSize); - } -} +/* + * ThreadPilot - Windows process memory priority P/Invoke declarations. + */ +namespace ThreadPilot.Platforms.Windows +{ + using System.Runtime.InteropServices; + using Microsoft.Win32.SafeHandles; + + internal static partial class ProcessMemoryPriorityNativeMethods + { + [LibraryImport("kernel32.dll", SetLastError = true)] + public static partial SafeProcessHandle OpenProcess( + ProcessAccessFlags access, + [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, + uint processId); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool GetProcessInformation( + SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref MemoryPriorityInformation processInformation, + uint processInformationSize); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool SetProcessInformation( + SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref MemoryPriorityInformation processInformation, + uint processInformationSize); + } +} diff --git a/Services/Abstractions/IGitHubReleaseClient.cs b/Services/Abstractions/IGitHubReleaseClient.cs index 15f0c2e..b6ba850 100644 --- a/Services/Abstractions/IGitHubReleaseClient.cs +++ b/Services/Abstractions/IGitHubReleaseClient.cs @@ -1,15 +1,12 @@ -namespace ThreadPilot.Services.Abstractions -{ - using System.Threading; - using System.Threading.Tasks; - - /// - /// Retrieves GitHub release payloads for update checks. - /// - public interface IGitHubReleaseClient - { - Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default); - - Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default); - } -} +namespace ThreadPilot.Services.Abstractions +{ + using System.Threading; + using System.Threading.Tasks; + + public interface IGitHubReleaseClient + { + Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default); + + Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default); + } +} diff --git a/Services/Abstractions/IProcessRunner.cs b/Services/Abstractions/IProcessRunner.cs index 83a8239..5a1228d 100644 --- a/Services/Abstractions/IProcessRunner.cs +++ b/Services/Abstractions/IProcessRunner.cs @@ -1,32 +1,16 @@ -/* - * ThreadPilot - process execution seam. - */ -namespace ThreadPilot.Services.Abstractions -{ - using System; - using System.Collections.Generic; - using System.Threading.Tasks; - - /// - /// Runs external processes with a bounded timeout and captured output. - /// - public interface IProcessRunner - { - /// - /// Executes a process and returns its exit code with captured output streams. - /// - /// Executable path. - /// Argument list passed verbatim to the process. - /// Maximum execution time before the process is treated as timed out. - /// The captured process result. - Task RunAsync(string fileName, IReadOnlyList arguments, TimeSpan timeout); - } - - /// - /// Immutable result returned by . - /// - /// Process exit code, or a synthetic failure code when launch/timeout fails. - /// Captured standard output. - /// Captured standard error. - public readonly record struct ProcessRunResult(int ExitCode, string StandardOutput, string StandardError); -} +/* + * ThreadPilot - process execution seam. + */ +namespace ThreadPilot.Services.Abstractions +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + + public interface IProcessRunner + { + Task RunAsync(string fileName, IReadOnlyList arguments, TimeSpan timeout); + } + + public readonly record struct ProcessRunResult(int ExitCode, string StandardOutput, string StandardError); +} diff --git a/Services/Abstractions/ISettingsStorage.cs b/Services/Abstractions/ISettingsStorage.cs index 3c5fa61..1cf0ba3 100644 --- a/Services/Abstractions/ISettingsStorage.cs +++ b/Services/Abstractions/ISettingsStorage.cs @@ -1,20 +1,17 @@ -namespace ThreadPilot.Services.Abstractions -{ - using System.Threading.Tasks; - - /// - /// Provides a seam for reading and writing persisted settings. - /// - public interface ISettingsStorage - { - bool Exists(string path); - - Task ReadAsync(string path); - - Task WriteAsync(string path, string content); - - void EnsureDirectoryForFile(string path); - - void Copy(string sourcePath, string destinationPath, bool overwrite); - } -} +namespace ThreadPilot.Services.Abstractions +{ + using System.Threading.Tasks; + + public interface ISettingsStorage + { + bool Exists(string path); + + Task ReadAsync(string path); + + Task WriteAsync(string path, string content); + + void EnsureDirectoryForFile(string path); + + void Copy(string sourcePath, string destinationPath, bool overwrite); + } +} diff --git a/Services/ActivityAuditService.cs b/Services/ActivityAuditService.cs index 2f3631f..d2abcfb 100644 --- a/Services/ActivityAuditService.cs +++ b/Services/ActivityAuditService.cs @@ -1,244 +1,244 @@ -namespace ThreadPilot.Services -{ - using Microsoft.Extensions.Logging; - - public sealed class ActivityAuditService : IActivityAuditService - { - private const int MaxEntries = 1000; - private readonly ILogger logger; - private readonly object syncRoot = new(); - private readonly List entries = new(); - - public event EventHandler? EntryAdded; - - public ActivityAuditService(ILogger logger) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public Task LogInfoAsync(string category, string message, string? details = null) => - this.AddEntryAsync(category, ActivityAuditSeverity.Info, message, details); - - public Task LogSuccessAsync(string category, string message, string? details = null) => - this.AddEntryAsync(category, ActivityAuditSeverity.Success, message, details); - - public Task LogWarningAsync(string category, string message, string? details = null) => - this.AddEntryAsync(category, ActivityAuditSeverity.Warning, message, details); - - public Task LogErrorAsync(string category, string message, string? details = null) => - this.AddEntryAsync(category, ActivityAuditSeverity.Error, message, details); - - public Task LogUserActionAsync(string action, string details, string? context = null) - { - var entry = ActivityAuditActionMapper.Map(action, details, context); - return this.AddEntryAsync(entry.Category, entry.Severity, entry.Message, entry.Details); - } - - public Task> GetEntriesAsync(DateTime? fromDate = null, DateTime? toDate = null) - { - lock (this.syncRoot) - { - IEnumerable snapshot = this.entries; - if (fromDate.HasValue) - { - snapshot = snapshot.Where(entry => entry.Timestamp >= fromDate.Value); - } - - if (toDate.HasValue) - { - snapshot = snapshot.Where(entry => entry.Timestamp <= toDate.Value); - } - - return Task.FromResult>( - snapshot - .OrderByDescending(entry => entry.Timestamp) - .ToList()); - } - } - - public Task ClearDisplayAsync() - { - lock (this.syncRoot) - { - this.entries.Clear(); - } - - return Task.CompletedTask; - } - - private Task AddEntryAsync(string category, ActivityAuditSeverity severity, string message, string? details) - { - if (string.IsNullOrWhiteSpace(message)) - { - return Task.CompletedTask; - } - - var entry = new ActivityAuditEntry - { - Timestamp = DateTime.Now, - Category = string.IsNullOrWhiteSpace(category) ? ActivityAuditCategories.Diagnostics : category.Trim(), - Severity = severity, - Message = message.Trim(), - Details = string.IsNullOrWhiteSpace(details) ? null : details.Trim(), - }; - - lock (this.syncRoot) - { - this.entries.Add(entry); - if (this.entries.Count > MaxEntries) - { - this.entries.RemoveRange(0, this.entries.Count - MaxEntries); - } - } - - this.logger.Log( - ToLogLevel(severity), - "Activity audit: {Category} {Severity}: {Message}", - entry.Category, - entry.Severity, - entry.Message); - this.EntryAdded?.Invoke(this, entry); - return Task.CompletedTask; - } - - private static LogLevel ToLogLevel(ActivityAuditSeverity severity) => - severity switch - { - ActivityAuditSeverity.Error => LogLevel.Error, - ActivityAuditSeverity.Warning => LogLevel.Warning, - _ => LogLevel.Information, - }; - } - - internal static class ActivityAuditCategories - { - public const string Process = "Process"; - public const string Affinity = "Affinity"; - public const string Priority = "Priority"; - public const string MemoryPriority = "Memory Priority"; - public const string Rules = "Rules"; - public const string PowerPlans = "Power Plans"; - public const string Settings = "Settings"; - public const string Tweaks = "Tweaks"; - public const string Optimization = "Optimization"; - public const string Diagnostics = "Diagnostics"; - public const string Safety = "Safety"; - } - - internal static class ActivityAuditActionMapper - { - public static ActivityAuditEntry Map(string action, string details, string? context) - { - var category = ResolveCategory(action); - var severity = ResolveSeverity(action, details); - return new ActivityAuditEntry - { - Category = category, - Severity = severity, - Message = string.IsNullOrWhiteSpace(details) ? action : details, - Details = context, - }; - } - - private static string ResolveCategory(string action) - { - if (action.StartsWith("ProcessAffinity", StringComparison.OrdinalIgnoreCase) || - action.StartsWith("CpuSets", StringComparison.OrdinalIgnoreCase)) - { - return ActivityAuditCategories.Affinity; - } - - if (action.StartsWith("ProcessPriority", StringComparison.OrdinalIgnoreCase)) - { - return ActivityAuditCategories.Priority; - } - - if (action.StartsWith("ProcessMemoryPriority", StringComparison.OrdinalIgnoreCase)) - { - return ActivityAuditCategories.MemoryPriority; - } - - if (action.StartsWith("PersistentRule", StringComparison.OrdinalIgnoreCase) || - action.Contains("Association", StringComparison.OrdinalIgnoreCase)) - { - return ActivityAuditCategories.Rules; - } - - if (action.StartsWith("PowerPlan", StringComparison.OrdinalIgnoreCase) || - action.StartsWith("PowerPlans", StringComparison.OrdinalIgnoreCase)) - { - return ActivityAuditCategories.PowerPlans; - } - - if (action.StartsWith("Theme", StringComparison.OrdinalIgnoreCase) || - action.StartsWith("Settings", StringComparison.OrdinalIgnoreCase) || - action.Contains("Configuration", StringComparison.OrdinalIgnoreCase)) - { - return ActivityAuditCategories.Settings; - } - - if (action.StartsWith("SystemTweak", StringComparison.OrdinalIgnoreCase) || - action.Contains("IdleServer", StringComparison.OrdinalIgnoreCase) || - action.Contains("RegistryPriority", StringComparison.OrdinalIgnoreCase)) - { - return ActivityAuditCategories.Tweaks; - } - - if (action.StartsWith("Optimization", StringComparison.OrdinalIgnoreCase)) - { - return ActivityAuditCategories.Optimization; - } - - if (action.Contains("Protected", StringComparison.OrdinalIgnoreCase) || - action.Contains("Elevation", StringComparison.OrdinalIgnoreCase)) - { - return ActivityAuditCategories.Safety; - } - - if (action.StartsWith("Process", StringComparison.OrdinalIgnoreCase)) - { - return ActivityAuditCategories.Process; - } - - return ActivityAuditCategories.Diagnostics; - } - - private static ActivityAuditSeverity ResolveSeverity(string action, string details) - { - if (ContainsAny(action, "Blocked", "Denied") || ContainsAny(details, "blocked", "denied", "anti-cheat", "protected")) - { - return ActivityAuditSeverity.Warning; - } - - if (ContainsAny(action, "Failed", "Failure", "Error") || ContainsAny(details, "failed", "error", "exited")) - { - return ActivityAuditSeverity.Error; - } - - if (ContainsAny( - action, - "Applied", - "Changed", - "Saved", - "Updated", - "Deleted", - "Imported", - "Added", - "Cleared", - "Refreshed", - "Started", - "Stopped", - "Exported", - "Opened", - "Copied")) - { - return ActivityAuditSeverity.Success; - } - - return ActivityAuditSeverity.Info; - } - - private static bool ContainsAny(string value, params string[] terms) => - terms.Any(term => value.Contains(term, StringComparison.OrdinalIgnoreCase)); - } -} +namespace ThreadPilot.Services +{ + using Microsoft.Extensions.Logging; + + public sealed class ActivityAuditService : IActivityAuditService + { + private const int MaxEntries = 1000; + private readonly ILogger logger; + private readonly object syncRoot = new(); + private readonly List entries = new(); + + public event EventHandler? EntryAdded; + + public ActivityAuditService(ILogger logger) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task LogInfoAsync(string category, string message, string? details = null) => + this.AddEntryAsync(category, ActivityAuditSeverity.Info, message, details); + + public Task LogSuccessAsync(string category, string message, string? details = null) => + this.AddEntryAsync(category, ActivityAuditSeverity.Success, message, details); + + public Task LogWarningAsync(string category, string message, string? details = null) => + this.AddEntryAsync(category, ActivityAuditSeverity.Warning, message, details); + + public Task LogErrorAsync(string category, string message, string? details = null) => + this.AddEntryAsync(category, ActivityAuditSeverity.Error, message, details); + + public Task LogUserActionAsync(string action, string details, string? context = null) + { + var entry = ActivityAuditActionMapper.Map(action, details, context); + return this.AddEntryAsync(entry.Category, entry.Severity, entry.Message, entry.Details); + } + + public Task> GetEntriesAsync(DateTime? fromDate = null, DateTime? toDate = null) + { + lock (this.syncRoot) + { + IEnumerable snapshot = this.entries; + if (fromDate.HasValue) + { + snapshot = snapshot.Where(entry => entry.Timestamp >= fromDate.Value); + } + + if (toDate.HasValue) + { + snapshot = snapshot.Where(entry => entry.Timestamp <= toDate.Value); + } + + return Task.FromResult>( + snapshot + .OrderByDescending(entry => entry.Timestamp) + .ToList()); + } + } + + public Task ClearDisplayAsync() + { + lock (this.syncRoot) + { + this.entries.Clear(); + } + + return Task.CompletedTask; + } + + private Task AddEntryAsync(string category, ActivityAuditSeverity severity, string message, string? details) + { + if (string.IsNullOrWhiteSpace(message)) + { + return Task.CompletedTask; + } + + var entry = new ActivityAuditEntry + { + Timestamp = DateTime.Now, + Category = string.IsNullOrWhiteSpace(category) ? ActivityAuditCategories.Diagnostics : category.Trim(), + Severity = severity, + Message = message.Trim(), + Details = string.IsNullOrWhiteSpace(details) ? null : details.Trim(), + }; + + lock (this.syncRoot) + { + this.entries.Add(entry); + if (this.entries.Count > MaxEntries) + { + this.entries.RemoveRange(0, this.entries.Count - MaxEntries); + } + } + + this.logger.Log( + ToLogLevel(severity), + "Activity audit: {Category} {Severity}: {Message}", + entry.Category, + entry.Severity, + entry.Message); + this.EntryAdded?.Invoke(this, entry); + return Task.CompletedTask; + } + + private static LogLevel ToLogLevel(ActivityAuditSeverity severity) => + severity switch + { + ActivityAuditSeverity.Error => LogLevel.Error, + ActivityAuditSeverity.Warning => LogLevel.Warning, + _ => LogLevel.Information, + }; + } + + internal static class ActivityAuditCategories + { + public const string Process = "Process"; + public const string Affinity = "Affinity"; + public const string Priority = "Priority"; + public const string MemoryPriority = "Memory Priority"; + public const string Rules = "Rules"; + public const string PowerPlans = "Power Plans"; + public const string Settings = "Settings"; + public const string Tweaks = "Tweaks"; + public const string Optimization = "Optimization"; + public const string Diagnostics = "Diagnostics"; + public const string Safety = "Safety"; + } + + internal static class ActivityAuditActionMapper + { + public static ActivityAuditEntry Map(string action, string details, string? context) + { + var category = ResolveCategory(action); + var severity = ResolveSeverity(action, details); + return new ActivityAuditEntry + { + Category = category, + Severity = severity, + Message = string.IsNullOrWhiteSpace(details) ? action : details, + Details = context, + }; + } + + private static string ResolveCategory(string action) + { + if (action.StartsWith("ProcessAffinity", StringComparison.OrdinalIgnoreCase) || + action.StartsWith("CpuSets", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.Affinity; + } + + if (action.StartsWith("ProcessPriority", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.Priority; + } + + if (action.StartsWith("ProcessMemoryPriority", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.MemoryPriority; + } + + if (action.StartsWith("PersistentRule", StringComparison.OrdinalIgnoreCase) || + action.Contains("Association", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.Rules; + } + + if (action.StartsWith("PowerPlan", StringComparison.OrdinalIgnoreCase) || + action.StartsWith("PowerPlans", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.PowerPlans; + } + + if (action.StartsWith("Theme", StringComparison.OrdinalIgnoreCase) || + action.StartsWith("Settings", StringComparison.OrdinalIgnoreCase) || + action.Contains("Configuration", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.Settings; + } + + if (action.StartsWith("SystemTweak", StringComparison.OrdinalIgnoreCase) || + action.Contains("IdleServer", StringComparison.OrdinalIgnoreCase) || + action.Contains("RegistryPriority", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.Tweaks; + } + + if (action.StartsWith("Optimization", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.Optimization; + } + + if (action.Contains("Protected", StringComparison.OrdinalIgnoreCase) || + action.Contains("Elevation", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.Safety; + } + + if (action.StartsWith("Process", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.Process; + } + + return ActivityAuditCategories.Diagnostics; + } + + private static ActivityAuditSeverity ResolveSeverity(string action, string details) + { + if (ContainsAny(action, "Blocked", "Denied") || ContainsAny(details, "blocked", "denied", "anti-cheat", "protected")) + { + return ActivityAuditSeverity.Warning; + } + + if (ContainsAny(action, "Failed", "Failure", "Error") || ContainsAny(details, "failed", "error", "exited")) + { + return ActivityAuditSeverity.Error; + } + + if (ContainsAny( + action, + "Applied", + "Changed", + "Saved", + "Updated", + "Deleted", + "Imported", + "Added", + "Cleared", + "Refreshed", + "Started", + "Stopped", + "Exported", + "Opened", + "Copied")) + { + return ActivityAuditSeverity.Success; + } + + return ActivityAuditSeverity.Info; + } + + private static bool ContainsAny(string value, params string[] terms) => + terms.Any(term => value.Contains(term, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/Services/AffinityApplyService.cs b/Services/AffinityApplyService.cs index c122ba3..9b4c4a8 100644 --- a/Services/AffinityApplyService.cs +++ b/Services/AffinityApplyService.cs @@ -1,652 +1,636 @@ -/* - * 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.ComponentModel; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - using ThreadPilot.Platforms.Windows; - - public enum AffinityApplyFailureReason - { - None, - InvalidMask, - ProcessTerminated, - AccessDenied, - VerificationMismatch, - ApplyFailed, - } - - public static class AffinityApplyErrorCodes - { - public const string None = "None"; - public const string AccessDenied = "AccessDenied"; - public const string AntiCheatOrProtectedProcessLikely = "AntiCheatOrProtectedProcessLikely"; - public const string ProcessExited = "ProcessExited"; - public const string InvalidSelection = "InvalidSelection"; - public const string InvalidTopology = "InvalidTopology"; - public const string CpuSetsUnavailable = "CpuSetsUnavailable"; - public const string LegacyFallbackUnsafe = "LegacyFallbackUnsafe"; - public const string NativeApplyFailed = "NativeApplyFailed"; - public const string UnknownError = "UnknownError"; - } - - public sealed record AffinityApplyResult - { - public bool Success { get; init; } - - public long RequestedMask { get; init; } - - public long VerifiedMask { get; init; } - - public AffinityApplyFailureReason FailureReason { get; init; } - - public string Message => string.IsNullOrWhiteSpace(this.UserMessage) ? this.TechnicalMessage : this.UserMessage; - - public string ErrorCode { get; init; } = AffinityApplyErrorCodes.None; - - public string UserMessage { get; init; } = string.Empty; - - public string TechnicalMessage { get; init; } = string.Empty; - - public bool IsAccessDenied { get; init; } - - public bool IsAntiCheatLikely { get; init; } - - public bool IsInvalidTopology { get; init; } - - public bool IsLegacyFallbackBlocked { get; init; } - - public bool UsedCpuSets { get; init; } - - public bool UsedLegacyAffinity { get; init; } - - public static AffinityApplyResult Succeeded(long requestedMask, long verifiedMask) => - new() - { - Success = true, - RequestedMask = requestedMask, - VerifiedMask = verifiedMask, - FailureReason = AffinityApplyFailureReason.None, - ErrorCode = AffinityApplyErrorCodes.None, - UserMessage = "Affinity applied successfully.", - TechnicalMessage = $"Affinity 0x{requestedMask:X} applied and verified as 0x{verifiedMask:X}.", - }; - - public static AffinityApplyResult SucceededWithCpuSets(string technicalMessage) => - new() - { - Success = true, - FailureReason = AffinityApplyFailureReason.None, - ErrorCode = AffinityApplyErrorCodes.None, - UserMessage = "Affinity applied successfully.", - TechnicalMessage = technicalMessage, - UsedCpuSets = true, - }; - - public static AffinityApplyResult SucceededWithLegacyFallback(long requestedMask, long verifiedMask) => - Succeeded(requestedMask, verifiedMask) with - { - UsedLegacyAffinity = true, - TechnicalMessage = $"CPU Sets failed; legacy affinity 0x{requestedMask:X} applied and verified as 0x{verifiedMask:X}.", - }; - - public static AffinityApplyResult Failed( - long requestedMask, - long verifiedMask, - AffinityApplyFailureReason failureReason, - string message) => - new() - { - Success = false, - RequestedMask = requestedMask, - VerifiedMask = verifiedMask, - FailureReason = failureReason, - ErrorCode = MapFailureReason(failureReason), - UserMessage = message, - TechnicalMessage = message, - IsAccessDenied = failureReason == AffinityApplyFailureReason.AccessDenied, - }; - - public static AffinityApplyResult Failed( - string errorCode, - string userMessage, - string technicalMessage, - bool isAccessDenied = false, - bool isAntiCheatLikely = false, - bool isInvalidTopology = false, - bool isLegacyFallbackBlocked = false, - long requestedMask = 0, - long verifiedMask = 0, - AffinityApplyFailureReason failureReason = AffinityApplyFailureReason.ApplyFailed) => - new() - { - Success = false, - RequestedMask = requestedMask, - VerifiedMask = verifiedMask, - FailureReason = failureReason, - ErrorCode = errorCode, - UserMessage = userMessage, - TechnicalMessage = technicalMessage, - IsAccessDenied = isAccessDenied, - IsAntiCheatLikely = isAntiCheatLikely, - IsInvalidTopology = isInvalidTopology || errorCode == AffinityApplyErrorCodes.InvalidTopology, - IsLegacyFallbackBlocked = isLegacyFallbackBlocked || errorCode == AffinityApplyErrorCodes.LegacyFallbackUnsafe, - }; - - private static string MapFailureReason(AffinityApplyFailureReason failureReason) => - failureReason switch - { - AffinityApplyFailureReason.None => AffinityApplyErrorCodes.None, - AffinityApplyFailureReason.InvalidMask => AffinityApplyErrorCodes.InvalidSelection, - AffinityApplyFailureReason.ProcessTerminated => AffinityApplyErrorCodes.ProcessExited, - AffinityApplyFailureReason.AccessDenied => AffinityApplyErrorCodes.AccessDenied, - AffinityApplyFailureReason.VerificationMismatch => AffinityApplyErrorCodes.NativeApplyFailed, - AffinityApplyFailureReason.ApplyFailed => AffinityApplyErrorCodes.NativeApplyFailed, - _ => AffinityApplyErrorCodes.UnknownError, - }; - } - - public interface IAffinityApplyService - { - Task ApplyAsync(ProcessModel process, long requestedMask); - - Task ApplyAsync(ProcessModel process, CpuSelection selection); - } - - internal sealed class CpuSelectionAffinityApplier - { - internal const string AccessDeniedUserMessage = - ProcessOperationUserMessages.AccessDenied; - - internal const string AntiCheatUserMessage = - ProcessOperationUserMessages.AntiCheatProtectedLikely; - - internal const string LegacyFallbackBlockedUserMessage = - ProcessOperationUserMessages.LegacyFallbackBlocked; - - internal const string InvalidSelectionUserMessage = - ProcessOperationUserMessages.InvalidTopology; - - private readonly Func cpuSetHandlerFactory; - private readonly Func> legacyAffinityApplier; - private readonly ILogger logger; - private readonly Action? cpuSetFailureCallback; - private readonly Action? auditCallback; - - public CpuSelectionAffinityApplier( - Func cpuSetHandlerFactory, - Func> legacyAffinityApplier, - ILogger logger, - Action? cpuSetFailureCallback = null, - Action? auditCallback = null) - { - this.cpuSetHandlerFactory = cpuSetHandlerFactory ?? throw new ArgumentNullException(nameof(cpuSetHandlerFactory)); - this.legacyAffinityApplier = legacyAffinityApplier ?? throw new ArgumentNullException(nameof(legacyAffinityApplier)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.cpuSetFailureCallback = cpuSetFailureCallback; - this.auditCallback = auditCallback; - } - - public async Task ApplyAsync(ProcessModel process, CpuSelection selection) - { - if (process == null || process.ProcessId <= 0) - { - return ProcessExited("Process is no longer running.", process); - } - - if (selection == null || (selection.CpuSetIds.Count == 0 && selection.LogicalProcessors.Count == 0)) - { - this.Audit(process, success: false); - return AffinityApplyResult.Failed( - AffinityApplyErrorCodes.InvalidSelection, - InvalidSelectionUserMessage, - "CpuSelection contains neither CPU Set IDs nor logical processors.", - isInvalidTopology: true, - failureReason: AffinityApplyFailureReason.InvalidMask); - } - - var cpuSetsResult = this.TryApplyCpuSets(process, selection); - if (cpuSetsResult != null) - { - return cpuSetsResult; - } - - this.cpuSetFailureCallback?.Invoke(process); - - var legacyMask = CpuSelection.ToLegacyAffinityMaskOrNull(selection); - if (!legacyMask.HasValue || legacyMask.Value <= 0) - { - this.Audit(process, success: false); - return AffinityApplyResult.Failed( - AffinityApplyErrorCodes.LegacyFallbackUnsafe, - LegacyFallbackBlockedUserMessage, - "CpuSelection cannot be represented as a non-zero single-group legacy affinity mask.", - isLegacyFallbackBlocked: true); - } - - try - { - var verifiedMask = await this.legacyAffinityApplier(process, legacyMask.Value).ConfigureAwait(false); - return AffinityApplyResult.SucceededWithLegacyFallback(legacyMask.Value, verifiedMask); - } - catch (Exception ex) when (AffinityApplyExceptionClassifier.IsAccessDenied(ex)) - { - return AccessDenied(ex, legacyMask.Value, process.ProcessorAffinity); - } - catch (Exception ex) when (AffinityApplyExceptionClassifier.IsProcessExited(ex)) - { - return ProcessExited("Process exited before legacy affinity fallback could be applied.", process, legacyMask.Value); - } - catch (Exception ex) - { - this.logger.LogWarning( - ex, - "Legacy affinity fallback failed for process {ProcessName} (PID: {ProcessId})", - process.Name, - process.ProcessId); - - return AffinityApplyResult.Failed( - AffinityApplyErrorCodes.NativeApplyFailed, - "ThreadPilot could not apply this CPU selection.", - ex.Message, - requestedMask: legacyMask.Value, - verifiedMask: process.ProcessorAffinity); - } - } - - private AffinityApplyResult? TryApplyCpuSets(ProcessModel process, CpuSelection selection) - { - try - { - var handler = this.cpuSetHandlerFactory(process); - if (!handler.IsValid) - { - this.logger.LogDebug( - "CPU Set handler is invalid for process {ProcessName} (PID: {ProcessId})", - process.Name, - process.ProcessId); - return null; - } - - var result = handler.ApplyCpuSelectionDetailed(selection); - if (result.Success) - { - this.Audit(process, success: true); - return AffinityApplyResult.SucceededWithCpuSets( - string.IsNullOrWhiteSpace(result.TechnicalMessage) - ? $"CPU Sets applied to process {process.Name} (PID: {process.ProcessId})." - : result.TechnicalMessage); - } - - if (result.IsAccessDenied || result.ErrorCode == AffinityApplyErrorCodes.AccessDenied) - { - this.Audit(process, success: false); - return AffinityApplyResult.Failed( - result.IsAntiCheatLikely - ? AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely - : AffinityApplyErrorCodes.AccessDenied, - result.IsAntiCheatLikely ? AntiCheatUserMessage : AccessDeniedUserMessage, - result.TechnicalMessage, - isAccessDenied: true, - isAntiCheatLikely: result.IsAntiCheatLikely, - verifiedMask: process.ProcessorAffinity, - failureReason: AffinityApplyFailureReason.AccessDenied); - } - - if (result.ErrorCode == AffinityApplyErrorCodes.InvalidTopology) - { - this.Audit(process, success: false); - return AffinityApplyResult.Failed( - AffinityApplyErrorCodes.InvalidTopology, - ProcessOperationUserMessages.InvalidTopology, - result.TechnicalMessage, - isInvalidTopology: true, - verifiedMask: process.ProcessorAffinity, - failureReason: AffinityApplyFailureReason.InvalidMask); - } - - this.logger.LogDebug( - "CPU Sets unavailable for process {ProcessName} (PID: {ProcessId}): {Message}", - process.Name, - process.ProcessId, - result.TechnicalMessage); - return null; - } - catch (Exception ex) when (AffinityApplyExceptionClassifier.IsAccessDenied(ex)) - { - this.Audit(process, success: false); - return AccessDenied(ex, 0, process.ProcessorAffinity); - } - catch (Exception ex) when (AffinityApplyExceptionClassifier.IsProcessExited(ex)) - { - this.Audit(process, success: false); - return ProcessExited("Process exited before CPU Sets could be applied.", process); - } - catch (Exception ex) - { - this.logger.LogDebug( - ex, - "CPU Sets failed for process {ProcessName} (PID: {ProcessId}); evaluating legacy fallback", - process.Name, - process.ProcessId); - return null; - } - } - - private void Audit(ProcessModel process, bool success) => - this.auditCallback?.Invoke(process, success); - - private static AffinityApplyResult AccessDenied(Exception ex, long requestedMask, long verifiedMask) - { - var antiCheatLikely = AffinityApplyExceptionClassifier.IsAntiCheatLikely(ex); - return AffinityApplyResult.Failed( - antiCheatLikely - ? AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely - : AffinityApplyErrorCodes.AccessDenied, - antiCheatLikely ? AntiCheatUserMessage : AccessDeniedUserMessage, - ex.Message, - isAccessDenied: true, - isAntiCheatLikely: antiCheatLikely, - requestedMask: requestedMask, - verifiedMask: verifiedMask, - failureReason: AffinityApplyFailureReason.AccessDenied); - } - - private static AffinityApplyResult ProcessExited(string userMessage, ProcessModel? process, long requestedMask = 0) => - AffinityApplyResult.Failed( - AffinityApplyErrorCodes.ProcessExited, - ProcessOperationUserMessages.ProcessExited, - userMessage, - requestedMask: requestedMask, - verifiedMask: process?.ProcessorAffinity ?? 0, - failureReason: AffinityApplyFailureReason.ProcessTerminated); - } - - internal static class AffinityApplyExceptionClassifier - { - public static bool IsAccessDenied(Exception ex) => - ex is UnauthorizedAccessException || - ex is Win32Exception { NativeErrorCode: 5 } || - IsInnerAccessDenied(ex.InnerException) || - ContainsAny( - ex.Message, - "access denied", - "anti-cheat", - "anti cheat", - "protected", - "insufficient privileges"); - - public static bool IsAntiCheatLikely(Exception ex) => - ContainsAny(ex.Message, "anti-cheat", "anti cheat", "protected") || - (ex.InnerException != null && IsAntiCheatLikely(ex.InnerException)); - - public static bool IsProcessExited(Exception ex) - { - if (ex is ArgumentException) - { - return true; - } - - var message = ex.Message ?? string.Empty; - if (ex is InvalidOperationException && - ContainsAny(message, "exit", "exited", "terminated", "not running", "has no process associated")) - { - return true; - } - - return ex.InnerException != null && IsProcessExited(ex.InnerException); - } - - private static bool IsInnerAccessDenied(Exception? ex) => ex != null && IsAccessDenied(ex); - - private static bool ContainsAny(string? value, params string[] needles) - { - var source = value ?? string.Empty; - return needles.Any(needle => source.Contains(needle, StringComparison.OrdinalIgnoreCase)); - } - } - - 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 Task ApplyAsync(ProcessModel process, CpuSelection selection) => - process == null - ? Task.FromResult(AffinityApplyResult.Failed( - AffinityApplyErrorCodes.ProcessExited, - ProcessOperationUserMessages.ProcessExited, - "ProcessModel is null.", - failureReason: AffinityApplyFailureReason.ProcessTerminated)) - : selection == null - ? Task.FromResult(AffinityApplyResult.Failed( - AffinityApplyErrorCodes.InvalidSelection, - CpuSelectionAffinityApplier.InvalidSelectionUserMessage, - "CpuSelection is null.", - isInvalidTopology: true, - failureReason: AffinityApplyFailureReason.InvalidMask)) - : this.processService.SetProcessorAffinity(process, selection); - - 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, - ProcessOperationUserMessages.InvalidTopology); - } - - if (!this.cpuTopologyService.IsAffinityMaskValid(requestedMask)) - { - return AffinityApplyResult.Failed( - AffinityApplyErrorCodes.InvalidTopology, - ProcessOperationUserMessages.InvalidTopology, - $"Affinity mask 0x{requestedMask:X} is not valid for this CPU topology.", - isInvalidTopology: true, - requestedMask: requestedMask, - verifiedMask: startingMask, - failureReason: AffinityApplyFailureReason.InvalidMask); - } - - if (!await this.IsProcessRunningAsync(process).ConfigureAwait(false)) - { - return AffinityApplyResult.Failed( - requestedMask, - startingMask, - AffinityApplyFailureReason.ProcessTerminated, - ProcessOperationUserMessages.ProcessExited); - } - - 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 AccessDenied(ex, requestedMask, process.ProcessorAffinity); - } - 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, - ProcessOperationUserMessages.ProcessExited); - } - 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, - "ThreadPilot could not apply this affinity change."); - } - - if (!await this.TryRefreshProcessInfoAsync(process).ConfigureAwait(false)) - { - return AffinityApplyResult.Failed( - requestedMask, - process.ProcessorAffinity, - AffinityApplyFailureReason.ProcessTerminated, - ProcessOperationUserMessages.ProcessExited); - } - - 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 AffinityApplyResult AccessDenied(Exception ex, long requestedMask, long verifiedMask) - { - var antiCheatLikely = AffinityApplyExceptionClassifier.IsAntiCheatLikely(ex); - return AffinityApplyResult.Failed( - antiCheatLikely - ? AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely - : AffinityApplyErrorCodes.AccessDenied, - antiCheatLikely - ? ProcessOperationUserMessages.AntiCheatProtectedLikely - : ProcessOperationUserMessages.AccessDenied, - ex.Message, - isAccessDenied: true, - isAntiCheatLikely: antiCheatLikely, - requestedMask: requestedMask, - verifiedMask: verifiedMask, - failureReason: AffinityApplyFailureReason.AccessDenied); - } - - 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; - } - } - } -} +namespace ThreadPilot.Services +{ + using System.ComponentModel; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + using ThreadPilot.Platforms.Windows; + + public enum AffinityApplyFailureReason + { + None, + InvalidMask, + ProcessTerminated, + AccessDenied, + VerificationMismatch, + ApplyFailed, + } + + public static class AffinityApplyErrorCodes + { + public const string None = "None"; + public const string AccessDenied = "AccessDenied"; + public const string AntiCheatOrProtectedProcessLikely = "AntiCheatOrProtectedProcessLikely"; + public const string ProcessExited = "ProcessExited"; + public const string InvalidSelection = "InvalidSelection"; + public const string InvalidTopology = "InvalidTopology"; + public const string CpuSetsUnavailable = "CpuSetsUnavailable"; + public const string LegacyFallbackUnsafe = "LegacyFallbackUnsafe"; + public const string NativeApplyFailed = "NativeApplyFailed"; + public const string UnknownError = "UnknownError"; + } + + public sealed record AffinityApplyResult + { + public bool Success { get; init; } + + public long RequestedMask { get; init; } + + public long VerifiedMask { get; init; } + + public AffinityApplyFailureReason FailureReason { get; init; } + + public string Message => string.IsNullOrWhiteSpace(this.UserMessage) ? this.TechnicalMessage : this.UserMessage; + + public string ErrorCode { get; init; } = AffinityApplyErrorCodes.None; + + public string UserMessage { get; init; } = string.Empty; + + public string TechnicalMessage { get; init; } = string.Empty; + + public bool IsAccessDenied { get; init; } + + public bool IsAntiCheatLikely { get; init; } + + public bool IsInvalidTopology { get; init; } + + public bool IsLegacyFallbackBlocked { get; init; } + + public bool UsedCpuSets { get; init; } + + public bool UsedLegacyAffinity { get; init; } + + public static AffinityApplyResult Succeeded(long requestedMask, long verifiedMask) => + new() + { + Success = true, + RequestedMask = requestedMask, + VerifiedMask = verifiedMask, + FailureReason = AffinityApplyFailureReason.None, + ErrorCode = AffinityApplyErrorCodes.None, + UserMessage = "Affinity applied successfully.", + TechnicalMessage = $"Affinity 0x{requestedMask:X} applied and verified as 0x{verifiedMask:X}.", + }; + + public static AffinityApplyResult SucceededWithCpuSets(string technicalMessage) => + new() + { + Success = true, + FailureReason = AffinityApplyFailureReason.None, + ErrorCode = AffinityApplyErrorCodes.None, + UserMessage = "Affinity applied successfully.", + TechnicalMessage = technicalMessage, + UsedCpuSets = true, + }; + + public static AffinityApplyResult SucceededWithLegacyFallback(long requestedMask, long verifiedMask) => + Succeeded(requestedMask, verifiedMask) with + { + UsedLegacyAffinity = true, + TechnicalMessage = $"CPU Sets failed; legacy affinity 0x{requestedMask:X} applied and verified as 0x{verifiedMask:X}.", + }; + + public static AffinityApplyResult Failed( + long requestedMask, + long verifiedMask, + AffinityApplyFailureReason failureReason, + string message) => + new() + { + Success = false, + RequestedMask = requestedMask, + VerifiedMask = verifiedMask, + FailureReason = failureReason, + ErrorCode = MapFailureReason(failureReason), + UserMessage = message, + TechnicalMessage = message, + IsAccessDenied = failureReason == AffinityApplyFailureReason.AccessDenied, + }; + + public static AffinityApplyResult Failed( + string errorCode, + string userMessage, + string technicalMessage, + bool isAccessDenied = false, + bool isAntiCheatLikely = false, + bool isInvalidTopology = false, + bool isLegacyFallbackBlocked = false, + long requestedMask = 0, + long verifiedMask = 0, + AffinityApplyFailureReason failureReason = AffinityApplyFailureReason.ApplyFailed) => + new() + { + Success = false, + RequestedMask = requestedMask, + VerifiedMask = verifiedMask, + FailureReason = failureReason, + ErrorCode = errorCode, + UserMessage = userMessage, + TechnicalMessage = technicalMessage, + IsAccessDenied = isAccessDenied, + IsAntiCheatLikely = isAntiCheatLikely, + IsInvalidTopology = isInvalidTopology || errorCode == AffinityApplyErrorCodes.InvalidTopology, + IsLegacyFallbackBlocked = isLegacyFallbackBlocked || errorCode == AffinityApplyErrorCodes.LegacyFallbackUnsafe, + }; + + private static string MapFailureReason(AffinityApplyFailureReason failureReason) => + failureReason switch + { + AffinityApplyFailureReason.None => AffinityApplyErrorCodes.None, + AffinityApplyFailureReason.InvalidMask => AffinityApplyErrorCodes.InvalidSelection, + AffinityApplyFailureReason.ProcessTerminated => AffinityApplyErrorCodes.ProcessExited, + AffinityApplyFailureReason.AccessDenied => AffinityApplyErrorCodes.AccessDenied, + AffinityApplyFailureReason.VerificationMismatch => AffinityApplyErrorCodes.NativeApplyFailed, + AffinityApplyFailureReason.ApplyFailed => AffinityApplyErrorCodes.NativeApplyFailed, + _ => AffinityApplyErrorCodes.UnknownError, + }; + } + + public interface IAffinityApplyService + { + Task ApplyAsync(ProcessModel process, long requestedMask); + + Task ApplyAsync(ProcessModel process, CpuSelection selection); + } + + internal sealed class CpuSelectionAffinityApplier + { + internal const string AccessDeniedUserMessage = + ProcessOperationUserMessages.AccessDenied; + + internal const string AntiCheatUserMessage = + ProcessOperationUserMessages.AntiCheatProtectedLikely; + + internal const string LegacyFallbackBlockedUserMessage = + ProcessOperationUserMessages.LegacyFallbackBlocked; + + internal const string InvalidSelectionUserMessage = + ProcessOperationUserMessages.InvalidTopology; + + private readonly Func cpuSetHandlerFactory; + private readonly Func> legacyAffinityApplier; + private readonly ILogger logger; + private readonly Action? cpuSetFailureCallback; + private readonly Action? auditCallback; + + public CpuSelectionAffinityApplier( + Func cpuSetHandlerFactory, + Func> legacyAffinityApplier, + ILogger logger, + Action? cpuSetFailureCallback = null, + Action? auditCallback = null) + { + this.cpuSetHandlerFactory = cpuSetHandlerFactory ?? throw new ArgumentNullException(nameof(cpuSetHandlerFactory)); + this.legacyAffinityApplier = legacyAffinityApplier ?? throw new ArgumentNullException(nameof(legacyAffinityApplier)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.cpuSetFailureCallback = cpuSetFailureCallback; + this.auditCallback = auditCallback; + } + + public async Task ApplyAsync(ProcessModel process, CpuSelection selection) + { + if (process == null || process.ProcessId <= 0) + { + return ProcessExited("Process is no longer running.", process); + } + + if (selection == null || (selection.CpuSetIds.Count == 0 && selection.LogicalProcessors.Count == 0)) + { + this.Audit(process, success: false); + return AffinityApplyResult.Failed( + AffinityApplyErrorCodes.InvalidSelection, + InvalidSelectionUserMessage, + "CpuSelection contains neither CPU Set IDs nor logical processors.", + isInvalidTopology: true, + failureReason: AffinityApplyFailureReason.InvalidMask); + } + + var cpuSetsResult = this.TryApplyCpuSets(process, selection); + if (cpuSetsResult != null) + { + return cpuSetsResult; + } + + this.cpuSetFailureCallback?.Invoke(process); + + var legacyMask = CpuSelection.ToLegacyAffinityMaskOrNull(selection); + if (!legacyMask.HasValue || legacyMask.Value <= 0) + { + this.Audit(process, success: false); + return AffinityApplyResult.Failed( + AffinityApplyErrorCodes.LegacyFallbackUnsafe, + LegacyFallbackBlockedUserMessage, + "CpuSelection cannot be represented as a non-zero single-group legacy affinity mask.", + isLegacyFallbackBlocked: true); + } + + try + { + var verifiedMask = await this.legacyAffinityApplier(process, legacyMask.Value).ConfigureAwait(false); + return AffinityApplyResult.SucceededWithLegacyFallback(legacyMask.Value, verifiedMask); + } + catch (Exception ex) when (AffinityApplyExceptionClassifier.IsAccessDenied(ex)) + { + return AccessDenied(ex, legacyMask.Value, process.ProcessorAffinity); + } + catch (Exception ex) when (AffinityApplyExceptionClassifier.IsProcessExited(ex)) + { + return ProcessExited("Process exited before legacy affinity fallback could be applied.", process, legacyMask.Value); + } + catch (Exception ex) + { + this.logger.LogWarning( + ex, + "Legacy affinity fallback failed for process {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + + return AffinityApplyResult.Failed( + AffinityApplyErrorCodes.NativeApplyFailed, + "ThreadPilot could not apply this CPU selection.", + ex.Message, + requestedMask: legacyMask.Value, + verifiedMask: process.ProcessorAffinity); + } + } + + private AffinityApplyResult? TryApplyCpuSets(ProcessModel process, CpuSelection selection) + { + try + { + var handler = this.cpuSetHandlerFactory(process); + if (!handler.IsValid) + { + this.logger.LogDebug( + "CPU Set handler is invalid for process {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + return null; + } + + var result = handler.ApplyCpuSelectionDetailed(selection); + if (result.Success) + { + this.Audit(process, success: true); + return AffinityApplyResult.SucceededWithCpuSets( + string.IsNullOrWhiteSpace(result.TechnicalMessage) + ? $"CPU Sets applied to process {process.Name} (PID: {process.ProcessId})." + : result.TechnicalMessage); + } + + if (result.IsAccessDenied || result.ErrorCode == AffinityApplyErrorCodes.AccessDenied) + { + this.Audit(process, success: false); + return AffinityApplyResult.Failed( + result.IsAntiCheatLikely + ? AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely + : AffinityApplyErrorCodes.AccessDenied, + result.IsAntiCheatLikely ? AntiCheatUserMessage : AccessDeniedUserMessage, + result.TechnicalMessage, + isAccessDenied: true, + isAntiCheatLikely: result.IsAntiCheatLikely, + verifiedMask: process.ProcessorAffinity, + failureReason: AffinityApplyFailureReason.AccessDenied); + } + + if (result.ErrorCode == AffinityApplyErrorCodes.InvalidTopology) + { + this.Audit(process, success: false); + return AffinityApplyResult.Failed( + AffinityApplyErrorCodes.InvalidTopology, + ProcessOperationUserMessages.InvalidTopology, + result.TechnicalMessage, + isInvalidTopology: true, + verifiedMask: process.ProcessorAffinity, + failureReason: AffinityApplyFailureReason.InvalidMask); + } + + this.logger.LogDebug( + "CPU Sets unavailable for process {ProcessName} (PID: {ProcessId}): {Message}", + process.Name, + process.ProcessId, + result.TechnicalMessage); + return null; + } + catch (Exception ex) when (AffinityApplyExceptionClassifier.IsAccessDenied(ex)) + { + this.Audit(process, success: false); + return AccessDenied(ex, 0, process.ProcessorAffinity); + } + catch (Exception ex) when (AffinityApplyExceptionClassifier.IsProcessExited(ex)) + { + this.Audit(process, success: false); + return ProcessExited("Process exited before CPU Sets could be applied.", process); + } + catch (Exception ex) + { + this.logger.LogDebug( + ex, + "CPU Sets failed for process {ProcessName} (PID: {ProcessId}); evaluating legacy fallback", + process.Name, + process.ProcessId); + return null; + } + } + + private void Audit(ProcessModel process, bool success) => + this.auditCallback?.Invoke(process, success); + + private static AffinityApplyResult AccessDenied(Exception ex, long requestedMask, long verifiedMask) + { + var antiCheatLikely = AffinityApplyExceptionClassifier.IsAntiCheatLikely(ex); + return AffinityApplyResult.Failed( + antiCheatLikely + ? AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely + : AffinityApplyErrorCodes.AccessDenied, + antiCheatLikely ? AntiCheatUserMessage : AccessDeniedUserMessage, + ex.Message, + isAccessDenied: true, + isAntiCheatLikely: antiCheatLikely, + requestedMask: requestedMask, + verifiedMask: verifiedMask, + failureReason: AffinityApplyFailureReason.AccessDenied); + } + + private static AffinityApplyResult ProcessExited(string userMessage, ProcessModel? process, long requestedMask = 0) => + AffinityApplyResult.Failed( + AffinityApplyErrorCodes.ProcessExited, + ProcessOperationUserMessages.ProcessExited, + userMessage, + requestedMask: requestedMask, + verifiedMask: process?.ProcessorAffinity ?? 0, + failureReason: AffinityApplyFailureReason.ProcessTerminated); + } + + internal static class AffinityApplyExceptionClassifier + { + public static bool IsAccessDenied(Exception ex) => + ex is UnauthorizedAccessException || + ex is Win32Exception { NativeErrorCode: 5 } || + IsInnerAccessDenied(ex.InnerException) || + ContainsAny( + ex.Message, + "access denied", + "anti-cheat", + "anti cheat", + "protected", + "insufficient privileges"); + + public static bool IsAntiCheatLikely(Exception ex) => + ContainsAny(ex.Message, "anti-cheat", "anti cheat", "protected") || + (ex.InnerException != null && IsAntiCheatLikely(ex.InnerException)); + + public static bool IsProcessExited(Exception ex) + { + if (ex is ArgumentException) + { + return true; + } + + var message = ex.Message ?? string.Empty; + if (ex is InvalidOperationException && + ContainsAny(message, "exit", "exited", "terminated", "not running", "has no process associated")) + { + return true; + } + + return ex.InnerException != null && IsProcessExited(ex.InnerException); + } + + private static bool IsInnerAccessDenied(Exception? ex) => ex != null && IsAccessDenied(ex); + + private static bool ContainsAny(string? value, params string[] needles) + { + var source = value ?? string.Empty; + return needles.Any(needle => source.Contains(needle, StringComparison.OrdinalIgnoreCase)); + } + } + + 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 Task ApplyAsync(ProcessModel process, CpuSelection selection) => + process == null + ? Task.FromResult(AffinityApplyResult.Failed( + AffinityApplyErrorCodes.ProcessExited, + ProcessOperationUserMessages.ProcessExited, + "ProcessModel is null.", + failureReason: AffinityApplyFailureReason.ProcessTerminated)) + : selection == null + ? Task.FromResult(AffinityApplyResult.Failed( + AffinityApplyErrorCodes.InvalidSelection, + CpuSelectionAffinityApplier.InvalidSelectionUserMessage, + "CpuSelection is null.", + isInvalidTopology: true, + failureReason: AffinityApplyFailureReason.InvalidMask)) + : this.processService.SetProcessorAffinity(process, selection); + + 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, + ProcessOperationUserMessages.InvalidTopology); + } + + if (!this.cpuTopologyService.IsAffinityMaskValid(requestedMask)) + { + return AffinityApplyResult.Failed( + AffinityApplyErrorCodes.InvalidTopology, + ProcessOperationUserMessages.InvalidTopology, + $"Affinity mask 0x{requestedMask:X} is not valid for this CPU topology.", + isInvalidTopology: true, + requestedMask: requestedMask, + verifiedMask: startingMask, + failureReason: AffinityApplyFailureReason.InvalidMask); + } + + if (!await this.IsProcessRunningAsync(process).ConfigureAwait(false)) + { + return AffinityApplyResult.Failed( + requestedMask, + startingMask, + AffinityApplyFailureReason.ProcessTerminated, + ProcessOperationUserMessages.ProcessExited); + } + + 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 AccessDenied(ex, requestedMask, process.ProcessorAffinity); + } + 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, + ProcessOperationUserMessages.ProcessExited); + } + 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, + "ThreadPilot could not apply this affinity change."); + } + + if (!await this.TryRefreshProcessInfoAsync(process).ConfigureAwait(false)) + { + return AffinityApplyResult.Failed( + requestedMask, + process.ProcessorAffinity, + AffinityApplyFailureReason.ProcessTerminated, + ProcessOperationUserMessages.ProcessExited); + } + + 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 AffinityApplyResult AccessDenied(Exception ex, long requestedMask, long verifiedMask) + { + var antiCheatLikely = AffinityApplyExceptionClassifier.IsAntiCheatLikely(ex); + return AffinityApplyResult.Failed( + antiCheatLikely + ? AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely + : AffinityApplyErrorCodes.AccessDenied, + antiCheatLikely + ? ProcessOperationUserMessages.AntiCheatProtectedLikely + : ProcessOperationUserMessages.AccessDenied, + ex.Message, + isAccessDenied: true, + isAntiCheatLikely: antiCheatLikely, + requestedMask: requestedMask, + verifiedMask: verifiedMask, + failureReason: AffinityApplyFailureReason.AccessDenied); + } + + 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/AppNavigationOptions.cs b/Services/AppNavigationOptions.cs index 3773e0a..466b405 100644 --- a/Services/AppNavigationOptions.cs +++ b/Services/AppNavigationOptions.cs @@ -1,26 +1,7 @@ -/* - * 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 -{ - /// - /// Compile-time navigation switches for optional surfaces. - /// - public static class AppNavigationOptions - { - public static bool ShowAdvancedDiagnostics => false; - } -} +namespace ThreadPilot.Services +{ + public static class AppNavigationOptions + { + public static bool ShowAdvancedDiagnostics => false; + } +} diff --git a/Services/AppRefreshPolicy.cs b/Services/AppRefreshPolicy.cs index e9f8455..91fdb85 100644 --- a/Services/AppRefreshPolicy.cs +++ b/Services/AppRefreshPolicy.cs @@ -1,94 +1,69 @@ -/* - * 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, - ForegroundDiagnosticsView, - 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 bool ShouldApplyTransition(AppActivityState? previousState, AppActivityState nextState) - { - return previousState != nextState; - } - - public static AppRefreshDecision Evaluate(AppActivityState state) - { - return state switch - { - AppActivityState.ForegroundProcessView => new AppRefreshDecision( - ProcessUiRefreshEnabled: true, - ImmediateProcessRefresh: true, - VirtualizedPreloadEnabled: true, - PerformanceUiMonitoringEnabled: false, - PowerPlanUiRefreshEnabled: true, - BackgroundAutomationEnabled: true), - AppActivityState.ForegroundDiagnosticsView => new AppRefreshDecision( - ProcessUiRefreshEnabled: false, - ImmediateProcessRefresh: false, - VirtualizedPreloadEnabled: false, - PerformanceUiMonitoringEnabled: true, - PowerPlanUiRefreshEnabled: true, - BackgroundAutomationEnabled: true), - AppActivityState.ForegroundOtherTab => new AppRefreshDecision( - ProcessUiRefreshEnabled: false, - ImmediateProcessRefresh: false, - VirtualizedPreloadEnabled: false, - PerformanceUiMonitoringEnabled: false, - 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), - }; - } - } -} +namespace ThreadPilot.Services +{ + public enum AppActivityState + { + ForegroundProcessView, + ForegroundDiagnosticsView, + ForegroundOtherTab, + Minimized, + TrayHidden, + } + + public sealed record AppRefreshDecision( + bool ProcessUiRefreshEnabled, + bool ImmediateProcessRefresh, + bool VirtualizedPreloadEnabled, + bool PerformanceUiMonitoringEnabled, + bool PowerPlanUiRefreshEnabled, + bool BackgroundAutomationEnabled); + + public static class AppRefreshPolicy + { + public static bool ShouldApplyTransition(AppActivityState? previousState, AppActivityState nextState) + { + return previousState != nextState; + } + + public static AppRefreshDecision Evaluate(AppActivityState state) + { + return state switch + { + AppActivityState.ForegroundProcessView => new AppRefreshDecision( + ProcessUiRefreshEnabled: true, + ImmediateProcessRefresh: true, + VirtualizedPreloadEnabled: true, + PerformanceUiMonitoringEnabled: false, + PowerPlanUiRefreshEnabled: true, + BackgroundAutomationEnabled: true), + AppActivityState.ForegroundDiagnosticsView => new AppRefreshDecision( + ProcessUiRefreshEnabled: false, + ImmediateProcessRefresh: false, + VirtualizedPreloadEnabled: false, + PerformanceUiMonitoringEnabled: true, + PowerPlanUiRefreshEnabled: true, + BackgroundAutomationEnabled: true), + AppActivityState.ForegroundOtherTab => new AppRefreshDecision( + ProcessUiRefreshEnabled: false, + ImmediateProcessRefresh: false, + VirtualizedPreloadEnabled: false, + PerformanceUiMonitoringEnabled: false, + 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/ApplicationSettingsService.cs b/Services/ApplicationSettingsService.cs index d7ae681..d952705 100644 --- a/Services/ApplicationSettingsService.cs +++ b/Services/ApplicationSettingsService.cs @@ -1,19 +1,3 @@ -/* - * 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; @@ -25,9 +9,6 @@ namespace ThreadPilot.Services using ThreadPilot.Models; using ThreadPilot.Services.Abstractions; - /// - /// Service for managing application settings with JSON persistence. - /// public class ApplicationSettingsService : IApplicationSettingsService { private readonly ILogger logger; diff --git a/Services/ApplicationVersionProvider.cs b/Services/ApplicationVersionProvider.cs index b62b44a..89e6c35 100644 --- a/Services/ApplicationVersionProvider.cs +++ b/Services/ApplicationVersionProvider.cs @@ -1,36 +1,36 @@ -/* - * ThreadPilot - application version provider for update checks. - */ -namespace ThreadPilot.Services -{ - using System; - using System.Linq; - using System.Reflection; - - public sealed class ApplicationVersionProvider : IApplicationVersionProvider - { - public SemanticVersion CurrentVersion - { - get - { - var rawVersion = GetRawVersion(); - return SemanticVersion.TryParse(rawVersion, out var version) - ? version - : new SemanticVersion(0, 0, 0); - } - } - - public string DisplayVersion => $"v{this.CurrentVersion}"; - - private static string GetRawVersion() - { - return typeof(App).Assembly - .GetCustomAttributes(typeof(AssemblyInformationalVersionAttribute), false) - .OfType() - .FirstOrDefault()? - .InformationalVersion - ?? typeof(App).Assembly.GetName().Version?.ToString() - ?? "0.0.0"; - } - } -} +/* + * ThreadPilot - application version provider for update checks. + */ +namespace ThreadPilot.Services +{ + using System; + using System.Linq; + using System.Reflection; + + public sealed class ApplicationVersionProvider : IApplicationVersionProvider + { + public SemanticVersion CurrentVersion + { + get + { + var rawVersion = GetRawVersion(); + return SemanticVersion.TryParse(rawVersion, out var version) + ? version + : new SemanticVersion(0, 0, 0); + } + } + + public string DisplayVersion => $"v{this.CurrentVersion}"; + + private static string GetRawVersion() + { + return typeof(App).Assembly + .GetCustomAttributes(typeof(AssemblyInformationalVersionAttribute), false) + .OfType() + .FirstOrDefault()? + .InformationalVersion + ?? typeof(App).Assembly.GetName().Version?.ToString() + ?? "0.0.0"; + } + } +} diff --git a/Services/AtomicFileWriter.cs b/Services/AtomicFileWriter.cs index 62ac9e0..21e4876 100644 --- a/Services/AtomicFileWriter.cs +++ b/Services/AtomicFileWriter.cs @@ -1,72 +1,56 @@ -/* - * 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.IO; - using System.Text; - using System.Threading.Tasks; - - internal static class AtomicFileWriter - { - public static async Task WriteAllTextAsync(string filePath, string content, Encoding? encoding = null) - { - if (string.IsNullOrWhiteSpace(filePath)) - { - throw new ArgumentException("File path cannot be null or empty.", nameof(filePath)); - } - - var targetDirectory = Path.GetDirectoryName(filePath); - if (string.IsNullOrEmpty(targetDirectory)) - { - throw new InvalidOperationException($"Cannot determine target directory for path '{filePath}'."); - } - - Directory.CreateDirectory(targetDirectory); - - var tempFilePath = Path.Combine(targetDirectory, $".{Path.GetFileName(filePath)}.{Guid.NewGuid():N}.tmp"); - var backupFilePath = filePath + ".bak"; - - try - { - encoding ??= new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - await File.WriteAllTextAsync(tempFilePath, content, encoding); - - if (File.Exists(filePath)) - { - File.Replace(tempFilePath, filePath, backupFilePath, ignoreMetadataErrors: true); - - if (File.Exists(backupFilePath)) - { - File.Delete(backupFilePath); - } - } - else - { - File.Move(tempFilePath, filePath); - } - } - finally - { - if (File.Exists(tempFilePath)) - { - File.Delete(tempFilePath); - } - } - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.IO; + using System.Text; + using System.Threading.Tasks; + + internal static class AtomicFileWriter + { + public static async Task WriteAllTextAsync(string filePath, string content, Encoding? encoding = null) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + throw new ArgumentException("File path cannot be null or empty.", nameof(filePath)); + } + + var targetDirectory = Path.GetDirectoryName(filePath); + if (string.IsNullOrEmpty(targetDirectory)) + { + throw new InvalidOperationException($"Cannot determine target directory for path '{filePath}'."); + } + + Directory.CreateDirectory(targetDirectory); + + var tempFilePath = Path.Combine(targetDirectory, $".{Path.GetFileName(filePath)}.{Guid.NewGuid():N}.tmp"); + var backupFilePath = filePath + ".bak"; + + try + { + encoding ??= new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + await File.WriteAllTextAsync(tempFilePath, content, encoding); + + if (File.Exists(filePath)) + { + File.Replace(tempFilePath, filePath, backupFilePath, ignoreMetadataErrors: true); + + if (File.Exists(backupFilePath)) + { + File.Delete(backupFilePath); + } + } + else + { + File.Move(tempFilePath, filePath); + } + } + finally + { + if (File.Exists(tempFilePath)) + { + File.Delete(tempFilePath); + } + } + } + } +} diff --git a/Services/AuthenticodeSignatureVerifier.cs b/Services/AuthenticodeSignatureVerifier.cs index 63b1735..29c8dac 100644 --- a/Services/AuthenticodeSignatureVerifier.cs +++ b/Services/AuthenticodeSignatureVerifier.cs @@ -1,46 +1,46 @@ -/* - * ThreadPilot - best-effort Authenticode signature detection. - */ -namespace ThreadPilot.Services -{ - using System; - using System.IO; - using System.Security.Cryptography; - using System.Security.Cryptography.X509Certificates; - - public sealed class AuthenticodeSignatureVerifier : IUpdateSignatureVerifier - { - public UpdateSignatureStatus Verify(string installerPath) - { - if (!File.Exists(installerPath)) - { - return UpdateSignatureStatus.Invalid; - } - - try - { - using var certificate = new X509Certificate2(X509Certificate.CreateFromSignedFile(installerPath)); - using var chain = new X509Chain - { - ChainPolicy = - { - RevocationMode = X509RevocationMode.Online, - RevocationFlag = X509RevocationFlag.ExcludeRoot, - }, - }; - - return chain.Build(certificate) - ? UpdateSignatureStatus.Valid - : UpdateSignatureStatus.Unknown; - } - catch (CryptographicException) - { - return UpdateSignatureStatus.Unknown; - } - catch (PlatformNotSupportedException) - { - return UpdateSignatureStatus.Unknown; - } - } - } -} +/* + * ThreadPilot - best-effort Authenticode signature detection. + */ +namespace ThreadPilot.Services +{ + using System; + using System.IO; + using System.Security.Cryptography; + using System.Security.Cryptography.X509Certificates; + + public sealed class AuthenticodeSignatureVerifier : IUpdateSignatureVerifier + { + public UpdateSignatureStatus Verify(string installerPath) + { + if (!File.Exists(installerPath)) + { + return UpdateSignatureStatus.Invalid; + } + + try + { + using var certificate = new X509Certificate2(X509Certificate.CreateFromSignedFile(installerPath)); + using var chain = new X509Chain + { + ChainPolicy = + { + RevocationMode = X509RevocationMode.Online, + RevocationFlag = X509RevocationFlag.ExcludeRoot, + }, + }; + + return chain.Build(certificate) + ? UpdateSignatureStatus.Valid + : UpdateSignatureStatus.Unknown; + } + catch (CryptographicException) + { + return UpdateSignatureStatus.Unknown; + } + catch (PlatformNotSupportedException) + { + return UpdateSignatureStatus.Unknown; + } + } + } +} diff --git a/Services/AutostartService.cs b/Services/AutostartService.cs index e591b68..a6ceb85 100644 --- a/Services/AutostartService.cs +++ b/Services/AutostartService.cs @@ -1,334 +1,315 @@ -/* - * 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.IO; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - using Microsoft.Win32; - - /// - /// Service for managing Windows autostart functionality using registry. - /// - public partial class AutostartService : IAutostartService - { - private const string REGISTRYKEYPATH = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; - private const string APPLICATIONNAME = "ThreadPilot"; - - private readonly ILogger logger; - private readonly IElevationService elevationService; - private readonly IElevatedTaskService elevatedTaskService; - private bool isAutostartEnabled; - private string? autostartPath; - - public event EventHandler? AutostartStatusChanged; - - public bool IsAutostartEnabled => this.isAutostartEnabled; - - public string? AutostartPath => this.autostartPath; - - public AutostartService(ILogger logger, IElevationService elevationService, IElevatedTaskService elevatedTaskService) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.elevationService = elevationService ?? throw new ArgumentNullException(nameof(elevationService)); - this.elevatedTaskService = elevatedTaskService ?? throw new ArgumentNullException(nameof(elevatedTaskService)); - - // Initialize current status without surfacing startup exceptions. - TaskSafety.FireAndForget(this.CheckAutostartStatusAsync(), ex => - { - LogAutostartInitializationFailed(this.logger, ex); - }); - } - - public async Task EnableAutostartAsync(bool startMinimized = true) - { - try - { - var executablePath = this.GetExecutablePath(); - if (string.IsNullOrEmpty(executablePath)) - { - LogAutostartExecutablePathMissing(this.logger); - return false; - } - - var arguments = this.GetAutostartArguments(startMinimized); - var fullCommand = $"\"{executablePath}\" {arguments}"; - - // Clean up legacy registry-based startup to keep a single elevated startup mechanism. - this.TryRemoveLegacyRegistryAutostart(); - - if (!this.elevationService.IsRunningAsAdministrator()) - { - LogAutostartRequiresElevation(this.logger); - - var elevationRequested = await this.elevationService.RequestElevationIfNeeded(); - if (!elevationRequested) - { - return false; - } - - LogAutostartDeferredToElevatedInstance(this.logger); - return false; - } - - var scheduledTaskCreated = await this.elevatedTaskService.EnsureAutostartTaskAsync(executablePath, arguments); - if (!scheduledTaskCreated) - { - LogAutostartTaskRegistrationFailed(this.logger); - return false; - } - - this.isAutostartEnabled = true; - this.autostartPath = fullCommand; - - LogAutostartEnabled(this.logger, fullCommand); - - this.AutostartStatusChanged?.Invoke(this, new AutostartStatusChangedEventArgs( - true, startMinimized, fullCommand)); - - return true; - } - catch (Exception ex) - { - LogEnableAutostartFailed(this.logger, ex); - - this.AutostartStatusChanged?.Invoke(this, new AutostartStatusChangedEventArgs( - false, startMinimized, null, ex)); - - return false; - } - } - - public async Task DisableAutostartAsync() - { - try - { - if (!this.elevationService.IsRunningAsAdministrator()) - { - LogAutostartDisableRequiresElevation(this.logger); - - var elevationRequested = await this.elevationService.RequestElevationIfNeeded(); - if (!elevationRequested) - { - return false; - } - - LogAutostartDisableDeferredToElevatedInstance(this.logger); - return false; - } - - this.TryRemoveLegacyRegistryAutostart(); - - var scheduledTaskRemoved = await this.elevatedTaskService.RemoveAutostartTaskAsync(); - if (!scheduledTaskRemoved) - { - LogAutostartTaskRemovalFailed(this.logger); - return false; - } - - LogAutostartDisabled(this.logger); - - this.isAutostartEnabled = false; - this.autostartPath = null; - - this.AutostartStatusChanged?.Invoke(this, new AutostartStatusChangedEventArgs(false)); - - return true; - } - catch (Exception ex) - { - LogDisableAutostartFailed(this.logger, ex); - - this.AutostartStatusChanged?.Invoke(this, new AutostartStatusChangedEventArgs( - false, false, null, ex)); - - return false; - } - } - - public async Task CheckAutostartStatusAsync() - { - try - { - var taskRegistered = await this.elevatedTaskService.IsAutostartTaskRegisteredAsync(); - var legacyRegistryValue = this.TryReadLegacyRegistryAutostart(); - - this.isAutostartEnabled = taskRegistered || !string.IsNullOrWhiteSpace(legacyRegistryValue); - this.autostartPath = taskRegistered - ? $"task://{this.elevatedTaskService.AutostartTaskName}" - : legacyRegistryValue; - - LogAutostartStatusChecked(this.logger, this.isAutostartEnabled, this.autostartPath); - - return this.isAutostartEnabled; - } - catch (Exception ex) - { - LogCheckAutostartStatusFailed(this.logger, ex); - this.isAutostartEnabled = false; - this.autostartPath = null; - return false; - } - } - - public async Task UpdateAutostartAsync(bool startMinimized = true) - { - if (!this.isAutostartEnabled) - { - return await this.EnableAutostartAsync(startMinimized); - } - - // Re-enable with new parameters - await this.DisableAutostartAsync(); - return await this.EnableAutostartAsync(startMinimized); - } - - public string GetAutostartArguments(bool startMinimized = true) - { - var args = new System.Collections.Generic.List(); - - if (startMinimized) - { - args.Add("--start-minimized"); - } - - // Add any other startup arguments as needed - args.Add("--autostart"); - - return string.Join(" ", args); - } - - private string? GetExecutablePath() - { - try - { - var assembly = System.Reflection.Assembly.GetExecutingAssembly(); - var location = assembly.Location; - - // Handle .NET Core/5+ scenarios where Location might be empty - if (string.IsNullOrEmpty(location)) - { - location = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; - } - - // If we're running from a .dll, try to find the .exe - if (!string.IsNullOrEmpty(location) && location.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) - { - var exePath = Path.ChangeExtension(location, ".exe"); - if (File.Exists(exePath)) - { - location = exePath; - } - } - - return location; - } - catch (Exception ex) - { - LogGetExecutablePathFailed(this.logger, ex); - return null; - } - } - - private string? TryReadLegacyRegistryAutostart() - { - try - { - using var key = Registry.CurrentUser.OpenSubKey(REGISTRYKEYPATH, false); - return key?.GetValue(APPLICATIONNAME) as string; - } - catch (Exception ex) - { - LogLegacyRegistryReadFailed(this.logger, ex); - return null; - } - } - - private void TryRemoveLegacyRegistryAutostart() - { - try - { - using var key = Registry.CurrentUser.OpenSubKey(REGISTRYKEYPATH, true); - if (key?.GetValue(APPLICATIONNAME) != null) - { - key.DeleteValue(APPLICATIONNAME, false); - LogLegacyRegistryEntryRemoved(this.logger); - } - } - catch (Exception ex) - { - LogLegacyRegistryCleanupFailed(this.logger, ex); - } - } - - [LoggerMessage(EventId = 4200, Level = LogLevel.Debug, Message = "Autostart status initialization failed")] - private static partial void LogAutostartInitializationFailed(ILogger logger, Exception ex); - - [LoggerMessage(EventId = 4201, Level = LogLevel.Error, Message = "Could not determine executable path for autostart")] - private static partial void LogAutostartExecutablePathMissing(ILogger logger); - - [LoggerMessage(EventId = 4203, Level = LogLevel.Information, Message = "Autostart enabled: {Command}")] - private static partial void LogAutostartEnabled(ILogger logger, string command); - - [LoggerMessage(EventId = 4204, Level = LogLevel.Error, Message = "Failed to enable autostart")] - private static partial void LogEnableAutostartFailed(ILogger logger, Exception ex); - - [LoggerMessage(EventId = 4206, Level = LogLevel.Information, Message = "Autostart disabled")] - private static partial void LogAutostartDisabled(ILogger logger); - - [LoggerMessage(EventId = 4207, Level = LogLevel.Error, Message = "Failed to disable autostart")] - private static partial void LogDisableAutostartFailed(ILogger logger, Exception ex); - - [LoggerMessage(EventId = 4208, Level = LogLevel.Debug, Message = "Autostart status checked: {IsEnabled}, Path: {Path}")] - private static partial void LogAutostartStatusChecked(ILogger logger, bool isEnabled, string? path); - - [LoggerMessage(EventId = 4209, Level = LogLevel.Error, Message = "Failed to check autostart status")] - private static partial void LogCheckAutostartStatusFailed(ILogger logger, Exception ex); - - [LoggerMessage(EventId = 4210, Level = LogLevel.Error, Message = "Failed to get executable path")] - private static partial void LogGetExecutablePathFailed(ILogger logger, Exception ex); - - [LoggerMessage(EventId = 4218, Level = LogLevel.Information, Message = "Autostart configuration requires elevation; requesting elevated restart")] - private static partial void LogAutostartRequiresElevation(ILogger logger); - - [LoggerMessage(EventId = 4219, Level = LogLevel.Information, Message = "Autostart enable request delegated to elevated instance")] - private static partial void LogAutostartDeferredToElevatedInstance(ILogger logger); - - [LoggerMessage(EventId = 4220, Level = LogLevel.Warning, Message = "Failed to register elevated autostart task")] - private static partial void LogAutostartTaskRegistrationFailed(ILogger logger); - - [LoggerMessage(EventId = 4221, Level = LogLevel.Information, Message = "Autostart disable requires elevation; requesting elevated restart")] - private static partial void LogAutostartDisableRequiresElevation(ILogger logger); - - [LoggerMessage(EventId = 4222, Level = LogLevel.Information, Message = "Autostart disable request delegated to elevated instance")] - private static partial void LogAutostartDisableDeferredToElevatedInstance(ILogger logger); - - [LoggerMessage(EventId = 4223, Level = LogLevel.Warning, Message = "Failed to remove elevated autostart task")] - private static partial void LogAutostartTaskRemovalFailed(ILogger logger); - - [LoggerMessage(EventId = 4224, Level = LogLevel.Debug, Message = "Legacy HKCU Run autostart value removed")] - private static partial void LogLegacyRegistryEntryRemoved(ILogger logger); - - [LoggerMessage(EventId = 4225, Level = LogLevel.Debug, Message = "Failed to read legacy HKCU Run autostart value")] - private static partial void LogLegacyRegistryReadFailed(ILogger logger, Exception ex); - - [LoggerMessage(EventId = 4226, Level = LogLevel.Debug, Message = "Failed to remove legacy HKCU Run autostart value")] - private static partial void LogLegacyRegistryCleanupFailed(ILogger logger, Exception ex); - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.IO; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using Microsoft.Win32; + + public partial class AutostartService : IAutostartService + { + private const string REGISTRYKEYPATH = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; + private const string APPLICATIONNAME = "ThreadPilot"; + + private readonly ILogger logger; + private readonly IElevationService elevationService; + private readonly IElevatedTaskService elevatedTaskService; + private bool isAutostartEnabled; + private string? autostartPath; + + public event EventHandler? AutostartStatusChanged; + + public bool IsAutostartEnabled => this.isAutostartEnabled; + + public string? AutostartPath => this.autostartPath; + + public AutostartService(ILogger logger, IElevationService elevationService, IElevatedTaskService elevatedTaskService) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.elevationService = elevationService ?? throw new ArgumentNullException(nameof(elevationService)); + this.elevatedTaskService = elevatedTaskService ?? throw new ArgumentNullException(nameof(elevatedTaskService)); + + // Initialize current status without surfacing startup exceptions. + TaskSafety.FireAndForget(this.CheckAutostartStatusAsync(), ex => + { + LogAutostartInitializationFailed(this.logger, ex); + }); + } + + public async Task EnableAutostartAsync(bool startMinimized = true) + { + try + { + var executablePath = this.GetExecutablePath(); + if (string.IsNullOrEmpty(executablePath)) + { + LogAutostartExecutablePathMissing(this.logger); + return false; + } + + var arguments = this.GetAutostartArguments(startMinimized); + var fullCommand = $"\"{executablePath}\" {arguments}"; + + // Clean up legacy registry-based startup to keep a single elevated startup mechanism. + this.TryRemoveLegacyRegistryAutostart(); + + if (!this.elevationService.IsRunningAsAdministrator()) + { + LogAutostartRequiresElevation(this.logger); + + var elevationRequested = await this.elevationService.RequestElevationIfNeeded(); + if (!elevationRequested) + { + return false; + } + + LogAutostartDeferredToElevatedInstance(this.logger); + return false; + } + + var scheduledTaskCreated = await this.elevatedTaskService.EnsureAutostartTaskAsync(executablePath, arguments); + if (!scheduledTaskCreated) + { + LogAutostartTaskRegistrationFailed(this.logger); + return false; + } + + this.isAutostartEnabled = true; + this.autostartPath = fullCommand; + + LogAutostartEnabled(this.logger, fullCommand); + + this.AutostartStatusChanged?.Invoke(this, new AutostartStatusChangedEventArgs( + true, startMinimized, fullCommand)); + + return true; + } + catch (Exception ex) + { + LogEnableAutostartFailed(this.logger, ex); + + this.AutostartStatusChanged?.Invoke(this, new AutostartStatusChangedEventArgs( + false, startMinimized, null, ex)); + + return false; + } + } + + public async Task DisableAutostartAsync() + { + try + { + if (!this.elevationService.IsRunningAsAdministrator()) + { + LogAutostartDisableRequiresElevation(this.logger); + + var elevationRequested = await this.elevationService.RequestElevationIfNeeded(); + if (!elevationRequested) + { + return false; + } + + LogAutostartDisableDeferredToElevatedInstance(this.logger); + return false; + } + + this.TryRemoveLegacyRegistryAutostart(); + + var scheduledTaskRemoved = await this.elevatedTaskService.RemoveAutostartTaskAsync(); + if (!scheduledTaskRemoved) + { + LogAutostartTaskRemovalFailed(this.logger); + return false; + } + + LogAutostartDisabled(this.logger); + + this.isAutostartEnabled = false; + this.autostartPath = null; + + this.AutostartStatusChanged?.Invoke(this, new AutostartStatusChangedEventArgs(false)); + + return true; + } + catch (Exception ex) + { + LogDisableAutostartFailed(this.logger, ex); + + this.AutostartStatusChanged?.Invoke(this, new AutostartStatusChangedEventArgs( + false, false, null, ex)); + + return false; + } + } + + public async Task CheckAutostartStatusAsync() + { + try + { + var taskRegistered = await this.elevatedTaskService.IsAutostartTaskRegisteredAsync(); + var legacyRegistryValue = this.TryReadLegacyRegistryAutostart(); + + this.isAutostartEnabled = taskRegistered || !string.IsNullOrWhiteSpace(legacyRegistryValue); + this.autostartPath = taskRegistered + ? $"task://{this.elevatedTaskService.AutostartTaskName}" + : legacyRegistryValue; + + LogAutostartStatusChecked(this.logger, this.isAutostartEnabled, this.autostartPath); + + return this.isAutostartEnabled; + } + catch (Exception ex) + { + LogCheckAutostartStatusFailed(this.logger, ex); + this.isAutostartEnabled = false; + this.autostartPath = null; + return false; + } + } + + public async Task UpdateAutostartAsync(bool startMinimized = true) + { + if (!this.isAutostartEnabled) + { + return await this.EnableAutostartAsync(startMinimized); + } + + // Re-enable with new parameters + await this.DisableAutostartAsync(); + return await this.EnableAutostartAsync(startMinimized); + } + + public string GetAutostartArguments(bool startMinimized = true) + { + var args = new System.Collections.Generic.List(); + + if (startMinimized) + { + args.Add("--start-minimized"); + } + + // Add any other startup arguments as needed + args.Add("--autostart"); + + return string.Join(" ", args); + } + + private string? GetExecutablePath() + { + try + { + var assembly = System.Reflection.Assembly.GetExecutingAssembly(); + var location = assembly.Location; + + // Handle .NET Core/5+ scenarios where Location might be empty + if (string.IsNullOrEmpty(location)) + { + location = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; + } + + // If we're running from a .dll, try to find the .exe + if (!string.IsNullOrEmpty(location) && location.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + var exePath = Path.ChangeExtension(location, ".exe"); + if (File.Exists(exePath)) + { + location = exePath; + } + } + + return location; + } + catch (Exception ex) + { + LogGetExecutablePathFailed(this.logger, ex); + return null; + } + } + + private string? TryReadLegacyRegistryAutostart() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(REGISTRYKEYPATH, false); + return key?.GetValue(APPLICATIONNAME) as string; + } + catch (Exception ex) + { + LogLegacyRegistryReadFailed(this.logger, ex); + return null; + } + } + + private void TryRemoveLegacyRegistryAutostart() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(REGISTRYKEYPATH, true); + if (key?.GetValue(APPLICATIONNAME) != null) + { + key.DeleteValue(APPLICATIONNAME, false); + LogLegacyRegistryEntryRemoved(this.logger); + } + } + catch (Exception ex) + { + LogLegacyRegistryCleanupFailed(this.logger, ex); + } + } + + [LoggerMessage(EventId = 4200, Level = LogLevel.Debug, Message = "Autostart status initialization failed")] + private static partial void LogAutostartInitializationFailed(ILogger logger, Exception ex); + + [LoggerMessage(EventId = 4201, Level = LogLevel.Error, Message = "Could not determine executable path for autostart")] + private static partial void LogAutostartExecutablePathMissing(ILogger logger); + + [LoggerMessage(EventId = 4203, Level = LogLevel.Information, Message = "Autostart enabled: {Command}")] + private static partial void LogAutostartEnabled(ILogger logger, string command); + + [LoggerMessage(EventId = 4204, Level = LogLevel.Error, Message = "Failed to enable autostart")] + private static partial void LogEnableAutostartFailed(ILogger logger, Exception ex); + + [LoggerMessage(EventId = 4206, Level = LogLevel.Information, Message = "Autostart disabled")] + private static partial void LogAutostartDisabled(ILogger logger); + + [LoggerMessage(EventId = 4207, Level = LogLevel.Error, Message = "Failed to disable autostart")] + private static partial void LogDisableAutostartFailed(ILogger logger, Exception ex); + + [LoggerMessage(EventId = 4208, Level = LogLevel.Debug, Message = "Autostart status checked: {IsEnabled}, Path: {Path}")] + private static partial void LogAutostartStatusChecked(ILogger logger, bool isEnabled, string? path); + + [LoggerMessage(EventId = 4209, Level = LogLevel.Error, Message = "Failed to check autostart status")] + private static partial void LogCheckAutostartStatusFailed(ILogger logger, Exception ex); + + [LoggerMessage(EventId = 4210, Level = LogLevel.Error, Message = "Failed to get executable path")] + private static partial void LogGetExecutablePathFailed(ILogger logger, Exception ex); + + [LoggerMessage(EventId = 4218, Level = LogLevel.Information, Message = "Autostart configuration requires elevation; requesting elevated restart")] + private static partial void LogAutostartRequiresElevation(ILogger logger); + + [LoggerMessage(EventId = 4219, Level = LogLevel.Information, Message = "Autostart enable request delegated to elevated instance")] + private static partial void LogAutostartDeferredToElevatedInstance(ILogger logger); + + [LoggerMessage(EventId = 4220, Level = LogLevel.Warning, Message = "Failed to register elevated autostart task")] + private static partial void LogAutostartTaskRegistrationFailed(ILogger logger); + + [LoggerMessage(EventId = 4221, Level = LogLevel.Information, Message = "Autostart disable requires elevation; requesting elevated restart")] + private static partial void LogAutostartDisableRequiresElevation(ILogger logger); + + [LoggerMessage(EventId = 4222, Level = LogLevel.Information, Message = "Autostart disable request delegated to elevated instance")] + private static partial void LogAutostartDisableDeferredToElevatedInstance(ILogger logger); + + [LoggerMessage(EventId = 4223, Level = LogLevel.Warning, Message = "Failed to remove elevated autostart task")] + private static partial void LogAutostartTaskRemovalFailed(ILogger logger); + + [LoggerMessage(EventId = 4224, Level = LogLevel.Debug, Message = "Legacy HKCU Run autostart value removed")] + private static partial void LogLegacyRegistryEntryRemoved(ILogger logger); + + [LoggerMessage(EventId = 4225, Level = LogLevel.Debug, Message = "Failed to read legacy HKCU Run autostart value")] + private static partial void LogLegacyRegistryReadFailed(ILogger logger, Exception ex); + + [LoggerMessage(EventId = 4226, Level = LogLevel.Debug, Message = "Failed to remove legacy HKCU Run autostart value")] + private static partial void LogLegacyRegistryCleanupFailed(ILogger logger, Exception ex); + } +} + diff --git a/Services/ConditionalProfileService.cs b/Services/ConditionalProfileService.cs index 03ed77a..677ed3d 100644 --- a/Services/ConditionalProfileService.cs +++ b/Services/ConditionalProfileService.cs @@ -1,597 +1,570 @@ -/* - * 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.Diagnostics; - using System.Linq; - using System.Text.Json; - using System.Threading; - using System.Threading.Tasks; - using System.Windows.Forms; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - - /// - /// Implementation of conditional process profile service. - /// - public class ConditionalProfileService : IConditionalProfileService, IDisposable - { - private readonly ILogger logger; - private readonly IProcessService processService; - private readonly IRetryPolicyService retryPolicy; - private readonly List profiles = new(); - private readonly System.Threading.Timer monitoringTimer; - private readonly SemaphoreSlim profileLock = new(1, 1); - - private SystemState lastSystemState = new(); - private bool isMonitoring; - private bool disposed; - - public bool IsMonitoring => this.isMonitoring; - - public event EventHandler? ProfileApplied; - - public event EventHandler? ProfileConflictResolved; - - public event EventHandler? SystemStateChanged; - - public ConditionalProfileService( - ILogger logger, - IProcessService processService, - IRetryPolicyService retryPolicy) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); - this.retryPolicy = retryPolicy ?? throw new ArgumentNullException(nameof(retryPolicy)); - - // Set up monitoring timer (check every 10 seconds) - this.monitoringTimer = new System.Threading.Timer(this.MonitoringCallback, null, Timeout.Infinite, Timeout.Infinite); - } - - public async Task InitializeAsync() - { - this.logger.LogInformation("Initializing ConditionalProfileService"); - - // Load initial system state - this.lastSystemState = await this.GetSystemStateAsync().ConfigureAwait(false); - - // Create some default profiles for demonstration - await this.CreateDefaultProfilesAsync().ConfigureAwait(false); - } - - public async Task AddProfileAsync(ConditionalProcessProfile profile) - { - await this.profileLock.WaitAsync().ConfigureAwait(false); - try - { - var (isValid, errors) = await this.ValidateProfileAsync(profile).ConfigureAwait(false); - if (!isValid) - { - throw new ArgumentException($"Invalid profile: {string.Join(", ", errors)}"); - } - - this.profiles.Add(profile); - this.logger.LogInformation( - "Added conditional profile: {ProfileName} for process {ProcessName}", - profile.Name, profile.ProcessName); - } - finally - { - this.profileLock.Release(); - } - } - - public async Task RemoveProfileAsync(string profileId) - { - await this.profileLock.WaitAsync().ConfigureAwait(false); - try - { - var profile = this.profiles.FirstOrDefault(p => p.Id == profileId); - if (profile != null) - { - this.profiles.Remove(profile); - this.logger.LogInformation("Removed conditional profile: {ProfileName}", profile.Name); - } - } - finally - { - this.profileLock.Release(); - } - } - - public async Task UpdateProfileAsync(ConditionalProcessProfile profile) - { - await this.profileLock.WaitAsync().ConfigureAwait(false); - try - { - var existingProfile = this.profiles.FirstOrDefault(p => p.Id == profile.Id); - if (existingProfile != null) - { - var index = this.profiles.IndexOf(existingProfile); - this.profiles[index] = profile; - this.logger.LogInformation("Updated conditional profile: {ProfileName}", profile.Name); - } - } - finally - { - this.profileLock.Release(); - } - } - - public async Task> GetAllProfilesAsync() - { - await this.profileLock.WaitAsync().ConfigureAwait(false); - try - { - return this.profiles.ToList(); - } - finally - { - this.profileLock.Release(); - } - } - - public async Task> GetProfilesForProcessAsync(string processName) - { - await this.profileLock.WaitAsync().ConfigureAwait(false); - try - { - return this.profiles - .Where(p => p.ProcessName.Equals(processName, StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - finally - { - this.profileLock.Release(); - } - } - - public async Task> EvaluateProfilesAsync(ProcessModel process) - { - var systemState = await this.GetSystemStateAsync().ConfigureAwait(false); - var applicableProfiles = new List(); - - await this.profileLock.WaitAsync().ConfigureAwait(false); - try - { - var processProfiles = this.profiles - .Where(p => p.ProcessName.Equals(process.Name, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - foreach (var profile in processProfiles) - { - if (profile.ShouldApply(process, systemState) && profile.CanApplyNow()) - { - applicableProfiles.Add(profile); - } - } - - // Sort by priority (higher priority first) - applicableProfiles.Sort((a, b) => b.Priority.CompareTo(a.Priority)); - } - finally - { - this.profileLock.Release(); - } - - return applicableProfiles; - } - - public async Task ApplyBestProfileAsync(ProcessModel process) - { - try - { - var applicableProfiles = await this.EvaluateProfilesAsync(process).ConfigureAwait(false); - - if (!applicableProfiles.Any()) - { - return false; - } - - ConditionalProcessProfile selectedProfile; - - if (applicableProfiles.Count == 1) - { - selectedProfile = applicableProfiles[0]; - } - else - { - // Handle conflicts - selectedProfile = this.ResolveProfileConflict(applicableProfiles, process); - - this.ProfileConflictResolved?.Invoke(this, new ProfileConflictEventArgs - { - ConflictingProfiles = applicableProfiles, - Process = process, - SelectedProfile = selectedProfile, - Resolution = "Priority-based selection", - }); - } - - // Apply the profile (simplified - would use actual process service) - var success = await this.ApplyProfileToProcessAsync(process, selectedProfile).ConfigureAwait(false); - - if (success) - { - selectedProfile.MarkAsApplied(); - - this.ProfileApplied?.Invoke(this, new ProfileApplicationEventArgs - { - Profile = selectedProfile, - Process = process, - SystemState = await this.GetSystemStateAsync().ConfigureAwait(false), - WasApplied = true, - Reason = "Conditions satisfied", - }); - } - - return success; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error applying profile for process {ProcessName}", process.Name); - return false; - } - } - - public async Task GetSystemStateAsync() - { - return await this.retryPolicy.ExecuteAsync( - async () => - { - var systemState = new SystemState - { - CurrentTime = DateTime.Now, - CpuUsage = await this.GetCpuUsageAsync().ConfigureAwait(false), - MemoryUsage = await this.GetMemoryUsageAsync().ConfigureAwait(false), - ProcessCount = await this.GetProcessCountAsync().ConfigureAwait(false), - IsOnBattery = this.GetBatteryStatus(), - BatteryLevel = this.GetBatteryLevel(), - IsUserIdle = this.GetUserIdleStatus(), - UserIdleTime = this.GetUserIdleTime(), - NetworkActivity = await this.GetNetworkActivityAsync().ConfigureAwait(false), - }; - - // Check if system state changed significantly - if (this.HasSystemStateChangedSignificantly(systemState, this.lastSystemState)) - { - this.SystemStateChanged?.Invoke(this, systemState); - this.lastSystemState = systemState; - } - - return systemState; - }, this.retryPolicy.CreateProcessOperationPolicy()).ConfigureAwait(false); - } - - public async Task StartMonitoringAsync() - { - if (this.isMonitoring) - { - return; - } - - this.logger.LogInformation("Starting conditional profile monitoring"); - this.isMonitoring = true; - - // Start monitoring timer (check every 10 seconds) - this.monitoringTimer.Change(TimeSpan.Zero, TimeSpan.FromSeconds(10)); - } - - public async Task StopMonitoringAsync() - { - if (!this.isMonitoring) - { - return; - } - - this.logger.LogInformation("Stopping conditional profile monitoring"); - this.isMonitoring = false; - - this.monitoringTimer.Change(Timeout.Infinite, Timeout.Infinite); - } - - public ConditionalProcessProfile ResolveProfileConflict(List conflictingProfiles, ProcessModel process) - { - // Simple resolution: highest priority wins - return conflictingProfiles.OrderByDescending(p => p.Priority).First(); - } - - public ConditionalProcessProfile CreateDefaultProfile(string processName) - { - return new ConditionalProcessProfile - { - Id = Guid.NewGuid().ToString(), - Name = $"Default Profile for {processName}", - ProcessName = processName, - Priority = 0, - AutoApplyDelay = TimeSpan.FromSeconds(5), - IsAutoApplyEnabled = true, - ConditionGroups = new List - { - new ConditionGroup - { - Name = "Default Conditions", - LogicalOperator = LogicalOperator.And, - Conditions = new List - { - new ProfileCondition - { - Name = "High CPU Usage", - ConditionType = ProfileConditionType.SystemLoad, - ComparisonOperator = ComparisonOperator.GreaterThan, - Value = 50.0, - Description = "Apply when system CPU usage is above 50%" - } - } - } - }, - }; - } - - public async Task<(bool IsValid, List Errors)> ValidateProfileAsync(ConditionalProcessProfile profile) - { - var errors = new List(); - - if (string.IsNullOrWhiteSpace(profile.Name)) - { - errors.Add("Profile name is required"); - } - - if (string.IsNullOrWhiteSpace(profile.ProcessName)) - { - errors.Add("Process name is required"); - } - - if (profile.AutoApplyDelay < TimeSpan.Zero) - { - errors.Add("Auto-apply delay cannot be negative"); - } - - // Validate condition groups - foreach (var group in profile.ConditionGroups) - { - if (!group.Conditions.Any() && !group.SubGroups.Any()) - { - errors.Add($"Condition group '{group.Name}' must have at least one condition or sub-group"); - } - } - - return (errors.Count == 0, errors); - } - - public async Task ExportProfilesToJsonAsync() - { - await this.profileLock.WaitAsync().ConfigureAwait(false); - try - { - return JsonSerializer.Serialize(this.profiles, new JsonSerializerOptions { WriteIndented = true }); - } - finally - { - this.profileLock.Release(); - } - } - - public async Task ImportProfilesFromJsonAsync(string json) - { - try - { - var importedProfiles = JsonSerializer.Deserialize>(json); - if (importedProfiles == null) - { - return 0; - } - - await this.profileLock.WaitAsync().ConfigureAwait(false); - try - { - var validProfiles = 0; - foreach (var profile in importedProfiles) - { - var (isValid, _) = await this.ValidateProfileAsync(profile).ConfigureAwait(false); - if (isValid) - { - this.profiles.Add(profile); - validProfiles++; - } - } - - this.logger.LogInformation( - "Imported {ValidProfiles} valid profiles out of {TotalProfiles}", - validProfiles, importedProfiles.Count); - - return validProfiles; - } - finally - { - this.profileLock.Release(); - } - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error importing profiles from JSON"); - return 0; - } - } - - private void MonitoringCallback(object? state) - { - TaskSafety.FireAndForget(this.MonitoringCallbackAsync(), ex => - { - this.logger.LogWarning(ex, "Error during profile monitoring cycle"); - }); - } - - private async Task MonitoringCallbackAsync() - { - if (!this.isMonitoring) - { - return; - } - - try - { - var processes = await this.processService.GetProcessesAsync().ConfigureAwait(false); - foreach (var process in processes) - { - await this.ApplyBestProfileAsync(process).ConfigureAwait(false); - } - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Error during profile monitoring cycle"); - } - } - - private async Task ApplyProfileToProcessAsync(ProcessModel process, ConditionalProcessProfile profile) - { - try - { - // Simplified profile application - would use actual process service methods - this.logger.LogInformation( - "Applying profile {ProfileName} to process {ProcessName}", - profile.Name, process.Name); - return true; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error applying profile {ProfileName} to process {ProcessName}", - profile.Name, process.Name); - return false; - } - } - - private async Task CreateDefaultProfilesAsync() - { - // Create some example conditional profiles - var gameProfile = new ConditionalProcessProfile - { - Id = Guid.NewGuid().ToString(), - Name = "High Performance Gaming", - ProcessName = "*", // Wildcard for any process - Priority = 10, - AutoApplyDelay = TimeSpan.FromSeconds(3), - ConditionGroups = new List - { - new ConditionGroup - { - Name = "Gaming Conditions", - LogicalOperator = LogicalOperator.And, - Conditions = new List - { - new ProfileCondition - { - Name = "High CPU Usage", - ConditionType = ProfileConditionType.SystemLoad, - ComparisonOperator = ComparisonOperator.GreaterThan, - Value = 70.0 - }, - new ProfileCondition - { - Name = "Evening Hours", - ConditionType = ProfileConditionType.TimeOfDay, - ComparisonOperator = ComparisonOperator.Between, - Value = 18.0, // 6 PM - SecondaryValue = 23.0 // 11 PM - } - } - } - }, - }; - - await this.AddProfileAsync(gameProfile).ConfigureAwait(false); - } - - private Task GetCpuUsageAsync() - { - // Simplified CPU usage calculation - return Task.FromResult(Environment.ProcessorCount * 10.0); // Placeholder - } - - private Task GetMemoryUsageAsync() - { - var totalMemory = GC.GetTotalMemory(false); - return Task.FromResult(totalMemory / (1024.0 * 1024.0)); // MB - } - - private async Task GetProcessCountAsync() - { - var processes = await this.processService.GetProcessesAsync().ConfigureAwait(false); - return processes.Count; - } - - private bool GetBatteryStatus() - { - return SystemInformation.PowerStatus.PowerLineStatus == PowerLineStatus.Offline; - } - - private int GetBatteryLevel() - { - return (int)(SystemInformation.PowerStatus.BatteryLifePercent * 100); - } - - private bool GetUserIdleStatus() - { - return this.GetUserIdleTime() > TimeSpan.FromMinutes(5); - } - - private TimeSpan GetUserIdleTime() - { - // Simplified - would use Windows API to get actual idle time - return TimeSpan.FromMinutes(1); - } - - private async Task GetNetworkActivityAsync() - { - // Simplified network activity measurement - return 0.0; // Placeholder - } - - private bool HasSystemStateChangedSignificantly(SystemState current, SystemState previous) - { - const double cpuThreshold = 10.0; - const double memoryThreshold = 100.0; // MB - - return Math.Abs(current.CpuUsage - previous.CpuUsage) > cpuThreshold || - Math.Abs(current.MemoryUsage - previous.MemoryUsage) > memoryThreshold || - current.IsOnBattery != previous.IsOnBattery; - } - - protected virtual void Dispose(bool disposing) - { - if (!this.disposed) - { - if (disposing) - { - this.monitoringTimer?.Dispose(); - this.profileLock?.Dispose(); - this.logger.LogInformation("ConditionalProfileService disposed"); - } - this.disposed = true; - } - } - - public void Dispose() - { - this.Dispose(disposing: true); - GC.SuppressFinalize(this); - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Linq; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using System.Windows.Forms; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public class ConditionalProfileService : IConditionalProfileService, IDisposable + { + private readonly ILogger logger; + private readonly IProcessService processService; + private readonly List profiles = new(); + private readonly System.Threading.Timer monitoringTimer; + private readonly SemaphoreSlim profileLock = new(1, 1); + + private SystemState lastSystemState = new(); + private bool isMonitoring; + private bool disposed; + + public bool IsMonitoring => this.isMonitoring; + + public event EventHandler? ProfileApplied; + + public event EventHandler? ProfileConflictResolved; + + public event EventHandler? SystemStateChanged; + + public ConditionalProfileService( + ILogger logger, + IProcessService processService) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); + + // Set up monitoring timer (check every 10 seconds) + this.monitoringTimer = new System.Threading.Timer(this.MonitoringCallback, null, Timeout.Infinite, Timeout.Infinite); + } + + public async Task InitializeAsync() + { + this.logger.LogInformation("Initializing ConditionalProfileService"); + + // Load initial system state + this.lastSystemState = await this.GetSystemStateAsync().ConfigureAwait(false); + + // Create some default profiles for demonstration + await this.CreateDefaultProfilesAsync().ConfigureAwait(false); + } + + public async Task AddProfileAsync(ConditionalProcessProfile profile) + { + await this.profileLock.WaitAsync().ConfigureAwait(false); + try + { + var (isValid, errors) = await this.ValidateProfileAsync(profile).ConfigureAwait(false); + if (!isValid) + { + throw new ArgumentException($"Invalid profile: {string.Join(", ", errors)}"); + } + + this.profiles.Add(profile); + this.logger.LogInformation( + "Added conditional profile: {ProfileName} for process {ProcessName}", + profile.Name, profile.ProcessName); + } + finally + { + this.profileLock.Release(); + } + } + + public async Task RemoveProfileAsync(string profileId) + { + await this.profileLock.WaitAsync().ConfigureAwait(false); + try + { + var profile = this.profiles.FirstOrDefault(p => p.Id == profileId); + if (profile != null) + { + this.profiles.Remove(profile); + this.logger.LogInformation("Removed conditional profile: {ProfileName}", profile.Name); + } + } + finally + { + this.profileLock.Release(); + } + } + + public async Task UpdateProfileAsync(ConditionalProcessProfile profile) + { + await this.profileLock.WaitAsync().ConfigureAwait(false); + try + { + var existingProfile = this.profiles.FirstOrDefault(p => p.Id == profile.Id); + if (existingProfile != null) + { + var index = this.profiles.IndexOf(existingProfile); + this.profiles[index] = profile; + this.logger.LogInformation("Updated conditional profile: {ProfileName}", profile.Name); + } + } + finally + { + this.profileLock.Release(); + } + } + + public async Task> GetAllProfilesAsync() + { + await this.profileLock.WaitAsync().ConfigureAwait(false); + try + { + return this.profiles.ToList(); + } + finally + { + this.profileLock.Release(); + } + } + + public async Task> GetProfilesForProcessAsync(string processName) + { + await this.profileLock.WaitAsync().ConfigureAwait(false); + try + { + return this.profiles + .Where(p => p.ProcessName.Equals(processName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + finally + { + this.profileLock.Release(); + } + } + + public async Task> EvaluateProfilesAsync(ProcessModel process) + { + var systemState = await this.GetSystemStateAsync().ConfigureAwait(false); + var applicableProfiles = new List(); + + await this.profileLock.WaitAsync().ConfigureAwait(false); + try + { + var processProfiles = this.profiles + .Where(p => p.ProcessName.Equals(process.Name, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var profile in processProfiles) + { + if (profile.ShouldApply(process, systemState) && profile.CanApplyNow()) + { + applicableProfiles.Add(profile); + } + } + + // Sort by priority (higher priority first) + applicableProfiles.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + } + finally + { + this.profileLock.Release(); + } + + return applicableProfiles; + } + + public async Task ApplyBestProfileAsync(ProcessModel process) + { + try + { + var applicableProfiles = await this.EvaluateProfilesAsync(process).ConfigureAwait(false); + + if (!applicableProfiles.Any()) + { + return false; + } + + ConditionalProcessProfile selectedProfile; + + if (applicableProfiles.Count == 1) + { + selectedProfile = applicableProfiles[0]; + } + else + { + // Handle conflicts + selectedProfile = this.ResolveProfileConflict(applicableProfiles, process); + + this.ProfileConflictResolved?.Invoke(this, new ProfileConflictEventArgs + { + ConflictingProfiles = applicableProfiles, + Process = process, + SelectedProfile = selectedProfile, + Resolution = "Priority-based selection", + }); + } + + // Apply the profile (simplified - would use actual process service) + var success = await this.ApplyProfileToProcessAsync(process, selectedProfile).ConfigureAwait(false); + + if (success) + { + selectedProfile.MarkAsApplied(); + + this.ProfileApplied?.Invoke(this, new ProfileApplicationEventArgs + { + Profile = selectedProfile, + Process = process, + SystemState = await this.GetSystemStateAsync().ConfigureAwait(false), + WasApplied = true, + Reason = "Conditions satisfied", + }); + } + + return success; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error applying profile for process {ProcessName}", process.Name); + return false; + } + } + + public async Task GetSystemStateAsync() + { + var systemState = new SystemState + { + CurrentTime = DateTime.Now, + CpuUsage = await this.GetCpuUsageAsync().ConfigureAwait(false), + MemoryUsage = await this.GetMemoryUsageAsync().ConfigureAwait(false), + ProcessCount = await this.GetProcessCountAsync().ConfigureAwait(false), + IsOnBattery = this.GetBatteryStatus(), + BatteryLevel = this.GetBatteryLevel(), + IsUserIdle = this.GetUserIdleStatus(), + UserIdleTime = this.GetUserIdleTime(), + NetworkActivity = await this.GetNetworkActivityAsync().ConfigureAwait(false), + }; + + if (this.HasSystemStateChangedSignificantly(systemState, this.lastSystemState)) + { + this.SystemStateChanged?.Invoke(this, systemState); + this.lastSystemState = systemState; + } + + return systemState; + } + + public async Task StartMonitoringAsync() + { + if (this.isMonitoring) + { + return; + } + + this.logger.LogInformation("Starting conditional profile monitoring"); + this.isMonitoring = true; + + // Start monitoring timer (check every 10 seconds) + this.monitoringTimer.Change(TimeSpan.Zero, TimeSpan.FromSeconds(10)); + } + + public async Task StopMonitoringAsync() + { + if (!this.isMonitoring) + { + return; + } + + this.logger.LogInformation("Stopping conditional profile monitoring"); + this.isMonitoring = false; + + this.monitoringTimer.Change(Timeout.Infinite, Timeout.Infinite); + } + + public ConditionalProcessProfile ResolveProfileConflict(List conflictingProfiles, ProcessModel process) + { + // Simple resolution: highest priority wins + return conflictingProfiles.OrderByDescending(p => p.Priority).First(); + } + + public ConditionalProcessProfile CreateDefaultProfile(string processName) + { + return new ConditionalProcessProfile + { + Id = Guid.NewGuid().ToString(), + Name = $"Default Profile for {processName}", + ProcessName = processName, + Priority = 0, + AutoApplyDelay = TimeSpan.FromSeconds(5), + IsAutoApplyEnabled = true, + ConditionGroups = new List + { + new ConditionGroup + { + Name = "Default Conditions", + LogicalOperator = LogicalOperator.And, + Conditions = new List + { + new ProfileCondition + { + Name = "High CPU Usage", + ConditionType = ProfileConditionType.SystemLoad, + ComparisonOperator = ComparisonOperator.GreaterThan, + Value = 50.0, + Description = "Apply when system CPU usage is above 50%" + } + } + } + }, + }; + } + + public async Task<(bool IsValid, List Errors)> ValidateProfileAsync(ConditionalProcessProfile profile) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(profile.Name)) + { + errors.Add("Profile name is required"); + } + + if (string.IsNullOrWhiteSpace(profile.ProcessName)) + { + errors.Add("Process name is required"); + } + + if (profile.AutoApplyDelay < TimeSpan.Zero) + { + errors.Add("Auto-apply delay cannot be negative"); + } + + // Validate condition groups + foreach (var group in profile.ConditionGroups) + { + if (!group.Conditions.Any() && !group.SubGroups.Any()) + { + errors.Add($"Condition group '{group.Name}' must have at least one condition or sub-group"); + } + } + + return (errors.Count == 0, errors); + } + + public async Task ExportProfilesToJsonAsync() + { + await this.profileLock.WaitAsync().ConfigureAwait(false); + try + { + return JsonSerializer.Serialize(this.profiles, new JsonSerializerOptions { WriteIndented = true }); + } + finally + { + this.profileLock.Release(); + } + } + + public async Task ImportProfilesFromJsonAsync(string json) + { + try + { + var importedProfiles = JsonSerializer.Deserialize>(json); + if (importedProfiles == null) + { + return 0; + } + + await this.profileLock.WaitAsync().ConfigureAwait(false); + try + { + var validProfiles = 0; + foreach (var profile in importedProfiles) + { + var (isValid, _) = await this.ValidateProfileAsync(profile).ConfigureAwait(false); + if (isValid) + { + this.profiles.Add(profile); + validProfiles++; + } + } + + this.logger.LogInformation( + "Imported {ValidProfiles} valid profiles out of {TotalProfiles}", + validProfiles, importedProfiles.Count); + + return validProfiles; + } + finally + { + this.profileLock.Release(); + } + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error importing profiles from JSON"); + return 0; + } + } + + private void MonitoringCallback(object? state) + { + TaskSafety.FireAndForget(this.MonitoringCallbackAsync(), ex => + { + this.logger.LogWarning(ex, "Error during profile monitoring cycle"); + }); + } + + private async Task MonitoringCallbackAsync() + { + if (!this.isMonitoring) + { + return; + } + + try + { + var processes = await this.processService.GetProcessesAsync().ConfigureAwait(false); + foreach (var process in processes) + { + await this.ApplyBestProfileAsync(process).ConfigureAwait(false); + } + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Error during profile monitoring cycle"); + } + } + + private async Task ApplyProfileToProcessAsync(ProcessModel process, ConditionalProcessProfile profile) + { + try + { + // Simplified profile application - would use actual process service methods + this.logger.LogInformation( + "Applying profile {ProfileName} to process {ProcessName}", + profile.Name, process.Name); + return true; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error applying profile {ProfileName} to process {ProcessName}", + profile.Name, process.Name); + return false; + } + } + + private async Task CreateDefaultProfilesAsync() + { + // Create some example conditional profiles + var gameProfile = new ConditionalProcessProfile + { + Id = Guid.NewGuid().ToString(), + Name = "High Performance Gaming", + ProcessName = "*", // Wildcard for any process + Priority = 10, + AutoApplyDelay = TimeSpan.FromSeconds(3), + ConditionGroups = new List + { + new ConditionGroup + { + Name = "Gaming Conditions", + LogicalOperator = LogicalOperator.And, + Conditions = new List + { + new ProfileCondition + { + Name = "High CPU Usage", + ConditionType = ProfileConditionType.SystemLoad, + ComparisonOperator = ComparisonOperator.GreaterThan, + Value = 70.0 + }, + new ProfileCondition + { + Name = "Evening Hours", + ConditionType = ProfileConditionType.TimeOfDay, + ComparisonOperator = ComparisonOperator.Between, + Value = 18.0, // 6 PM + SecondaryValue = 23.0 // 11 PM + } + } + } + }, + }; + + await this.AddProfileAsync(gameProfile).ConfigureAwait(false); + } + + private Task GetCpuUsageAsync() + { + // Simplified CPU usage calculation + return Task.FromResult(Environment.ProcessorCount * 10.0); // Placeholder + } + + private Task GetMemoryUsageAsync() + { + var totalMemory = GC.GetTotalMemory(false); + return Task.FromResult(totalMemory / (1024.0 * 1024.0)); // MB + } + + private async Task GetProcessCountAsync() + { + var processes = await this.processService.GetProcessesAsync().ConfigureAwait(false); + return processes.Count; + } + + private bool GetBatteryStatus() + { + return SystemInformation.PowerStatus.PowerLineStatus == PowerLineStatus.Offline; + } + + private int GetBatteryLevel() + { + return (int)(SystemInformation.PowerStatus.BatteryLifePercent * 100); + } + + private bool GetUserIdleStatus() + { + return this.GetUserIdleTime() > TimeSpan.FromMinutes(5); + } + + private TimeSpan GetUserIdleTime() + { + // Simplified - would use Windows API to get actual idle time + return TimeSpan.FromMinutes(1); + } + + private async Task GetNetworkActivityAsync() + { + // Simplified network activity measurement + return 0.0; // Placeholder + } + + private bool HasSystemStateChangedSignificantly(SystemState current, SystemState previous) + { + const double cpuThreshold = 10.0; + const double memoryThreshold = 100.0; // MB + + return Math.Abs(current.CpuUsage - previous.CpuUsage) > cpuThreshold || + Math.Abs(current.MemoryUsage - previous.MemoryUsage) > memoryThreshold || + current.IsOnBattery != previous.IsOnBattery; + } + + protected virtual void Dispose(bool disposing) + { + if (!this.disposed) + { + if (disposing) + { + this.monitoringTimer?.Dispose(); + this.profileLock?.Dispose(); + this.logger.LogInformation("ConditionalProfileService disposed"); + } + this.disposed = true; + } + } + + public void Dispose() + { + this.Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} + diff --git a/Services/Core/BaseSystemService.cs b/Services/Core/BaseSystemService.cs index 811b166..1614f8b 100644 --- a/Services/Core/BaseSystemService.cs +++ b/Services/Core/BaseSystemService.cs @@ -1,124 +1,105 @@ -/* - * 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.Core -{ - using System; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - - /// - /// Base implementation for system services with common functionality. - /// - public abstract class BaseSystemService : ISystemService, IDisposable - { - protected readonly ILogger Logger; - private bool isAvailable; - private bool disposed; - - public bool IsAvailable - { - get => this.isAvailable; - protected set - { - if (this.isAvailable != value) - { - this.isAvailable = value; - this.OnAvailabilityChanged(value); - } - } - } - - public event EventHandler? AvailabilityChanged; - - protected BaseSystemService(ILogger logger) - { - this.Logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public virtual async Task InitializeAsync() - { - try - { - this.Logger.LogInformation("Initializing {ServiceType}", this.GetType().Name); - await this.InitializeServiceAsync(); - this.IsAvailable = true; - this.Logger.LogInformation("{ServiceType} initialized successfully", this.GetType().Name); - } - catch (Exception ex) - { - this.Logger.LogError(ex, "Failed to initialize {ServiceType}", this.GetType().Name); - this.IsAvailable = false; - throw; - } - } - - public virtual async Task DisposeAsync() - { - if (this.disposed) - { - return; - } - - try - { - this.Logger.LogInformation("Disposing {ServiceType}", this.GetType().Name); - await this.DisposeServiceAsync(); - this.IsAvailable = false; - } - catch (Exception ex) - { - this.Logger.LogError(ex, "Error disposing {ServiceType}", this.GetType().Name); - } - finally - { - this.disposed = true; - } - } - - protected abstract Task InitializeServiceAsync(); - - protected abstract Task DisposeServiceAsync(); - - protected virtual void OnAvailabilityChanged(bool isAvailable, string? reason = null) - { - this.AvailabilityChanged?.Invoke(this, new ServiceAvailabilityChangedEventArgs(isAvailable, reason)); - } - - protected void ThrowIfDisposed() - { - if (this.disposed) - { - throw new ObjectDisposedException(this.GetType().Name); - } - } - - public void Dispose() - { - this.Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!this.disposed && disposing) - { - _ = Task.Run(async () => await this.DisposeAsync()); - } - } - } -} - +namespace ThreadPilot.Services.Core +{ + using System; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + + public abstract class BaseSystemService : ISystemService, IDisposable + { + protected readonly ILogger Logger; + private bool isAvailable; + private bool disposed; + + public bool IsAvailable + { + get => this.isAvailable; + protected set + { + if (this.isAvailable != value) + { + this.isAvailable = value; + this.OnAvailabilityChanged(value); + } + } + } + + public event EventHandler? AvailabilityChanged; + + protected BaseSystemService(ILogger logger) + { + this.Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public virtual async Task InitializeAsync() + { + try + { + this.Logger.LogInformation("Initializing {ServiceType}", this.GetType().Name); + await this.InitializeServiceAsync(); + this.IsAvailable = true; + this.Logger.LogInformation("{ServiceType} initialized successfully", this.GetType().Name); + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Failed to initialize {ServiceType}", this.GetType().Name); + this.IsAvailable = false; + throw; + } + } + + public virtual async Task DisposeAsync() + { + if (this.disposed) + { + return; + } + + try + { + this.Logger.LogInformation("Disposing {ServiceType}", this.GetType().Name); + await this.DisposeServiceAsync(); + this.IsAvailable = false; + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Error disposing {ServiceType}", this.GetType().Name); + } + finally + { + this.disposed = true; + } + } + + protected abstract Task InitializeServiceAsync(); + + protected abstract Task DisposeServiceAsync(); + + protected virtual void OnAvailabilityChanged(bool isAvailable, string? reason = null) + { + this.AvailabilityChanged?.Invoke(this, new ServiceAvailabilityChangedEventArgs(isAvailable, reason)); + } + + protected void ThrowIfDisposed() + { + if (this.disposed) + { + throw new ObjectDisposedException(this.GetType().Name); + } + } + + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!this.disposed && disposing) + { + _ = Task.Run(async () => await this.DisposeAsync()); + } + } + } +} + diff --git a/Services/Core/ISystemService.cs b/Services/Core/ISystemService.cs index ac7bcaa..4875784 100644 --- a/Services/Core/ISystemService.cs +++ b/Services/Core/ISystemService.cs @@ -1,63 +1,29 @@ -/* - * 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.Core -{ - using System; - - /// - /// Base interface for core system services that interact directly with the operating system. - /// - public interface ISystemService - { - /// - /// Gets a value indicating whether gets whether the service is currently available and functional. - /// - bool IsAvailable { get; } - - /// - /// Event fired when the service availability changes - /// - event EventHandler? AvailabilityChanged; - - /// - /// Initialize the service. - /// - Task InitializeAsync(); - - /// - /// Cleanup and dispose of service resources. - /// - Task DisposeAsync(); - } - - /// - /// Event args for service availability changes. - /// - public class ServiceAvailabilityChangedEventArgs : EventArgs - { - public bool IsAvailable { get; } - - public string? Reason { get; } - - public ServiceAvailabilityChangedEventArgs(bool isAvailable, string? reason = null) - { - this.IsAvailable = isAvailable; - this.Reason = reason; - } - } -} - +namespace ThreadPilot.Services.Core +{ + using System; + + public interface ISystemService + { + bool IsAvailable { get; } + + event EventHandler? AvailabilityChanged; + + Task InitializeAsync(); + + Task DisposeAsync(); + } + + public class ServiceAvailabilityChangedEventArgs : EventArgs + { + public bool IsAvailable { get; } + + public string? Reason { get; } + + public ServiceAvailabilityChangedEventArgs(bool isAvailable, string? reason = null) + { + this.IsAvailable = isAvailable; + this.Reason = reason; + } + } +} + diff --git a/Services/CoreMaskService.cs b/Services/CoreMaskService.cs index a124735..9dd8fb1 100644 --- a/Services/CoreMaskService.cs +++ b/Services/CoreMaskService.cs @@ -1,918 +1,872 @@ -/* - * 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.Diagnostics; - using System.IO; - using System.Linq; - using System.Text; - using System.Text.Json; - using System.Threading; - using System.Threading.Tasks; - using System.Windows; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - - /// - /// Service for managing CPU core affinity masks - /// Based on CPUSetSetter's AppConfig and LogicalProcessorMask system. - /// - public class CoreMaskService : ICoreMaskService - { - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - PropertyNameCaseInsensitive = true, - ReadCommentHandling = JsonCommentHandling.Skip, - AllowTrailingCommas = true, - }; - - private readonly ILogger logger; - private readonly ICpuTopologyService cpuTopologyService; - private readonly IServiceProvider serviceProvider; - private readonly ICpuTopologyProvider? cpuTopologyProvider; - private readonly CpuSelectionMigrationService cpuSelectionMigrationService; - private readonly string masksFilePath; - private bool initialized = false; - private int topologyBackfillInProgress; - - // Tracks which masks are actively applied to processes - private readonly Dictionary activeProcessMasks = new(); // ProcessId -> MaskId - - public ObservableCollection AvailableMasks { get; private set; } = new(); - - public CoreMask? DefaultMask => this.AvailableMasks.FirstOrDefault(m => m.IsDefault); - - /// - /// The "All Cores" baseline mask - cannot be deleted. - /// - private const string ALLCORESMASKNAME = "All Cores"; - private const string NOCORE0MASKNAME = "No Core 0"; - - public CoreMaskService( - ILogger logger, - ICpuTopologyService cpuTopologyService, - IServiceProvider serviceProvider, - ICpuTopologyProvider? cpuTopologyProvider = null, - CpuSelectionMigrationService? cpuSelectionMigrationService = null, - string? masksFilePath = null) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.cpuTopologyService = cpuTopologyService ?? throw new ArgumentNullException(nameof(cpuTopologyService)); - this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - this.cpuTopologyProvider = cpuTopologyProvider; - this.cpuSelectionMigrationService = cpuSelectionMigrationService ?? new CpuSelectionMigrationService(); - - if (string.IsNullOrWhiteSpace(masksFilePath)) - { - StoragePaths.EnsureAppDataDirectories(); - this.masksFilePath = StoragePaths.CoreMasksFilePath; - } - else - { - Directory.CreateDirectory(Path.GetDirectoryName(masksFilePath)!); - this.masksFilePath = masksFilePath; - } - - this.cpuTopologyService.TopologyDetected += this.OnTopologyDetected; - } - - public async Task InitializeAsync() - { - if (this.initialized) - { - return; - } - - this.logger.LogInformation("Initializing CoreMaskService..."); - - await this.LoadMasksAsync(); - - if (this.AvailableMasks.Count == 0) - { - this.logger.LogInformation("No masks found, creating defaults..."); - } - - if (await this.BackfillBuiltInDefaultMasksAsync()) - { - await this.SaveMasksAsync(); - } - - this.initialized = true; - this.logger.LogInformation("CoreMaskService initialized with {Count} masks", this.AvailableMasks.Count); - } - - private void OnTopologyDetected(object? sender, CpuTopologyDetectedEventArgs e) - { - if (!this.initialized || !e.DetectionSuccessful) - { - return; - } - - var dispatcher = Application.Current?.Dispatcher; - if (dispatcher != null) - { - _ = dispatcher.InvokeAsync(async () => await this.BackfillBuiltInDefaultMasksAndSaveAsync()); - return; - } - - _ = Task.Run(this.BackfillBuiltInDefaultMasksAndSaveAsync); - } - - private async Task BackfillBuiltInDefaultMasksAndSaveAsync() - { - if (Interlocked.Exchange(ref this.topologyBackfillInProgress, 1) != 0) - { - return; - } - - try - { - if (await this.BackfillBuiltInDefaultMasksAsync()) - { - await this.SaveMasksAsync(); - } - } - finally - { - Interlocked.Exchange(ref this.topologyBackfillInProgress, 0); - } - } - - public async Task CreateMaskAsync(string name, string description, IEnumerable boolMask) - { - var mask = new CoreMask - { - Name = name, - Description = description, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - }; - - foreach (var bit in boolMask) - { - mask.BoolMask.Add(bit); - } - - this.AvailableMasks.Add(mask); - await this.SaveMasksAsync(); - - this.logger.LogInformation( - "Created new mask '{Name}' with {Count} cores selected", - name, mask.SelectedCoreCount); - - return mask; - } - - public async Task UpdateMaskAsync(CoreMask mask) - { - if (mask == null) - { - throw new ArgumentNullException(nameof(mask)); - } - - var existing = this.GetMaskById(mask.Id); - if (existing == null) - { - this.logger.LogWarning("Cannot update mask {Id}: not found", mask.Id); - return; - } - - mask.UpdatedAt = DateTime.UtcNow; - await this.SaveMasksAsync(); - - this.logger.LogInformation("Updated mask '{Name}'", mask.Name); - } - - public async Task DeleteMaskAsync(string maskId) - { - var mask = this.GetMaskById(maskId); - if (mask == null) - { - this.logger.LogWarning("Cannot delete mask {Id}: not found", maskId); - return; - } - - // Cannot delete the "All Cores" baseline mask - if (mask.Name == ALLCORESMASKNAME) - { - this.logger.LogWarning("Cannot delete 'All Cores' baseline mask"); - throw new InvalidOperationException("Cannot delete the 'All Cores' baseline mask - it is required as the default fallback"); - } - - // Check if mask is actively applied to running processes - if (await this.IsMaskActivelyAppliedAsync(maskId)) - { - this.logger.LogWarning("Cannot delete mask '{Name}': it is actively applied to running processes", mask.Name); - throw new InvalidOperationException($"Cannot delete mask '{mask.Name}' - it is currently applied to running processes. Please change the mask on those processes first."); - } - - this.AvailableMasks.Remove(mask); - await this.SaveMasksAsync(); - - this.logger.LogInformation("Deleted mask '{Name}'", mask.Name); - } - - public CoreMask? GetMaskById(string maskId) - { - return this.AvailableMasks.FirstOrDefault(m => m.Id == maskId); - } - - public CoreMask? GetMaskByName(string name) - { - return this.AvailableMasks.FirstOrDefault(m => - m.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); - } - - public async Task SaveMasksAsync() - { - try - { - await this.ApplyCpuSelectionMigrationAsync().ConfigureAwait(false); - - var data = this.AvailableMasks.Select(m => new - { - id = m.Id, - name = m.Name, - description = m.Description, - boolMask = m.BoolMask.ToList(), - profileSchemaVersion = m.ProfileSchemaVersion, - cpuSelection = m.CpuSelection, - cpuSelectionMigration = m.CpuSelectionMigration, - isDefault = m.IsDefault, - isEnabled = m.IsEnabled, - createdAt = m.CreatedAt, - updatedAt = m.UpdatedAt, - }).ToList(); - - var json = JsonSerializer.Serialize(data, JsonOptions); - - await AtomicFileWriter.WriteAllTextAsync(this.masksFilePath, json, Encoding.UTF8); - this.logger.LogDebug("Saved {Count} masks to {Path}", this.AvailableMasks.Count, this.masksFilePath); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to save masks to {Path}", this.masksFilePath); - throw; - } - } - - public async Task LoadMasksAsync() - { - try - { - if (!File.Exists(this.masksFilePath)) - { - this.logger.LogInformation("Masks file not found at {Path}, will create defaults", this.masksFilePath); - return; - } - - var json = await File.ReadAllTextAsync(this.masksFilePath); - var data = JsonSerializer.Deserialize>(json, JsonOptions); - - if (data == null) - { - this.logger.LogWarning("Failed to deserialize masks from {Path}", this.masksFilePath); - return; - } - - this.AvailableMasks.Clear(); - - foreach (var item in data) - { - try - { - var mask = new CoreMask - { - Id = item.GetProperty("id").GetString() ?? Guid.NewGuid().ToString(), - Name = item.GetProperty("name").GetString() ?? "Unnamed", - Description = item.GetProperty("description").GetString() ?? string.Empty, - ProfileSchemaVersion = item.TryGetProperty("profileSchemaVersion", out var schemaVersion) - ? schemaVersion.GetInt32() - : CpuAffinityProfileSchemaVersions.Legacy, - IsDefault = item.GetProperty("isDefault").GetBoolean(), - IsEnabled = item.GetProperty("isEnabled").GetBoolean(), - CreatedAt = item.GetProperty("createdAt").GetDateTime(), - UpdatedAt = item.GetProperty("updatedAt").GetDateTime(), - }; - - var boolMask = item.GetProperty("boolMask"); - foreach (var bit in boolMask.EnumerateArray()) - { - mask.BoolMask.Add(bit.GetBoolean()); - } - - if (item.TryGetProperty("cpuSelection", out var cpuSelectionElement) && - cpuSelectionElement.ValueKind != JsonValueKind.Null) - { - mask.CpuSelection = cpuSelectionElement.Deserialize(JsonOptions); - } - - if (item.TryGetProperty("cpuSelectionMigration", out var migrationElement) && - migrationElement.ValueKind != JsonValueKind.Null) - { - mask.CpuSelectionMigration = migrationElement.Deserialize(JsonOptions); - } - - this.AvailableMasks.Add(mask); - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to load individual mask, skipping"); - } - } - - await this.ApplyCpuSelectionMigrationAsync().ConfigureAwait(false); - this.logger.LogInformation("Loaded {Count} masks from {Path}", this.AvailableMasks.Count, this.masksFilePath); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to load masks from {Path}", this.masksFilePath); - } - } - - public async Task IsMaskReferencedByProfilesAsync(string maskId) - { - try - { - var profileNames = await this.GetProfilesReferencingMaskAsync(maskId); - return profileNames.Any(); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to check if mask {MaskId} is referenced by profiles", maskId); - return false; - } - } - - private async Task ApplyCpuSelectionMigrationAsync() - { - var topology = await this.TryGetTopologySnapshotAsync().ConfigureAwait(false); - if (topology == null) - { - return; - } - - foreach (var mask in this.AvailableMasks) - { - if (mask.CpuSelection != null) - { - mask.ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection; - continue; - } - - if (mask.BoolMask.Count == 0) - { - continue; - } - - var migrated = this.cpuSelectionMigrationService.MigrateFromLegacyCoreMask( - mask.BoolMask.ToList(), - topology); - mask.ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection; - mask.CpuSelection = migrated.Selection; - mask.CpuSelectionMigration = migrated.Metadata; - } - } - - private async Task TryGetTopologySnapshotAsync() - { - if (this.cpuTopologyProvider == null) - { - return null; - } - - try - { - return await this.cpuTopologyProvider.GetTopologySnapshotAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to get CPU topology snapshot for core mask CpuSelection migration"); - return null; - } - } - - public async Task IsMaskActivelyAppliedAsync(string maskId) - { - try - { - // Check our tracking dictionary for active process masks - var isActive = this.activeProcessMasks.ContainsValue(maskId); - - if (isActive) - { - // Verify processes are still running - var deadProcesses = new List(); - foreach (var kvp in this.activeProcessMasks.Where(x => x.Value == maskId)) - { - try - { - Process.GetProcessById(kvp.Key); - } - catch (ArgumentException) - { - // Process no longer exists - deadProcesses.Add(kvp.Key); - } - } - - // Clean up dead processes - foreach (var pid in deadProcesses) - { - this.activeProcessMasks.Remove(pid); - } - - // Re-check after cleanup - isActive = this.activeProcessMasks.ContainsValue(maskId); - } - - await Task.CompletedTask; - return isActive; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to check if mask {MaskId} is actively applied", maskId); - return false; - } - } - - public async Task> GetProfilesReferencingMaskAsync(string maskId) - { - var referencingProfiles = new List(); - - try - { - // Get the association service to check profiles - var associationService = this.serviceProvider.GetService(typeof(IProcessPowerPlanAssociationService)) as IProcessPowerPlanAssociationService; - if (associationService != null) - { - var associations = await associationService.GetAssociationsAsync(); - foreach (var association in associations) - { - if (association.CoreMaskId == maskId) - { - var profileName = !string.IsNullOrEmpty(association.Description) - ? association.Description - : association.ExecutableName; - referencingProfiles.Add(profileName); - } - } - } - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to get profiles referencing mask {MaskId}", maskId); - } - - return referencingProfiles; - } - - public async Task UpdateProfilesToDefaultMaskAsync(string maskId) - { - try - { - var allCoresMask = this.GetAllCoresMask(); - if (allCoresMask == null) - { - this.logger.LogError("Cannot update profiles: 'All Cores' mask not found"); - return; - } - - var associationService = this.serviceProvider.GetService(typeof(IProcessPowerPlanAssociationService)) as IProcessPowerPlanAssociationService; - if (associationService != null) - { - var associations = await associationService.GetAssociationsAsync(); - foreach (var association in associations) - { - if (association.CoreMaskId == maskId) - { - association.CoreMaskId = allCoresMask.Id; - association.CoreMaskName = allCoresMask.Name; - await associationService.UpdateAssociationAsync(association); - this.logger.LogInformation("Updated association '{Name}' to use 'All Cores' mask", association.ExecutableName); - } - } - } - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to update profiles to default mask"); - } - } - - public CoreMask? GetAllCoresMask() - { - return this.AvailableMasks.FirstOrDefault(m => m.Name == ALLCORESMASKNAME); - } - - /// - /// Registers that a mask is being applied to a process. - /// - public void RegisterMaskApplication(int processId, string maskId) - { - this.activeProcessMasks[processId] = maskId; - this.logger.LogDebug("Registered mask {MaskId} for process {ProcessId}", maskId, processId); - } - - /// - /// Unregisters a mask application when a process exits or mask is removed. - /// - public void UnregisterMaskApplication(int processId) - { - if (this.activeProcessMasks.Remove(processId)) - { - this.logger.LogDebug("Unregistered mask for process {ProcessId}", processId); - } - } - - /// - /// Gets all processes that have a specific mask applied. - /// - public IEnumerable GetProcessesWithMask(string maskId) - { - return this.activeProcessMasks.Where(x => x.Value == maskId).Select(x => x.Key); - } - - public async Task CreateDefaultMasksAsync() - { - bool changed = await this.BackfillBuiltInDefaultMasksAsync(); - if (changed) - { - await this.SaveMasksAsync(); - } - - this.logger.LogInformation( - "Created or backfilled default masks with topology-aware presets; total masks: {Count}", - this.AvailableMasks.Count); - } - - private async Task BackfillBuiltInDefaultMasksAsync() - { - var topology = this.cpuTopologyService.CurrentTopology; - int coreCount = this.ResolveLogicalCoreCount(topology); - bool topologyConfident = topology?.TopologyDetectionSuccessful == true; - bool hasHyperThreading = topology?.HasHyperThreading == true; - bool canCreateNoSmtVariants = topologyConfident && hasHyperThreading; - bool changed = false; - - // Collect all default masks with their "no SMT" variants - var defaultMasks = new List<(string name, List boolMask, string description)>(); - - // Determine CPU manufacturer for naming convention - bool isIntel = topology?.CpuBrand?.Contains("Intel", StringComparison.OrdinalIgnoreCase) == true; - bool isAmd = topology?.CpuBrand?.Contains("AMD", StringComparison.OrdinalIgnoreCase) == true; - string noSmtSuffix = isIntel ? " no HT" : " no SMT"; - - // 1. Always add "All Cores" baseline mask (IsDefault = true, cannot be deleted) - var allCoresMask = new CoreMask - { - Name = ALLCORESMASKNAME, - Description = "Use all available CPU cores - baseline mask", - IsDefault = true, - IsEnabled = true, - }; - for (int i = 0; i < coreCount; i++) - { - allCoresMask.BoolMask.Add(true); - } - - changed |= this.AddBuiltInMaskIfMissing(allCoresMask); - - if (coreCount > 1) - { - var noCoreZeroMask = new CoreMask - { - Name = NOCORE0MASKNAME, - Description = "Use all logical CPUs except CPU 0", - IsDefault = false, - IsEnabled = true, - }; - - for (int i = 0; i < coreCount; i++) - { - noCoreZeroMask.BoolMask.Add(i != 0); - } - - changed |= this.AddBuiltInMaskIfMissing(noCoreZeroMask); - } - - // 2. Intel Hybrid Architecture: P-Cores, E-Cores, LPE-Cores (Arrow Lake+) - if (topology != null && topology.HasIntelHybrid) - { - // Detect efficiency class distribution for LPE support - var efficiencyClasses = topology.LogicalCores - .Select(c => this.GetEfficiencyClass(c)) - .Distinct() - .OrderByDescending(x => x) - .ToList(); - - bool hasLpeCores = efficiencyClasses.Count >= 3; // P, E, LPE - int pClass = hasLpeCores ? 2 : 1; - int eClass = hasLpeCores ? 1 : 0; - int lpeClass = 0; - - // P-Cores mask - var pCoresBoolMask = new List(); - for (int i = 0; i < coreCount; i++) - { - var core = topology.LogicalCores.FirstOrDefault(c => c.LogicalCoreId == i); - pCoresBoolMask.Add(this.GetEfficiencyClass(core) == pClass); - } - if (pCoresBoolMask.Any(b => b)) - { - defaultMasks.Add(("P-Cores", pCoresBoolMask, "Intel Performance cores (highest performance)")); - } - - // E-Cores mask - var eCoresBoolMask = new List(); - for (int i = 0; i < coreCount; i++) - { - var core = topology.LogicalCores.FirstOrDefault(c => c.LogicalCoreId == i); - eCoresBoolMask.Add(this.GetEfficiencyClass(core) == eClass); - } - if (eCoresBoolMask.Any(b => b)) - { - defaultMasks.Add(("E-Cores", eCoresBoolMask, "Intel Efficiency cores (power efficient)")); - } - - // LPE-Cores mask (Arrow Lake and beyond) - if (hasLpeCores) - { - var lpeCoresBoolMask = new List(); - for (int i = 0; i < coreCount; i++) - { - var core = topology.LogicalCores.FirstOrDefault(c => c.LogicalCoreId == i); - lpeCoresBoolMask.Add(this.GetEfficiencyClass(core) == lpeClass); - } - if (lpeCoresBoolMask.Any(b => b)) - { - defaultMasks.Add(("LPE-Cores", lpeCoresBoolMask, "Intel Low-Power Efficiency cores (ultra power efficient)")); - } - } - - this.logger.LogInformation("Created Intel Hybrid masks (P/E{0})", hasLpeCores ? "/LPE" : string.Empty); - } - - // 3. AMD CCD Masks with Cache/Freq differentiation (like CPU Set Setter) - if (topology != null && topology.HasAmdCcd) - { - await this.CreateAmdCcdMasksAsync(topology, defaultMasks, coreCount); - } - - // 4. Generate "no SMT/HT" variants for each mask - var resultMasks = new List(); - foreach (var (name, boolMask, description) in defaultMasks) - { - // Original mask - resultMasks.Add(this.CreateCoreMaskFromBoolList(name, boolMask, description)); - - // Skip "no HT" variants for E-Cores and LPE-Cores since they don't have HyperThreading - // Only P-Cores on Intel hybrid architectures have HT - if (name == "E-Cores" || name == "LPE-Cores") - { - continue; - } - - // No SMT variant - if (canCreateNoSmtVariants) - { - var noSmtMask = this.StripSMT(boolMask, topology, out bool wasStripped); - if (wasStripped) - { - resultMasks.Add(this.CreateCoreMaskFromBoolList( - name + noSmtSuffix, - noSmtMask, - description + " (no HyperThreading/SMT)")); - } - } - } - - // 5. "All no HT/SMT" as the last mask - if (canCreateNoSmtVariants) - { - var allCoresBoolMask = Enumerable.Repeat(true, coreCount).ToList(); - var allNoSmtMask = this.StripSMT(allCoresBoolMask, topology, out bool hasStripped); - if (hasStripped) - { - resultMasks.Add(this.CreateCoreMaskFromBoolList( - "All" + noSmtSuffix, - allNoSmtMask, - "All physical cores without HyperThreading/SMT")); - } - } - - // Add all generated masks to AvailableMasks - foreach (var mask in resultMasks) - { - changed |= this.AddBuiltInMaskIfMissing(mask); - } - - await Task.CompletedTask; - return changed; - } - - private int ResolveLogicalCoreCount(CpuTopologyModel? topology) - { - if (topology?.TopologyDetectionSuccessful == true && topology.TotalLogicalCores > 0) - { - return topology.TotalLogicalCores; - } - - return Environment.ProcessorCount; - } - - private bool AddBuiltInMaskIfMissing(CoreMask mask) - { - if (this.AvailableMasks.Any(existing => - existing.Name.Equals(mask.Name, StringComparison.OrdinalIgnoreCase))) - { - return false; - } - - this.AvailableMasks.Add(mask); - this.logger.LogInformation("Backfilled built-in core mask '{Name}'", mask.Name); - return true; - } - - /// - /// Creates AMD CCD masks with Cache/Freq differentiation (X3D support) - /// Based on CPU Set Setter's GetDefaultLogicalProcessorMasks. - /// - private async Task CreateAmdCcdMasksAsync( - CpuTopologyModel topology, - List<(string name, List boolMask, string description)> defaultMasks, - int coreCount) - { - try - { - var ccdIds = topology.AvailableCcds.ToList(); - - if (ccdIds.Count < 2) - { - // Single CCD - just create one CCD mask - if (ccdIds.Count == 1) - { - var ccdBoolMask = new List(); - for (int i = 0; i < coreCount; i++) - { - var core = topology.LogicalCores.FirstOrDefault(c => c.LogicalCoreId == i); - ccdBoolMask.Add(core?.CcdId == ccdIds[0]); - } - defaultMasks.Add(($"CCD{ccdIds[0]}", ccdBoolMask, $"AMD Core Complex Die {ccdIds[0]}")); - } - return; - } - - // Multiple CCDs - try to detect X3D (Cache vs Freq CCDs) - // X3D chips have one CCD with significantly more L3 cache - // For simplicity, we'll create numbered CCD masks - // TODO: Implement L3 cache size detection for X3D differentiation - - foreach (var ccdId in ccdIds) - { - var ccdBoolMask = new List(); - for (int i = 0; i < coreCount; i++) - { - var core = topology.LogicalCores.FirstOrDefault(c => c.LogicalCoreId == i); - ccdBoolMask.Add(core?.CcdId == ccdId); - } - - if (ccdBoolMask.Any(b => b)) - { - defaultMasks.Add(($"CCD{ccdId}", ccdBoolMask, $"AMD Core Complex Die {ccdId}")); - } - } - - this.logger.LogInformation( - "Created {Count} AMD CCD masks for CCDs: {CCDs}", - ccdIds.Count, string.Join(", ", ccdIds)); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to create AMD CCD masks"); - } - } - - /// - /// Strips SMT/HT threads from a bool mask, keeping only physical cores (T0) - /// Based on CPU Set Setter's StripSMT method. - /// - private List StripSMT(List boolMask, CpuTopologyModel? topology, out bool hasStripped) - { - var result = new List(boolMask.Count); - hasStripped = false; - - var coreById = topology?.LogicalCores.ToDictionary(c => c.LogicalCoreId); - var primaryThreadIds = new HashSet(); - - if (coreById != null) - { - foreach (var group in coreById.Values.GroupBy(c => c.PhysicalCoreId)) - { - var primary = group.OrderBy(c => c.LogicalCoreId).First(); - primaryThreadIds.Add(primary.LogicalCoreId); - } - } - - bool topologyBased = topology?.TopologyDetectionSuccessful == true && primaryThreadIds.Count > 0; - - for (int i = 0; i < boolMask.Count; i++) - { - bool isSMTThread = false; - bool keepBit = boolMask[i]; - - if (keepBit) - { - if (topologyBased && coreById != null && coreById.TryGetValue(i, out var core)) - { - isSMTThread = core.IsHyperThreaded && !primaryThreadIds.Contains(i); - } - else - { - // Fallback heuristic based on naming - var fallbackCore = coreById != null && coreById.TryGetValue(i, out var c) ? c : null; - var name = fallbackCore?.LogicalProcessorName; - if (!string.IsNullOrEmpty(name) && name.Length >= 2) - { - var lastTwo = name.Substring(name.Length - 2); - if (lastTwo.StartsWith("T") || lastTwo.StartsWith("_T")) - { - isSMTThread = !name.EndsWith("T0") && !name.EndsWith("_T0"); - } - } - else if (fallbackCore?.IsHyperThreaded == true && fallbackCore.HyperThreadSibling.HasValue) - { - isSMTThread = fallbackCore.LogicalCoreId > fallbackCore.HyperThreadSibling.Value; - } - } - } - - if (keepBit && isSMTThread) - { - hasStripped = true; - } - - result.Add(keepBit && !isSMTThread); - } - - return result; - } - - /// - /// Gets the efficiency class of a core (for Intel Hybrid detection). - /// - private int GetEfficiencyClass(CpuCoreModel? core) - { - if (core == null) - { - return 0; - } - - return core.CoreType switch - { - CpuCoreType.PerformanceCore => 2, // Highest efficiency class - CpuCoreType.EfficiencyCore => 1, // Middle efficiency class - _ => 0, // Lowest (or unknown/LPE) - }; - } - - /// - /// Creates a CoreMask from a bool list. - /// - private CoreMask CreateCoreMaskFromBoolList(string name, List boolMask, string description) - { - var mask = new CoreMask - { - Name = name, - Description = description, - IsDefault = false, - IsEnabled = true, - }; - - foreach (var bit in boolMask) - { - mask.BoolMask.Add(bit); - } - - return mask; - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Collections.ObjectModel; + using System.Diagnostics; + using System.IO; + using System.Linq; + using System.Text; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using System.Windows; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public class CoreMaskService : ICoreMaskService + { + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + private readonly ILogger logger; + private readonly ICpuTopologyService cpuTopologyService; + private readonly IServiceProvider serviceProvider; + private readonly ICpuTopologyProvider? cpuTopologyProvider; + private readonly CpuSelectionMigrationService cpuSelectionMigrationService; + private readonly string masksFilePath; + private bool initialized = false; + private int topologyBackfillInProgress; + + // Tracks which masks are actively applied to processes + private readonly Dictionary activeProcessMasks = new(); // ProcessId -> MaskId + + public ObservableCollection AvailableMasks { get; private set; } = new(); + + public CoreMask? DefaultMask => this.AvailableMasks.FirstOrDefault(m => m.IsDefault); + + private const string ALLCORESMASKNAME = "All Cores"; + private const string NOCORE0MASKNAME = "No Core 0"; + + public CoreMaskService( + ILogger logger, + ICpuTopologyService cpuTopologyService, + IServiceProvider serviceProvider, + ICpuTopologyProvider? cpuTopologyProvider = null, + CpuSelectionMigrationService? cpuSelectionMigrationService = null, + string? masksFilePath = null) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.cpuTopologyService = cpuTopologyService ?? throw new ArgumentNullException(nameof(cpuTopologyService)); + this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + this.cpuTopologyProvider = cpuTopologyProvider; + this.cpuSelectionMigrationService = cpuSelectionMigrationService ?? new CpuSelectionMigrationService(); + + if (string.IsNullOrWhiteSpace(masksFilePath)) + { + StoragePaths.EnsureAppDataDirectories(); + this.masksFilePath = StoragePaths.CoreMasksFilePath; + } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(masksFilePath)!); + this.masksFilePath = masksFilePath; + } + + this.cpuTopologyService.TopologyDetected += this.OnTopologyDetected; + } + + public async Task InitializeAsync() + { + if (this.initialized) + { + return; + } + + this.logger.LogInformation("Initializing CoreMaskService..."); + + await this.LoadMasksAsync(); + + if (this.AvailableMasks.Count == 0) + { + this.logger.LogInformation("No masks found, creating defaults..."); + } + + if (await this.BackfillBuiltInDefaultMasksAsync()) + { + await this.SaveMasksAsync(); + } + + this.initialized = true; + this.logger.LogInformation("CoreMaskService initialized with {Count} masks", this.AvailableMasks.Count); + } + + private void OnTopologyDetected(object? sender, CpuTopologyDetectedEventArgs e) + { + if (!this.initialized || !e.DetectionSuccessful) + { + return; + } + + var dispatcher = Application.Current?.Dispatcher; + if (dispatcher != null) + { + _ = dispatcher.InvokeAsync(async () => await this.BackfillBuiltInDefaultMasksAndSaveAsync()); + return; + } + + _ = Task.Run(this.BackfillBuiltInDefaultMasksAndSaveAsync); + } + + private async Task BackfillBuiltInDefaultMasksAndSaveAsync() + { + if (Interlocked.Exchange(ref this.topologyBackfillInProgress, 1) != 0) + { + return; + } + + try + { + if (await this.BackfillBuiltInDefaultMasksAsync()) + { + await this.SaveMasksAsync(); + } + } + finally + { + Interlocked.Exchange(ref this.topologyBackfillInProgress, 0); + } + } + + public async Task CreateMaskAsync(string name, string description, IEnumerable boolMask) + { + var mask = new CoreMask + { + Name = name, + Description = description, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + foreach (var bit in boolMask) + { + mask.BoolMask.Add(bit); + } + + this.AvailableMasks.Add(mask); + await this.SaveMasksAsync(); + + this.logger.LogInformation( + "Created new mask '{Name}' with {Count} cores selected", + name, mask.SelectedCoreCount); + + return mask; + } + + public async Task UpdateMaskAsync(CoreMask mask) + { + if (mask == null) + { + throw new ArgumentNullException(nameof(mask)); + } + + var existing = this.GetMaskById(mask.Id); + if (existing == null) + { + this.logger.LogWarning("Cannot update mask {Id}: not found", mask.Id); + return; + } + + mask.UpdatedAt = DateTime.UtcNow; + await this.SaveMasksAsync(); + + this.logger.LogInformation("Updated mask '{Name}'", mask.Name); + } + + public async Task DeleteMaskAsync(string maskId) + { + var mask = this.GetMaskById(maskId); + if (mask == null) + { + this.logger.LogWarning("Cannot delete mask {Id}: not found", maskId); + return; + } + + // Cannot delete the "All Cores" baseline mask + if (mask.Name == ALLCORESMASKNAME) + { + this.logger.LogWarning("Cannot delete 'All Cores' baseline mask"); + throw new InvalidOperationException("Cannot delete the 'All Cores' baseline mask - it is required as the default fallback"); + } + + // Check if mask is actively applied to running processes + if (await this.IsMaskActivelyAppliedAsync(maskId)) + { + this.logger.LogWarning("Cannot delete mask '{Name}': it is actively applied to running processes", mask.Name); + throw new InvalidOperationException($"Cannot delete mask '{mask.Name}' - it is currently applied to running processes. Please change the mask on those processes first."); + } + + this.AvailableMasks.Remove(mask); + await this.SaveMasksAsync(); + + this.logger.LogInformation("Deleted mask '{Name}'", mask.Name); + } + + public CoreMask? GetMaskById(string maskId) + { + return this.AvailableMasks.FirstOrDefault(m => m.Id == maskId); + } + + public CoreMask? GetMaskByName(string name) + { + return this.AvailableMasks.FirstOrDefault(m => + m.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + } + + public async Task SaveMasksAsync() + { + try + { + await this.ApplyCpuSelectionMigrationAsync().ConfigureAwait(false); + + var data = this.AvailableMasks.Select(m => new + { + id = m.Id, + name = m.Name, + description = m.Description, + boolMask = m.BoolMask.ToList(), + profileSchemaVersion = m.ProfileSchemaVersion, + cpuSelection = m.CpuSelection, + cpuSelectionMigration = m.CpuSelectionMigration, + isDefault = m.IsDefault, + isEnabled = m.IsEnabled, + createdAt = m.CreatedAt, + updatedAt = m.UpdatedAt, + }).ToList(); + + var json = JsonSerializer.Serialize(data, JsonOptions); + + await AtomicFileWriter.WriteAllTextAsync(this.masksFilePath, json, Encoding.UTF8); + this.logger.LogDebug("Saved {Count} masks to {Path}", this.AvailableMasks.Count, this.masksFilePath); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to save masks to {Path}", this.masksFilePath); + throw; + } + } + + public async Task LoadMasksAsync() + { + try + { + if (!File.Exists(this.masksFilePath)) + { + this.logger.LogInformation("Masks file not found at {Path}, will create defaults", this.masksFilePath); + return; + } + + var json = await File.ReadAllTextAsync(this.masksFilePath); + var data = JsonSerializer.Deserialize>(json, JsonOptions); + + if (data == null) + { + this.logger.LogWarning("Failed to deserialize masks from {Path}", this.masksFilePath); + return; + } + + this.AvailableMasks.Clear(); + + foreach (var item in data) + { + try + { + var mask = new CoreMask + { + Id = item.GetProperty("id").GetString() ?? Guid.NewGuid().ToString(), + Name = item.GetProperty("name").GetString() ?? "Unnamed", + Description = item.GetProperty("description").GetString() ?? string.Empty, + ProfileSchemaVersion = item.TryGetProperty("profileSchemaVersion", out var schemaVersion) + ? schemaVersion.GetInt32() + : CpuAffinityProfileSchemaVersions.Legacy, + IsDefault = item.GetProperty("isDefault").GetBoolean(), + IsEnabled = item.GetProperty("isEnabled").GetBoolean(), + CreatedAt = item.GetProperty("createdAt").GetDateTime(), + UpdatedAt = item.GetProperty("updatedAt").GetDateTime(), + }; + + var boolMask = item.GetProperty("boolMask"); + foreach (var bit in boolMask.EnumerateArray()) + { + mask.BoolMask.Add(bit.GetBoolean()); + } + + if (item.TryGetProperty("cpuSelection", out var cpuSelectionElement) && + cpuSelectionElement.ValueKind != JsonValueKind.Null) + { + mask.CpuSelection = cpuSelectionElement.Deserialize(JsonOptions); + } + + if (item.TryGetProperty("cpuSelectionMigration", out var migrationElement) && + migrationElement.ValueKind != JsonValueKind.Null) + { + mask.CpuSelectionMigration = migrationElement.Deserialize(JsonOptions); + } + + this.AvailableMasks.Add(mask); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to load individual mask, skipping"); + } + } + + await this.ApplyCpuSelectionMigrationAsync().ConfigureAwait(false); + this.logger.LogInformation("Loaded {Count} masks from {Path}", this.AvailableMasks.Count, this.masksFilePath); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to load masks from {Path}", this.masksFilePath); + } + } + + public async Task IsMaskReferencedByProfilesAsync(string maskId) + { + try + { + var profileNames = await this.GetProfilesReferencingMaskAsync(maskId); + return profileNames.Any(); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to check if mask {MaskId} is referenced by profiles", maskId); + return false; + } + } + + private async Task ApplyCpuSelectionMigrationAsync() + { + var topology = await this.TryGetTopologySnapshotAsync().ConfigureAwait(false); + if (topology == null) + { + return; + } + + foreach (var mask in this.AvailableMasks) + { + if (mask.CpuSelection != null) + { + mask.ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection; + continue; + } + + if (mask.BoolMask.Count == 0) + { + continue; + } + + var migrated = this.cpuSelectionMigrationService.MigrateFromLegacyCoreMask( + mask.BoolMask.ToList(), + topology); + mask.ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection; + mask.CpuSelection = migrated.Selection; + mask.CpuSelectionMigration = migrated.Metadata; + } + } + + private async Task TryGetTopologySnapshotAsync() + { + if (this.cpuTopologyProvider == null) + { + return null; + } + + try + { + return await this.cpuTopologyProvider.GetTopologySnapshotAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to get CPU topology snapshot for core mask CpuSelection migration"); + return null; + } + } + + public async Task IsMaskActivelyAppliedAsync(string maskId) + { + try + { + // Check our tracking dictionary for active process masks + var isActive = this.activeProcessMasks.ContainsValue(maskId); + + if (isActive) + { + // Verify processes are still running + var deadProcesses = new List(); + foreach (var kvp in this.activeProcessMasks.Where(x => x.Value == maskId)) + { + try + { + Process.GetProcessById(kvp.Key); + } + catch (ArgumentException) + { + // Process no longer exists + deadProcesses.Add(kvp.Key); + } + } + + // Clean up dead processes + foreach (var pid in deadProcesses) + { + this.activeProcessMasks.Remove(pid); + } + + // Re-check after cleanup + isActive = this.activeProcessMasks.ContainsValue(maskId); + } + + await Task.CompletedTask; + return isActive; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to check if mask {MaskId} is actively applied", maskId); + return false; + } + } + + public async Task> GetProfilesReferencingMaskAsync(string maskId) + { + var referencingProfiles = new List(); + + try + { + // Get the association service to check profiles + var associationService = this.serviceProvider.GetService(typeof(IProcessPowerPlanAssociationService)) as IProcessPowerPlanAssociationService; + if (associationService != null) + { + var associations = await associationService.GetAssociationsAsync(); + foreach (var association in associations) + { + if (association.CoreMaskId == maskId) + { + var profileName = !string.IsNullOrEmpty(association.Description) + ? association.Description + : association.ExecutableName; + referencingProfiles.Add(profileName); + } + } + } + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to get profiles referencing mask {MaskId}", maskId); + } + + return referencingProfiles; + } + + public async Task UpdateProfilesToDefaultMaskAsync(string maskId) + { + try + { + var allCoresMask = this.GetAllCoresMask(); + if (allCoresMask == null) + { + this.logger.LogError("Cannot update profiles: 'All Cores' mask not found"); + return; + } + + var associationService = this.serviceProvider.GetService(typeof(IProcessPowerPlanAssociationService)) as IProcessPowerPlanAssociationService; + if (associationService != null) + { + var associations = await associationService.GetAssociationsAsync(); + foreach (var association in associations) + { + if (association.CoreMaskId == maskId) + { + association.CoreMaskId = allCoresMask.Id; + association.CoreMaskName = allCoresMask.Name; + await associationService.UpdateAssociationAsync(association); + this.logger.LogInformation("Updated association '{Name}' to use 'All Cores' mask", association.ExecutableName); + } + } + } + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to update profiles to default mask"); + } + } + + public CoreMask? GetAllCoresMask() + { + return this.AvailableMasks.FirstOrDefault(m => m.Name == ALLCORESMASKNAME); + } + + public void RegisterMaskApplication(int processId, string maskId) + { + this.activeProcessMasks[processId] = maskId; + this.logger.LogDebug("Registered mask {MaskId} for process {ProcessId}", maskId, processId); + } + + public void UnregisterMaskApplication(int processId) + { + if (this.activeProcessMasks.Remove(processId)) + { + this.logger.LogDebug("Unregistered mask for process {ProcessId}", processId); + } + } + + public IEnumerable GetProcessesWithMask(string maskId) + { + return this.activeProcessMasks.Where(x => x.Value == maskId).Select(x => x.Key); + } + + public async Task CreateDefaultMasksAsync() + { + bool changed = await this.BackfillBuiltInDefaultMasksAsync(); + if (changed) + { + await this.SaveMasksAsync(); + } + + this.logger.LogInformation( + "Created or backfilled default masks with topology-aware presets; total masks: {Count}", + this.AvailableMasks.Count); + } + + private async Task BackfillBuiltInDefaultMasksAsync() + { + var topology = this.cpuTopologyService.CurrentTopology; + int coreCount = this.ResolveLogicalCoreCount(topology); + bool topologyConfident = topology?.TopologyDetectionSuccessful == true; + bool hasHyperThreading = topology?.HasHyperThreading == true; + bool canCreateNoSmtVariants = topologyConfident && hasHyperThreading; + bool changed = false; + + // Collect all default masks with their "no SMT" variants + var defaultMasks = new List<(string name, List boolMask, string description)>(); + + // Determine CPU manufacturer for naming convention + bool isIntel = topology?.CpuBrand?.Contains("Intel", StringComparison.OrdinalIgnoreCase) == true; + bool isAmd = topology?.CpuBrand?.Contains("AMD", StringComparison.OrdinalIgnoreCase) == true; + string noSmtSuffix = isIntel ? " no HT" : " no SMT"; + + // 1. Always add "All Cores" baseline mask (IsDefault = true, cannot be deleted) + var allCoresMask = new CoreMask + { + Name = ALLCORESMASKNAME, + Description = "Use all available CPU cores - baseline mask", + IsDefault = true, + IsEnabled = true, + }; + for (int i = 0; i < coreCount; i++) + { + allCoresMask.BoolMask.Add(true); + } + + changed |= this.AddBuiltInMaskIfMissing(allCoresMask); + + if (coreCount > 1) + { + var noCoreZeroMask = new CoreMask + { + Name = NOCORE0MASKNAME, + Description = "Use all logical CPUs except CPU 0", + IsDefault = false, + IsEnabled = true, + }; + + for (int i = 0; i < coreCount; i++) + { + noCoreZeroMask.BoolMask.Add(i != 0); + } + + changed |= this.AddBuiltInMaskIfMissing(noCoreZeroMask); + } + + // 2. Intel Hybrid Architecture: P-Cores, E-Cores, LPE-Cores (Arrow Lake+) + if (topology != null && topology.HasIntelHybrid) + { + // Detect efficiency class distribution for LPE support + var efficiencyClasses = topology.LogicalCores + .Select(c => this.GetEfficiencyClass(c)) + .Distinct() + .OrderByDescending(x => x) + .ToList(); + + bool hasLpeCores = efficiencyClasses.Count >= 3; // P, E, LPE + int pClass = hasLpeCores ? 2 : 1; + int eClass = hasLpeCores ? 1 : 0; + int lpeClass = 0; + + // P-Cores mask + var pCoresBoolMask = new List(); + for (int i = 0; i < coreCount; i++) + { + var core = topology.LogicalCores.FirstOrDefault(c => c.LogicalCoreId == i); + pCoresBoolMask.Add(this.GetEfficiencyClass(core) == pClass); + } + if (pCoresBoolMask.Any(b => b)) + { + defaultMasks.Add(("P-Cores", pCoresBoolMask, "Intel Performance cores (highest performance)")); + } + + // E-Cores mask + var eCoresBoolMask = new List(); + for (int i = 0; i < coreCount; i++) + { + var core = topology.LogicalCores.FirstOrDefault(c => c.LogicalCoreId == i); + eCoresBoolMask.Add(this.GetEfficiencyClass(core) == eClass); + } + if (eCoresBoolMask.Any(b => b)) + { + defaultMasks.Add(("E-Cores", eCoresBoolMask, "Intel Efficiency cores (power efficient)")); + } + + // LPE-Cores mask (Arrow Lake and beyond) + if (hasLpeCores) + { + var lpeCoresBoolMask = new List(); + for (int i = 0; i < coreCount; i++) + { + var core = topology.LogicalCores.FirstOrDefault(c => c.LogicalCoreId == i); + lpeCoresBoolMask.Add(this.GetEfficiencyClass(core) == lpeClass); + } + if (lpeCoresBoolMask.Any(b => b)) + { + defaultMasks.Add(("LPE-Cores", lpeCoresBoolMask, "Intel Low-Power Efficiency cores (ultra power efficient)")); + } + } + + this.logger.LogInformation("Created Intel Hybrid masks (P/E{0})", hasLpeCores ? "/LPE" : string.Empty); + } + + // 3. AMD CCD Masks with Cache/Freq differentiation (like CPU Set Setter) + if (topology != null && topology.HasAmdCcd) + { + await this.CreateAmdCcdMasksAsync(topology, defaultMasks, coreCount); + } + + // 4. Generate "no SMT/HT" variants for each mask + var resultMasks = new List(); + foreach (var (name, boolMask, description) in defaultMasks) + { + // Original mask + resultMasks.Add(this.CreateCoreMaskFromBoolList(name, boolMask, description)); + + // Skip "no HT" variants for E-Cores and LPE-Cores since they don't have HyperThreading + // Only P-Cores on Intel hybrid architectures have HT + if (name == "E-Cores" || name == "LPE-Cores") + { + continue; + } + + // No SMT variant + if (canCreateNoSmtVariants) + { + var noSmtMask = this.StripSMT(boolMask, topology, out bool wasStripped); + if (wasStripped) + { + resultMasks.Add(this.CreateCoreMaskFromBoolList( + name + noSmtSuffix, + noSmtMask, + description + " (no HyperThreading/SMT)")); + } + } + } + + // 5. "All no HT/SMT" as the last mask + if (canCreateNoSmtVariants) + { + var allCoresBoolMask = Enumerable.Repeat(true, coreCount).ToList(); + var allNoSmtMask = this.StripSMT(allCoresBoolMask, topology, out bool hasStripped); + if (hasStripped) + { + resultMasks.Add(this.CreateCoreMaskFromBoolList( + "All" + noSmtSuffix, + allNoSmtMask, + "All physical cores without HyperThreading/SMT")); + } + } + + // Add all generated masks to AvailableMasks + foreach (var mask in resultMasks) + { + changed |= this.AddBuiltInMaskIfMissing(mask); + } + + await Task.CompletedTask; + return changed; + } + + private int ResolveLogicalCoreCount(CpuTopologyModel? topology) + { + if (topology?.TopologyDetectionSuccessful == true && topology.TotalLogicalCores > 0) + { + return topology.TotalLogicalCores; + } + + return Environment.ProcessorCount; + } + + private bool AddBuiltInMaskIfMissing(CoreMask mask) + { + if (this.AvailableMasks.Any(existing => + existing.Name.Equals(mask.Name, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + this.AvailableMasks.Add(mask); + this.logger.LogInformation("Backfilled built-in core mask '{Name}'", mask.Name); + return true; + } + + private async Task CreateAmdCcdMasksAsync( + CpuTopologyModel topology, + List<(string name, List boolMask, string description)> defaultMasks, + int coreCount) + { + try + { + var ccdIds = topology.AvailableCcds.ToList(); + + if (ccdIds.Count < 2) + { + // Single CCD - just create one CCD mask + if (ccdIds.Count == 1) + { + var ccdBoolMask = new List(); + for (int i = 0; i < coreCount; i++) + { + var core = topology.LogicalCores.FirstOrDefault(c => c.LogicalCoreId == i); + ccdBoolMask.Add(core?.CcdId == ccdIds[0]); + } + defaultMasks.Add(($"CCD{ccdIds[0]}", ccdBoolMask, $"AMD Core Complex Die {ccdIds[0]}")); + } + return; + } + + // Multiple CCDs - try to detect X3D (Cache vs Freq CCDs) + // X3D chips have one CCD with significantly more L3 cache + // For simplicity, we'll create numbered CCD masks + // TODO: Implement L3 cache size detection for X3D differentiation + + foreach (var ccdId in ccdIds) + { + var ccdBoolMask = new List(); + for (int i = 0; i < coreCount; i++) + { + var core = topology.LogicalCores.FirstOrDefault(c => c.LogicalCoreId == i); + ccdBoolMask.Add(core?.CcdId == ccdId); + } + + if (ccdBoolMask.Any(b => b)) + { + defaultMasks.Add(($"CCD{ccdId}", ccdBoolMask, $"AMD Core Complex Die {ccdId}")); + } + } + + this.logger.LogInformation( + "Created {Count} AMD CCD masks for CCDs: {CCDs}", + ccdIds.Count, string.Join(", ", ccdIds)); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to create AMD CCD masks"); + } + } + + private List StripSMT(List boolMask, CpuTopologyModel? topology, out bool hasStripped) + { + var result = new List(boolMask.Count); + hasStripped = false; + + var coreById = topology?.LogicalCores.ToDictionary(c => c.LogicalCoreId); + var primaryThreadIds = new HashSet(); + + if (coreById != null) + { + foreach (var group in coreById.Values.GroupBy(c => c.PhysicalCoreId)) + { + var primary = group.OrderBy(c => c.LogicalCoreId).First(); + primaryThreadIds.Add(primary.LogicalCoreId); + } + } + + bool topologyBased = topology?.TopologyDetectionSuccessful == true && primaryThreadIds.Count > 0; + + for (int i = 0; i < boolMask.Count; i++) + { + bool isSMTThread = false; + bool keepBit = boolMask[i]; + + if (keepBit) + { + if (topologyBased && coreById != null && coreById.TryGetValue(i, out var core)) + { + isSMTThread = core.IsHyperThreaded && !primaryThreadIds.Contains(i); + } + else + { + // Fallback heuristic based on naming + var fallbackCore = coreById != null && coreById.TryGetValue(i, out var c) ? c : null; + var name = fallbackCore?.LogicalProcessorName; + if (!string.IsNullOrEmpty(name) && name.Length >= 2) + { + var lastTwo = name.Substring(name.Length - 2); + if (lastTwo.StartsWith("T") || lastTwo.StartsWith("_T")) + { + isSMTThread = !name.EndsWith("T0") && !name.EndsWith("_T0"); + } + } + else if (fallbackCore?.IsHyperThreaded == true && fallbackCore.HyperThreadSibling.HasValue) + { + isSMTThread = fallbackCore.LogicalCoreId > fallbackCore.HyperThreadSibling.Value; + } + } + } + + if (keepBit && isSMTThread) + { + hasStripped = true; + } + + result.Add(keepBit && !isSMTThread); + } + + return result; + } + + private int GetEfficiencyClass(CpuCoreModel? core) + { + if (core == null) + { + return 0; + } + + return core.CoreType switch + { + CpuCoreType.PerformanceCore => 2, // Highest efficiency class + CpuCoreType.EfficiencyCore => 1, // Middle efficiency class + _ => 0, // Lowest (or unknown/LPE) + }; + } + + private CoreMask CreateCoreMaskFromBoolList(string name, List boolMask, string description) + { + var mask = new CoreMask + { + Name = name, + Description = description, + IsDefault = false, + IsEnabled = true, + }; + + foreach (var bit in boolMask) + { + mask.BoolMask.Add(bit); + } + + return mask; + } + } +} diff --git a/Services/CpuPresetGenerationOptions.cs b/Services/CpuPresetGenerationOptions.cs index cd77711..31c4baf 100644 --- a/Services/CpuPresetGenerationOptions.cs +++ b/Services/CpuPresetGenerationOptions.cs @@ -1,28 +1,12 @@ -/* - * 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 sealed record CpuPresetGenerationOptions - { - public bool ExcludeCpu0ForGaming { get; init; } = true; - - public IReadOnlySet DeletedGeneratedPresetIds { get; init; } = - new HashSet(StringComparer.Ordinal); - - public bool IncludeExperimentalPresets { get; init; } - } -} +namespace ThreadPilot.Services +{ + public sealed record CpuPresetGenerationOptions + { + public bool ExcludeCpu0ForGaming { get; init; } = true; + + public IReadOnlySet DeletedGeneratedPresetIds { get; init; } = + new HashSet(StringComparer.Ordinal); + + public bool IncludeExperimentalPresets { get; init; } + } +} diff --git a/Services/CpuPresetGenerator.cs b/Services/CpuPresetGenerator.cs index f3f31b4..3492377 100644 --- a/Services/CpuPresetGenerator.cs +++ b/Services/CpuPresetGenerator.cs @@ -1,340 +1,324 @@ -/* - * 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 ThreadPilot.Models; - - public sealed class CpuPresetGenerator : ICpuPresetGenerator - { - private const string GamingWarning = - "Suggested default. Results may vary by game and system. You can edit or delete this preset."; - - public IReadOnlyList Generate( - CpuTopologySnapshot topology, - CpuPresetGenerationOptions? options = null) - { - ArgumentNullException.ThrowIfNull(topology); - - var resolvedOptions = options ?? new CpuPresetGenerationOptions(); - var presets = new List(); - - AddPreset( - presets, - resolvedOptions, - CreatePreset( - "all-cores", - "All cores", - "Use every logical processor reported by the current CPU topology.", - topology.LogicalProcessors, - topology, - "Uses all logical processors from the topology snapshot.")); - - var allPhysicalProcessors = HasCoreIndexForAllProcessors(topology) - ? SelectOneLogicalProcessorPerCore(topology.LogicalProcessors, topology) - : []; - if (allPhysicalProcessors.Count > 0) - { - AddPreset( - presets, - resolvedOptions, - CreatePreset( - "all-physical-cores", - "All physical cores / no SMT", - "Use one logical processor per physical core.", - allPhysicalProcessors, - topology, - "Uses CoreIndex and SMT sibling metadata to select one logical processor per core.")); - } - - var allExceptCpu0 = topology.LogicalProcessors - .Where(processor => processor.GlobalIndex != 0) - .ToList(); - if (topology.LogicalProcessors.Count >= 2 && allExceptCpu0.Count > 0) - { - AddPreset( - presets, - resolvedOptions, - CreatePreset( - "all-except-cpu0", - "All except CPU0", - "Use every logical processor except global CPU index 0.", - allExceptCpu0, - topology, - "Excludes GlobalIndex 0 while keeping the remaining topology-aware processor refs.")); - } - - var efficiencyClasses = topology.LogicalProcessors - .Select(processor => topology.TryGetEfficiencyClass(processor, out var efficiencyClass) - ? (byte?)efficiencyClass - : null) - .Where(efficiencyClass => efficiencyClass.HasValue) - .Select(efficiencyClass => efficiencyClass!.Value) - .Distinct() - .OrderBy(efficiencyClass => efficiencyClass) - .ToList(); - - var hasDistinctEfficiencyClasses = efficiencyClasses.Count >= 2; - List pCoreProcessors = []; - if (hasDistinctEfficiencyClasses) - { - var performanceClass = efficiencyClasses.Max(); - pCoreProcessors = topology.LogicalProcessors - .Where(processor => - topology.TryGetEfficiencyClass(processor, out var efficiencyClass) && - efficiencyClass == performanceClass) - .ToList(); - var eCoreProcessors = topology.LogicalProcessors - .Where(processor => - topology.TryGetEfficiencyClass(processor, out var efficiencyClass) && - efficiencyClass < performanceClass) - .ToList(); - - AddPreset( - presets, - resolvedOptions, - CreatePreset( - "p-cores-only", - "P-cores only", - "Use logical processors in the highest EfficiencyClass.", - pCoreProcessors, - topology, - "Uses the highest EfficiencyClass in the topology snapshot as performance cores.")); - - if (HasCoreIndexForProcessors(pCoreProcessors, topology)) - { - AddPreset( - presets, - resolvedOptions, - CreatePreset( - "p-cores-no-smt", - "P-cores only / no SMT", - "Use one logical processor per performance core.", - SelectOneLogicalProcessorPerCore(pCoreProcessors, topology), - topology, - "Uses EfficiencyClass plus CoreIndex and SMT sibling metadata to choose one logical processor per P-core.")); - } - - AddPreset( - presets, - resolvedOptions, - CreatePreset( - "e-cores-only", - "E-cores only", - "Use logical processors below the highest EfficiencyClass.", - eCoreProcessors, - topology, - "Uses EfficiencyClass values below the performance class as efficiency cores.", - "Usually not recommended for games. Useful for background tasks.")); - } - - if (topology.Signature.LastLevelCacheGroupCount > 1 && HasCoreIndexForAllProcessors(topology)) - { - var l3Groups = topology.LogicalProcessors - .Select(processor => topology.TryGetLastLevelCacheIndex(processor, out var cacheIndex) - ? new { Processor = processor, CacheIndex = (int?)cacheIndex } - : null) - .Where(item => item?.CacheIndex != null) - .GroupBy(item => item!.CacheIndex!.Value) - .OrderBy(group => group.Key); - - foreach (var group in l3Groups) - { - AddPreset( - presets, - resolvedOptions, - CreatePreset( - $"l3-group-{group.Key}-physical", - $"L3 group {group.Key} / physical cores", - $"Use one logical processor per core in L3/cache group {group.Key}.", - SelectOneLogicalProcessorPerCore(group.Select(item => item!.Processor), topology), - topology, - $"Based on LastLevelCacheIndex/L3 cache group {group.Key}, not on CPU SKU naming.")); - } - } - - var bestGamingSourceId = SelectBestGamingSourcePresetId(presets, resolvedOptions); - if (bestGamingSourceId != null) - { - var sourcePreset = presets.Single(preset => preset.PresetId == bestGamingSourceId); - AddPreset( - presets, - resolvedOptions, - CreatePreset( - "best-gaming", - "Best gaming suggestion", - "Suggested topology-aware starting point for games.", - sourcePreset.Selection.LogicalProcessors, - topology, - CreateBestGamingReason(bestGamingSourceId), - GamingWarning, - sourcePresetId: bestGamingSourceId)); - } - - AddPreset( - presets, - resolvedOptions, - CreatePreset( - "safe-compatibility", - "Safe compatibility", - "Use every logical processor for maximum compatibility.", - topology.LogicalProcessors, - topology, - "Maximum compatibility.")); - - // TODO: X3D CCD-only presets require reliable cache/topology detection. - // Do not generate X3D CCD-only until it can be detected with confidence. - return presets; - } - - private static string? SelectBestGamingSourcePresetId( - IReadOnlyList presets, - CpuPresetGenerationOptions options) - { - var orderedCandidates = options.ExcludeCpu0ForGaming - ? new[] - { - "p-cores-no-smt", - "l3-group-0-physical", - "all-physical-cores", - "all-except-cpu0", - "all-cores", - } - : new[] - { - "p-cores-no-smt", - "l3-group-0-physical", - "all-physical-cores", - "all-cores", - }; - - return orderedCandidates.FirstOrDefault(candidate => - presets.Any(preset => preset.PresetId == candidate)); - } - - private static string CreateBestGamingReason(string sourcePresetId) => - sourcePresetId switch - { - "p-cores-no-smt" => - "Selected P-cores without SMT because the topology exposes distinct performance and efficiency core classes.", - "l3-group-0-physical" => - "Selected physical cores from L3/cache group 0 because the topology exposes multiple L3 groups and no P/E core classes.", - "all-physical-cores" => - "Selected one logical processor per physical core because reliable CoreIndex metadata is available.", - "all-except-cpu0" => - "Selected all logical processors except CPU0 as a conservative gaming-oriented fallback.", - "all-cores" => - "Selected all logical processors as the safest fallback because no more specific topology preset was available.", - _ => - "Selected the best available topology-aware preset for this CPU.", - }; - - private static bool HasCoreIndexForAllProcessors(CpuTopologySnapshot topology) => - HasCoreIndexForProcessors(topology.LogicalProcessors, topology); - - private static bool HasCoreIndexForProcessors( - IEnumerable processors, - CpuTopologySnapshot topology) - { - var processorList = processors.ToList(); - return processorList.Count > 0 && - processorList.All(processor => topology.TryGetCoreIndex(processor, out _)); - } - - private static List SelectOneLogicalProcessorPerCore( - IEnumerable processors, - CpuTopologySnapshot topology) - { - return processors - .Select(processor => - { - topology.TryGetCoreIndex(processor, out var coreIndex); - return new - { - Processor = processor, - CoreIndex = coreIndex, - SmtSiblingCount = topology.GetSmtSiblingGlobalIndexes(processor).Count, - }; - }) - .GroupBy(item => item.CoreIndex) - .OrderBy(group => group.Key) - .Select(group => group - .OrderBy(item => item.Processor.GlobalIndex) - .ThenBy(item => item.SmtSiblingCount) - .First() - .Processor) - .ToList(); - } - - private static CpuPreset CreatePreset( - string presetId, - string name, - string description, - IEnumerable processors, - CpuTopologySnapshot topology, - string reason, - string? warning = null, - string? sourcePresetId = null, - bool reviewRequired = false) - { - var selectedProcessors = processors - .Distinct() - .OrderBy(processor => processor.GlobalIndex) - .ThenBy(processor => processor.Group) - .ThenBy(processor => processor.LogicalProcessorNumber) - .ToList(); - - return new CpuPreset - { - PresetId = presetId, - Name = name, - Description = description, - Selection = CpuSelection.FromProcessors(selectedProcessors, topology, reason), - Reason = reason, - SourcePresetId = sourcePresetId, - Warning = warning, - GeneratedByTopologySignature = topology.Signature, - IsUserEditable = true, - IsGenerated = true, - ReviewRequired = reviewRequired, - }; - } - - private static void AddPreset( - List presets, - CpuPresetGenerationOptions options, - CpuPreset preset) - { - if (options.DeletedGeneratedPresetIds.Contains(preset.PresetId) || - preset.Selection.LogicalProcessors.Count == 0 || - presets.Any(existing => existing.PresetId == preset.PresetId)) - { - return; - } - - var duplicateSamePurpose = presets.Any(existing => - existing.Reason == preset.Reason && - existing.Selection.GlobalLogicalProcessorIndexes.SequenceEqual( - preset.Selection.GlobalLogicalProcessorIndexes)); - if (duplicateSamePurpose) - { - return; - } - - presets.Add(preset); - } - } -} +namespace ThreadPilot.Services +{ + using ThreadPilot.Models; + + public sealed class CpuPresetGenerator : ICpuPresetGenerator + { + private const string GamingWarning = + "Suggested default. Results may vary by game and system. You can edit or delete this preset."; + + public IReadOnlyList Generate( + CpuTopologySnapshot topology, + CpuPresetGenerationOptions? options = null) + { + ArgumentNullException.ThrowIfNull(topology); + + var resolvedOptions = options ?? new CpuPresetGenerationOptions(); + var presets = new List(); + + AddPreset( + presets, + resolvedOptions, + CreatePreset( + "all-cores", + "All cores", + "Use every logical processor reported by the current CPU topology.", + topology.LogicalProcessors, + topology, + "Uses all logical processors from the topology snapshot.")); + + var allPhysicalProcessors = HasCoreIndexForAllProcessors(topology) + ? SelectOneLogicalProcessorPerCore(topology.LogicalProcessors, topology) + : []; + if (allPhysicalProcessors.Count > 0) + { + AddPreset( + presets, + resolvedOptions, + CreatePreset( + "all-physical-cores", + "All physical cores / no SMT", + "Use one logical processor per physical core.", + allPhysicalProcessors, + topology, + "Uses CoreIndex and SMT sibling metadata to select one logical processor per core.")); + } + + var allExceptCpu0 = topology.LogicalProcessors + .Where(processor => processor.GlobalIndex != 0) + .ToList(); + if (topology.LogicalProcessors.Count >= 2 && allExceptCpu0.Count > 0) + { + AddPreset( + presets, + resolvedOptions, + CreatePreset( + "all-except-cpu0", + "All except CPU0", + "Use every logical processor except global CPU index 0.", + allExceptCpu0, + topology, + "Excludes GlobalIndex 0 while keeping the remaining topology-aware processor refs.")); + } + + var efficiencyClasses = topology.LogicalProcessors + .Select(processor => topology.TryGetEfficiencyClass(processor, out var efficiencyClass) + ? (byte?)efficiencyClass + : null) + .Where(efficiencyClass => efficiencyClass.HasValue) + .Select(efficiencyClass => efficiencyClass!.Value) + .Distinct() + .OrderBy(efficiencyClass => efficiencyClass) + .ToList(); + + var hasDistinctEfficiencyClasses = efficiencyClasses.Count >= 2; + List pCoreProcessors = []; + if (hasDistinctEfficiencyClasses) + { + var performanceClass = efficiencyClasses.Max(); + pCoreProcessors = topology.LogicalProcessors + .Where(processor => + topology.TryGetEfficiencyClass(processor, out var efficiencyClass) && + efficiencyClass == performanceClass) + .ToList(); + var eCoreProcessors = topology.LogicalProcessors + .Where(processor => + topology.TryGetEfficiencyClass(processor, out var efficiencyClass) && + efficiencyClass < performanceClass) + .ToList(); + + AddPreset( + presets, + resolvedOptions, + CreatePreset( + "p-cores-only", + "P-cores only", + "Use logical processors in the highest EfficiencyClass.", + pCoreProcessors, + topology, + "Uses the highest EfficiencyClass in the topology snapshot as performance cores.")); + + if (HasCoreIndexForProcessors(pCoreProcessors, topology)) + { + AddPreset( + presets, + resolvedOptions, + CreatePreset( + "p-cores-no-smt", + "P-cores only / no SMT", + "Use one logical processor per performance core.", + SelectOneLogicalProcessorPerCore(pCoreProcessors, topology), + topology, + "Uses EfficiencyClass plus CoreIndex and SMT sibling metadata to choose one logical processor per P-core.")); + } + + AddPreset( + presets, + resolvedOptions, + CreatePreset( + "e-cores-only", + "E-cores only", + "Use logical processors below the highest EfficiencyClass.", + eCoreProcessors, + topology, + "Uses EfficiencyClass values below the performance class as efficiency cores.", + "Usually not recommended for games. Useful for background tasks.")); + } + + if (topology.Signature.LastLevelCacheGroupCount > 1 && HasCoreIndexForAllProcessors(topology)) + { + var l3Groups = topology.LogicalProcessors + .Select(processor => topology.TryGetLastLevelCacheIndex(processor, out var cacheIndex) + ? new { Processor = processor, CacheIndex = (int?)cacheIndex } + : null) + .Where(item => item?.CacheIndex != null) + .GroupBy(item => item!.CacheIndex!.Value) + .OrderBy(group => group.Key); + + foreach (var group in l3Groups) + { + AddPreset( + presets, + resolvedOptions, + CreatePreset( + $"l3-group-{group.Key}-physical", + $"L3 group {group.Key} / physical cores", + $"Use one logical processor per core in L3/cache group {group.Key}.", + SelectOneLogicalProcessorPerCore(group.Select(item => item!.Processor), topology), + topology, + $"Based on LastLevelCacheIndex/L3 cache group {group.Key}, not on CPU SKU naming.")); + } + } + + var bestGamingSourceId = SelectBestGamingSourcePresetId(presets, resolvedOptions); + if (bestGamingSourceId != null) + { + var sourcePreset = presets.Single(preset => preset.PresetId == bestGamingSourceId); + AddPreset( + presets, + resolvedOptions, + CreatePreset( + "best-gaming", + "Best gaming suggestion", + "Suggested topology-aware starting point for games.", + sourcePreset.Selection.LogicalProcessors, + topology, + CreateBestGamingReason(bestGamingSourceId), + GamingWarning, + sourcePresetId: bestGamingSourceId)); + } + + AddPreset( + presets, + resolvedOptions, + CreatePreset( + "safe-compatibility", + "Safe compatibility", + "Use every logical processor for maximum compatibility.", + topology.LogicalProcessors, + topology, + "Maximum compatibility.")); + + // TODO: X3D CCD-only presets require reliable cache/topology detection. + // Do not generate X3D CCD-only until it can be detected with confidence. + return presets; + } + + private static string? SelectBestGamingSourcePresetId( + IReadOnlyList presets, + CpuPresetGenerationOptions options) + { + var orderedCandidates = options.ExcludeCpu0ForGaming + ? new[] + { + "p-cores-no-smt", + "l3-group-0-physical", + "all-physical-cores", + "all-except-cpu0", + "all-cores", + } + : new[] + { + "p-cores-no-smt", + "l3-group-0-physical", + "all-physical-cores", + "all-cores", + }; + + return orderedCandidates.FirstOrDefault(candidate => + presets.Any(preset => preset.PresetId == candidate)); + } + + private static string CreateBestGamingReason(string sourcePresetId) => + sourcePresetId switch + { + "p-cores-no-smt" => + "Selected P-cores without SMT because the topology exposes distinct performance and efficiency core classes.", + "l3-group-0-physical" => + "Selected physical cores from L3/cache group 0 because the topology exposes multiple L3 groups and no P/E core classes.", + "all-physical-cores" => + "Selected one logical processor per physical core because reliable CoreIndex metadata is available.", + "all-except-cpu0" => + "Selected all logical processors except CPU0 as a conservative gaming-oriented fallback.", + "all-cores" => + "Selected all logical processors as the safest fallback because no more specific topology preset was available.", + _ => + "Selected the best available topology-aware preset for this CPU.", + }; + + private static bool HasCoreIndexForAllProcessors(CpuTopologySnapshot topology) => + HasCoreIndexForProcessors(topology.LogicalProcessors, topology); + + private static bool HasCoreIndexForProcessors( + IEnumerable processors, + CpuTopologySnapshot topology) + { + var processorList = processors.ToList(); + return processorList.Count > 0 && + processorList.All(processor => topology.TryGetCoreIndex(processor, out _)); + } + + private static List SelectOneLogicalProcessorPerCore( + IEnumerable processors, + CpuTopologySnapshot topology) + { + return processors + .Select(processor => + { + topology.TryGetCoreIndex(processor, out var coreIndex); + return new + { + Processor = processor, + CoreIndex = coreIndex, + SmtSiblingCount = topology.GetSmtSiblingGlobalIndexes(processor).Count, + }; + }) + .GroupBy(item => item.CoreIndex) + .OrderBy(group => group.Key) + .Select(group => group + .OrderBy(item => item.Processor.GlobalIndex) + .ThenBy(item => item.SmtSiblingCount) + .First() + .Processor) + .ToList(); + } + + private static CpuPreset CreatePreset( + string presetId, + string name, + string description, + IEnumerable processors, + CpuTopologySnapshot topology, + string reason, + string? warning = null, + string? sourcePresetId = null, + bool reviewRequired = false) + { + var selectedProcessors = processors + .Distinct() + .OrderBy(processor => processor.GlobalIndex) + .ThenBy(processor => processor.Group) + .ThenBy(processor => processor.LogicalProcessorNumber) + .ToList(); + + return new CpuPreset + { + PresetId = presetId, + Name = name, + Description = description, + Selection = CpuSelection.FromProcessors(selectedProcessors, topology, reason), + Reason = reason, + SourcePresetId = sourcePresetId, + Warning = warning, + GeneratedByTopologySignature = topology.Signature, + IsUserEditable = true, + IsGenerated = true, + ReviewRequired = reviewRequired, + }; + } + + private static void AddPreset( + List presets, + CpuPresetGenerationOptions options, + CpuPreset preset) + { + if (options.DeletedGeneratedPresetIds.Contains(preset.PresetId) || + preset.Selection.LogicalProcessors.Count == 0 || + presets.Any(existing => existing.PresetId == preset.PresetId)) + { + return; + } + + var duplicateSamePurpose = presets.Any(existing => + existing.Reason == preset.Reason && + existing.Selection.GlobalLogicalProcessorIndexes.SequenceEqual( + preset.Selection.GlobalLogicalProcessorIndexes)); + if (duplicateSamePurpose) + { + return; + } + + presets.Add(preset); + } + } +} diff --git a/Services/CpuSelectionMigrationResult.cs b/Services/CpuSelectionMigrationResult.cs index 5b25804..7e88e3b 100644 --- a/Services/CpuSelectionMigrationResult.cs +++ b/Services/CpuSelectionMigrationResult.cs @@ -1,24 +1,8 @@ -/* - * 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 ThreadPilot.Models; - - public sealed record CpuSelectionMigrationResult( - CpuSelection Selection, - CpuSelectionMigrationMetadata Metadata); -} +namespace ThreadPilot.Services +{ + using ThreadPilot.Models; + + public sealed record CpuSelectionMigrationResult( + CpuSelection Selection, + CpuSelectionMigrationMetadata Metadata); +} diff --git a/Services/CpuSelectionMigrationService.cs b/Services/CpuSelectionMigrationService.cs index eb6799c..2c3008a 100644 --- a/Services/CpuSelectionMigrationService.cs +++ b/Services/CpuSelectionMigrationService.cs @@ -1,168 +1,152 @@ -/* - * 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 ThreadPilot.Models; - - public sealed class CpuSelectionMigrationService - { - public CpuSelectionMigrationResult MigrateFromLegacyAffinityMask( - long mask, - CpuTopologySnapshot topology) - { - ArgumentNullException.ThrowIfNull(topology); - - var selection = CpuSelection.FromLegacyAffinityMask(mask, topology); - var reviewRequired = topology.Signature.LogicalProcessorCount > 64 || - topology.Signature.ProcessorGroupCount > 1; - - return new CpuSelectionMigrationResult( - selection, - new CpuSelectionMigrationMetadata - { - CreatedFromLegacyAffinityMask = true, - ReviewRequired = reviewRequired, - MigrationConfidence = reviewRequired ? "Medium" : "High", - Reason = reviewRequired - ? "Migrated from a legacy affinity mask on a topology that may not be fully represented by legacy masks." - : "Migrated from a legacy affinity mask.", - TopologySignature = topology.Signature, - SourceLegacyAffinityMask = mask, - }); - } - - public CpuSelectionMigrationResult MigrateFromLegacyCoreMask( - IReadOnlyList coreMask, - CpuTopologySnapshot topology) - { - ArgumentNullException.ThrowIfNull(coreMask); - ArgumentNullException.ThrowIfNull(topology); - - var orderedProcessors = topology.LogicalProcessors - .OrderBy(processor => processor.GlobalIndex) - .ThenBy(processor => processor.Group) - .ThenBy(processor => processor.LogicalProcessorNumber) - .ToList(); - var selectedProcessors = orderedProcessors - .Take(Math.Min(coreMask.Count, orderedProcessors.Count)) - .Where((_, index) => coreMask[index]) - .ToList(); - var reviewRequired = coreMask.Count != orderedProcessors.Count; - var selection = CpuSelection.FromProcessors( - selectedProcessors, - topology, - "Migrated from legacy core mask"); - - return new CpuSelectionMigrationResult( - selection, - new CpuSelectionMigrationMetadata - { - CreatedFromLegacyCoreMask = true, - ReviewRequired = reviewRequired, - MigrationConfidence = reviewRequired ? "Medium" : "High", - Reason = reviewRequired - ? "Migrated from a legacy core mask whose length differs from the current topology." - : "Migrated from a legacy core mask.", - TopologySignature = topology.Signature, - }); - } - - public long? BuildLegacyAffinityMaskIfRepresentable(CpuSelection selection) => - CpuSelection.ToLegacyAffinityMaskOrNull(selection); - - public bool ShouldRequireReview( - CpuSelection selection, - CpuTopologySignature? savedSignature, - CpuTopologySnapshot currentTopology) - { - ArgumentNullException.ThrowIfNull(selection); - ArgumentNullException.ThrowIfNull(currentTopology); - - if (savedSignature == null || savedSignature != currentTopology.Signature) - { - return true; - } - - var currentProcessors = currentTopology.LogicalProcessors.ToHashSet(); - return selection.LogicalProcessors.Any(processor => !currentProcessors.Contains(processor)); - } - - public ProcessProfileSnapshot MigrateProcessProfile( - ProcessProfileSnapshot profile, - CpuTopologySnapshot topology) - { - ArgumentNullException.ThrowIfNull(profile); - ArgumentNullException.ThrowIfNull(topology); - - if (profile.CpuSelection != null) - { - profile.ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection; - profile.CpuSelectionMigration ??= new CpuSelectionMigrationMetadata - { - ReviewRequired = this.ShouldRequireReview( - profile.CpuSelection, - profile.CpuSelection.Metadata.TopologySignature, - topology), - MigrationConfidence = "High", - Reason = "Profile already contains a CpuSelection.", - TopologySignature = profile.CpuSelection.Metadata.TopologySignature, - SourceLegacyAffinityMask = profile.ProcessorAffinity, - }; - return profile; - } - - var migrated = this.MigrateFromLegacyAffinityMask(profile.ProcessorAffinity, topology); - profile.ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection; - profile.CpuSelection = migrated.Selection; - profile.CpuSelectionMigration = migrated.Metadata; - return profile; - } - - public ProcessProfileSnapshot PrepareProcessProfileForSave( - ProcessProfileSnapshot profile, - CpuTopologySnapshot topology) - { - ArgumentNullException.ThrowIfNull(profile); - ArgumentNullException.ThrowIfNull(topology); - - if (profile.CpuSelection == null) - { - this.MigrateProcessProfile(profile, topology); - } - - profile.ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection; - if (profile.CpuSelection != null) - { - var legacyMask = this.BuildLegacyAffinityMaskIfRepresentable(profile.CpuSelection); - if (legacyMask.HasValue) - { - profile.ProcessorAffinity = legacyMask.Value; - } - } - - profile.CpuSelectionMigration ??= new CpuSelectionMigrationMetadata - { - MigrationConfidence = "High", - Reason = "Saved with CpuSelection profile schema.", - TopologySignature = topology.Signature, - SourceLegacyAffinityMask = profile.ProcessorAffinity, - }; - - return profile; - } - } -} +namespace ThreadPilot.Services +{ + using ThreadPilot.Models; + + public sealed class CpuSelectionMigrationService + { + public CpuSelectionMigrationResult MigrateFromLegacyAffinityMask( + long mask, + CpuTopologySnapshot topology) + { + ArgumentNullException.ThrowIfNull(topology); + + var selection = CpuSelection.FromLegacyAffinityMask(mask, topology); + var reviewRequired = topology.Signature.LogicalProcessorCount > 64 || + topology.Signature.ProcessorGroupCount > 1; + + return new CpuSelectionMigrationResult( + selection, + new CpuSelectionMigrationMetadata + { + CreatedFromLegacyAffinityMask = true, + ReviewRequired = reviewRequired, + MigrationConfidence = reviewRequired ? "Medium" : "High", + Reason = reviewRequired + ? "Migrated from a legacy affinity mask on a topology that may not be fully represented by legacy masks." + : "Migrated from a legacy affinity mask.", + TopologySignature = topology.Signature, + SourceLegacyAffinityMask = mask, + }); + } + + public CpuSelectionMigrationResult MigrateFromLegacyCoreMask( + IReadOnlyList coreMask, + CpuTopologySnapshot topology) + { + ArgumentNullException.ThrowIfNull(coreMask); + ArgumentNullException.ThrowIfNull(topology); + + var orderedProcessors = topology.LogicalProcessors + .OrderBy(processor => processor.GlobalIndex) + .ThenBy(processor => processor.Group) + .ThenBy(processor => processor.LogicalProcessorNumber) + .ToList(); + var selectedProcessors = orderedProcessors + .Take(Math.Min(coreMask.Count, orderedProcessors.Count)) + .Where((_, index) => coreMask[index]) + .ToList(); + var reviewRequired = coreMask.Count != orderedProcessors.Count; + var selection = CpuSelection.FromProcessors( + selectedProcessors, + topology, + "Migrated from legacy core mask"); + + return new CpuSelectionMigrationResult( + selection, + new CpuSelectionMigrationMetadata + { + CreatedFromLegacyCoreMask = true, + ReviewRequired = reviewRequired, + MigrationConfidence = reviewRequired ? "Medium" : "High", + Reason = reviewRequired + ? "Migrated from a legacy core mask whose length differs from the current topology." + : "Migrated from a legacy core mask.", + TopologySignature = topology.Signature, + }); + } + + public long? BuildLegacyAffinityMaskIfRepresentable(CpuSelection selection) => + CpuSelection.ToLegacyAffinityMaskOrNull(selection); + + public bool ShouldRequireReview( + CpuSelection selection, + CpuTopologySignature? savedSignature, + CpuTopologySnapshot currentTopology) + { + ArgumentNullException.ThrowIfNull(selection); + ArgumentNullException.ThrowIfNull(currentTopology); + + if (savedSignature == null || savedSignature != currentTopology.Signature) + { + return true; + } + + var currentProcessors = currentTopology.LogicalProcessors.ToHashSet(); + return selection.LogicalProcessors.Any(processor => !currentProcessors.Contains(processor)); + } + + public ProcessProfileSnapshot MigrateProcessProfile( + ProcessProfileSnapshot profile, + CpuTopologySnapshot topology) + { + ArgumentNullException.ThrowIfNull(profile); + ArgumentNullException.ThrowIfNull(topology); + + if (profile.CpuSelection != null) + { + profile.ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection; + profile.CpuSelectionMigration ??= new CpuSelectionMigrationMetadata + { + ReviewRequired = this.ShouldRequireReview( + profile.CpuSelection, + profile.CpuSelection.Metadata.TopologySignature, + topology), + MigrationConfidence = "High", + Reason = "Profile already contains a CpuSelection.", + TopologySignature = profile.CpuSelection.Metadata.TopologySignature, + SourceLegacyAffinityMask = profile.ProcessorAffinity, + }; + return profile; + } + + var migrated = this.MigrateFromLegacyAffinityMask(profile.ProcessorAffinity, topology); + profile.ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection; + profile.CpuSelection = migrated.Selection; + profile.CpuSelectionMigration = migrated.Metadata; + return profile; + } + + public ProcessProfileSnapshot PrepareProcessProfileForSave( + ProcessProfileSnapshot profile, + CpuTopologySnapshot topology) + { + ArgumentNullException.ThrowIfNull(profile); + ArgumentNullException.ThrowIfNull(topology); + + if (profile.CpuSelection == null) + { + this.MigrateProcessProfile(profile, topology); + } + + profile.ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection; + if (profile.CpuSelection != null) + { + var legacyMask = this.BuildLegacyAffinityMaskIfRepresentable(profile.CpuSelection); + if (legacyMask.HasValue) + { + profile.ProcessorAffinity = legacyMask.Value; + } + } + + profile.CpuSelectionMigration ??= new CpuSelectionMigrationMetadata + { + MigrationConfidence = "High", + Reason = "Saved with CpuSelection profile schema.", + TopologySignature = topology.Signature, + SourceLegacyAffinityMask = profile.ProcessorAffinity, + }; + + return profile; + } + } +} diff --git a/Services/CpuTopologyService.cs b/Services/CpuTopologyService.cs index 5dcbf23..2a44da2 100644 --- a/Services/CpuTopologyService.cs +++ b/Services/CpuTopologyService.cs @@ -1,871 +1,836 @@ -/* - * 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.Diagnostics; - using System.Linq; - using System.Management; - using System.Runtime.InteropServices; - using System.Threading; - using System.Threading.Tasks; - using Microsoft.Extensions.Caching.Memory; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - - /// - /// Service for detecting CPU topology using WMI and Windows APIs. - /// - public class CpuTopologyService : ICpuTopologyService - { - private readonly ILogger logger; - private readonly IMemoryCache cache; - private readonly SemaphoreSlim detectSemaphore = new(1, 1); - private CpuTopologyModel? currentTopology; - - private const string TOPOLOGYCACHEKEY = "cpu_topology"; - private static readonly TimeSpan CACHEDURATION = TimeSpan.FromHours(1); - private const int ERRORINSUFFICIENTBUFFER = 122; - - public event EventHandler? TopologyDetected; - - public CpuTopologyModel? CurrentTopology => this.currentTopology; - - private enum LOGICAL_PROCESSOR_RELATIONSHIP - { - RelationProcessorCore = 0, - RelationNumaNode = 1, - RelationCache = 2, - RelationProcessorPackage = 3, - RelationGroup = 4, - RelationProcessorDie = 5, - RelationNumaNodeEx = 6, - RelationProcessorModule = 7, - RelationAll = 0xFFFF, - } - - [StructLayout(LayoutKind.Sequential)] - private struct GROUP_AFFINITY - { - public UIntPtr Mask; - public ushort Group; - public ushort Reserved0; - public ushort Reserved1; - public ushort Reserved2; - } - - [StructLayout(LayoutKind.Sequential)] - private unsafe struct PROCESSOR_RELATIONSHIP - { - public byte Flags; - public byte EfficiencyClass; - public fixed byte Reserved[20]; - public ushort GroupCount; - public GROUP_AFFINITY GroupMask; - } - - [StructLayout(LayoutKind.Sequential)] - private unsafe struct SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX - { - public LOGICAL_PROCESSOR_RELATIONSHIP Relationship; - public int Size; - public PROCESSOR_RELATIONSHIP Processor; - } - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool GetLogicalProcessorInformationEx( - LOGICAL_PROCESSOR_RELATIONSHIP relationshipType, - IntPtr buffer, - ref int returnedLength); - - public CpuTopologyService(ILogger logger, IMemoryCache? cache = null) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.cache = cache ?? new MemoryCache(new MemoryCacheOptions - { - SizeLimit = 10, - CompactionPercentage = 0.1, - }); - } - - public async Task DetectTopologyAsync() - { - // PERFORMANCE IMPROVEMENT: Check cache first to avoid expensive WMI calls - if (this.cache.TryGetValue(TOPOLOGYCACHEKEY, out CpuTopologyModel? cachedTopology) && cachedTopology != null) - { - this.logger.LogInformation("CPU topology retrieved from cache"); - this.currentTopology = cachedTopology; - return cachedTopology; - } - - await this.detectSemaphore.WaitAsync(); - - try - { - // Re-check cache after entering the critical section - if (this.cache.TryGetValue(TOPOLOGYCACHEKEY, out cachedTopology) && cachedTopology != null) - { - this.logger.LogInformation("CPU topology retrieved from cache after synchronization"); - this.currentTopology = cachedTopology; - return cachedTopology; - } - - this.logger.LogInformation("Starting CPU topology detection (cache miss)"); - - var topology = new CpuTopologyModel(); - - // Get basic system information - await this.DetectBasicCpuInfoAsync(topology); - - // Detect logical cores using multiple methods - await this.DetectLogicalCoresAsync(topology); - - // Try to detect advanced topology (CCD, P/E cores, etc.) - await this.DetectAdvancedTopologyAsync(topology); - - // Validate and finalize topology - this.ValidateTopology(topology); - - this.currentTopology = topology; - topology.TopologyDetectionSuccessful = true; - - // PERFORMANCE IMPROVEMENT: Cache the topology to avoid expensive WMI calls - this.cache.Set( - TOPOLOGYCACHEKEY, - topology, - new MemoryCacheEntryOptions() - .SetAbsoluteExpiration(CACHEDURATION) - .SetSize(1)); - - this.logger.LogInformation( - "CPU topology detection completed successfully and cached. " + - "Logical CPUs: {LogicalCores}, Physical CPUs: {PhysicalCores}, " + - "Sockets: {Sockets}, HT: {HasHT}, Hybrid: {HasHybrid}, CCD: {HasCcd}", - topology.TotalLogicalCores, topology.TotalPhysicalCores, topology.TotalSockets, - topology.HasHyperThreading, topology.HasIntelHybrid, topology.HasAmdCcd); - - this.TopologyDetected?.Invoke(this, new CpuTopologyDetectedEventArgs(topology, true)); - return topology; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to detect CPU topology"); - - // Create fallback topology - var fallbackTopology = this.CreateFallbackTopology(); - this.currentTopology = fallbackTopology; - - this.TopologyDetected?.Invoke(this, new CpuTopologyDetectedEventArgs(fallbackTopology, false, ex.Message)); - return fallbackTopology; - } - finally - { - this.detectSemaphore.Release(); - } - } - - private async Task DetectBasicCpuInfoAsync(CpuTopologyModel topology) - { - try - { - using var searcher = new ManagementObjectSearcher("SELECT * FROM Win32_Processor"); - using var collection = searcher.Get(); - - foreach (ManagementObject processor in collection) - { - topology.CpuBrand = processor["Name"]?.ToString() ?? "Unknown"; - topology.CpuArchitecture = processor["Architecture"]?.ToString() ?? "Unknown"; - break; // Take first processor for basic info - } - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to detect basic CPU info via WMI"); - } - } - - private async Task DetectLogicalCoresAsync(CpuTopologyModel topology) - { - try - { - // Method 1: Use official Windows topology API for physical/logical CPU mapping. - if (this.TryDetectCoresViaWindowsApi(topology)) - { - return; - } - - // Method 2: Use Environment.ProcessorCount as baseline - int logicalCoreCount = Environment.ProcessorCount; - - // Method 3: Try WMI for more detailed information - await this.DetectCoresViaWmiAsync(topology); - - // If WMI failed, create basic topology - if (topology.LogicalCores.Count == 0) - { - this.CreateBasicTopology(topology, logicalCoreCount); - } - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to detect logical cores, using fallback"); - this.CreateBasicTopology(topology, Environment.ProcessorCount); - } - } - - private bool TryDetectCoresViaWindowsApi(CpuTopologyModel topology) - { - try - { - int requiredLength = 0; - if (GetLogicalProcessorInformationEx(LOGICAL_PROCESSOR_RELATIONSHIP.RelationProcessorCore, IntPtr.Zero, ref requiredLength)) - { - // Expected first call should fail with insufficient buffer. - return false; - } - - int firstError = Marshal.GetLastWin32Error(); - if (firstError != ERRORINSUFFICIENTBUFFER || requiredLength <= 0) - { - this.logger.LogWarning("GetLogicalProcessorInformationEx probe failed with Win32 error {Error}", firstError); - return false; - } - - IntPtr buffer = Marshal.AllocHGlobal(requiredLength); - try - { - if (!GetLogicalProcessorInformationEx(LOGICAL_PROCESSOR_RELATIONSHIP.RelationProcessorCore, buffer, ref requiredLength)) - { - this.logger.LogWarning("GetLogicalProcessorInformationEx read failed with Win32 error {Error}", Marshal.GetLastWin32Error()); - return false; - } - - var discovered = new List<(int PhysicalCpuId, int LogicalCpuId, byte EfficiencyClass)>(); - int offset = 0; - int physicalCpuId = 0; - - while (offset < requiredLength) - { - IntPtr itemPtr = IntPtr.Add(buffer, offset); - var info = Marshal.PtrToStructure(itemPtr); - - if (info.Size <= 0) - { - break; - } - - if (info.Relationship == LOGICAL_PROCESSOR_RELATIONSHIP.RelationProcessorCore) - { - var processor = info.Processor; - int groupCount = processor.GroupCount; - int groupMaskOffset = Marshal.OffsetOf(nameof(PROCESSOR_RELATIONSHIP.GroupMask)).ToInt32(); - IntPtr groupMaskPtr = IntPtr.Add(itemPtr, 8 + groupMaskOffset); - - var logicalCpuIdsForCore = new List(); - - for (int g = 0; g < groupCount; g++) - { - int stride = Marshal.SizeOf(); - var groupAffinity = Marshal.PtrToStructure(IntPtr.Add(groupMaskPtr, g * stride)); - - // This app currently represents affinity with a single 64-bit mask. - if (groupAffinity.Group != 0) - { - this.logger.LogWarning("Detected processor group {Group}; falling back to WMI/core-count topology path", groupAffinity.Group); - return false; - } - - ulong mask = groupAffinity.Mask.ToUInt64(); - logicalCpuIdsForCore.AddRange(GetSetBitIndices(mask)); - } - - foreach (int logicalCpuId in logicalCpuIdsForCore.Distinct().OrderBy(id => id)) - { - discovered.Add((physicalCpuId, logicalCpuId, processor.EfficiencyClass)); - } - - physicalCpuId++; - } - - offset += info.Size; - } - - if (discovered.Count == 0) - { - return false; - } - - topology.LogicalCores.Clear(); - foreach (var entry in discovered.OrderBy(d => d.LogicalCpuId)) - { - topology.LogicalCores.Add(new CpuCoreModel - { - LogicalCoreId = entry.LogicalCpuId, - PhysicalCoreId = entry.PhysicalCpuId, - SocketId = 0, - CoreType = CpuCoreType.Standard, - Label = $"CPU {entry.LogicalCpuId}", - LogicalProcessorName = $"CPU{entry.PhysicalCpuId}_T0", - IsEnabled = true, - }); - } - - this.ApplyHyperThreadingFromPhysicalMapping(topology); - this.ApplyCoreTypeFromEfficiencyClass(topology, discovered); - - this.logger.LogInformation( - "Detected CPU topology via GetLogicalProcessorInformationEx: {LogicalCpuCount} logical CPUs, {PhysicalCpuCount} physical CPUs", - topology.TotalLogicalCores, - topology.TotalPhysicalCores); - - return true; - } - finally - { - Marshal.FreeHGlobal(buffer); - } - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "GetLogicalProcessorInformationEx topology detection failed"); - return false; - } - } - - private static IEnumerable GetSetBitIndices(ulong mask) - { - for (int bit = 0; bit < 64; bit++) - { - if ((mask & (1UL << bit)) != 0) - { - yield return bit; - } - } - } - - private void ApplyHyperThreadingFromPhysicalMapping(CpuTopologyModel topology) - { - foreach (var coreGroup in topology.LogicalCores.GroupBy(c => c.PhysicalCoreId)) - { - var siblings = coreGroup.OrderBy(c => c.LogicalCoreId).ToList(); - if (siblings.Count <= 1) - { - continue; - } - - for (int i = 0; i < siblings.Count; i++) - { - var isLogicalSibling = i > 0; - siblings[i].IsHyperThreaded = isLogicalSibling; - siblings[i].HyperThreadSibling = siblings.Count >= 2 - ? (isLogicalSibling ? siblings[0].LogicalCoreId : siblings[1].LogicalCoreId) - : null; - - siblings[i].LogicalProcessorName = $"CPU{siblings[i].PhysicalCoreId}_T{i}"; - } - } - } - - private void ApplyCoreTypeFromEfficiencyClass( - CpuTopologyModel topology, - List<(int PhysicalCpuId, int LogicalCpuId, byte EfficiencyClass)> discovered) - { - var byPhysical = discovered - .GroupBy(d => d.PhysicalCpuId) - .Select(g => new { PhysicalCpuId = g.Key, EfficiencyClass = g.Min(x => x.EfficiencyClass) }) - .ToList(); - - var classes = byPhysical - .Select(x => x.EfficiencyClass) - .Distinct() - .OrderBy(v => v) - .ToList(); - - if (classes.Count <= 1) - { - return; - } - - byte performanceClass = classes.Min(); - foreach (var logicalCpu in topology.LogicalCores) - { - byte classValue = byPhysical.First(x => x.PhysicalCpuId == logicalCpu.PhysicalCoreId).EfficiencyClass; - logicalCpu.CoreType = classValue == performanceClass - ? CpuCoreType.PerformanceCore - : CpuCoreType.EfficiencyCore; - } - } - - private async Task DetectCoresViaWmiAsync(CpuTopologyModel topology) - { - try - { - // First, get physical processor information - var physicalCoreCount = 0; - var logicalCoreCount = 0; - - using (var processorSearcher = new ManagementObjectSearcher("SELECT * FROM Win32_Processor")) - using (var processorCollection = processorSearcher.Get()) - { - foreach (ManagementObject processor in processorCollection) - { - var numberOfCores = Convert.ToInt32(processor["NumberOfCores"] ?? 0); - var numberOfLogicalProcessors = Convert.ToInt32(processor["NumberOfLogicalProcessors"] ?? 0); - - physicalCoreCount += numberOfCores; - logicalCoreCount += numberOfLogicalProcessors; - - this.logger.LogInformation( - "Detected CPU: {Cores} physical CPUs, {LogicalProcessors} logical processors", - numberOfCores, numberOfLogicalProcessors); - } - } - - // If WMI didn't provide the info, fall back to Environment.ProcessorCount - if (logicalCoreCount == 0) - { - logicalCoreCount = Environment.ProcessorCount; - physicalCoreCount = logicalCoreCount; // Assume no HT if we can't detect - } - - // Create logical cores with proper physical core mapping - var hasHyperThreading = logicalCoreCount > physicalCoreCount; - var threadsPerCore = hasHyperThreading ? logicalCoreCount / physicalCoreCount : 1; - - for (int logicalId = 0; logicalId < logicalCoreCount; logicalId++) - { - var physicalId = logicalId / threadsPerCore; - var threadIndexOnCore = logicalId % threadsPerCore; - var isHyperThreaded = hasHyperThreading && (threadIndexOnCore != 0); - var htSibling = hasHyperThreading ? (threadIndexOnCore == 0 ? logicalId + 1 : logicalId - 1) : (int?)null; - - var core = new CpuCoreModel - { - LogicalCoreId = logicalId, - PhysicalCoreId = physicalId, - SocketId = 0, // Will be refined later - Label = $"CPU {logicalId}", - LogicalProcessorName = $"CPU{physicalId}_T{threadIndexOnCore}", // T0 = physical, T1+ = SMT - IsEnabled = true, - IsHyperThreaded = isHyperThreaded, - HyperThreadSibling = htSibling, - }; - - topology.LogicalCores.Add(core); - } - - this.logger.LogInformation( - "Created topology: {LogicalCores} logical CPUs, {PhysicalCores} physical CPUs, HT: {HasHT}", - logicalCoreCount, physicalCoreCount, hasHyperThreading); - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "WMI logical processor detection failed"); - } - } - - private void CreateBasicTopology(CpuTopologyModel topology, int logicalCoreCount) - { - topology.LogicalCores.Clear(); - - for (int i = 0; i < logicalCoreCount; i++) - { - var core = new CpuCoreModel - { - LogicalCoreId = i, - PhysicalCoreId = i, // Assume no HT for basic topology - SocketId = 0, - CoreType = CpuCoreType.Standard, - Label = $"CPU {i}", - LogicalProcessorName = $"CPU{i}_T0", // All physical CPUs in basic fallback (no HT detected) - IsEnabled = true, - }; - - topology.LogicalCores.Add(core); - } - } - - private async Task DetectAdvancedTopologyAsync(CpuTopologyModel topology) - { - // Try to detect Intel Hybrid (P/E cores) - await this.DetectIntelHybridAsync(topology); - - // Try to detect AMD CCD information - await this.DetectAmdCcdAsync(topology); - - // Try to detect HyperThreading - this.DetectHyperThreading(topology); - } - - private async Task DetectIntelHybridAsync(CpuTopologyModel topology) - { - try - { - // Intel Hybrid detection is complex and requires specific APIs - // For now, we'll use heuristics based on CPU brand and core count patterns - if (topology.CpuBrand.Contains("Intel", StringComparison.OrdinalIgnoreCase)) - { - // Preserve already-detected core type data from official API if present. - if (topology.LogicalCores.Any(c => c.CoreType == CpuCoreType.PerformanceCore || c.CoreType == CpuCoreType.EfficiencyCore)) - { - return; - } - - // Check for 12th gen or later Intel processors (Alder Lake+) - if (topology.CpuBrand.Contains("12th") || topology.CpuBrand.Contains("13th") || - topology.CpuBrand.Contains("14th") || topology.CpuBrand.Contains("15th")) - { - // Heuristic: Assume first cores are P-cores, later ones are E-cores - // This is a simplified approach - real detection would require CPUID - var totalCores = topology.LogicalCores.Count; - var estimatedPCores = Math.Min(8, totalCores / 2); // Rough estimate - - for (int i = 0; i < topology.LogicalCores.Count; i++) - { - if (i < estimatedPCores * 2) // P-cores with HT - { - topology.LogicalCores[i].CoreType = CpuCoreType.PerformanceCore; - } - else - { - topology.LogicalCores[i].CoreType = CpuCoreType.EfficiencyCore; - } - } - } - } - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to detect Intel Hybrid topology"); - } - } - - private async Task DetectAmdCcdAsync(CpuTopologyModel topology) - { - try - { - if (topology.CpuBrand.Contains("AMD", StringComparison.OrdinalIgnoreCase)) - { - // AMD CCD detection - improved heuristic - // Only assign CCD if we actually have multiple CCDs - var totalPhysicalCores = topology.TotalPhysicalCores; - var coresPerCcd = 8; // Typical for Zen 2/3/4 - - // Only assign CCD IDs if we have more than 8 physical cores (indicating multiple CCDs) - if (totalPhysicalCores > coresPerCcd) - { - for (int i = 0; i < topology.LogicalCores.Count; i++) - { - var physicalCoreId = topology.LogicalCores[i].PhysicalCoreId; - topology.LogicalCores[i].CcdId = physicalCoreId / coresPerCcd; - topology.LogicalCores[i].CoreType = CpuCoreType.Zen3; // Default assumption - } - - this.logger.LogInformation( - "Detected AMD multi-CCD configuration: {PhysicalCores} physical cores, estimated {CcdCount} CCDs", - totalPhysicalCores, (totalPhysicalCores + coresPerCcd - 1) / coresPerCcd); - } - else - { - // Single CCD or small core count - don't assign CCD IDs - foreach (var core in topology.LogicalCores) - { - core.CoreType = CpuCoreType.Zen3; // Default assumption - } - - this.logger.LogInformation("Detected AMD single-CCD configuration: {PhysicalCores} physical cores", totalPhysicalCores); - } - } - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to detect AMD CCD topology"); - } - } - - private void DetectHyperThreading(CpuTopologyModel topology) - { - try - { - // Normalize HT metadata by physical CPU mapping first. - var groupedByPhysical = topology.LogicalCores - .GroupBy(c => c.PhysicalCoreId) - .Select(g => g.OrderBy(c => c.LogicalCoreId).ToList()) - .ToList(); - - if (groupedByPhysical.Any(g => g.Count > 1)) - { - foreach (var siblings in groupedByPhysical) - { - for (int i = 0; i < siblings.Count; i++) - { - var isLogicalSibling = i > 0; - siblings[i].IsHyperThreaded = isLogicalSibling; - siblings[i].HyperThreadSibling = siblings.Count >= 2 - ? (isLogicalSibling ? siblings[0].LogicalCoreId : siblings[1].LogicalCoreId) - : null; - - if (string.IsNullOrWhiteSpace(siblings[i].LogicalProcessorName)) - { - siblings[i].LogicalProcessorName = $"CPU{siblings[i].PhysicalCoreId}_T{i}"; - } - } - } - - return; - } - - // Fallback HT detection: if we only have flat sequential data. - var logicalCount = topology.LogicalCores.Count; - var physicalCount = topology.TotalPhysicalCores; - - if (logicalCount > physicalCount) - { - // Mark pairs as primary/logical siblings conservatively. - for (int i = 0; i < topology.LogicalCores.Count; i += 2) - { - if (i + 1 < topology.LogicalCores.Count) - { - topology.LogicalCores[i].IsHyperThreaded = false; - topology.LogicalCores[i].HyperThreadSibling = i + 1; - topology.LogicalCores[i + 1].IsHyperThreaded = true; - topology.LogicalCores[i + 1].HyperThreadSibling = i; - } - } - } - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to detect HyperThreading"); - } - } - - private void ValidateTopology(CpuTopologyModel topology) - { - // Ensure we have at least one core - if (topology.LogicalCores.Count == 0) - { - this.CreateBasicTopology(topology, Environment.ProcessorCount); - } - - if (string.IsNullOrWhiteSpace(topology.CpuBrand)) - { - topology.CpuBrand = "Unknown"; - } - - if (string.IsNullOrWhiteSpace(topology.CpuArchitecture)) - { - topology.CpuArchitecture = RuntimeInformation.ProcessArchitecture.ToString(); - } - - // Ensure logical core IDs are sequential - for (int i = 0; i < topology.LogicalCores.Count; i++) - { - topology.LogicalCores[i].LogicalCoreId = i; - } - - // Normalize physical core IDs to avoid invalid/negative mappings - var normalizedPhysicalCoreIds = topology.LogicalCores - .Select((core, index) => new - { - core, - physical = core.PhysicalCoreId >= 0 ? core.PhysicalCoreId : index, - }) - .GroupBy(x => x.physical) - .OrderBy(g => g.Key) - .Select((group, normalizedId) => new { group, normalizedId }) - .ToList(); - - foreach (var item in normalizedPhysicalCoreIds) - { - foreach (var entry in item.group) - { - entry.core.PhysicalCoreId = item.normalizedId; - } - } - - // Update labels with explicit logical-to-physical CPU mapping. - foreach (var core in topology.LogicalCores) - { - var typeLabel = core.CoreType switch - { - CpuCoreType.PerformanceCore => "P-", - CpuCoreType.EfficiencyCore => "E-", - _ => string.Empty, - }; - - var threadIndex = GetThreadIndexOnPhysicalCpu(core, topology); - var roleLabel = threadIndex == 0 - ? $"PH{core.PhysicalCoreId}" - : $"L{threadIndex}/PH{core.PhysicalCoreId}"; - - core.Label = $"{typeLabel}CPU {core.LogicalCoreId} ({roleLabel})"; - - if (string.IsNullOrWhiteSpace(core.LogicalProcessorName)) - { - threadIndex = Math.Max(0, core.LogicalCoreId - core.PhysicalCoreId); - core.LogicalProcessorName = $"CPU{core.PhysicalCoreId}_T{threadIndex}"; - } - } - } - - private static int GetThreadIndexOnPhysicalCpu(CpuCoreModel core, CpuTopologyModel topology) - { - if (!string.IsNullOrWhiteSpace(core.LogicalProcessorName)) - { - var marker = core.LogicalProcessorName.LastIndexOf("_T", StringComparison.Ordinal); - if (marker >= 0) - { - var suffix = core.LogicalProcessorName[(marker + 2)..]; - if (int.TryParse(suffix, out int parsedIndex)) - { - return Math.Max(0, parsedIndex); - } - } - } - - var orderedSiblings = topology.LogicalCores - .Where(c => c.PhysicalCoreId == core.PhysicalCoreId) - .OrderBy(c => c.LogicalCoreId) - .ToList(); - - var index = orderedSiblings.FindIndex(c => c.LogicalCoreId == core.LogicalCoreId); - return index >= 0 ? index : 0; - } - - private CpuTopologyModel CreateFallbackTopology() - { - var topology = new CpuTopologyModel(); - this.CreateBasicTopology(topology, Environment.ProcessorCount); - topology.TopologyDetectionSuccessful = false; - return topology; - } - - private long CalculateFullAffinityMask(int logicalCoreCount) - { - // Affinity masks are represented as signed 64-bit values in this application. - // For 63+ logical cores, use all available bits to avoid undefined shifts. - return logicalCoreCount >= 63 - ? -1L - : (1L << logicalCoreCount) - 1; - } - - public IEnumerable GetAffinityPresets() - { - if (this.currentTopology == null) - { - return Enumerable.Empty(); - } - - var presets = new List(); - - // All CPUs preset - presets.Add(new CpuAffinityPreset - { - Name = "All CPUs", - Description = $"All {this.currentTopology.TotalLogicalCores} logical CPUs", - AffinityMask = this.CalculateFullAffinityMask(this.currentTopology.TotalLogicalCores), - IsAvailable = true, - }); - - // Physical CPUs only (if HT is available) - if (this.currentTopology.HasHyperThreading) - { - presets.Add(new CpuAffinityPreset - { - Name = "No HT", - Description = $"All {this.currentTopology.TotalPhysicalCores} physical CPUs (no Hyper-Threading)", - AffinityMask = this.currentTopology.GetPhysicalCoresAffinityMask(), - IsAvailable = this.currentTopology.GetPhysicalCoresAffinityMask() != 0, - }); - } - - // Performance CPUs (Intel Hybrid) - if (this.currentTopology.HasIntelHybrid && this.currentTopology.PerformanceCores.Any()) - { - presets.Add(new CpuAffinityPreset - { - Name = "Performance CPUs", - Description = $"Intel P-CPUs ({this.currentTopology.PerformanceCores.Count()} logical CPUs)", - AffinityMask = this.currentTopology.GetPerformanceCoresAffinityMask(), - IsAvailable = this.currentTopology.GetPerformanceCoresAffinityMask() != 0, - }); - } - - // Efficiency CPUs (Intel Hybrid) - if (this.currentTopology.HasIntelHybrid && this.currentTopology.EfficiencyCores.Any()) - { - presets.Add(new CpuAffinityPreset - { - Name = "Efficiency CPUs", - Description = $"Intel E-CPUs ({this.currentTopology.EfficiencyCores.Count()} logical CPUs)", - AffinityMask = this.currentTopology.GetEfficiencyCoresAffinityMask(), - IsAvailable = this.currentTopology.GetEfficiencyCoresAffinityMask() != 0, - }); - } - - // CCD presets (AMD) - if (this.currentTopology.HasAmdCcd) - { - foreach (var ccdId in this.currentTopology.AvailableCcds) - { - var ccdCores = this.currentTopology.GetCoresByCcd(ccdId); - presets.Add(new CpuAffinityPreset - { - Name = $"CCD {ccdId}", - Description = $"AMD CCD {ccdId} ({ccdCores.Count()} logical CPUs)", - AffinityMask = this.currentTopology.GetCcdAffinityMask(ccdId), - IsAvailable = this.currentTopology.GetCcdAffinityMask(ccdId) != 0, - }); - } - } - - return presets; - } - - public bool IsAffinityMaskValid(long affinityMask) - { - if (this.currentTopology == null) - { - return false; - } - - // Long-based affinity masks cannot represent cores beyond bit 62 explicitly. - // Accept any non-zero mask for large-core systems and let runtime APIs enforce final validity. - if (this.currentTopology.TotalLogicalCores >= 63) - { - return affinityMask != 0; - } - - var maxMask = this.CalculateFullAffinityMask(this.currentTopology.TotalLogicalCores); - return affinityMask > 0 && affinityMask <= maxMask; - } - - public int GetMaxLogicalCores() - { - return this.currentTopology?.TotalLogicalCores ?? Environment.ProcessorCount; - } - - public async Task RefreshTopologyAsync() - { - this.cache.Remove(TOPOLOGYCACHEKEY); - await this.DetectTopologyAsync(); - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Linq; + using System.Management; + using System.Runtime.InteropServices; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public class CpuTopologyService : ICpuTopologyService + { + private readonly ILogger logger; + private readonly SemaphoreSlim detectSemaphore = new(1, 1); + private CpuTopologyModel? currentTopology; + private DateTime topologyCachedAtUtc = DateTime.MinValue; + + private static readonly TimeSpan CACHEDURATION = TimeSpan.FromHours(1); + private const int ERRORINSUFFICIENTBUFFER = 122; + + public event EventHandler? TopologyDetected; + + public CpuTopologyModel? CurrentTopology => this.currentTopology; + + private enum LOGICAL_PROCESSOR_RELATIONSHIP + { + RelationProcessorCore = 0, + RelationNumaNode = 1, + RelationCache = 2, + RelationProcessorPackage = 3, + RelationGroup = 4, + RelationProcessorDie = 5, + RelationNumaNodeEx = 6, + RelationProcessorModule = 7, + RelationAll = 0xFFFF, + } + + [StructLayout(LayoutKind.Sequential)] + private struct GROUP_AFFINITY + { + public UIntPtr Mask; + public ushort Group; + public ushort Reserved0; + public ushort Reserved1; + public ushort Reserved2; + } + + [StructLayout(LayoutKind.Sequential)] + private unsafe struct PROCESSOR_RELATIONSHIP + { + public byte Flags; + public byte EfficiencyClass; + public fixed byte Reserved[20]; + public ushort GroupCount; + public GROUP_AFFINITY GroupMask; + } + + [StructLayout(LayoutKind.Sequential)] + private unsafe struct SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX + { + public LOGICAL_PROCESSOR_RELATIONSHIP Relationship; + public int Size; + public PROCESSOR_RELATIONSHIP Processor; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetLogicalProcessorInformationEx( + LOGICAL_PROCESSOR_RELATIONSHIP relationshipType, + IntPtr buffer, + ref int returnedLength); + + public CpuTopologyService(ILogger logger) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task DetectTopologyAsync() + { + if (this.currentTopology != null && DateTime.UtcNow - this.topologyCachedAtUtc < CACHEDURATION) + { + this.logger.LogInformation("CPU topology retrieved from cache"); + return this.currentTopology; + } + + await this.detectSemaphore.WaitAsync(); + + try + { + if (this.currentTopology != null && DateTime.UtcNow - this.topologyCachedAtUtc < CACHEDURATION) + { + this.logger.LogInformation("CPU topology retrieved from cache after synchronization"); + return this.currentTopology; + } + + this.logger.LogInformation("Starting CPU topology detection (cache miss)"); + + var topology = new CpuTopologyModel(); + + // Get basic system information + await this.DetectBasicCpuInfoAsync(topology); + + // Detect logical cores using multiple methods + await this.DetectLogicalCoresAsync(topology); + + // Try to detect advanced topology (CCD, P/E cores, etc.) + await this.DetectAdvancedTopologyAsync(topology); + + // Validate and finalize topology + this.ValidateTopology(topology); + + this.currentTopology = topology; + topology.TopologyDetectionSuccessful = true; + + this.topologyCachedAtUtc = DateTime.UtcNow; + + this.logger.LogInformation( + "CPU topology detection completed successfully and cached. " + + "Logical CPUs: {LogicalCores}, Physical CPUs: {PhysicalCores}, " + + "Sockets: {Sockets}, HT: {HasHT}, Hybrid: {HasHybrid}, CCD: {HasCcd}", + topology.TotalLogicalCores, topology.TotalPhysicalCores, topology.TotalSockets, + topology.HasHyperThreading, topology.HasIntelHybrid, topology.HasAmdCcd); + + this.TopologyDetected?.Invoke(this, new CpuTopologyDetectedEventArgs(topology, true)); + return topology; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to detect CPU topology"); + + // Create fallback topology + var fallbackTopology = this.CreateFallbackTopology(); + this.currentTopology = fallbackTopology; + + this.TopologyDetected?.Invoke(this, new CpuTopologyDetectedEventArgs(fallbackTopology, false, ex.Message)); + return fallbackTopology; + } + finally + { + this.detectSemaphore.Release(); + } + } + + private async Task DetectBasicCpuInfoAsync(CpuTopologyModel topology) + { + try + { + using var searcher = new ManagementObjectSearcher("SELECT * FROM Win32_Processor"); + using var collection = searcher.Get(); + + foreach (ManagementObject processor in collection) + { + topology.CpuBrand = processor["Name"]?.ToString() ?? "Unknown"; + topology.CpuArchitecture = processor["Architecture"]?.ToString() ?? "Unknown"; + break; // Take first processor for basic info + } + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to detect basic CPU info via WMI"); + } + } + + private async Task DetectLogicalCoresAsync(CpuTopologyModel topology) + { + try + { + // Method 1: Use official Windows topology API for physical/logical CPU mapping. + if (this.TryDetectCoresViaWindowsApi(topology)) + { + return; + } + + // Method 2: Use Environment.ProcessorCount as baseline + int logicalCoreCount = Environment.ProcessorCount; + + // Method 3: Try WMI for more detailed information + await this.DetectCoresViaWmiAsync(topology); + + // If WMI failed, create basic topology + if (topology.LogicalCores.Count == 0) + { + this.CreateBasicTopology(topology, logicalCoreCount); + } + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to detect logical cores, using fallback"); + this.CreateBasicTopology(topology, Environment.ProcessorCount); + } + } + + private bool TryDetectCoresViaWindowsApi(CpuTopologyModel topology) + { + try + { + int requiredLength = 0; + if (GetLogicalProcessorInformationEx(LOGICAL_PROCESSOR_RELATIONSHIP.RelationProcessorCore, IntPtr.Zero, ref requiredLength)) + { + // Expected first call should fail with insufficient buffer. + return false; + } + + int firstError = Marshal.GetLastWin32Error(); + if (firstError != ERRORINSUFFICIENTBUFFER || requiredLength <= 0) + { + this.logger.LogWarning("GetLogicalProcessorInformationEx probe failed with Win32 error {Error}", firstError); + return false; + } + + IntPtr buffer = Marshal.AllocHGlobal(requiredLength); + try + { + if (!GetLogicalProcessorInformationEx(LOGICAL_PROCESSOR_RELATIONSHIP.RelationProcessorCore, buffer, ref requiredLength)) + { + this.logger.LogWarning("GetLogicalProcessorInformationEx read failed with Win32 error {Error}", Marshal.GetLastWin32Error()); + return false; + } + + var discovered = new List<(int PhysicalCpuId, int LogicalCpuId, byte EfficiencyClass)>(); + int offset = 0; + int physicalCpuId = 0; + + while (offset < requiredLength) + { + IntPtr itemPtr = IntPtr.Add(buffer, offset); + var info = Marshal.PtrToStructure(itemPtr); + + if (info.Size <= 0) + { + break; + } + + if (info.Relationship == LOGICAL_PROCESSOR_RELATIONSHIP.RelationProcessorCore) + { + var processor = info.Processor; + int groupCount = processor.GroupCount; + int groupMaskOffset = Marshal.OffsetOf(nameof(PROCESSOR_RELATIONSHIP.GroupMask)).ToInt32(); + IntPtr groupMaskPtr = IntPtr.Add(itemPtr, 8 + groupMaskOffset); + + var logicalCpuIdsForCore = new List(); + + for (int g = 0; g < groupCount; g++) + { + int stride = Marshal.SizeOf(); + var groupAffinity = Marshal.PtrToStructure(IntPtr.Add(groupMaskPtr, g * stride)); + + // This app currently represents affinity with a single 64-bit mask. + if (groupAffinity.Group != 0) + { + this.logger.LogWarning("Detected processor group {Group}; falling back to WMI/core-count topology path", groupAffinity.Group); + return false; + } + + ulong mask = groupAffinity.Mask.ToUInt64(); + logicalCpuIdsForCore.AddRange(GetSetBitIndices(mask)); + } + + foreach (int logicalCpuId in logicalCpuIdsForCore.Distinct().OrderBy(id => id)) + { + discovered.Add((physicalCpuId, logicalCpuId, processor.EfficiencyClass)); + } + + physicalCpuId++; + } + + offset += info.Size; + } + + if (discovered.Count == 0) + { + return false; + } + + topology.LogicalCores.Clear(); + foreach (var entry in discovered.OrderBy(d => d.LogicalCpuId)) + { + topology.LogicalCores.Add(new CpuCoreModel + { + LogicalCoreId = entry.LogicalCpuId, + PhysicalCoreId = entry.PhysicalCpuId, + SocketId = 0, + CoreType = CpuCoreType.Standard, + Label = $"CPU {entry.LogicalCpuId}", + LogicalProcessorName = $"CPU{entry.PhysicalCpuId}_T0", + IsEnabled = true, + }); + } + + this.ApplyHyperThreadingFromPhysicalMapping(topology); + this.ApplyCoreTypeFromEfficiencyClass(topology, discovered); + + this.logger.LogInformation( + "Detected CPU topology via GetLogicalProcessorInformationEx: {LogicalCpuCount} logical CPUs, {PhysicalCpuCount} physical CPUs", + topology.TotalLogicalCores, + topology.TotalPhysicalCores); + + return true; + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "GetLogicalProcessorInformationEx topology detection failed"); + return false; + } + } + + private static IEnumerable GetSetBitIndices(ulong mask) + { + for (int bit = 0; bit < 64; bit++) + { + if ((mask & (1UL << bit)) != 0) + { + yield return bit; + } + } + } + + private void ApplyHyperThreadingFromPhysicalMapping(CpuTopologyModel topology) + { + foreach (var coreGroup in topology.LogicalCores.GroupBy(c => c.PhysicalCoreId)) + { + var siblings = coreGroup.OrderBy(c => c.LogicalCoreId).ToList(); + if (siblings.Count <= 1) + { + continue; + } + + for (int i = 0; i < siblings.Count; i++) + { + var isLogicalSibling = i > 0; + siblings[i].IsHyperThreaded = isLogicalSibling; + siblings[i].HyperThreadSibling = siblings.Count >= 2 + ? (isLogicalSibling ? siblings[0].LogicalCoreId : siblings[1].LogicalCoreId) + : null; + + siblings[i].LogicalProcessorName = $"CPU{siblings[i].PhysicalCoreId}_T{i}"; + } + } + } + + private void ApplyCoreTypeFromEfficiencyClass( + CpuTopologyModel topology, + List<(int PhysicalCpuId, int LogicalCpuId, byte EfficiencyClass)> discovered) + { + var byPhysical = discovered + .GroupBy(d => d.PhysicalCpuId) + .Select(g => new { PhysicalCpuId = g.Key, EfficiencyClass = g.Min(x => x.EfficiencyClass) }) + .ToList(); + + var classes = byPhysical + .Select(x => x.EfficiencyClass) + .Distinct() + .OrderBy(v => v) + .ToList(); + + if (classes.Count <= 1) + { + return; + } + + byte performanceClass = classes.Min(); + foreach (var logicalCpu in topology.LogicalCores) + { + byte classValue = byPhysical.First(x => x.PhysicalCpuId == logicalCpu.PhysicalCoreId).EfficiencyClass; + logicalCpu.CoreType = classValue == performanceClass + ? CpuCoreType.PerformanceCore + : CpuCoreType.EfficiencyCore; + } + } + + private async Task DetectCoresViaWmiAsync(CpuTopologyModel topology) + { + try + { + // First, get physical processor information + var physicalCoreCount = 0; + var logicalCoreCount = 0; + + using (var processorSearcher = new ManagementObjectSearcher("SELECT * FROM Win32_Processor")) + using (var processorCollection = processorSearcher.Get()) + { + foreach (ManagementObject processor in processorCollection) + { + var numberOfCores = Convert.ToInt32(processor["NumberOfCores"] ?? 0); + var numberOfLogicalProcessors = Convert.ToInt32(processor["NumberOfLogicalProcessors"] ?? 0); + + physicalCoreCount += numberOfCores; + logicalCoreCount += numberOfLogicalProcessors; + + this.logger.LogInformation( + "Detected CPU: {Cores} physical CPUs, {LogicalProcessors} logical processors", + numberOfCores, numberOfLogicalProcessors); + } + } + + // If WMI didn't provide the info, fall back to Environment.ProcessorCount + if (logicalCoreCount == 0) + { + logicalCoreCount = Environment.ProcessorCount; + physicalCoreCount = logicalCoreCount; // Assume no HT if we can't detect + } + + // Create logical cores with proper physical core mapping + var hasHyperThreading = logicalCoreCount > physicalCoreCount; + var threadsPerCore = hasHyperThreading ? logicalCoreCount / physicalCoreCount : 1; + + for (int logicalId = 0; logicalId < logicalCoreCount; logicalId++) + { + var physicalId = logicalId / threadsPerCore; + var threadIndexOnCore = logicalId % threadsPerCore; + var isHyperThreaded = hasHyperThreading && (threadIndexOnCore != 0); + var htSibling = hasHyperThreading ? (threadIndexOnCore == 0 ? logicalId + 1 : logicalId - 1) : (int?)null; + + var core = new CpuCoreModel + { + LogicalCoreId = logicalId, + PhysicalCoreId = physicalId, + SocketId = 0, // Will be refined later + Label = $"CPU {logicalId}", + LogicalProcessorName = $"CPU{physicalId}_T{threadIndexOnCore}", // T0 = physical, T1+ = SMT + IsEnabled = true, + IsHyperThreaded = isHyperThreaded, + HyperThreadSibling = htSibling, + }; + + topology.LogicalCores.Add(core); + } + + this.logger.LogInformation( + "Created topology: {LogicalCores} logical CPUs, {PhysicalCores} physical CPUs, HT: {HasHT}", + logicalCoreCount, physicalCoreCount, hasHyperThreading); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "WMI logical processor detection failed"); + } + } + + private void CreateBasicTopology(CpuTopologyModel topology, int logicalCoreCount) + { + topology.LogicalCores.Clear(); + + for (int i = 0; i < logicalCoreCount; i++) + { + var core = new CpuCoreModel + { + LogicalCoreId = i, + PhysicalCoreId = i, // Assume no HT for basic topology + SocketId = 0, + CoreType = CpuCoreType.Standard, + Label = $"CPU {i}", + LogicalProcessorName = $"CPU{i}_T0", // All physical CPUs in basic fallback (no HT detected) + IsEnabled = true, + }; + + topology.LogicalCores.Add(core); + } + } + + private async Task DetectAdvancedTopologyAsync(CpuTopologyModel topology) + { + // Try to detect Intel Hybrid (P/E cores) + await this.DetectIntelHybridAsync(topology); + + // Try to detect AMD CCD information + await this.DetectAmdCcdAsync(topology); + + // Try to detect HyperThreading + this.DetectHyperThreading(topology); + } + + private async Task DetectIntelHybridAsync(CpuTopologyModel topology) + { + try + { + // Intel Hybrid detection is complex and requires specific APIs + // For now, we'll use heuristics based on CPU brand and core count patterns + if (topology.CpuBrand.Contains("Intel", StringComparison.OrdinalIgnoreCase)) + { + // Preserve already-detected core type data from official API if present. + if (topology.LogicalCores.Any(c => c.CoreType == CpuCoreType.PerformanceCore || c.CoreType == CpuCoreType.EfficiencyCore)) + { + return; + } + + // Check for 12th gen or later Intel processors (Alder Lake+) + if (topology.CpuBrand.Contains("12th") || topology.CpuBrand.Contains("13th") || + topology.CpuBrand.Contains("14th") || topology.CpuBrand.Contains("15th")) + { + // Heuristic: Assume first cores are P-cores, later ones are E-cores + // This is a simplified approach - real detection would require CPUID + var totalCores = topology.LogicalCores.Count; + var estimatedPCores = Math.Min(8, totalCores / 2); // Rough estimate + + for (int i = 0; i < topology.LogicalCores.Count; i++) + { + if (i < estimatedPCores * 2) // P-cores with HT + { + topology.LogicalCores[i].CoreType = CpuCoreType.PerformanceCore; + } + else + { + topology.LogicalCores[i].CoreType = CpuCoreType.EfficiencyCore; + } + } + } + } + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to detect Intel Hybrid topology"); + } + } + + private async Task DetectAmdCcdAsync(CpuTopologyModel topology) + { + try + { + if (topology.CpuBrand.Contains("AMD", StringComparison.OrdinalIgnoreCase)) + { + // AMD CCD detection - improved heuristic + // Only assign CCD if we actually have multiple CCDs + var totalPhysicalCores = topology.TotalPhysicalCores; + var coresPerCcd = 8; // Typical for Zen 2/3/4 + + // Only assign CCD IDs if we have more than 8 physical cores (indicating multiple CCDs) + if (totalPhysicalCores > coresPerCcd) + { + for (int i = 0; i < topology.LogicalCores.Count; i++) + { + var physicalCoreId = topology.LogicalCores[i].PhysicalCoreId; + topology.LogicalCores[i].CcdId = physicalCoreId / coresPerCcd; + topology.LogicalCores[i].CoreType = CpuCoreType.Zen3; // Default assumption + } + + this.logger.LogInformation( + "Detected AMD multi-CCD configuration: {PhysicalCores} physical cores, estimated {CcdCount} CCDs", + totalPhysicalCores, (totalPhysicalCores + coresPerCcd - 1) / coresPerCcd); + } + else + { + // Single CCD or small core count - don't assign CCD IDs + foreach (var core in topology.LogicalCores) + { + core.CoreType = CpuCoreType.Zen3; // Default assumption + } + + this.logger.LogInformation("Detected AMD single-CCD configuration: {PhysicalCores} physical cores", totalPhysicalCores); + } + } + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to detect AMD CCD topology"); + } + } + + private void DetectHyperThreading(CpuTopologyModel topology) + { + try + { + // Normalize HT metadata by physical CPU mapping first. + var groupedByPhysical = topology.LogicalCores + .GroupBy(c => c.PhysicalCoreId) + .Select(g => g.OrderBy(c => c.LogicalCoreId).ToList()) + .ToList(); + + if (groupedByPhysical.Any(g => g.Count > 1)) + { + foreach (var siblings in groupedByPhysical) + { + for (int i = 0; i < siblings.Count; i++) + { + var isLogicalSibling = i > 0; + siblings[i].IsHyperThreaded = isLogicalSibling; + siblings[i].HyperThreadSibling = siblings.Count >= 2 + ? (isLogicalSibling ? siblings[0].LogicalCoreId : siblings[1].LogicalCoreId) + : null; + + if (string.IsNullOrWhiteSpace(siblings[i].LogicalProcessorName)) + { + siblings[i].LogicalProcessorName = $"CPU{siblings[i].PhysicalCoreId}_T{i}"; + } + } + } + + return; + } + + // Fallback HT detection: if we only have flat sequential data. + var logicalCount = topology.LogicalCores.Count; + var physicalCount = topology.TotalPhysicalCores; + + if (logicalCount > physicalCount) + { + // Mark pairs as primary/logical siblings conservatively. + for (int i = 0; i < topology.LogicalCores.Count; i += 2) + { + if (i + 1 < topology.LogicalCores.Count) + { + topology.LogicalCores[i].IsHyperThreaded = false; + topology.LogicalCores[i].HyperThreadSibling = i + 1; + topology.LogicalCores[i + 1].IsHyperThreaded = true; + topology.LogicalCores[i + 1].HyperThreadSibling = i; + } + } + } + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to detect HyperThreading"); + } + } + + private void ValidateTopology(CpuTopologyModel topology) + { + // Ensure we have at least one core + if (topology.LogicalCores.Count == 0) + { + this.CreateBasicTopology(topology, Environment.ProcessorCount); + } + + if (string.IsNullOrWhiteSpace(topology.CpuBrand)) + { + topology.CpuBrand = "Unknown"; + } + + if (string.IsNullOrWhiteSpace(topology.CpuArchitecture)) + { + topology.CpuArchitecture = RuntimeInformation.ProcessArchitecture.ToString(); + } + + // Ensure logical core IDs are sequential + for (int i = 0; i < topology.LogicalCores.Count; i++) + { + topology.LogicalCores[i].LogicalCoreId = i; + } + + // Normalize physical core IDs to avoid invalid/negative mappings + var normalizedPhysicalCoreIds = topology.LogicalCores + .Select((core, index) => new + { + core, + physical = core.PhysicalCoreId >= 0 ? core.PhysicalCoreId : index, + }) + .GroupBy(x => x.physical) + .OrderBy(g => g.Key) + .Select((group, normalizedId) => new { group, normalizedId }) + .ToList(); + + foreach (var item in normalizedPhysicalCoreIds) + { + foreach (var entry in item.group) + { + entry.core.PhysicalCoreId = item.normalizedId; + } + } + + // Update labels with explicit logical-to-physical CPU mapping. + foreach (var core in topology.LogicalCores) + { + var typeLabel = core.CoreType switch + { + CpuCoreType.PerformanceCore => "P-", + CpuCoreType.EfficiencyCore => "E-", + _ => string.Empty, + }; + + var threadIndex = GetThreadIndexOnPhysicalCpu(core, topology); + var roleLabel = threadIndex == 0 + ? $"PH{core.PhysicalCoreId}" + : $"L{threadIndex}/PH{core.PhysicalCoreId}"; + + core.Label = $"{typeLabel}CPU {core.LogicalCoreId} ({roleLabel})"; + + if (string.IsNullOrWhiteSpace(core.LogicalProcessorName)) + { + threadIndex = Math.Max(0, core.LogicalCoreId - core.PhysicalCoreId); + core.LogicalProcessorName = $"CPU{core.PhysicalCoreId}_T{threadIndex}"; + } + } + } + + private static int GetThreadIndexOnPhysicalCpu(CpuCoreModel core, CpuTopologyModel topology) + { + if (!string.IsNullOrWhiteSpace(core.LogicalProcessorName)) + { + var marker = core.LogicalProcessorName.LastIndexOf("_T", StringComparison.Ordinal); + if (marker >= 0) + { + var suffix = core.LogicalProcessorName[(marker + 2)..]; + if (int.TryParse(suffix, out int parsedIndex)) + { + return Math.Max(0, parsedIndex); + } + } + } + + var orderedSiblings = topology.LogicalCores + .Where(c => c.PhysicalCoreId == core.PhysicalCoreId) + .OrderBy(c => c.LogicalCoreId) + .ToList(); + + var index = orderedSiblings.FindIndex(c => c.LogicalCoreId == core.LogicalCoreId); + return index >= 0 ? index : 0; + } + + private CpuTopologyModel CreateFallbackTopology() + { + var topology = new CpuTopologyModel(); + this.CreateBasicTopology(topology, Environment.ProcessorCount); + topology.TopologyDetectionSuccessful = false; + return topology; + } + + private long CalculateFullAffinityMask(int logicalCoreCount) + { + // Affinity masks are represented as signed 64-bit values in this application. + // For 63+ logical cores, use all available bits to avoid undefined shifts. + return logicalCoreCount >= 63 + ? -1L + : (1L << logicalCoreCount) - 1; + } + + public IEnumerable GetAffinityPresets() + { + if (this.currentTopology == null) + { + return Enumerable.Empty(); + } + + var presets = new List(); + + // All CPUs preset + presets.Add(new CpuAffinityPreset + { + Name = "All CPUs", + Description = $"All {this.currentTopology.TotalLogicalCores} logical CPUs", + AffinityMask = this.CalculateFullAffinityMask(this.currentTopology.TotalLogicalCores), + IsAvailable = true, + }); + + // Physical CPUs only (if HT is available) + if (this.currentTopology.HasHyperThreading) + { + presets.Add(new CpuAffinityPreset + { + Name = "No HT", + Description = $"All {this.currentTopology.TotalPhysicalCores} physical CPUs (no Hyper-Threading)", + AffinityMask = this.currentTopology.GetPhysicalCoresAffinityMask(), + IsAvailable = this.currentTopology.GetPhysicalCoresAffinityMask() != 0, + }); + } + + // Performance CPUs (Intel Hybrid) + if (this.currentTopology.HasIntelHybrid && this.currentTopology.PerformanceCores.Any()) + { + presets.Add(new CpuAffinityPreset + { + Name = "Performance CPUs", + Description = $"Intel P-CPUs ({this.currentTopology.PerformanceCores.Count()} logical CPUs)", + AffinityMask = this.currentTopology.GetPerformanceCoresAffinityMask(), + IsAvailable = this.currentTopology.GetPerformanceCoresAffinityMask() != 0, + }); + } + + // Efficiency CPUs (Intel Hybrid) + if (this.currentTopology.HasIntelHybrid && this.currentTopology.EfficiencyCores.Any()) + { + presets.Add(new CpuAffinityPreset + { + Name = "Efficiency CPUs", + Description = $"Intel E-CPUs ({this.currentTopology.EfficiencyCores.Count()} logical CPUs)", + AffinityMask = this.currentTopology.GetEfficiencyCoresAffinityMask(), + IsAvailable = this.currentTopology.GetEfficiencyCoresAffinityMask() != 0, + }); + } + + // CCD presets (AMD) + if (this.currentTopology.HasAmdCcd) + { + foreach (var ccdId in this.currentTopology.AvailableCcds) + { + var ccdCores = this.currentTopology.GetCoresByCcd(ccdId); + presets.Add(new CpuAffinityPreset + { + Name = $"CCD {ccdId}", + Description = $"AMD CCD {ccdId} ({ccdCores.Count()} logical CPUs)", + AffinityMask = this.currentTopology.GetCcdAffinityMask(ccdId), + IsAvailable = this.currentTopology.GetCcdAffinityMask(ccdId) != 0, + }); + } + } + + return presets; + } + + public bool IsAffinityMaskValid(long affinityMask) + { + if (this.currentTopology == null) + { + return false; + } + + // Long-based affinity masks cannot represent cores beyond bit 62 explicitly. + // Accept any non-zero mask for large-core systems and let runtime APIs enforce final validity. + if (this.currentTopology.TotalLogicalCores >= 63) + { + return affinityMask != 0; + } + + var maxMask = this.CalculateFullAffinityMask(this.currentTopology.TotalLogicalCores); + return affinityMask > 0 && affinityMask <= maxMask; + } + + public int GetMaxLogicalCores() + { + return this.currentTopology?.TotalLogicalCores ?? Environment.ProcessorCount; + } + + public async Task RefreshTopologyAsync() + { + this.currentTopology = null; + this.topologyCachedAtUtc = DateTime.MinValue; + await this.DetectTopologyAsync(); + } + } +} + diff --git a/Services/ElevatedTaskService.cs b/Services/ElevatedTaskService.cs index 5d9508b..fe9ad83 100644 --- a/Services/ElevatedTaskService.cs +++ b/Services/ElevatedTaskService.cs @@ -1,433 +1,414 @@ -/* - * 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.Diagnostics; - using System.IO; - using System.Reflection; - using System.Security; - using System.Security.Principal; - using System.Text; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - using ThreadPilot.Services.Abstractions; - - /// - /// Manages Scheduled Tasks used for persistent elevated launch and elevated autostart. - /// - public partial class ElevatedTaskService : IElevatedTaskService - { - private static readonly string SchTasksExecutablePath = Path.Combine(Environment.SystemDirectory, "schtasks.exe"); - private static readonly TimeSpan ScheduledTaskTimeout = TimeSpan.FromSeconds(20); - - private readonly ILogger logger; - private readonly IProcessRunner processRunner; - private readonly Func? executablePathProvider; - private readonly Func? currentUserProvider; - - public ElevatedTaskService(ILogger logger, IProcessRunner processRunner) - : this(logger, processRunner, null, null) - { - } - - public ElevatedTaskService( - ILogger logger, - IProcessRunner processRunner, - Func? executablePathProvider, - Func? currentUserProvider) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.processRunner = processRunner ?? throw new ArgumentNullException(nameof(processRunner)); - this.executablePathProvider = executablePathProvider; - this.currentUserProvider = currentUserProvider; - } - - public string LaunchTaskName => "ThreadPilot_Launch"; - - public string AutostartTaskName => "ThreadPilot_Startup"; - - public async Task EnsureLaunchTaskAsync() - { - try - { - var executablePath = this.executablePathProvider?.Invoke() ?? this.GetExecutablePath(); - if (!IsValidExecutablePath(executablePath)) - { - LogSkipEnsureLaunchTaskInvalidPath(this.logger, executablePath ?? "(null)"); - return false; - } - - var taskXmlPath = Path.Combine(Path.GetTempPath(), $"threadpilot-launch-task-{Guid.NewGuid():N}.xml"); - try - { - WriteLaunchTaskDefinition( - taskXmlPath, - executablePath!, - this.currentUserProvider?.Invoke() ?? GetCurrentUserName()); - - var result = await this.RunSchTasksAsync(new List - { - "/Create", - "/TN", this.LaunchTaskName, - "/XML", taskXmlPath, - "/F", - }); - - if (result.ExitCode == 0) - { - LogLaunchTaskEnsured(this.logger, this.LaunchTaskName, executablePath!); - return true; - } - - LogEnsureLaunchTaskFailed(this.logger, result.ExitCode, result.StandardError); - return false; - } - finally - { - TryDeleteFile(taskXmlPath, this.logger); - } - } - catch (Exception ex) - { - LogEnsureLaunchTaskException(this.logger, ex); - return false; - } - } - - public async Task TryRunLaunchTaskAsync() - { - try - { - var result = await this.RunSchTasksAsync(new List - { - "/Run", - "/TN", this.LaunchTaskName, - }); - - if (result.ExitCode == 0) - { - LogLaunchTaskStarted(this.logger, this.LaunchTaskName); - return true; - } - - LogRunLaunchTaskFailed(this.logger, result.ExitCode, result.StandardError); - return false; - } - catch (Exception ex) - { - LogRunLaunchTaskException(this.logger, ex); - return false; - } - } - - public async Task EnsureAutostartTaskAsync(string executablePath, string arguments) - { - try - { - if (!IsValidExecutablePath(executablePath)) - { - LogSkipEnsureAutostartTaskInvalidPath(this.logger, executablePath); - return false; - } - - var taskRunCommand = BuildCommand(executablePath, arguments); - var result = await this.RunSchTasksAsync(new List - { - "/Create", - "/TN", this.AutostartTaskName, - "/TR", taskRunCommand, - "/SC", "ONLOGON", - "/RL", "HIGHEST", - "/F", - "/RU", Environment.UserName, - }); - - if (result.ExitCode == 0) - { - LogAutostartTaskEnsured(this.logger, this.AutostartTaskName); - return true; - } - - LogEnsureAutostartTaskFailed(this.logger, result.ExitCode, result.StandardError); - return false; - } - catch (Exception ex) - { - LogEnsureAutostartTaskException(this.logger, ex); - return false; - } - } - - public async Task RemoveAutostartTaskAsync() - { - try - { - var result = await this.RunSchTasksAsync(new List - { - "/Delete", - "/TN", this.AutostartTaskName, - "/F", - }); - - if (result.ExitCode == 0) - { - LogAutostartTaskRemoved(this.logger, this.AutostartTaskName); - return true; - } - - // Exit code 1 is expected when task doesn't exist; treat as already removed. - if (result.ExitCode == 1) - { - LogAutostartTaskAlreadyRemoved(this.logger, this.AutostartTaskName); - return true; - } - - LogRemoveAutostartTaskFailed(this.logger, result.ExitCode, result.StandardError); - return false; - } - catch (Exception ex) - { - LogRemoveAutostartTaskException(this.logger, ex); - return false; - } - } - - public async Task IsAutostartTaskRegisteredAsync() - { - try - { - var result = await this.RunSchTasksAsync(new List - { - "/Query", - "/TN", this.AutostartTaskName, - }); - - var exists = result.ExitCode == 0; - LogAutostartTaskQueryResult(this.logger, this.AutostartTaskName, exists, result.ExitCode); - return exists; - } - catch (Exception ex) - { - LogAutostartTaskQueryException(this.logger, ex); - return false; - } - } - - private string? GetExecutablePath() - { - try - { - var currentPath = Process.GetCurrentProcess().MainModule?.FileName; - if (IsValidExecutablePath(currentPath)) - { - return currentPath; - } - - var assemblyLocation = Assembly.GetExecutingAssembly().Location; - if (!string.IsNullOrWhiteSpace(assemblyLocation) && - assemblyLocation.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) - { - var candidatePath = Path.ChangeExtension(assemblyLocation, ".exe"); - if (IsValidExecutablePath(candidatePath)) - { - return candidatePath; - } - } - - return IsValidExecutablePath(assemblyLocation) - ? assemblyLocation - : null; - } - catch (Exception ex) - { - LogGetExecutablePathFailed(this.logger, ex); - return null; - } - } - - private static string BuildCommand(string executablePath, string arguments) - { - var trimmedArguments = arguments?.Trim(); - return string.IsNullOrWhiteSpace(trimmedArguments) - ? $"\"{executablePath}\"" - : $"\"{executablePath}\" {trimmedArguments}"; - } - - private static bool IsValidExecutablePath(string? executablePath) - { - if (string.IsNullOrWhiteSpace(executablePath) || !Path.IsPathRooted(executablePath)) - { - return false; - } - - return File.Exists(executablePath) && - string.Equals(Path.GetExtension(executablePath), ".exe", StringComparison.OrdinalIgnoreCase); - } - - private static string GetCurrentUserName() - { - var userName = WindowsIdentity.GetCurrent().Name; - if (string.IsNullOrWhiteSpace(userName)) - { - throw new InvalidOperationException("Could not determine current user identity for launch task registration."); - } - - return userName; - } - - private static void WriteLaunchTaskDefinition(string taskXmlPath, string executablePath, string userName) - { - if (string.IsNullOrWhiteSpace(userName)) - { - throw new InvalidOperationException("Could not determine current user identity for launch task registration."); - } - - var workingDirectory = Path.GetDirectoryName(executablePath); - if (string.IsNullOrWhiteSpace(workingDirectory)) - { - throw new InvalidOperationException("Could not determine working directory for launch task registration."); - } - - var escapedUserName = SecurityElement.Escape(userName); - var escapedExecutablePath = SecurityElement.Escape(executablePath); - var escapedArguments = SecurityElement.Escape("--launched-via-task"); - var escapedWorkingDirectory = SecurityElement.Escape(workingDirectory); - - var taskXml = $@" - - - ThreadPilot - Launches ThreadPilot with highest available privileges on demand. - - - - {escapedUserName} - InteractiveToken - HighestAvailable - - - - IgnoreNew - false - false - false - false - false - - false - false - - true - true - false - false - false - PT0S - 7 - - - - {escapedExecutablePath} - {escapedArguments} - {escapedWorkingDirectory} - - -"; - - File.WriteAllText(taskXmlPath, taskXml, Encoding.Unicode); - } - - private Task RunSchTasksAsync(IReadOnlyList arguments) - { - return this.processRunner.RunAsync(SchTasksExecutablePath, arguments, ScheduledTaskTimeout); - } - - private static void TryDeleteFile(string path, ILogger logger) - { - try - { - if (File.Exists(path)) - { - File.Delete(path); - } - } - catch (Exception ex) - { - LogDeleteTemporaryFileFailed(logger, path, ex); - } - } - - [LoggerMessage(EventId = 4250, Level = LogLevel.Warning, Message = "Skipping launch task registration due to invalid executable path: {Path}")] - private static partial void LogSkipEnsureLaunchTaskInvalidPath(ILogger logger, string path); - - [LoggerMessage(EventId = 4251, Level = LogLevel.Information, Message = "Ensured elevated launch task '{TaskName}' for executable '{ExecutablePath}'")] - private static partial void LogLaunchTaskEnsured(ILogger logger, string taskName, string executablePath); - - [LoggerMessage(EventId = 4252, Level = LogLevel.Warning, Message = "Failed to ensure elevated launch task. Exit code: {ExitCode}, Error: {Error}")] - private static partial void LogEnsureLaunchTaskFailed(ILogger logger, int exitCode, string error); - - [LoggerMessage(EventId = 4253, Level = LogLevel.Warning, Message = "Exception while ensuring elevated launch task")] - private static partial void LogEnsureLaunchTaskException(ILogger logger, Exception ex); - - [LoggerMessage(EventId = 4254, Level = LogLevel.Information, Message = "Started elevated launch task '{TaskName}'")] - private static partial void LogLaunchTaskStarted(ILogger logger, string taskName); - - [LoggerMessage(EventId = 4255, Level = LogLevel.Warning, Message = "Failed to run elevated launch task. Exit code: {ExitCode}, Error: {Error}")] - private static partial void LogRunLaunchTaskFailed(ILogger logger, int exitCode, string error); - - [LoggerMessage(EventId = 4256, Level = LogLevel.Warning, Message = "Exception while running elevated launch task")] - private static partial void LogRunLaunchTaskException(ILogger logger, Exception ex); - - [LoggerMessage(EventId = 4257, Level = LogLevel.Warning, Message = "Skipping autostart task registration due to invalid executable path: {Path}")] - private static partial void LogSkipEnsureAutostartTaskInvalidPath(ILogger logger, string path); - - [LoggerMessage(EventId = 4258, Level = LogLevel.Information, Message = "Ensured elevated autostart task '{TaskName}'")] - private static partial void LogAutostartTaskEnsured(ILogger logger, string taskName); - - [LoggerMessage(EventId = 4259, Level = LogLevel.Warning, Message = "Failed to ensure elevated autostart task. Exit code: {ExitCode}, Error: {Error}")] - private static partial void LogEnsureAutostartTaskFailed(ILogger logger, int exitCode, string error); - - [LoggerMessage(EventId = 4260, Level = LogLevel.Warning, Message = "Exception while ensuring elevated autostart task")] - private static partial void LogEnsureAutostartTaskException(ILogger logger, Exception ex); - - [LoggerMessage(EventId = 4261, Level = LogLevel.Information, Message = "Removed elevated autostart task '{TaskName}'")] - private static partial void LogAutostartTaskRemoved(ILogger logger, string taskName); - - [LoggerMessage(EventId = 4262, Level = LogLevel.Debug, Message = "Elevated autostart task '{TaskName}' was already absent")] - private static partial void LogAutostartTaskAlreadyRemoved(ILogger logger, string taskName); - - [LoggerMessage(EventId = 4263, Level = LogLevel.Warning, Message = "Failed to remove elevated autostart task. Exit code: {ExitCode}, Error: {Error}")] - private static partial void LogRemoveAutostartTaskFailed(ILogger logger, int exitCode, string error); - - [LoggerMessage(EventId = 4264, Level = LogLevel.Warning, Message = "Exception while removing elevated autostart task")] - private static partial void LogRemoveAutostartTaskException(ILogger logger, Exception ex); - - [LoggerMessage(EventId = 4265, Level = LogLevel.Debug, Message = "Autostart task query for '{TaskName}': Exists={Exists}, ExitCode={ExitCode}")] - private static partial void LogAutostartTaskQueryResult(ILogger logger, string taskName, bool exists, int exitCode); - - [LoggerMessage(EventId = 4266, Level = LogLevel.Warning, Message = "Exception while querying elevated autostart task")] - private static partial void LogAutostartTaskQueryException(ILogger logger, Exception ex); - - [LoggerMessage(EventId = 4267, Level = LogLevel.Warning, Message = "Failed to resolve executable path while ensuring elevated tasks")] - private static partial void LogGetExecutablePathFailed(ILogger logger, Exception ex); - - [LoggerMessage(EventId = 4268, Level = LogLevel.Debug, Message = "Failed to delete temporary file '{Path}'")] - private static partial void LogDeleteTemporaryFileFailed(ILogger logger, string path, Exception ex); - } -} +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.IO; + using System.Reflection; + using System.Security; + using System.Security.Principal; + using System.Text; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using ThreadPilot.Services.Abstractions; + + public partial class ElevatedTaskService : IElevatedTaskService + { + private static readonly string SchTasksExecutablePath = Path.Combine(Environment.SystemDirectory, "schtasks.exe"); + private static readonly TimeSpan ScheduledTaskTimeout = TimeSpan.FromSeconds(20); + + private readonly ILogger logger; + private readonly IProcessRunner processRunner; + private readonly Func? executablePathProvider; + private readonly Func? currentUserProvider; + + public ElevatedTaskService(ILogger logger, IProcessRunner processRunner) + : this(logger, processRunner, null, null) + { + } + + public ElevatedTaskService( + ILogger logger, + IProcessRunner processRunner, + Func? executablePathProvider, + Func? currentUserProvider) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.processRunner = processRunner ?? throw new ArgumentNullException(nameof(processRunner)); + this.executablePathProvider = executablePathProvider; + this.currentUserProvider = currentUserProvider; + } + + public string LaunchTaskName => "ThreadPilot_Launch"; + + public string AutostartTaskName => "ThreadPilot_Startup"; + + public async Task EnsureLaunchTaskAsync() + { + try + { + var executablePath = this.executablePathProvider?.Invoke() ?? this.GetExecutablePath(); + if (!IsValidExecutablePath(executablePath)) + { + LogSkipEnsureLaunchTaskInvalidPath(this.logger, executablePath ?? "(null)"); + return false; + } + + var taskXmlPath = Path.Combine(Path.GetTempPath(), $"threadpilot-launch-task-{Guid.NewGuid():N}.xml"); + try + { + WriteLaunchTaskDefinition( + taskXmlPath, + executablePath!, + this.currentUserProvider?.Invoke() ?? GetCurrentUserName()); + + var result = await this.RunSchTasksAsync(new List + { + "/Create", + "/TN", this.LaunchTaskName, + "/XML", taskXmlPath, + "/F", + }); + + if (result.ExitCode == 0) + { + LogLaunchTaskEnsured(this.logger, this.LaunchTaskName, executablePath!); + return true; + } + + LogEnsureLaunchTaskFailed(this.logger, result.ExitCode, result.StandardError); + return false; + } + finally + { + TryDeleteFile(taskXmlPath, this.logger); + } + } + catch (Exception ex) + { + LogEnsureLaunchTaskException(this.logger, ex); + return false; + } + } + + public async Task TryRunLaunchTaskAsync() + { + try + { + var result = await this.RunSchTasksAsync(new List + { + "/Run", + "/TN", this.LaunchTaskName, + }); + + if (result.ExitCode == 0) + { + LogLaunchTaskStarted(this.logger, this.LaunchTaskName); + return true; + } + + LogRunLaunchTaskFailed(this.logger, result.ExitCode, result.StandardError); + return false; + } + catch (Exception ex) + { + LogRunLaunchTaskException(this.logger, ex); + return false; + } + } + + public async Task EnsureAutostartTaskAsync(string executablePath, string arguments) + { + try + { + if (!IsValidExecutablePath(executablePath)) + { + LogSkipEnsureAutostartTaskInvalidPath(this.logger, executablePath); + return false; + } + + var taskRunCommand = BuildCommand(executablePath, arguments); + var result = await this.RunSchTasksAsync(new List + { + "/Create", + "/TN", this.AutostartTaskName, + "/TR", taskRunCommand, + "/SC", "ONLOGON", + "/RL", "HIGHEST", + "/F", + "/RU", Environment.UserName, + }); + + if (result.ExitCode == 0) + { + LogAutostartTaskEnsured(this.logger, this.AutostartTaskName); + return true; + } + + LogEnsureAutostartTaskFailed(this.logger, result.ExitCode, result.StandardError); + return false; + } + catch (Exception ex) + { + LogEnsureAutostartTaskException(this.logger, ex); + return false; + } + } + + public async Task RemoveAutostartTaskAsync() + { + try + { + var result = await this.RunSchTasksAsync(new List + { + "/Delete", + "/TN", this.AutostartTaskName, + "/F", + }); + + if (result.ExitCode == 0) + { + LogAutostartTaskRemoved(this.logger, this.AutostartTaskName); + return true; + } + + // Exit code 1 is expected when task doesn't exist; treat as already removed. + if (result.ExitCode == 1) + { + LogAutostartTaskAlreadyRemoved(this.logger, this.AutostartTaskName); + return true; + } + + LogRemoveAutostartTaskFailed(this.logger, result.ExitCode, result.StandardError); + return false; + } + catch (Exception ex) + { + LogRemoveAutostartTaskException(this.logger, ex); + return false; + } + } + + public async Task IsAutostartTaskRegisteredAsync() + { + try + { + var result = await this.RunSchTasksAsync(new List + { + "/Query", + "/TN", this.AutostartTaskName, + }); + + var exists = result.ExitCode == 0; + LogAutostartTaskQueryResult(this.logger, this.AutostartTaskName, exists, result.ExitCode); + return exists; + } + catch (Exception ex) + { + LogAutostartTaskQueryException(this.logger, ex); + return false; + } + } + + private string? GetExecutablePath() + { + try + { + var currentPath = Process.GetCurrentProcess().MainModule?.FileName; + if (IsValidExecutablePath(currentPath)) + { + return currentPath; + } + + var assemblyLocation = Assembly.GetExecutingAssembly().Location; + if (!string.IsNullOrWhiteSpace(assemblyLocation) && + assemblyLocation.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + var candidatePath = Path.ChangeExtension(assemblyLocation, ".exe"); + if (IsValidExecutablePath(candidatePath)) + { + return candidatePath; + } + } + + return IsValidExecutablePath(assemblyLocation) + ? assemblyLocation + : null; + } + catch (Exception ex) + { + LogGetExecutablePathFailed(this.logger, ex); + return null; + } + } + + private static string BuildCommand(string executablePath, string arguments) + { + var trimmedArguments = arguments?.Trim(); + return string.IsNullOrWhiteSpace(trimmedArguments) + ? $"\"{executablePath}\"" + : $"\"{executablePath}\" {trimmedArguments}"; + } + + private static bool IsValidExecutablePath(string? executablePath) + { + if (string.IsNullOrWhiteSpace(executablePath) || !Path.IsPathRooted(executablePath)) + { + return false; + } + + return File.Exists(executablePath) && + string.Equals(Path.GetExtension(executablePath), ".exe", StringComparison.OrdinalIgnoreCase); + } + + private static string GetCurrentUserName() + { + var userName = WindowsIdentity.GetCurrent().Name; + if (string.IsNullOrWhiteSpace(userName)) + { + throw new InvalidOperationException("Could not determine current user identity for launch task registration."); + } + + return userName; + } + + private static void WriteLaunchTaskDefinition(string taskXmlPath, string executablePath, string userName) + { + if (string.IsNullOrWhiteSpace(userName)) + { + throw new InvalidOperationException("Could not determine current user identity for launch task registration."); + } + + var workingDirectory = Path.GetDirectoryName(executablePath); + if (string.IsNullOrWhiteSpace(workingDirectory)) + { + throw new InvalidOperationException("Could not determine working directory for launch task registration."); + } + + var escapedUserName = SecurityElement.Escape(userName); + var escapedExecutablePath = SecurityElement.Escape(executablePath); + var escapedArguments = SecurityElement.Escape("--launched-via-task"); + var escapedWorkingDirectory = SecurityElement.Escape(workingDirectory); + + var taskXml = $@" + + + ThreadPilot + Launches ThreadPilot with highest available privileges on demand. + + + + {escapedUserName} + InteractiveToken + HighestAvailable + + + + IgnoreNew + false + false + false + false + false + + false + false + + true + true + false + false + false + PT0S + 7 + + + + {escapedExecutablePath} + {escapedArguments} + {escapedWorkingDirectory} + + +"; + + File.WriteAllText(taskXmlPath, taskXml, Encoding.Unicode); + } + + private Task RunSchTasksAsync(IReadOnlyList arguments) + { + return this.processRunner.RunAsync(SchTasksExecutablePath, arguments, ScheduledTaskTimeout); + } + + private static void TryDeleteFile(string path, ILogger logger) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) + { + LogDeleteTemporaryFileFailed(logger, path, ex); + } + } + + [LoggerMessage(EventId = 4250, Level = LogLevel.Warning, Message = "Skipping launch task registration due to invalid executable path: {Path}")] + private static partial void LogSkipEnsureLaunchTaskInvalidPath(ILogger logger, string path); + + [LoggerMessage(EventId = 4251, Level = LogLevel.Information, Message = "Ensured elevated launch task '{TaskName}' for executable '{ExecutablePath}'")] + private static partial void LogLaunchTaskEnsured(ILogger logger, string taskName, string executablePath); + + [LoggerMessage(EventId = 4252, Level = LogLevel.Warning, Message = "Failed to ensure elevated launch task. Exit code: {ExitCode}, Error: {Error}")] + private static partial void LogEnsureLaunchTaskFailed(ILogger logger, int exitCode, string error); + + [LoggerMessage(EventId = 4253, Level = LogLevel.Warning, Message = "Exception while ensuring elevated launch task")] + private static partial void LogEnsureLaunchTaskException(ILogger logger, Exception ex); + + [LoggerMessage(EventId = 4254, Level = LogLevel.Information, Message = "Started elevated launch task '{TaskName}'")] + private static partial void LogLaunchTaskStarted(ILogger logger, string taskName); + + [LoggerMessage(EventId = 4255, Level = LogLevel.Warning, Message = "Failed to run elevated launch task. Exit code: {ExitCode}, Error: {Error}")] + private static partial void LogRunLaunchTaskFailed(ILogger logger, int exitCode, string error); + + [LoggerMessage(EventId = 4256, Level = LogLevel.Warning, Message = "Exception while running elevated launch task")] + private static partial void LogRunLaunchTaskException(ILogger logger, Exception ex); + + [LoggerMessage(EventId = 4257, Level = LogLevel.Warning, Message = "Skipping autostart task registration due to invalid executable path: {Path}")] + private static partial void LogSkipEnsureAutostartTaskInvalidPath(ILogger logger, string path); + + [LoggerMessage(EventId = 4258, Level = LogLevel.Information, Message = "Ensured elevated autostart task '{TaskName}'")] + private static partial void LogAutostartTaskEnsured(ILogger logger, string taskName); + + [LoggerMessage(EventId = 4259, Level = LogLevel.Warning, Message = "Failed to ensure elevated autostart task. Exit code: {ExitCode}, Error: {Error}")] + private static partial void LogEnsureAutostartTaskFailed(ILogger logger, int exitCode, string error); + + [LoggerMessage(EventId = 4260, Level = LogLevel.Warning, Message = "Exception while ensuring elevated autostart task")] + private static partial void LogEnsureAutostartTaskException(ILogger logger, Exception ex); + + [LoggerMessage(EventId = 4261, Level = LogLevel.Information, Message = "Removed elevated autostart task '{TaskName}'")] + private static partial void LogAutostartTaskRemoved(ILogger logger, string taskName); + + [LoggerMessage(EventId = 4262, Level = LogLevel.Debug, Message = "Elevated autostart task '{TaskName}' was already absent")] + private static partial void LogAutostartTaskAlreadyRemoved(ILogger logger, string taskName); + + [LoggerMessage(EventId = 4263, Level = LogLevel.Warning, Message = "Failed to remove elevated autostart task. Exit code: {ExitCode}, Error: {Error}")] + private static partial void LogRemoveAutostartTaskFailed(ILogger logger, int exitCode, string error); + + [LoggerMessage(EventId = 4264, Level = LogLevel.Warning, Message = "Exception while removing elevated autostart task")] + private static partial void LogRemoveAutostartTaskException(ILogger logger, Exception ex); + + [LoggerMessage(EventId = 4265, Level = LogLevel.Debug, Message = "Autostart task query for '{TaskName}': Exists={Exists}, ExitCode={ExitCode}")] + private static partial void LogAutostartTaskQueryResult(ILogger logger, string taskName, bool exists, int exitCode); + + [LoggerMessage(EventId = 4266, Level = LogLevel.Warning, Message = "Exception while querying elevated autostart task")] + private static partial void LogAutostartTaskQueryException(ILogger logger, Exception ex); + + [LoggerMessage(EventId = 4267, Level = LogLevel.Warning, Message = "Failed to resolve executable path while ensuring elevated tasks")] + private static partial void LogGetExecutablePathFailed(ILogger logger, Exception ex); + + [LoggerMessage(EventId = 4268, Level = LogLevel.Debug, Message = "Failed to delete temporary file '{Path}'")] + private static partial void LogDeleteTemporaryFileFailed(ILogger logger, string path, Exception ex); + } +} diff --git a/Services/ElevationService.cs b/Services/ElevationService.cs index 0ab5b00..83cd81d 100644 --- a/Services/ElevationService.cs +++ b/Services/ElevationService.cs @@ -1,242 +1,223 @@ -/* - * 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.Diagnostics; - using System.IO; - using System.Linq; - using System.Security.Principal; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - using System.Windows; - using Microsoft.Extensions.Logging; - - /// - /// Service for managing application elevation and administrator privileges. - /// - public partial class ElevationService : IElevationService - { - private readonly ILogger logger; - private readonly ISecurityService securityService; - private readonly SemaphoreSlim elevationRequestSemaphore = new(1, 1); - - public ElevationService(ILogger logger, ISecurityService securityService) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.securityService = securityService ?? throw new ArgumentNullException(nameof(securityService)); - } - - public bool IsRunningAsAdministrator() - { - try - { - var identity = WindowsIdentity.GetCurrent(); - var principal = new WindowsPrincipal(identity); - var isAdmin = principal.IsInRole(WindowsBuiltInRole.Administrator); - - LogAdministratorPrivilegeCheck(this.logger, isAdmin); - return isAdmin; - } - catch (Exception ex) - { - LogPrivilegeCheckFailed(this.logger, ex); - return false; - } - } - - public async Task RequestElevationIfNeeded() - { - if (this.IsRunningAsAdministrator()) - { - LogAlreadyElevated(this.logger); - return true; - } - - LogRequestingElevation(this.logger); - - // Show elevation prompt to user - var result = System.Windows.MessageBox.Show( - "ThreadPilot requires administrator privileges to manage process affinity and power plans.\n\n" + - "Would you like to restart the application with administrator privileges?", - "Administrator Privileges Required", - MessageBoxButton.YesNo, - MessageBoxImage.Question); - - if (result != MessageBoxResult.Yes) - { - LogUserDeclinedElevation(this.logger); - return false; - } - - return await this.RestartWithElevation(); - } - - public async Task RestartWithElevation(string[]? arguments = null) - { - await this.elevationRequestSemaphore.WaitAsync(); - try - { - var currentProcess = Process.GetCurrentProcess(); - var executablePath = currentProcess.MainModule?.FileName; - - if (string.IsNullOrWhiteSpace(executablePath) || !Path.IsPathFullyQualified(executablePath) || !File.Exists(executablePath)) - { - LogMissingExecutablePath(this.logger); - return false; - } - - // Combine current arguments with any additional arguments - var currentArgs = Environment.GetCommandLineArgs().Skip(1).ToArray(); - var allArgs = arguments != null ? currentArgs.Concat(arguments).ToArray() : currentArgs; - var argumentString = string.Join(" ", allArgs.Select(EscapeCommandLineArgument)); - var workingDirectory = Path.GetDirectoryName(executablePath); - - var startInfo = new ProcessStartInfo - { - FileName = executablePath, - Arguments = argumentString, - UseShellExecute = true, - Verb = "runas", // This triggers UAC elevation - WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? Environment.SystemDirectory : workingDirectory, - }; - - LogStartingElevatedProcess(this.logger, executablePath, argumentString); - - var elevatedProcess = Process.Start(startInfo); - if (elevatedProcess != null) - { - LogElevatedProcessStarted(this.logger); - - // Audit the elevation request - await this.securityService.AuditElevatedAction("ApplicationRestart", "Self", true); - - // Shutdown current instance - await Task.Delay(1000); // Give the new process time to start - System.Windows.Application.Current.Shutdown(); - return true; - } - else - { - LogElevatedProcessStartFailed(this.logger); - return false; - } - } - catch (Exception ex) - { - LogRestartWithElevationFailed(this.logger, ex); - await this.securityService.AuditElevatedAction("ApplicationRestart", "Self", false); - - // Show user-friendly error message - System.Windows.MessageBox.Show( - "Failed to restart with administrator privileges. Please manually run ThreadPilot as administrator.", - "Elevation Failed", - MessageBoxButton.OK, - MessageBoxImage.Warning); - - return false; - } - finally - { - this.elevationRequestSemaphore.Release(); - } - } - - public bool ValidateElevationForOperation(string operation) - { - var isElevated = this.IsRunningAsAdministrator(); - var isValidOperation = this.securityService.ValidateElevatedOperation(operation); - - var canPerform = isElevated && isValidOperation; - - LogElevationValidation(this.logger, operation, isElevated, isValidOperation, canPerform); - - return canPerform; - } - - public string GetElevationStatus() - { - return this.IsRunningAsAdministrator() - ? "Running with Administrator privileges" - : "Running with limited privileges"; - } - - private static string EscapeCommandLineArgument(string argument) - { - if (string.IsNullOrEmpty(argument)) - { - return "\"\""; - } - - var escaped = new StringBuilder(); - escaped.Append('"'); - - foreach (var c in argument) - { - if (c == '"') - { - escaped.Append("\\\""); - } - else - { - escaped.Append(c); - } - } - - escaped.Append('"'); - return escaped.ToString(); - } - - [LoggerMessage(EventId = 4100, Level = LogLevel.Debug, Message = "Administrator privilege check: {IsAdmin}")] - private static partial void LogAdministratorPrivilegeCheck(ILogger logger, bool isAdmin); - - [LoggerMessage(EventId = 4101, Level = LogLevel.Error, Message = "Failed to check administrator privileges")] - private static partial void LogPrivilegeCheckFailed(ILogger logger, Exception ex); - - [LoggerMessage(EventId = 4102, Level = LogLevel.Debug, Message = "Application is already running with administrator privileges")] - private static partial void LogAlreadyElevated(ILogger logger); - - [LoggerMessage(EventId = 4103, Level = LogLevel.Information, Message = "Requesting elevation to administrator privileges")] - private static partial void LogRequestingElevation(ILogger logger); - - [LoggerMessage(EventId = 4104, Level = LogLevel.Information, Message = "User declined elevation request")] - private static partial void LogUserDeclinedElevation(ILogger logger); - - [LoggerMessage(EventId = 4105, Level = LogLevel.Error, Message = "Could not determine executable path for elevation")] - private static partial void LogMissingExecutablePath(ILogger logger); - - [LoggerMessage(EventId = 4106, Level = LogLevel.Information, Message = "Starting elevated process: {FileName} {Arguments}")] - private static partial void LogStartingElevatedProcess(ILogger logger, string fileName, string arguments); - - [LoggerMessage(EventId = 4107, Level = LogLevel.Information, Message = "Elevated process started successfully. Shutting down current instance.")] - private static partial void LogElevatedProcessStarted(ILogger logger); - - [LoggerMessage(EventId = 4108, Level = LogLevel.Error, Message = "Failed to start elevated process")] - private static partial void LogElevatedProcessStartFailed(ILogger logger); - - [LoggerMessage(EventId = 4109, Level = LogLevel.Error, Message = "Failed to restart with elevation")] - private static partial void LogRestartWithElevationFailed(ILogger logger, Exception ex); - - [LoggerMessage( - EventId = 4110, - Level = LogLevel.Debug, - Message = "Elevation validation for operation '{Operation}': Elevated={IsElevated}, Valid={IsValid}, CanPerform={CanPerform}")] - private static partial void LogElevationValidation(ILogger logger, string operation, bool isElevated, bool isValid, bool canPerform); - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Diagnostics; + using System.IO; + using System.Linq; + using System.Security.Principal; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using System.Windows; + using Microsoft.Extensions.Logging; + + public partial class ElevationService : IElevationService + { + private readonly ILogger logger; + private readonly ISecurityService securityService; + private readonly SemaphoreSlim elevationRequestSemaphore = new(1, 1); + + public ElevationService(ILogger logger, ISecurityService securityService) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.securityService = securityService ?? throw new ArgumentNullException(nameof(securityService)); + } + + public bool IsRunningAsAdministrator() + { + try + { + var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + var isAdmin = principal.IsInRole(WindowsBuiltInRole.Administrator); + + LogAdministratorPrivilegeCheck(this.logger, isAdmin); + return isAdmin; + } + catch (Exception ex) + { + LogPrivilegeCheckFailed(this.logger, ex); + return false; + } + } + + public async Task RequestElevationIfNeeded() + { + if (this.IsRunningAsAdministrator()) + { + LogAlreadyElevated(this.logger); + return true; + } + + LogRequestingElevation(this.logger); + + // Show elevation prompt to user + var result = System.Windows.MessageBox.Show( + "ThreadPilot requires administrator privileges to manage process affinity and power plans.\n\n" + + "Would you like to restart the application with administrator privileges?", + "Administrator Privileges Required", + MessageBoxButton.YesNo, + MessageBoxImage.Question); + + if (result != MessageBoxResult.Yes) + { + LogUserDeclinedElevation(this.logger); + return false; + } + + return await this.RestartWithElevation(); + } + + public async Task RestartWithElevation(string[]? arguments = null) + { + await this.elevationRequestSemaphore.WaitAsync(); + try + { + var currentProcess = Process.GetCurrentProcess(); + var executablePath = currentProcess.MainModule?.FileName; + + if (string.IsNullOrWhiteSpace(executablePath) || !Path.IsPathFullyQualified(executablePath) || !File.Exists(executablePath)) + { + LogMissingExecutablePath(this.logger); + return false; + } + + // Combine current arguments with any additional arguments + var currentArgs = Environment.GetCommandLineArgs().Skip(1).ToArray(); + var allArgs = arguments != null ? currentArgs.Concat(arguments).ToArray() : currentArgs; + var argumentString = string.Join(" ", allArgs.Select(EscapeCommandLineArgument)); + var workingDirectory = Path.GetDirectoryName(executablePath); + + var startInfo = new ProcessStartInfo + { + FileName = executablePath, + Arguments = argumentString, + UseShellExecute = true, + Verb = "runas", // This triggers UAC elevation + WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? Environment.SystemDirectory : workingDirectory, + }; + + LogStartingElevatedProcess(this.logger, executablePath, argumentString); + + var elevatedProcess = Process.Start(startInfo); + if (elevatedProcess != null) + { + LogElevatedProcessStarted(this.logger); + + // Audit the elevation request + await this.securityService.AuditElevatedAction("ApplicationRestart", "Self", true); + + // Shutdown current instance + await Task.Delay(1000); // Give the new process time to start + System.Windows.Application.Current.Shutdown(); + return true; + } + else + { + LogElevatedProcessStartFailed(this.logger); + return false; + } + } + catch (Exception ex) + { + LogRestartWithElevationFailed(this.logger, ex); + await this.securityService.AuditElevatedAction("ApplicationRestart", "Self", false); + + // Show user-friendly error message + System.Windows.MessageBox.Show( + "Failed to restart with administrator privileges. Please manually run ThreadPilot as administrator.", + "Elevation Failed", + MessageBoxButton.OK, + MessageBoxImage.Warning); + + return false; + } + finally + { + this.elevationRequestSemaphore.Release(); + } + } + + public bool ValidateElevationForOperation(string operation) + { + var isElevated = this.IsRunningAsAdministrator(); + var isValidOperation = this.securityService.ValidateElevatedOperation(operation); + + var canPerform = isElevated && isValidOperation; + + LogElevationValidation(this.logger, operation, isElevated, isValidOperation, canPerform); + + return canPerform; + } + + public string GetElevationStatus() + { + return this.IsRunningAsAdministrator() + ? "Running with Administrator privileges" + : "Running with limited privileges"; + } + + private static string EscapeCommandLineArgument(string argument) + { + if (string.IsNullOrEmpty(argument)) + { + return "\"\""; + } + + var escaped = new StringBuilder(); + escaped.Append('"'); + + foreach (var c in argument) + { + if (c == '"') + { + escaped.Append("\\\""); + } + else + { + escaped.Append(c); + } + } + + escaped.Append('"'); + return escaped.ToString(); + } + + [LoggerMessage(EventId = 4100, Level = LogLevel.Debug, Message = "Administrator privilege check: {IsAdmin}")] + private static partial void LogAdministratorPrivilegeCheck(ILogger logger, bool isAdmin); + + [LoggerMessage(EventId = 4101, Level = LogLevel.Error, Message = "Failed to check administrator privileges")] + private static partial void LogPrivilegeCheckFailed(ILogger logger, Exception ex); + + [LoggerMessage(EventId = 4102, Level = LogLevel.Debug, Message = "Application is already running with administrator privileges")] + private static partial void LogAlreadyElevated(ILogger logger); + + [LoggerMessage(EventId = 4103, Level = LogLevel.Information, Message = "Requesting elevation to administrator privileges")] + private static partial void LogRequestingElevation(ILogger logger); + + [LoggerMessage(EventId = 4104, Level = LogLevel.Information, Message = "User declined elevation request")] + private static partial void LogUserDeclinedElevation(ILogger logger); + + [LoggerMessage(EventId = 4105, Level = LogLevel.Error, Message = "Could not determine executable path for elevation")] + private static partial void LogMissingExecutablePath(ILogger logger); + + [LoggerMessage(EventId = 4106, Level = LogLevel.Information, Message = "Starting elevated process: {FileName} {Arguments}")] + private static partial void LogStartingElevatedProcess(ILogger logger, string fileName, string arguments); + + [LoggerMessage(EventId = 4107, Level = LogLevel.Information, Message = "Elevated process started successfully. Shutting down current instance.")] + private static partial void LogElevatedProcessStarted(ILogger logger); + + [LoggerMessage(EventId = 4108, Level = LogLevel.Error, Message = "Failed to start elevated process")] + private static partial void LogElevatedProcessStartFailed(ILogger logger); + + [LoggerMessage(EventId = 4109, Level = LogLevel.Error, Message = "Failed to restart with elevation")] + private static partial void LogRestartWithElevationFailed(ILogger logger, Exception ex); + + [LoggerMessage( + EventId = 4110, + Level = LogLevel.Debug, + Message = "Elevation validation for operation '{Operation}': Elevated={IsElevated}, Valid={IsValid}, CanPerform={CanPerform}")] + private static partial void LogElevationValidation(ILogger logger, string operation, bool isElevated, bool isValid, bool canPerform); + } +} + diff --git a/Services/EnhancedLoggingService.cs b/Services/EnhancedLoggingService.cs index 492987d..7ac3482 100644 --- a/Services/EnhancedLoggingService.cs +++ b/Services/EnhancedLoggingService.cs @@ -1,578 +1,556 @@ -/* - * 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; - using System.Collections.Generic; - using System.IO; - using System.Linq; - using System.Text.Json; - using System.Threading; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - - /// - /// Enhanced logging service with file persistence and structured logging. - /// - public class EnhancedLoggingService : IEnhancedLoggingService, IDisposable - { - private readonly ILogger logger; - private readonly IApplicationSettingsService settingsService; - private readonly SemaphoreSlim fileLock = new(1, 1); - private readonly ConcurrentQueue logQueue = new(); - private readonly System.Threading.Timer flushTimer; - private readonly string logDirectory; - private string currentLogFilePath; - private bool isInitialized; - private bool disposed; - - // PERFORMANCE IMPROVEMENT: Correlation tracking for better debugging - internal readonly AsyncLocal CorrelationId = new(); - internal readonly ConcurrentDictionary OperationStartTimes = new(); - - public string CurrentLogFilePath => this.currentLogFilePath; - - public string LogDirectoryPath => this.logDirectory; - - public bool IsDebugLoggingEnabled => this.settingsService.Settings.EnableDebugLogging; - - public event EventHandler? CriticalErrorOccurred; - - public EnhancedLoggingService(ILogger logger, IApplicationSettingsService settingsService) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); - - // Set up log directory - this.logDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ThreadPilot", "Logs"); - this.currentLogFilePath = this.GetCurrentLogFilePath(); - - // Create flush timer (flush every 5 seconds) - this.flushTimer = new System.Threading.Timer(this.FlushLogs, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); - } - - public async Task InitializeAsync() - { - if (this.isInitialized) - { - return; - } - - try - { - // Ensure log directory exists - Directory.CreateDirectory(this.logDirectory); - - // Create initial log file if it doesn't exist - if (!File.Exists(this.currentLogFilePath)) - { - await this.CreateNewLogFileAsync(); - } - - // Log initialization - await this.LogSystemEventAsync("LoggingService", "Enhanced logging service initialized", LogLevel.Information); - - // Clean up old logs - await this.CleanupOldLogsAsync(); - - this.isInitialized = true; - this.logger.LogInformation("Enhanced logging service initialized. Log directory: {LogDirectory}", this.logDirectory); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to initialize enhanced logging service"); - throw; - } - } - - public async Task LogPowerPlanChangeAsync(string fromPlan, string toPlan, string reason, string? processName = null) - { - var properties = new Dictionary - { - ["FromPlan"] = fromPlan, - ["ToPlan"] = toPlan, - ["Reason"] = reason, - ["ProcessName"] = processName ?? "N/A", - }; - - var message = processName != null - ? $"Power plan changed from '{fromPlan}' to '{toPlan}' due to process '{processName}' ({reason})" - : $"Power plan changed from '{fromPlan}' to '{toPlan}' ({reason})"; - - await this.LogStructuredEventAsync("PowerPlan", message, LogLevel.Information, properties); - } - - public async Task LogProcessMonitoringEventAsync(string eventType, string processName, int processId, string details) - { - var properties = new Dictionary - { - ["EventType"] = eventType, - ["ProcessName"] = processName, - ["ProcessId"] = processId, - ["Details"] = details, - }; - - var message = $"Process monitoring event: {eventType} - {processName} (PID: {processId}) - {details}"; - await this.LogStructuredEventAsync("ProcessMonitoring", message, LogLevel.Information, properties); - } - - public async Task LogUserActionAsync(string action, string details, string? context = null) - { - var properties = new Dictionary - { - ["Action"] = action, - ["Details"] = details, - ["Context"] = context ?? "N/A", - }; - - var message = $"User action: {action} - {details}"; - if (!string.IsNullOrEmpty(context)) - { - message += $" (Context: {context})"; - } - - await this.LogStructuredEventAsync("UserAction", message, LogLevel.Information, properties); - } - - public async Task LogSystemEventAsync(string eventType, string message, LogLevel level = LogLevel.Information) - { - var properties = new Dictionary - { - ["EventType"] = eventType, - }; - - await this.LogStructuredEventAsync("System", message, level, properties); - } - - public async Task LogErrorAsync(Exception exception, string context, Dictionary? additionalData = null) - { - var properties = new Dictionary - { - ["Context"] = context, - ["ExceptionType"] = exception.GetType().Name, - ["StackTrace"] = exception.StackTrace ?? "N/A", - }; - - if (additionalData != null) - { - foreach (var kvp in additionalData) - { - properties[kvp.Key] = kvp.Value; - } - } - - var message = $"Error in {context}: {exception.Message}"; - await this.LogStructuredEventAsync("Error", message, LogLevel.Error, properties, exception); - - // Raise critical error event for severe exceptions - if (exception is OutOfMemoryException or StackOverflowException or AccessViolationException) - { - this.CriticalErrorOccurred?.Invoke(this, new CriticalErrorEventArgs(exception, context, additionalData)); - } - } - - public async Task LogApplicationLifecycleEventAsync(string eventType, string details) - { - var properties = new Dictionary - { - ["EventType"] = eventType, - ["Details"] = details, - ["Version"] = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown", - }; - - var message = $"Application {eventType}: {details}"; - await this.LogStructuredEventAsync("Lifecycle", message, LogLevel.Information, properties); - } - - public IDisposable BeginScope(string operationName, object? parameters = null) - { - var correlationId = Guid.NewGuid().ToString("N")[..8]; - this.CorrelationId.Value = correlationId; - this.OperationStartTimes[correlationId] = DateTime.UtcNow; - - var parametersDict = parameters != null - ? JsonSerializer.Serialize(parameters) - : "{}"; - - this.logger.LogInformation( - "Operation {OperationName} started with correlation {CorrelationId} and parameters {Parameters}", - operationName, correlationId, parametersDict); - - return new OperationScope(this, operationName, correlationId); - } - - public string? GetCurrentCorrelationId() - { - return this.CorrelationId.Value; - } - - private async Task LogStructuredEventAsync(string category, string message, LogLevel level, Dictionary properties, Exception? exception = null) - { - if (!this.isInitialized && category != "System") - { - return; - } - - // Skip debug messages if debug logging is disabled - if (level == LogLevel.Debug && !this.IsDebugLoggingEnabled) - { - return; - } - - var logEntry = new LogEntry - { - Timestamp = DateTime.UtcNow, - Level = level, - Category = category, - Message = message, - Exception = exception?.ToString(), - Properties = properties, - CorrelationId = Thread.CurrentThread.ManagedThreadId.ToString(), - }; - - this.logQueue.Enqueue(logEntry); - - // Force immediate flush for errors and critical events - if (level >= LogLevel.Error) - { - await this.FlushLogsAsync(); - } - } - - private void FlushLogs(object? state) - { - TaskSafety.FireAndForget(this.FlushLogsAsync(), ex => - { - this.logger.LogWarning(ex, "Periodic log flush failed"); - }); - } - - private async Task FlushLogsAsync() - { - if (this.logQueue.IsEmpty) - { - return; - } - - await this.fileLock.WaitAsync(); - try - { - // Check if we need to rotate the log file - await this.CheckLogRotationAsync(); - - var logEntries = new List(); - while (this.logQueue.TryDequeue(out var entry)) - { - logEntries.Add(entry); - } - - if (logEntries.Count == 0) - { - return; - } - - // Write entries to file - await this.WriteLogEntriesToFileAsync(logEntries); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to flush logs to file"); - } - finally - { - this.fileLock.Release(); - } - } - - private async Task WriteLogEntriesToFileAsync(List entries) - { - var logLines = entries.Select(this.FormatLogEntry); - await File.AppendAllLinesAsync(this.currentLogFilePath, logLines); - } - - private string FormatLogEntry(LogEntry entry) - { - var logData = new - { - timestamp = entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff"), - level = entry.Level.ToString(), - category = entry.Category, - message = entry.Message, - exception = entry.Exception, - properties = entry.Properties, - correlationId = entry.CorrelationId, - }; - - return JsonSerializer.Serialize(logData, new JsonSerializerOptions { WriteIndented = false }); - } - - private async Task CheckLogRotationAsync() - { - var fileInfo = new FileInfo(this.currentLogFilePath); - var maxSizeBytes = this.settingsService.Settings.MaxLogFileSizeMb * 1024 * 1024; - - if (fileInfo.Exists && fileInfo.Length > maxSizeBytes) - { - // Rotate log file - var rotatedPath = Path.Combine(this.logDirectory, $"ThreadPilot_{DateTime.UtcNow:yyyyMMdd_HHmmss}.log"); - File.Move(this.currentLogFilePath, rotatedPath); - await this.CreateNewLogFileAsync(); - } - } - - private async Task CreateNewLogFileAsync() - { - this.currentLogFilePath = this.GetCurrentLogFilePath(); - await File.WriteAllTextAsync(this.currentLogFilePath, $"# ThreadPilot Log File - Created {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC{Environment.NewLine}"); - } - - private string GetCurrentLogFilePath() - { - return Path.Combine(this.logDirectory, "ThreadPilot.log"); - } - - public async Task> GetRecentLogEntriesAsync(int count = 100) - { - return await this.GetLogEntriesAsync(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow); - } - - public async Task> GetLogEntriesAsync(DateTime fromDate, DateTime toDate) - { - var entries = new List(); - - await this.fileLock.WaitAsync(); - try - { - var logFiles = Directory.GetFiles(this.logDirectory, "*.log") - .OrderByDescending(f => new FileInfo(f).CreationTime); - - foreach (var logFile in logFiles) - { - var fileEntries = await this.ReadLogEntriesFromFileAsync(logFile, fromDate, toDate); - entries.AddRange(fileEntries); - } - - return entries.OrderByDescending(e => e.Timestamp).Take(1000).ToList(); - } - finally - { - this.fileLock.Release(); - } - } - - private async Task> ReadLogEntriesFromFileAsync(string filePath, DateTime fromDate, DateTime toDate) - { - var entries = new List(); - - try - { - var lines = await File.ReadAllLinesAsync(filePath); - foreach (var line in lines) - { - if (line.StartsWith("#") || string.IsNullOrWhiteSpace(line)) - { - continue; - } - - try - { - var logData = JsonSerializer.Deserialize(line); - var timestamp = DateTime.Parse(logData.GetProperty("timestamp").GetString()!); - - if (timestamp >= fromDate && timestamp <= toDate) - { - var entry = new LogEntry - { - Timestamp = timestamp, - Level = Enum.Parse(logData.GetProperty("level").GetString()!), - Category = logData.GetProperty("category").GetString()!, - Message = logData.GetProperty("message").GetString()!, - Exception = logData.TryGetProperty("exception", out var ex) ? ex.GetString() : null, - CorrelationId = logData.TryGetProperty("correlationId", out var cid) ? cid.GetString() : null, - }; - - if (logData.TryGetProperty("properties", out var props)) - { - entry.Properties = JsonSerializer.Deserialize>(props.GetRawText()) ?? new(); - } - - entries.Add(entry); - } - } - catch - { - // Skip malformed log entries - } - } - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to read log entries from file: {FilePath}", filePath); - } - - return entries; - } - - public async Task CleanupOldLogsAsync() - { - await this.fileLock.WaitAsync(); - try - { - var retentionDate = DateTime.UtcNow.AddDays(-this.settingsService.Settings.LogRetentionDays); - var logFiles = Directory.GetFiles(this.logDirectory, "*.log"); - - foreach (var logFile in logFiles) - { - var fileInfo = new FileInfo(logFile); - if (fileInfo.CreationTime < retentionDate && Path.GetFileName(logFile) != "ThreadPilot.log") - { - try - { - File.Delete(logFile); - this.logger.LogDebug("Deleted old log file: {LogFile}", logFile); - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to delete old log file: {LogFile}", logFile); - } - } - } - } - finally - { - this.fileLock.Release(); - } - } - - public async Task GetLogStatisticsAsync() - { - await this.fileLock.WaitAsync(); - try - { - var stats = new LogFileStatistics(); - var logFiles = Directory.GetFiles(this.logDirectory, "*.log"); - - stats.TotalLogFiles = logFiles.Length; - - foreach (var logFile in logFiles) - { - var fileInfo = new FileInfo(logFile); - stats.TotalLogSizeBytes += fileInfo.Length; - - if (Path.GetFileName(logFile) == "ThreadPilot.log") - { - stats.CurrentFileSizeBytes = fileInfo.Length; - } - - if (stats.OldestLogDate == default || fileInfo.CreationTime < stats.OldestLogDate) - { - stats.OldestLogDate = fileInfo.CreationTime; - } - - if (fileInfo.CreationTime > stats.NewestLogDate) - { - stats.NewestLogDate = fileInfo.CreationTime; - } - } - - return stats; - } - finally - { - this.fileLock.Release(); - } - } - - public async Task ExportLogsAsync(DateTime fromDate, DateTime toDate, string? exportPath = null) - { - exportPath ??= Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.Desktop), - $"ThreadPilot_Logs_{DateTime.Now:yyyyMMdd_HHmmss}.txt"); - - var entries = await this.GetLogEntriesAsync(fromDate, toDate); - var exportLines = entries.Select(e => $"{e.Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{e.Level}] {e.Category}: {e.Message}"); - - await File.WriteAllLinesAsync(exportPath, exportLines); - return exportPath; - } - - public async Task UpdateConfigurationAsync(bool enableDebugLogging, int maxFileSizeMb, int retentionDays) - { - var updatedSettings = this.settingsService.Settings; - updatedSettings.EnableDebugLogging = enableDebugLogging; - updatedSettings.MaxLogFileSizeMb = maxFileSizeMb; - updatedSettings.LogRetentionDays = retentionDays; - - await this.settingsService.UpdateSettingsAsync(updatedSettings); - await this.LogSystemEventAsync("Configuration", $"Logging configuration updated: Debug={enableDebugLogging}, MaxSize={maxFileSizeMb}MB, Retention={retentionDays}days"); - } - - public void Dispose() - { - if (this.disposed) - { - return; - } - - this.flushTimer?.Dispose(); - this.FlushLogsAsync().Wait(TimeSpan.FromSeconds(5)); - this.fileLock?.Dispose(); - this.disposed = true; - } - } - - /// - /// Operation scope for correlation tracking. - /// - internal class OperationScope : IDisposable - { - private readonly EnhancedLoggingService loggingService; - private readonly string operationName; - private readonly string correlationId; - private readonly DateTime startTime; - private bool disposed; - - public OperationScope(EnhancedLoggingService loggingService, string operationName, string correlationId) - { - this.loggingService = loggingService; - this.operationName = operationName; - this.correlationId = correlationId; - this.startTime = DateTime.UtcNow; - } - - public void Dispose() - { - if (this.disposed) - { - return; - } - - var duration = DateTime.UtcNow - this.startTime; - this.loggingService.OperationStartTimes.TryRemove(this.correlationId, out _); - this.loggingService.CorrelationId.Value = null; - - // Use the public logging method instead of accessing private _logger - _ = this.loggingService.LogSystemEventAsync( - "OperationCompleted", - $"Operation {this.operationName} completed with correlation {this.correlationId} in {duration.TotalMilliseconds}ms"); - - this.disposed = true; - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + + public class EnhancedLoggingService : IEnhancedLoggingService, IDisposable + { + private readonly ILogger logger; + private readonly IApplicationSettingsService settingsService; + private readonly SemaphoreSlim fileLock = new(1, 1); + private readonly ConcurrentQueue logQueue = new(); + private readonly System.Threading.Timer flushTimer; + private readonly string logDirectory; + private string currentLogFilePath; + private bool isInitialized; + private bool disposed; + + // PERFORMANCE IMPROVEMENT: Correlation tracking for better debugging + internal readonly AsyncLocal CorrelationId = new(); + internal readonly ConcurrentDictionary OperationStartTimes = new(); + + public string CurrentLogFilePath => this.currentLogFilePath; + + public string LogDirectoryPath => this.logDirectory; + + public bool IsDebugLoggingEnabled => this.settingsService.Settings.EnableDebugLogging; + + public event EventHandler? CriticalErrorOccurred; + + public EnhancedLoggingService(ILogger logger, IApplicationSettingsService settingsService) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + + // Set up log directory + this.logDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ThreadPilot", "Logs"); + this.currentLogFilePath = this.GetCurrentLogFilePath(); + + // Create flush timer (flush every 5 seconds) + this.flushTimer = new System.Threading.Timer(this.FlushLogs, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); + } + + public async Task InitializeAsync() + { + if (this.isInitialized) + { + return; + } + + try + { + // Ensure log directory exists + Directory.CreateDirectory(this.logDirectory); + + // Create initial log file if it doesn't exist + if (!File.Exists(this.currentLogFilePath)) + { + await this.CreateNewLogFileAsync(); + } + + // Log initialization + await this.LogSystemEventAsync("LoggingService", "Enhanced logging service initialized", LogLevel.Information); + + // Clean up old logs + await this.CleanupOldLogsAsync(); + + this.isInitialized = true; + this.logger.LogInformation("Enhanced logging service initialized. Log directory: {LogDirectory}", this.logDirectory); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to initialize enhanced logging service"); + throw; + } + } + + public async Task LogPowerPlanChangeAsync(string fromPlan, string toPlan, string reason, string? processName = null) + { + var properties = new Dictionary + { + ["FromPlan"] = fromPlan, + ["ToPlan"] = toPlan, + ["Reason"] = reason, + ["ProcessName"] = processName ?? "N/A", + }; + + var message = processName != null + ? $"Power plan changed from '{fromPlan}' to '{toPlan}' due to process '{processName}' ({reason})" + : $"Power plan changed from '{fromPlan}' to '{toPlan}' ({reason})"; + + await this.LogStructuredEventAsync("PowerPlan", message, LogLevel.Information, properties); + } + + public async Task LogProcessMonitoringEventAsync(string eventType, string processName, int processId, string details) + { + var properties = new Dictionary + { + ["EventType"] = eventType, + ["ProcessName"] = processName, + ["ProcessId"] = processId, + ["Details"] = details, + }; + + var message = $"Process monitoring event: {eventType} - {processName} (PID: {processId}) - {details}"; + await this.LogStructuredEventAsync("ProcessMonitoring", message, LogLevel.Information, properties); + } + + public async Task LogUserActionAsync(string action, string details, string? context = null) + { + var properties = new Dictionary + { + ["Action"] = action, + ["Details"] = details, + ["Context"] = context ?? "N/A", + }; + + var message = $"User action: {action} - {details}"; + if (!string.IsNullOrEmpty(context)) + { + message += $" (Context: {context})"; + } + + await this.LogStructuredEventAsync("UserAction", message, LogLevel.Information, properties); + } + + public async Task LogSystemEventAsync(string eventType, string message, LogLevel level = LogLevel.Information) + { + var properties = new Dictionary + { + ["EventType"] = eventType, + }; + + await this.LogStructuredEventAsync("System", message, level, properties); + } + + public async Task LogErrorAsync(Exception exception, string context, Dictionary? additionalData = null) + { + var properties = new Dictionary + { + ["Context"] = context, + ["ExceptionType"] = exception.GetType().Name, + ["StackTrace"] = exception.StackTrace ?? "N/A", + }; + + if (additionalData != null) + { + foreach (var kvp in additionalData) + { + properties[kvp.Key] = kvp.Value; + } + } + + var message = $"Error in {context}: {exception.Message}"; + await this.LogStructuredEventAsync("Error", message, LogLevel.Error, properties, exception); + + // Raise critical error event for severe exceptions + if (exception is OutOfMemoryException or StackOverflowException or AccessViolationException) + { + this.CriticalErrorOccurred?.Invoke(this, new CriticalErrorEventArgs(exception, context, additionalData)); + } + } + + public async Task LogApplicationLifecycleEventAsync(string eventType, string details) + { + var properties = new Dictionary + { + ["EventType"] = eventType, + ["Details"] = details, + ["Version"] = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown", + }; + + var message = $"Application {eventType}: {details}"; + await this.LogStructuredEventAsync("Lifecycle", message, LogLevel.Information, properties); + } + + public IDisposable BeginScope(string operationName, object? parameters = null) + { + var correlationId = Guid.NewGuid().ToString("N")[..8]; + this.CorrelationId.Value = correlationId; + this.OperationStartTimes[correlationId] = DateTime.UtcNow; + + var parametersDict = parameters != null + ? JsonSerializer.Serialize(parameters) + : "{}"; + + this.logger.LogInformation( + "Operation {OperationName} started with correlation {CorrelationId} and parameters {Parameters}", + operationName, correlationId, parametersDict); + + return new OperationScope(this, operationName, correlationId); + } + + public string? GetCurrentCorrelationId() + { + return this.CorrelationId.Value; + } + + private async Task LogStructuredEventAsync(string category, string message, LogLevel level, Dictionary properties, Exception? exception = null) + { + if (!this.isInitialized && category != "System") + { + return; + } + + // Skip debug messages if debug logging is disabled + if (level == LogLevel.Debug && !this.IsDebugLoggingEnabled) + { + return; + } + + var logEntry = new LogEntry + { + Timestamp = DateTime.UtcNow, + Level = level, + Category = category, + Message = message, + Exception = exception?.ToString(), + Properties = properties, + CorrelationId = Thread.CurrentThread.ManagedThreadId.ToString(), + }; + + this.logQueue.Enqueue(logEntry); + + // Force immediate flush for errors and critical events + if (level >= LogLevel.Error) + { + await this.FlushLogsAsync(); + } + } + + private void FlushLogs(object? state) + { + TaskSafety.FireAndForget(this.FlushLogsAsync(), ex => + { + this.logger.LogWarning(ex, "Periodic log flush failed"); + }); + } + + private async Task FlushLogsAsync() + { + if (this.logQueue.IsEmpty) + { + return; + } + + await this.fileLock.WaitAsync(); + try + { + // Check if we need to rotate the log file + await this.CheckLogRotationAsync(); + + var logEntries = new List(); + while (this.logQueue.TryDequeue(out var entry)) + { + logEntries.Add(entry); + } + + if (logEntries.Count == 0) + { + return; + } + + // Write entries to file + await this.WriteLogEntriesToFileAsync(logEntries); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to flush logs to file"); + } + finally + { + this.fileLock.Release(); + } + } + + private async Task WriteLogEntriesToFileAsync(List entries) + { + var logLines = entries.Select(this.FormatLogEntry); + await File.AppendAllLinesAsync(this.currentLogFilePath, logLines); + } + + private string FormatLogEntry(LogEntry entry) + { + var logData = new + { + timestamp = entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff"), + level = entry.Level.ToString(), + category = entry.Category, + message = entry.Message, + exception = entry.Exception, + properties = entry.Properties, + correlationId = entry.CorrelationId, + }; + + return JsonSerializer.Serialize(logData, new JsonSerializerOptions { WriteIndented = false }); + } + + private async Task CheckLogRotationAsync() + { + var fileInfo = new FileInfo(this.currentLogFilePath); + var maxSizeBytes = this.settingsService.Settings.MaxLogFileSizeMb * 1024 * 1024; + + if (fileInfo.Exists && fileInfo.Length > maxSizeBytes) + { + // Rotate log file + var rotatedPath = Path.Combine(this.logDirectory, $"ThreadPilot_{DateTime.UtcNow:yyyyMMdd_HHmmss}.log"); + File.Move(this.currentLogFilePath, rotatedPath); + await this.CreateNewLogFileAsync(); + } + } + + private async Task CreateNewLogFileAsync() + { + this.currentLogFilePath = this.GetCurrentLogFilePath(); + await File.WriteAllTextAsync(this.currentLogFilePath, $"# ThreadPilot Log File - Created {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC{Environment.NewLine}"); + } + + private string GetCurrentLogFilePath() + { + return Path.Combine(this.logDirectory, "ThreadPilot.log"); + } + + public async Task> GetRecentLogEntriesAsync(int count = 100) + { + return await this.GetLogEntriesAsync(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow); + } + + public async Task> GetLogEntriesAsync(DateTime fromDate, DateTime toDate) + { + var entries = new List(); + + await this.fileLock.WaitAsync(); + try + { + var logFiles = Directory.GetFiles(this.logDirectory, "*.log") + .OrderByDescending(f => new FileInfo(f).CreationTime); + + foreach (var logFile in logFiles) + { + var fileEntries = await this.ReadLogEntriesFromFileAsync(logFile, fromDate, toDate); + entries.AddRange(fileEntries); + } + + return entries.OrderByDescending(e => e.Timestamp).Take(1000).ToList(); + } + finally + { + this.fileLock.Release(); + } + } + + private async Task> ReadLogEntriesFromFileAsync(string filePath, DateTime fromDate, DateTime toDate) + { + var entries = new List(); + + try + { + var lines = await File.ReadAllLinesAsync(filePath); + foreach (var line in lines) + { + if (line.StartsWith("#") || string.IsNullOrWhiteSpace(line)) + { + continue; + } + + try + { + var logData = JsonSerializer.Deserialize(line); + var timestamp = DateTime.Parse(logData.GetProperty("timestamp").GetString()!); + + if (timestamp >= fromDate && timestamp <= toDate) + { + var entry = new LogEntry + { + Timestamp = timestamp, + Level = Enum.Parse(logData.GetProperty("level").GetString()!), + Category = logData.GetProperty("category").GetString()!, + Message = logData.GetProperty("message").GetString()!, + Exception = logData.TryGetProperty("exception", out var ex) ? ex.GetString() : null, + CorrelationId = logData.TryGetProperty("correlationId", out var cid) ? cid.GetString() : null, + }; + + if (logData.TryGetProperty("properties", out var props)) + { + entry.Properties = JsonSerializer.Deserialize>(props.GetRawText()) ?? new(); + } + + entries.Add(entry); + } + } + catch + { + // Skip malformed log entries + } + } + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to read log entries from file: {FilePath}", filePath); + } + + return entries; + } + + public async Task CleanupOldLogsAsync() + { + await this.fileLock.WaitAsync(); + try + { + var retentionDate = DateTime.UtcNow.AddDays(-this.settingsService.Settings.LogRetentionDays); + var logFiles = Directory.GetFiles(this.logDirectory, "*.log"); + + foreach (var logFile in logFiles) + { + var fileInfo = new FileInfo(logFile); + if (fileInfo.CreationTime < retentionDate && Path.GetFileName(logFile) != "ThreadPilot.log") + { + try + { + File.Delete(logFile); + this.logger.LogDebug("Deleted old log file: {LogFile}", logFile); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to delete old log file: {LogFile}", logFile); + } + } + } + } + finally + { + this.fileLock.Release(); + } + } + + public async Task GetLogStatisticsAsync() + { + await this.fileLock.WaitAsync(); + try + { + var stats = new LogFileStatistics(); + var logFiles = Directory.GetFiles(this.logDirectory, "*.log"); + + stats.TotalLogFiles = logFiles.Length; + + foreach (var logFile in logFiles) + { + var fileInfo = new FileInfo(logFile); + stats.TotalLogSizeBytes += fileInfo.Length; + + if (Path.GetFileName(logFile) == "ThreadPilot.log") + { + stats.CurrentFileSizeBytes = fileInfo.Length; + } + + if (stats.OldestLogDate == default || fileInfo.CreationTime < stats.OldestLogDate) + { + stats.OldestLogDate = fileInfo.CreationTime; + } + + if (fileInfo.CreationTime > stats.NewestLogDate) + { + stats.NewestLogDate = fileInfo.CreationTime; + } + } + + return stats; + } + finally + { + this.fileLock.Release(); + } + } + + public async Task ExportLogsAsync(DateTime fromDate, DateTime toDate, string? exportPath = null) + { + exportPath ??= Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Desktop), + $"ThreadPilot_Logs_{DateTime.Now:yyyyMMdd_HHmmss}.txt"); + + var entries = await this.GetLogEntriesAsync(fromDate, toDate); + var exportLines = entries.Select(e => $"{e.Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{e.Level}] {e.Category}: {e.Message}"); + + await File.WriteAllLinesAsync(exportPath, exportLines); + return exportPath; + } + + public async Task UpdateConfigurationAsync(bool enableDebugLogging, int maxFileSizeMb, int retentionDays) + { + var updatedSettings = this.settingsService.Settings; + updatedSettings.EnableDebugLogging = enableDebugLogging; + updatedSettings.MaxLogFileSizeMb = maxFileSizeMb; + updatedSettings.LogRetentionDays = retentionDays; + + await this.settingsService.UpdateSettingsAsync(updatedSettings); + await this.LogSystemEventAsync("Configuration", $"Logging configuration updated: Debug={enableDebugLogging}, MaxSize={maxFileSizeMb}MB, Retention={retentionDays}days"); + } + + public void Dispose() + { + if (this.disposed) + { + return; + } + + this.flushTimer?.Dispose(); + this.FlushLogsAsync().Wait(TimeSpan.FromSeconds(5)); + this.fileLock?.Dispose(); + this.disposed = true; + } + } + + internal class OperationScope : IDisposable + { + private readonly EnhancedLoggingService loggingService; + private readonly string operationName; + private readonly string correlationId; + private readonly DateTime startTime; + private bool disposed; + + public OperationScope(EnhancedLoggingService loggingService, string operationName, string correlationId) + { + this.loggingService = loggingService; + this.operationName = operationName; + this.correlationId = correlationId; + this.startTime = DateTime.UtcNow; + } + + public void Dispose() + { + if (this.disposed) + { + return; + } + + var duration = DateTime.UtcNow - this.startTime; + this.loggingService.OperationStartTimes.TryRemove(this.correlationId, out _); + this.loggingService.CorrelationId.Value = null; + + // Use the public logging method instead of accessing private _logger + _ = this.loggingService.LogSystemEventAsync( + "OperationCompleted", + $"Operation {this.operationName} completed with correlation {this.correlationId} in {duration.TotalMilliseconds}ms"); + + this.disposed = true; + } + } +} + diff --git a/Services/FileSettingsStorage.cs b/Services/FileSettingsStorage.cs index 200c4f0..35ee7ff 100644 --- a/Services/FileSettingsStorage.cs +++ b/Services/FileSettingsStorage.cs @@ -1,46 +1,43 @@ -namespace ThreadPilot.Services -{ - using System; - using System.IO; - using System.Text; - using System.Threading.Tasks; - using ThreadPilot.Services.Abstractions; - - /// - /// Default filesystem-backed settings storage. - /// - public sealed class FileSettingsStorage : ISettingsStorage - { - public void Copy(string sourcePath, string destinationPath, bool overwrite) - { - File.Copy(sourcePath, destinationPath, overwrite); - } - - public void EnsureDirectoryForFile(string path) - { - var directory = Path.GetDirectoryName(path); - if (!string.IsNullOrWhiteSpace(directory)) - { - Directory.CreateDirectory(directory); - } - } - - public bool Exists(string path) - { - return File.Exists(path); - } - - public async Task ReadAsync(string path) - { - return this.Exists(path) - ? await File.ReadAllTextAsync(path) - : null; - } - - public Task WriteAsync(string path, string content) - { - this.EnsureDirectoryForFile(path); - return AtomicFileWriter.WriteAllTextAsync(path, content, Encoding.UTF8); - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.IO; + using System.Text; + using System.Threading.Tasks; + using ThreadPilot.Services.Abstractions; + + public sealed class FileSettingsStorage : ISettingsStorage + { + public void Copy(string sourcePath, string destinationPath, bool overwrite) + { + File.Copy(sourcePath, destinationPath, overwrite); + } + + public void EnsureDirectoryForFile(string path) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + } + + public bool Exists(string path) + { + return File.Exists(path); + } + + public async Task ReadAsync(string path) + { + return this.Exists(path) + ? await File.ReadAllTextAsync(path) + : null; + } + + public Task WriteAsync(string path, string content) + { + this.EnsureDirectoryForFile(path); + return AtomicFileWriter.WriteAllTextAsync(path, content, Encoding.UTF8); + } + } +} diff --git a/Services/ForegroundProcessService.cs b/Services/ForegroundProcessService.cs index d3c6541..77e88ca 100644 --- a/Services/ForegroundProcessService.cs +++ b/Services/ForegroundProcessService.cs @@ -1,74 +1,58 @@ -/* - * 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; - } - } - } -} +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/GameBoostService.cs b/Services/GameBoostService.cs deleted file mode 100644 index b23dd36..0000000 --- a/Services/GameBoostService.cs +++ /dev/null @@ -1,646 +0,0 @@ -/* - * 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 . - */ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using ThreadPilot.Models; - -namespace ThreadPilot.Services -{ - /// - /// Service for managing Game Boost mode functionality - /// - public class GameBoostService : IGameBoostService - { - private readonly ILogger _logger; - private readonly IPowerPlanService _powerPlanService; - private readonly IProcessService _processService; - private readonly INotificationService _notificationService; - private readonly IApplicationSettingsService _settingsService; - - private ApplicationSettingsModel _settings; - private bool _isGameBoostActive; - private ProcessModel? _currentGameProcess; - private string? _previousPowerPlanId; - private DateTime? _gameBoostStartTime; - private readonly List _knownGameExecutables; - - public event EventHandler? GameBoostActivated; - public event EventHandler? GameBoostDeactivated; - public event EventHandler? GameDetected; - - public bool IsGameBoostActive => _isGameBoostActive; - public ProcessModel? CurrentGameProcess => _currentGameProcess; - public IReadOnlyList KnownGameExecutables => _knownGameExecutables.AsReadOnly(); - - public GameBoostService( - ILogger logger, - IPowerPlanService powerPlanService, - IProcessService processService, - INotificationService notificationService, - IApplicationSettingsService settingsService) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _powerPlanService = powerPlanService ?? throw new ArgumentNullException(nameof(powerPlanService)); - _processService = processService ?? throw new ArgumentNullException(nameof(processService)); - _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService)); - _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); - - _settings = _settingsService.Settings; - _knownGameExecutables = InitializeKnownGames(); - - // Subscribe to settings changes - _settingsService.SettingsChanged += OnSettingsChanged; - - _logger.LogInformation("Game Boost service initialized with {Count} known games", _knownGameExecutables.Count); - } - - public async Task EnableGameBoostAsync() - { - try - { - if (!_settings.EnableGameBoostMode) - { - _logger.LogWarning("Game Boost mode is disabled in settings"); - return false; - } - - _logger.LogInformation("Game Boost mode enabled"); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to enable Game Boost mode"); - return false; - } - } - - public async Task DisableGameBoostAsync() - { - try - { - if (_isGameBoostActive) - { - await DeactivateGameBoostAsync(); - } - - _logger.LogInformation("Game Boost mode disabled"); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to disable Game Boost mode"); - return false; - } - } - - public async Task ActivateGameBoostAsync(ProcessModel process) - { - try - { - if (_isGameBoostActive && _currentGameProcess?.ProcessId == process.ProcessId) - { - _logger.LogDebug("Game Boost already active for process {ProcessName}", process.Name); - return true; - } - - // Deactivate current boost if active - if (_isGameBoostActive) - { - await DeactivateGameBoostAsync(); - } - - // Store current power plan - var currentPowerPlan = await _powerPlanService.GetActivePowerPlan(); - _previousPowerPlanId = currentPowerPlan?.Guid; - - // Apply Game Boost power plan - var gameBoostPowerPlanId = !string.IsNullOrEmpty(_settings.GameBoostPowerPlanId) - ? _settings.GameBoostPowerPlanId - : await GetHighPerformancePowerPlanIdAsync(); - - if (!string.IsNullOrEmpty(gameBoostPowerPlanId)) - { - await _powerPlanService.SetActivePowerPlanByGuidAsync(gameBoostPowerPlanId); - } - - // Set high priority if enabled - if (_settings.GameBoostSetHighPriority) - { - await SetProcessPriorityAsync(process, ProcessPriorityClass.High); - } - - // Optimize CPU affinity if enabled - if (_settings.GameBoostOptimizeCpuAffinity) - { - await OptimizeCpuAffinityAsync(process); - } - - _isGameBoostActive = true; - _currentGameProcess = process; - _gameBoostStartTime = DateTime.Now; - - _logger.LogInformation("Game Boost activated for {ProcessName} (PID: {ProcessId})", - process.Name, process.ProcessId); - - // Fire events - GameDetected?.Invoke(this, new GameDetectedEventArgs(process, _knownGameExecutables.Contains(process.Name.ToLowerInvariant()))); - GameBoostActivated?.Invoke(this, new GameBoostActivatedEventArgs(process, gameBoostPowerPlanId ?? "")); - - await _notificationService.ShowSuccessNotificationAsync( - "Game Boost Activated", - $"Game Boost mode activated for {process.Name}"); - - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to activate Game Boost for process {ProcessName}", process.Name); - return false; - } - } - - public async Task DeactivateGameBoostAsync() - { - try - { - if (!_isGameBoostActive) - { - return true; - } - - var duration = _gameBoostStartTime.HasValue - ? DateTime.Now - _gameBoostStartTime.Value - : TimeSpan.Zero; - - // Restore previous power plan - if (!string.IsNullOrEmpty(_previousPowerPlanId)) - { - await _powerPlanService.SetActivePowerPlanByGuidAsync(_previousPowerPlanId); - } - else if (!string.IsNullOrEmpty(_settings.DefaultPowerPlanId)) - { - await _powerPlanService.SetActivePowerPlanByGuidAsync(_settings.DefaultPowerPlanId); - } - - var gameProcess = _currentGameProcess; - var restoredPowerPlanId = _previousPowerPlanId ?? _settings.DefaultPowerPlanId; - - _isGameBoostActive = false; - _currentGameProcess = null; - _previousPowerPlanId = null; - _gameBoostStartTime = null; - - _logger.LogInformation("Game Boost deactivated after {Duration}", duration); - - GameBoostDeactivated?.Invoke(this, new GameBoostDeactivatedEventArgs( - gameProcess, restoredPowerPlanId, duration)); - - await _notificationService.ShowNotificationAsync( - "Game Boost Deactivated", - $"Game Boost mode deactivated after {duration:hh\\:mm\\:ss}", - NotificationType.Information); - - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to deactivate Game Boost"); - return false; - } - } - - public async Task AddKnownGameAsync(string executableName) - { - if (string.IsNullOrWhiteSpace(executableName)) - return false; - - var normalizedName = executableName.ToLowerInvariant(); - if (!_knownGameExecutables.Contains(normalizedName)) - { - _knownGameExecutables.Add(normalizedName); - _logger.LogInformation("Added known game: {ExecutableName}", executableName); - return true; - } - - return false; - } - - public async Task RemoveKnownGameAsync(string executableName) - { - if (string.IsNullOrWhiteSpace(executableName)) - return false; - - var normalizedName = executableName.ToLowerInvariant(); - var removed = _knownGameExecutables.Remove(normalizedName); - - if (removed) - { - _logger.LogInformation("Removed known game: {ExecutableName}", executableName); - } - - return removed; - } - - public IReadOnlyList GetKnownGameExecutables() - { - return _knownGameExecutables.ToList().AsReadOnly(); - } - - public bool IsGameProcess(ProcessModel process) - { - if (process == null || string.IsNullOrEmpty(process.Name)) - return false; - - var processName = process.Name.ToLowerInvariant(); - - // Check against known games list - if (_knownGameExecutables.Contains(processName)) - return true; - - // Auto-detection heuristics (if enabled) - if (_settings.GameBoostAutoDetectGames) - { - return IsLikelyGameProcess(process); - } - - return false; - } - - private void OnSettingsChanged(object? sender, ApplicationSettingsChangedEventArgs e) - { - try - { - _settings = e.NewSettings; - _logger.LogDebug("Game Boost settings updated"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating Game Boost settings"); - } - } - - private List InitializeKnownGames() - { - return new List - { - // Game Launchers - "steam.exe", - "steamwebhelper.exe", - "origin.exe", - "epicgameslauncher.exe", - "uplay.exe", - "ubisoft connect.exe", - "battlenet.exe", - "battle.net.exe", - "gog.exe", - "gog galaxy.exe", - "rockstarlauncher.exe", - "bethesdanetlauncher.exe", - "ea desktop.exe", - "xbox.exe", - "xboxapp.exe", - "gamepass.exe", - - // Popular Games - FPS/Shooters - "csgo.exe", - "cs2.exe", - "valorant.exe", - "valorant-win64-shipping.exe", - "fortniteclient-win64-shipping.exe", - "fortnite.exe", - "apex_legends.exe", - "r5apex.exe", - "overwatch.exe", - "overwatch2.exe", - "cod.exe", - "modernwarfare.exe", - "warzone.exe", - "blackops.exe", - "rainbow6.exe", - "rainbowsix.exe", - "pubg.exe", - "tslgame.exe", - "bf1.exe", - "bfv.exe", - "bf2042.exe", - "titanfall2.exe", - "doom.exe", - "doomslayers.exe", - "doom eternal.exe", - "halo.exe", - "haloinfinite.exe", - "destiny2.exe", - - // Popular Games - MOBA/Strategy - "dota2.exe", - "league of legends.exe", - "lol.exe", - "riotclientservices.exe", - "teamfighttactics.exe", - "starcraft2.exe", - "sc2.exe", - "warcraft3.exe", - "aoe2de.exe", - "aoe4.exe", - "civilization6.exe", - "civ6.exe", - "totalwar.exe", - - // Popular Games - RPG/Adventure - "witcher3.exe", - "cyberpunk2077.exe", - "skyrim.exe", - "skyrimse.exe", - "fallout4.exe", - "fallout76.exe", - "elderscrollsonline.exe", - "wow.exe", - "worldofwarcraft.exe", - "ffxiv.exe", - "ffxiv_dx11.exe", - "guildwars2.exe", - "newworld.exe", - "lostark.exe", - "diablo3.exe", - "diablo4.exe", - "pathofexile.exe", - "borderlands3.exe", - "masseffect.exe", - "dragonage.exe", - "assassinscreed.exe", - "farcry.exe", - "watchdogs.exe", - - // Popular Games - Open World/Action - "gta5.exe", - "gtav.exe", - "rdr2.exe", - "reddeadredemption2.exe", - "minecraft.exe", - "minecraftlauncher.exe", - "javaw.exe", // Minecraft Java - "terraria.exe", - "stardewvalley.exe", - "subnautica.exe", - "nomanssky.exe", - "spiderman.exe", - "godofwar.exe", - "horizonzerodawn.exe", - "deathstranding.exe", - - // Popular Games - Racing/Sports - "forza.exe", - "forzahorizon.exe", - "granturismo.exe", - "f1.exe", - "dirt.exe", - "wreckfest.exe", - "fifa.exe", - "nba2k.exe", - "madden.exe", - "rocketleague.exe", - - // Popular Games - Simulation/Building - "citiesskylines.exe", - "simcity.exe", - "planetcoaster.exe", - "twopointcampus.exe", - "kerbalspaceprogram.exe", - "factorio.exe", - "satisfactory.exe", - "valheim.exe", - "rust.exe", - "ark.exe", - "7daystodie.exe", - "greenhell.exe", - "theforest.exe", - - // VR Games - "vrchat.exe", - "beatsaber.exe", - "halflife alyx.exe", - "pavlov.exe", - "boneworks.exe", - - // Indie/Popular Smaller Games - "amongus.exe", - "fallguys.exe", - "cuphead.exe", - "hollowknight.exe", - "celeste.exe", - "ori.exe", - "hades.exe", - "deadcells.exe", - "riskofrain2.exe", - "deeprockgalactic.exe", - "seaofthieves.exe", - "phasmophobia.exe", - "genshinimpact.exe", - "honkaiimpact.exe" - }; - } - - private bool IsLikelyGameProcess(ProcessModel process) - { - try - { - var processName = process.Name.ToLowerInvariant(); - var processPath = process.ExecutablePath?.ToLowerInvariant() ?? ""; - - // Skip system processes and common applications - if (IsSystemOrCommonProcess(processName)) - return false; - - // Check for game-related keywords in process name - if (HasGameKeywords(processName)) - return true; - - // Check for game-related paths - if (HasGamePath(processPath)) - return true; - - // Check for game engines - if (HasGameEngineIndicators(processName, processPath)) - return true; - - // Check for executable patterns common in games - if (HasGameExecutablePatterns(processName)) - return true; - - return false; - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Error in game detection heuristics for process {ProcessName}", process.Name); - return false; - } - } - - private bool IsSystemOrCommonProcess(string processName) - { - var systemProcesses = new[] - { - "explorer.exe", "dwm.exe", "winlogon.exe", "csrss.exe", "smss.exe", "wininit.exe", - "services.exe", "lsass.exe", "svchost.exe", "taskhost.exe", "taskhostw.exe", - "conhost.exe", "audiodg.exe", "spoolsv.exe", "winlogon.exe", "userinit.exe", - "chrome.exe", "firefox.exe", "msedge.exe", "iexplore.exe", "opera.exe", - "notepad.exe", "calc.exe", "mspaint.exe", "wordpad.exe", "cmd.exe", "powershell.exe", - "winword.exe", "excel.exe", "powerpoint.exe", "outlook.exe", "onenote.exe", - "photoshop.exe", "illustrator.exe", "premiere.exe", "aftereffects.exe", - "code.exe", "devenv.exe", "rider.exe", "intellij.exe", "eclipse.exe", - "discord.exe", "slack.exe", "teams.exe", "zoom.exe", "skype.exe", - "spotify.exe", "vlc.exe", "wmplayer.exe", "itunes.exe", "winamp.exe", - "7z.exe", "winrar.exe", "winzip.exe", "filezilla.exe", "putty.exe" - }; - - return systemProcesses.Contains(processName); - } - - private bool HasGameKeywords(string processName) - { - var gameKeywords = new[] - { - "game", "launcher", "client", "engine", "unity", "unreal", "godot", "cryengine", - "steam", "epic", "origin", "uplay", "battlenet", "gog", "rockstar", - "minecraft", "roblox", "fortnite", "valorant", "csgo", "dota", "lol", - "wow", "overwatch", "apex", "pubg", "cod", "battlefield", "destiny", - "cyberpunk", "witcher", "skyrim", "fallout", "gta", "rdr", "assassin", - "farcry", "watchdog", "borderlands", "diablo", "starcraft", "warcraft", - "civilization", "totalwar", "aoe", "fifa", "nba", "madden", "forza", - "racing", "simulator", "tycoon", "builder", "strategy", "rpg", "mmo", - "shooter", "adventure", "action", "puzzle", "platformer", "indie" - }; - - return gameKeywords.Any(keyword => processName.Contains(keyword)); - } - - private bool HasGamePath(string processPath) - { - if (string.IsNullOrEmpty(processPath)) - return false; - - var gamePaths = new[] - { - @"\steam\steamapps\", @"\steamapps\common\", @"\steam games\", - @"\epic games\", @"\epicgames\", @"\epic\", - @"\origin games\", @"\origin\", @"\ea games\", - @"\ubisoft\", @"\uplay\", @"\ubisoft game launcher\", - @"\gog galaxy\", @"\gog games\", @"\gog.com\", - @"\battle.net\", @"\battlenet\", @"\blizzard\", - @"\rockstar games\", @"\rockstar\", - @"\xbox games\", @"\microsoft games\", @"\windowsapps\", - @"\games\", @"\gaming\", @"\my games\", - @"\program files\games\", @"\program files (x86)\games\", - @"\minecraft\", @"\roblox\", @"\riot games\", - @"\square enix\", @"\activision\", @"\electronic arts\", - @"\2k games\", @"\bethesda\", @"\cd projekt red\", - @"\valve\", @"\id software\", @"\bungie\" - }; - - return gamePaths.Any(path => processPath.Contains(path)); - } - - private bool HasGameEngineIndicators(string processName, string processPath) - { - var engineIndicators = new[] - { - "unity", "unreal", "ue4", "ue5", "godot", "cryengine", "frostbite", - "source", "idtech", "creation", "anvil", "dunia", "snowdrop", - "decima", "fox", "mt framework", "luminous", "crystal tools", - "gamebryo", "havok", "physx", "directx", "opengl", "vulkan" - }; - - return engineIndicators.Any(indicator => - processName.Contains(indicator) || processPath.Contains(indicator)); - } - - private bool HasGameExecutablePatterns(string processName) - { - // Common patterns in game executables - var patterns = new[] - { - // Shipping builds (Unreal Engine) - "shipping.exe", "-shipping.exe", "_shipping.exe", - // Win64 builds - "win64.exe", "-win64.exe", "_win64.exe", - // Game suffixes - "game.exe", "_game.exe", "-game.exe", - // Client executables - "client.exe", "_client.exe", "-client.exe", - // Launcher patterns - "launcher.exe", "_launcher.exe", "-launcher.exe", - // Engine patterns - "engine.exe", "_engine.exe", "-engine.exe", - // Common game number patterns (sequels) - "2.exe", "3.exe", "4.exe", "5.exe", "2077.exe", "2042.exe" - }; - - return patterns.Any(pattern => processName.EndsWith(pattern)); - } - - private async Task GetHighPerformancePowerPlanIdAsync() - { - try - { - var powerPlans = await _powerPlanService.GetPowerPlansAsync(); - var highPerformancePlan = powerPlans.FirstOrDefault(p => - p.Name.Contains("High performance", StringComparison.OrdinalIgnoreCase) || - p.Name.Contains("Ultimate Performance", StringComparison.OrdinalIgnoreCase)); - - return highPerformancePlan?.Guid; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get high performance power plan"); - return null; - } - } - - private async Task SetProcessPriorityAsync(ProcessModel processModel, ProcessPriorityClass priority) - { - try - { - var process = Process.GetProcessById(processModel.ProcessId); - process.PriorityClass = priority; - _logger.LogDebug("Set process {ProcessName} priority to {Priority}", processModel.Name, priority); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to set process priority for {ProcessName}", processModel.Name); - } - } - - private async Task OptimizeCpuAffinityAsync(ProcessModel processModel) - { - try - { - // This would integrate with the CPU topology service - // For now, just log the intent - _logger.LogDebug("CPU affinity optimization requested for {ProcessName}", processModel.Name); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to optimize CPU affinity for {ProcessName}", processModel.Name); - } - } - } -} - diff --git a/Services/GameDetectionService.cs b/Services/GameDetectionService.cs deleted file mode 100644 index ba6d535..0000000 --- a/Services/GameDetectionService.cs +++ /dev/null @@ -1,692 +0,0 @@ -/* - * 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 . - */ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Microsoft.Win32; -using ThreadPilot.Models; - -namespace ThreadPilot.Services -{ - /// - /// Service for detecting running games and applying optimal performance profiles - /// - public class GameDetectionService : IGameDetectionService - { - private readonly ILogger _logger; - private readonly IProcessService _processService; - private readonly ICpuTopologyService _cpuTopologyService; - private readonly IPowerPlanService _powerPlanService; - private readonly Dictionary _gameProfiles; - private readonly Dictionary _gameStartTimes; - - // ADVANCED GAME DETECTION: ML and performance monitoring - private readonly Dictionary _gameOverrides = new(); - private readonly Dictionary _monitoredGames = new(); - private readonly List _gameKeywords = new() - { - "game", "gaming", "play", "steam", "epic", "origin", "uplay", "battle.net", - "launcher", "client", "engine", "unity", "unreal", "directx", "opengl", "vulkan" - }; - private readonly List _gamesFolders = new() - { - "games", "steam", "steamapps", "epic games", "origin games", "uplay", "battle.net" - }; - - public event EventHandler? GameDetected; - public event EventHandler? GameStopped; - - public GameDetectionService( - ILogger logger, - IProcessService processService, - ICpuTopologyService cpuTopologyService, - IPowerPlanService powerPlanService) - { - _logger = logger; - _processService = processService; - _cpuTopologyService = cpuTopologyService; - _powerPlanService = powerPlanService; - _gameProfiles = new Dictionary(); - _gameStartTimes = new Dictionary(); - - InitializeDefaultGameProfiles(); - } - - public async Task ExtractProcessFeaturesAsync(ProcessModel process) - { - var (threadCount, handleCount) = GetRuntimeProcessMetrics(process.ProcessId); - - var features = new ProcessFeatures - { - ProcessName = process.Name, - ExecutablePath = process.ExecutablePath ?? string.Empty, - HasVisibleWindow = process.HasVisibleWindow, - CpuUsage = process.CpuUsage, - MemoryUsage = process.MemoryUsage, - ThreadCount = threadCount, - HandleCount = handleCount - }; - - try - { - // Check for graphics API DLLs - features.HasDirectXDlls = await HasLoadedDllAsync(process, "d3d", "dxgi", "d3d11", "d3d12"); - features.HasOpenGLDlls = await HasLoadedDllAsync(process, "opengl32", "glu32"); - features.HasVulkanDlls = await HasLoadedDllAsync(process, "vulkan"); - features.HasAudioDlls = await HasLoadedDllAsync(process, "dsound", "xaudio", "fmod"); - - // Check file properties - if (!string.IsNullOrEmpty(features.ExecutablePath) && File.Exists(features.ExecutablePath)) - { - var fileInfo = FileVersionInfo.GetVersionInfo(features.ExecutablePath); - features.FileDescription = fileInfo.FileDescription ?? string.Empty; - features.CompanyName = fileInfo.CompanyName ?? string.Empty; - } - - // Check if in games folder - features.IsInGamesFolder = _gamesFolders.Any(folder => - features.ExecutablePath.Contains(folder, StringComparison.OrdinalIgnoreCase)); - - // Check for game keywords - features.HasGameKeywords = _gameKeywords.Any(keyword => - features.ProcessName.Contains(keyword, StringComparison.OrdinalIgnoreCase) || - features.FileDescription.Contains(keyword, StringComparison.OrdinalIgnoreCase)); - - // Check if fullscreen (simplified check) - features.IsFullscreen = process.HasVisibleWindow && process.MainWindowTitle != null; - - _logger.LogDebug("Extracted features for {ProcessName}: DirectX={HasDirectX}, OpenGL={HasOpenGL}, GamesFolder={IsInGamesFolder}", - process.Name, features.HasDirectXDlls, features.HasOpenGLDlls, features.IsInGamesFolder); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error extracting features for process {ProcessName}", process.Name); - } - - return features; - } - - private static (int ThreadCount, int HandleCount) GetRuntimeProcessMetrics(int processId) - { - try - { - using var liveProcess = Process.GetProcessById(processId); - return (liveProcess.Threads.Count, liveProcess.HandleCount); - } - catch - { - return (0, 0); - } - } - - public async Task GetGamePerformanceAsync(ProcessModel process) - { - var metrics = new GamePerformanceMetrics - { - ProcessId = process.ProcessId, - GameName = process.Name, - CpuUsage = process.CpuUsage, - MemoryUsage = process.MemoryUsage, - Timestamp = DateTime.UtcNow, - IsFullscreen = process.HasVisibleWindow - }; - - try - { - // Estimate frame rate based on CPU usage patterns (simplified) - metrics.FrameRate = EstimateFrameRate(process); - - // Get GPU usage (would require additional APIs in real implementation) - metrics.GpuUsage = 0.0; // Placeholder - metrics.GpuMemoryUsage = 0; // Placeholder - - // Get window resolution (simplified) - metrics.Resolution = process.HasVisibleWindow ? "1920x1080" : "N/A"; // Placeholder - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error getting performance metrics for {ProcessName}", process.Name); - } - - return metrics; - } - - public async Task DetectGameWithMLAsync(ProcessModel process) - { - try - { - // Check manual overrides first - if (_gameOverrides.TryGetValue(process.Name.ToLower(), out var isGameOverride)) - { - return new GameDetectionResult - { - IsGame = isGameOverride, - Confidence = 1.0f, - GameName = process.Name, - DetectionMethod = "Manual Override", - DetectionTime = DateTime.UtcNow - }; - } - - // Extract features for ML classification - var features = await ExtractProcessFeaturesAsync(process); - - // Simple ML-like scoring based on features (can be replaced with actual ML model) - var score = CalculateGameScore(features); - - var result = new GameDetectionResult - { - IsGame = score >= 0.5f, - Confidence = score, - GameName = features.ProcessName, - DetectionMethod = "ML Classification", - Features = ConvertFeaturesToDictionary(features), - DetectionTime = DateTime.UtcNow - }; - - _logger.LogDebug("ML game detection for {ProcessName}: IsGame={IsGame}, Confidence={Confidence:P1}", - process.Name, result.IsGame, result.Confidence); - - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in ML game detection for process {ProcessName}", process.Name); - return new GameDetectionResult - { - IsGame = false, - Confidence = 0.0f, - GameName = process.Name, - DetectionMethod = "Error", - DetectionTime = DateTime.UtcNow - }; - } - } - - public async Task DetectGameAsync(ProcessModel process) - { - try - { - // Check known games database first - if (_gameProfiles.TryGetValue(process.Name.ToLower(), out var profile)) - { - profile.LastDetected = DateTime.UtcNow; - profile.DetectionCount++; - - // Track game start time - if (!_gameStartTimes.ContainsKey(process.Name)) - { - _gameStartTimes[process.Name] = DateTime.UtcNow; - GameDetected?.Invoke(this, new GameProfileDetectedEventArgs(process, profile)); - } - - return profile; - } - - // Check Steam games - if (await IsSteamGameAsync(process)) - { - var steamProfile = await GetSteamGameProfileAsync(process); - if (steamProfile != null) - { - return steamProfile; - } - } - - // Check Epic Games - if (await IsEpicGameAsync(process)) - { - var epicProfile = await GetEpicGameProfileAsync(process); - if (epicProfile != null) - { - return epicProfile; - } - } - - // Check for common game patterns - if (IsLikelyGame(process)) - { - return CreateGenericGameProfile(process); - } - - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error detecting game for process {ProcessName}", process.Name); - return null; - } - } - - public async Task> GetRunningGamesAsync() - { - var runningGames = new List(); - var processes = await _processService.GetActiveApplicationsAsync(); - - foreach (var process in processes) - { - var gameProfile = await DetectGameAsync(process); - if (gameProfile != null) - { - runningGames.Add(gameProfile); - } - } - - return runningGames; - } - - public async Task ApplyGameOptimizationsAsync(ProcessModel process, GameProfile gameProfile) - { - try - { - _logger.LogInformation("Applying optimizations for game {GameName} (Process: {ProcessName})", - gameProfile.Name, process.Name); - - var success = true; - - // Apply CPU affinity - if (!string.IsNullOrEmpty(gameProfile.OptimalCores)) - { - var affinityMask = await CalculateOptimalAffinityMask(gameProfile.OptimalCores); - if (affinityMask.HasValue) - { - await _processService.SetProcessorAffinity(process, (long)affinityMask.Value); - } - } - - // Apply process priority - await _processService.SetProcessPriority(process, gameProfile.Priority); - - // Apply power plan if specified - if (!string.IsNullOrEmpty(gameProfile.PowerPlan)) - { - var powerPlans = await _powerPlanService.GetPowerPlansAsync(); - var targetPlan = powerPlans.FirstOrDefault(p => - p.Name.Contains(gameProfile.PowerPlan, StringComparison.OrdinalIgnoreCase)); - - if (targetPlan != null) - { - success &= await _powerPlanService.SetActivePowerPlan(targetPlan); - } - } - - return success; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error applying game optimizations for {ProcessName}", process.Name); - return false; - } - } - - public async Task IsSteamGameAsync(ProcessModel process) - { - try - { - // Check if process is launched by Steam - var parentProcess = GetParentProcess(process.ProcessId); - if (parentProcess?.ProcessName?.Contains("steam", StringComparison.OrdinalIgnoreCase) == true) - { - return true; - } - - // Check Steam installation directory - var steamPath = GetSteamInstallPath(); - if (!string.IsNullOrEmpty(steamPath) && !string.IsNullOrEmpty(process.ExecutablePath)) - { - return process.ExecutablePath.StartsWith(steamPath, StringComparison.OrdinalIgnoreCase); - } - - return false; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking if process is Steam game: {ProcessName}", process.Name); - return false; - } - } - - public async Task IsEpicGameAsync(ProcessModel process) - { - try - { - // Check if process is launched by Epic Games Launcher - var parentProcess = GetParentProcess(process.ProcessId); - if (parentProcess?.ProcessName?.Contains("EpicGamesLauncher", StringComparison.OrdinalIgnoreCase) == true || - parentProcess?.ProcessName?.Contains("UnrealEngineLauncher", StringComparison.OrdinalIgnoreCase) == true) - { - return true; - } - - // Check Epic Games installation directory - var epicPath = GetEpicGamesInstallPath(); - if (!string.IsNullOrEmpty(epicPath) && !string.IsNullOrEmpty(process.ExecutablePath)) - { - return process.ExecutablePath.StartsWith(epicPath, StringComparison.OrdinalIgnoreCase); - } - - return false; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking if process is Epic game: {ProcessName}", process.Name); - return false; - } - } - - public async Task GetSteamGameProfileAsync(ProcessModel process) - { - // Create a Steam-specific game profile - return new GameProfile - { - Name = $"Steam: {process.Name}", - ProcessName = process.Name, - OptimalCores = "Physical", // Steam games generally benefit from physical cores - Priority = ProcessPriorityClass.High, - PowerPlan = "High Performance", - Category = GameCategory.Unknown, - Description = "Steam game detected automatically" - }; - } - - public async Task GetEpicGameProfileAsync(ProcessModel process) - { - // Create an Epic-specific game profile - return new GameProfile - { - Name = $"Epic: {process.Name}", - ProcessName = process.Name, - OptimalCores = "Physical", // Epic games generally benefit from physical cores - Priority = ProcessPriorityClass.High, - PowerPlan = "High Performance", - Category = GameCategory.Unknown, - Description = "Epic Games game detected automatically" - }; - } - - public async Task AddCustomGameProfileAsync(string processName, GameProfile profile) - { - _gameProfiles[processName.ToLower()] = profile; - _logger.LogInformation("Added custom game profile for {ProcessName}", processName); - } - - public async Task RemoveCustomGameProfileAsync(string processName) - { - if (_gameProfiles.Remove(processName.ToLower())) - { - _logger.LogInformation("Removed custom game profile for {ProcessName}", processName); - } - } - - public async Task> GetAllGameProfilesAsync() - { - return new Dictionary(_gameProfiles); - } - - private void InitializeDefaultGameProfiles() - { - // Popular FPS games - _gameProfiles["valorant.exe"] = new GameProfile - { - Name = "Valorant", - ProcessName = "valorant.exe", - OptimalCores = "Physical", - Priority = ProcessPriorityClass.High, - PowerPlan = "High Performance", - Category = GameCategory.FPS, - Description = "Riot Games' tactical FPS" - }; - - _gameProfiles["csgo.exe"] = new GameProfile - { - Name = "Counter-Strike: Global Offensive", - ProcessName = "csgo.exe", - OptimalCores = "Physical", - Priority = ProcessPriorityClass.High, - PowerPlan = "High Performance", - Category = GameCategory.FPS - }; - - _gameProfiles["cs2.exe"] = new GameProfile - { - Name = "Counter-Strike 2", - ProcessName = "cs2.exe", - OptimalCores = "P-Cores", - Priority = ProcessPriorityClass.High, - PowerPlan = "High Performance", - Category = GameCategory.FPS - }; - - _gameProfiles["cyberpunk2077.exe"] = new GameProfile - { - Name = "Cyberpunk 2077", - ProcessName = "cyberpunk2077.exe", - OptimalCores = "P-Cores", - Priority = ProcessPriorityClass.High, - PowerPlan = "High Performance", - Category = GameCategory.RPG - }; - - _gameProfiles["fortnite.exe"] = new GameProfile - { - Name = "Fortnite", - ProcessName = "fortnite.exe", - OptimalCores = "Physical", - Priority = ProcessPriorityClass.High, - PowerPlan = "High Performance", - Category = GameCategory.FPS - }; - - _gameProfiles["league of legends.exe"] = new GameProfile - { - Name = "League of Legends", - ProcessName = "league of legends.exe", - OptimalCores = "Physical", - Priority = ProcessPriorityClass.AboveNormal, - PowerPlan = "High Performance", - Category = GameCategory.MOBA - }; - } - - private async Task CalculateOptimalAffinityMask(string optimalCores) - { - try - { - var topology = await _cpuTopologyService.DetectTopologyAsync(); - if (topology == null) return null; - - return optimalCores switch - { - "Physical" => new IntPtr(topology.GetPhysicalCoresAffinityMask()), - "P-Cores" => new IntPtr(topology.GetPerformanceCoresAffinityMask()), - "E-Cores" => new IntPtr(topology.GetEfficiencyCoresAffinityMask()), - "All" => new IntPtr(topology.CalculateAffinityMask(topology.LogicalCores)), - _ => null - }; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error calculating affinity mask for {OptimalCores}", optimalCores); - return null; - } - } - - private float CalculateGameScore(ProcessFeatures features) - { - float score = 0.0f; - - // Graphics API indicators (strong indicators) - if (features.HasDirectXDlls) score += 0.3f; - if (features.HasOpenGLDlls) score += 0.25f; - if (features.HasVulkanDlls) score += 0.3f; - - // Audio indicators - if (features.HasAudioDlls) score += 0.1f; - - // Location indicators - if (features.IsInGamesFolder) score += 0.2f; - - // Keyword indicators - if (features.HasGameKeywords) score += 0.15f; - - // Window and resource usage indicators - if (features.HasVisibleWindow) score += 0.1f; - if (features.IsFullscreen) score += 0.15f; - if (features.CpuUsage > 10.0) score += 0.1f; - if (features.MemoryUsage > 100 * 1024 * 1024) score += 0.05f; // > 100MB - - // Company indicators - var gameCompanies = new[] { "valve", "epic", "ubisoft", "ea", "activision", "blizzard", "steam" }; - if (gameCompanies.Any(company => features.CompanyName.Contains(company, StringComparison.OrdinalIgnoreCase))) - score += 0.1f; - - // Clamp score to [0, 1] - return Math.Min(1.0f, Math.Max(0.0f, score)); - } - - private Dictionary ConvertFeaturesToDictionary(ProcessFeatures features) - { - return new Dictionary - { - ["ProcessName"] = features.ProcessName, - ["HasDirectXDlls"] = features.HasDirectXDlls, - ["HasOpenGLDlls"] = features.HasOpenGLDlls, - ["HasVulkanDlls"] = features.HasVulkanDlls, - ["HasAudioDlls"] = features.HasAudioDlls, - ["IsInGamesFolder"] = features.IsInGamesFolder, - ["HasGameKeywords"] = features.HasGameKeywords, - ["HasVisibleWindow"] = features.HasVisibleWindow, - ["IsFullscreen"] = features.IsFullscreen, - ["CpuUsage"] = features.CpuUsage, - ["MemoryUsage"] = features.MemoryUsage, - ["CompanyName"] = features.CompanyName - }; - } - - private async Task HasLoadedDllAsync(ProcessModel process, params string[] dllNames) - { - try - { - // Simplified check - in real implementation would check loaded modules - // For now, check if executable path contains any of the DLL indicators - var execPath = process.ExecutablePath?.ToLower() ?? string.Empty; - return dllNames.Any(dll => execPath.Contains(dll.ToLower())); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Error checking DLLs for process {ProcessName}", process.Name); - return false; - } - } - - private float EstimateFrameRate(ProcessModel process) - { - // Simplified frame rate estimation based on CPU usage patterns - // In real implementation, would use performance counters or graphics APIs - if (process.CpuUsage > 20.0) - return 60.0f; // Assume 60 FPS for high CPU usage games - else if (process.CpuUsage > 10.0) - return 30.0f; // Assume 30 FPS for moderate CPU usage - else - return 0.0f; // Not actively rendering - } - - private static bool IsLikelyGame(ProcessModel process) - { - var gameIndicators = new[] - { - "game", "launcher", "client", "engine", "unity", "unreal", - "dx11", "dx12", "vulkan", "opengl" - }; - - var processName = process.Name.ToLower(); - var executablePath = process.ExecutablePath?.ToLower() ?? ""; - - return gameIndicators.Any(indicator => - processName.Contains(indicator) || executablePath.Contains(indicator)) || - process.HasVisibleWindow && process.CpuUsage > 5.0; // High CPU with window - } - - private GameProfile CreateGenericGameProfile(ProcessModel process) - { - return new GameProfile - { - Name = $"Generic Game: {process.Name}", - ProcessName = process.Name, - OptimalCores = "Physical", - Priority = ProcessPriorityClass.AboveNormal, - PowerPlan = "Balanced", - Category = GameCategory.Unknown, - Description = "Automatically detected game" - }; - } - - private static Process? GetParentProcess(int processId) - { - try - { - using var process = Process.GetProcessById(processId); - var parentId = GetParentProcessId(processId); - return parentId > 0 ? Process.GetProcessById(parentId) : null; - } - catch - { - return null; - } - } - - private static int GetParentProcessId(int processId) - { - // Implementation would use WMI or P/Invoke to get parent process ID - // Simplified for now - return 0; - } - - private static string? GetSteamInstallPath() - { - try - { - using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\WOW6432Node\Valve\Steam") ?? - Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Valve\Steam"); - return key?.GetValue("InstallPath")?.ToString(); - } - catch - { - return null; - } - } - - private static string? GetEpicGamesInstallPath() - { - try - { - var epicPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), - "Epic", "EpicGamesLauncher"); - return Directory.Exists(epicPath) ? epicPath : null; - } - catch - { - return null; - } - } - } -} - diff --git a/Services/GameModeService.cs b/Services/GameModeService.cs index fd5fdf6..7550192 100644 --- a/Services/GameModeService.cs +++ b/Services/GameModeService.cs @@ -1,136 +1,112 @@ -/* - * 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.Threading.Tasks; - using Microsoft.Extensions.Logging; - using Microsoft.Win32; - - /// - /// Service for managing Windows Game Mode settings - /// Windows Game Mode can interfere with CPU Sets and affinity, particularly on AMD systems - /// Reference: CPU Set Setter warning system. - /// - public class GameModeService : IGameModeService - { - private readonly ILogger logger; - private const string GameBarKeyPath = @"Software\Microsoft\GameBar"; - private const string GameModeValueName = "AutoGameModeEnabled"; - - public GameModeService(ILogger logger) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - public async Task IsGameModeEnabledAsync() - { - await Task.CompletedTask; // Make async for consistency - - try - { - using var key = Registry.CurrentUser.OpenSubKey(GameBarKeyPath, writable: false); - if (key == null) - { - this.logger.LogDebug("GameBar registry key not found, assuming Game Mode is disabled"); - return false; - } - - var value = key.GetValue(GameModeValueName); - if (value is int intValue) - { - bool isEnabled = intValue != 0; - this.logger.LogDebug("Game Mode status: {Status}", isEnabled ? "Enabled" : "Disabled"); - return isEnabled; - } - - this.logger.LogDebug("GameMode value not found, assuming disabled"); - return false; - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to read Game Mode registry key, assuming disabled"); - return false; - } - } - - /// - public async Task SetGameModeAsync(bool enabled) - { - await Task.CompletedTask; // Make async for consistency - - try - { - using var key = Registry.CurrentUser.OpenSubKey(GameBarKeyPath, writable: true); - if (key == null) - { - this.logger.LogWarning("GameBar registry key not found, cannot modify Game Mode"); - return false; - } - - key.SetValue(GameModeValueName, enabled ? 1 : 0, RegistryValueKind.DWord); - this.logger.LogInformation("Set Windows Game Mode to {State}", enabled ? "enabled" : "disabled"); - return true; - } - catch (UnauthorizedAccessException ex) - { - this.logger.LogWarning(ex, "Insufficient permissions to modify Game Mode registry key"); - return false; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to set Game Mode to {State}", enabled ? "enabled" : "disabled"); - return false; - } - } - - /// - public async Task DisableGameModeForAffinityAsync() - { - try - { - bool isEnabled = await this.IsGameModeEnabledAsync(); - if (!isEnabled) - { - this.logger.LogDebug("Game Mode already disabled, no action needed"); - return false; - } - - this.logger.LogInformation("Game Mode is enabled, disabling for better CPU affinity control"); - bool success = await this.SetGameModeAsync(false); - - if (success) - { - this.logger.LogInformation("Successfully disabled Windows Game Mode for CPU affinity optimization"); - } - else - { - this.logger.LogWarning("Failed to disable Game Mode, CPU affinity may be affected"); - } - - return success; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error disabling Game Mode for affinity"); - return false; - } - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using Microsoft.Win32; + + public class GameModeService : IGameModeService + { + private readonly ILogger logger; + private const string GameBarKeyPath = @"Software\Microsoft\GameBar"; + private const string GameModeValueName = "AutoGameModeEnabled"; + + public GameModeService(ILogger logger) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task IsGameModeEnabledAsync() + { + await Task.CompletedTask; // Make async for consistency + + try + { + using var key = Registry.CurrentUser.OpenSubKey(GameBarKeyPath, writable: false); + if (key == null) + { + this.logger.LogDebug("GameBar registry key not found, assuming Game Mode is disabled"); + return false; + } + + var value = key.GetValue(GameModeValueName); + if (value is int intValue) + { + bool isEnabled = intValue != 0; + this.logger.LogDebug("Game Mode status: {Status}", isEnabled ? "Enabled" : "Disabled"); + return isEnabled; + } + + this.logger.LogDebug("GameMode value not found, assuming disabled"); + return false; + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to read Game Mode registry key, assuming disabled"); + return false; + } + } + + public async Task SetGameModeAsync(bool enabled) + { + await Task.CompletedTask; // Make async for consistency + + try + { + using var key = Registry.CurrentUser.OpenSubKey(GameBarKeyPath, writable: true); + if (key == null) + { + this.logger.LogWarning("GameBar registry key not found, cannot modify Game Mode"); + return false; + } + + key.SetValue(GameModeValueName, enabled ? 1 : 0, RegistryValueKind.DWord); + this.logger.LogInformation("Set Windows Game Mode to {State}", enabled ? "enabled" : "disabled"); + return true; + } + catch (UnauthorizedAccessException ex) + { + this.logger.LogWarning(ex, "Insufficient permissions to modify Game Mode registry key"); + return false; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to set Game Mode to {State}", enabled ? "enabled" : "disabled"); + return false; + } + } + + public async Task DisableGameModeForAffinityAsync() + { + try + { + bool isEnabled = await this.IsGameModeEnabledAsync(); + if (!isEnabled) + { + this.logger.LogDebug("Game Mode already disabled, no action needed"); + return false; + } + + this.logger.LogInformation("Game Mode is enabled, disabling for better CPU affinity control"); + bool success = await this.SetGameModeAsync(false); + + if (success) + { + this.logger.LogInformation("Successfully disabled Windows Game Mode for CPU affinity optimization"); + } + else + { + this.logger.LogWarning("Failed to disable Game Mode, CPU affinity may be affected"); + } + + return success; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error disabling Game Mode for affinity"); + return false; + } + } + } +} + diff --git a/Services/GitHubReleaseClient.cs b/Services/GitHubReleaseClient.cs index 655ce50..370d1d2 100644 --- a/Services/GitHubReleaseClient.cs +++ b/Services/GitHubReleaseClient.cs @@ -1,33 +1,30 @@ -namespace ThreadPilot.Services -{ - using System; - using System.Net.Http; - using System.Threading; - using System.Threading.Tasks; - using ThreadPilot.Services.Abstractions; - - /// - /// HTTP client wrapper for GitHub release metadata. - /// - public sealed class GitHubReleaseClient : IGitHubReleaseClient - { - private readonly HttpClient httpClient; - - public GitHubReleaseClient(HttpClient httpClient) - { - this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); - } - - public Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default) - { - var url = $"https://api.github.com/repos/{owner}/{repo}/releases/latest"; - return this.httpClient.GetStringAsync(url, cancellationToken); - } - - public Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default) - { - var url = $"https://api.github.com/repos/{owner}/{repo}/releases"; - return this.httpClient.GetStringAsync(url, cancellationToken); - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using ThreadPilot.Services.Abstractions; + + public sealed class GitHubReleaseClient : IGitHubReleaseClient + { + private readonly HttpClient httpClient; + + public GitHubReleaseClient(HttpClient httpClient) + { + this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default) + { + var url = $"https://api.github.com/repos/{owner}/{repo}/releases/latest"; + return this.httpClient.GetStringAsync(url, cancellationToken); + } + + public Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default) + { + var url = $"https://api.github.com/repos/{owner}/{repo}/releases"; + return this.httpClient.GetStringAsync(url, cancellationToken); + } + } +} diff --git a/Services/GitHubUpdateChecker.cs b/Services/GitHubUpdateChecker.cs index 621b31e..3a6d4b0 100644 --- a/Services/GitHubUpdateChecker.cs +++ b/Services/GitHubUpdateChecker.cs @@ -1,143 +1,127 @@ -/* - * 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.Linq; - using System.Text.Json; - using System.Threading; - using System.Threading.Tasks; - using ThreadPilot.Services.Abstractions; - - public sealed class GitHubUpdateChecker - { - private readonly IGitHubReleaseClient gitHubReleaseClient; - - private record LatestRelease( - string Tag_name, - bool Prerelease, - bool Draft, - string Html_url, - IReadOnlyList? Assets); - - private record LatestReleaseAsset(string Name, string Browser_download_url, long Size); - - public GitHubUpdateChecker(IGitHubReleaseClient gitHubReleaseClient) - { - this.gitHubReleaseClient = gitHubReleaseClient ?? throw new ArgumentNullException(nameof(gitHubReleaseClient)); - } - - public async Task<(Version? latest, string? releaseUrl)> GetLatestVersionAsync( - string owner, - string repo, - CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(owner)) - { - throw new ArgumentException("Owner is required", nameof(owner)); - } - - if (string.IsNullOrWhiteSpace(repo)) - { - throw new ArgumentException("Repository is required", nameof(repo)); - } - - var json = await this.gitHubReleaseClient.GetLatestReleaseJsonAsync(owner, repo, cancellationToken); - var release = JsonSerializer.Deserialize(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - }); - - if (release is null || release.Draft || release.Prerelease || string.IsNullOrWhiteSpace(release.Tag_name)) - { - return (null, null); - } - - var tag = release.Tag_name.Trim(); - if (tag.StartsWith("v", StringComparison.OrdinalIgnoreCase)) - { - tag = tag[1..]; - } - - var sanitized = tag.Split('-', '+')[0]; - - return Version.TryParse(sanitized, out var version) - ? (version, release.Html_url) - : (null, release.Html_url); - } - - public async Task GetLatestReleaseInfoAsync( - string owner, - string repo, - bool includePrereleases = false, - CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(owner)) - { - throw new ArgumentException("Owner is required", nameof(owner)); - } - - if (string.IsNullOrWhiteSpace(repo)) - { - throw new ArgumentException("Repository is required", nameof(repo)); - } - - var json = await this.gitHubReleaseClient.GetReleasesJsonAsync(owner, repo, cancellationToken).ConfigureAwait(false); - var releases = JsonSerializer.Deserialize>(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - }); - - if (releases == null || releases.Count == 0) - { - return null; - } - - return releases - .Where(release => !release.Draft) - .Where(release => includePrereleases || !release.Prerelease) - .Select(TryMapRelease) - .Where(release => release != null) - .Cast() - .OrderByDescending(release => release.Version) - .FirstOrDefault(); - } - - private static UpdateReleaseInfo? TryMapRelease(LatestRelease release) - { - if (!SemanticVersion.TryParse(release.Tag_name, out var version) || - string.IsNullOrWhiteSpace(release.Html_url) || - !Uri.TryCreate(release.Html_url, UriKind.Absolute, out var releasePageUrl)) - { - return null; - } - - var assets = (release.Assets ?? Array.Empty()) - .Where(asset => !string.IsNullOrWhiteSpace(asset.Name)) - .Where(asset => Uri.TryCreate(asset.Browser_download_url, UriKind.Absolute, out _)) - .Select(asset => new UpdateAsset( - asset.Name, - new Uri(asset.Browser_download_url, UriKind.Absolute), - asset.Size)) - .ToArray(); - - return new UpdateReleaseInfo(version, release.Tag_name, releasePageUrl, release.Prerelease, assets); - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using ThreadPilot.Services.Abstractions; + + public sealed class GitHubUpdateChecker + { + private readonly IGitHubReleaseClient gitHubReleaseClient; + + private record LatestRelease( + string Tag_name, + bool Prerelease, + bool Draft, + string Html_url, + IReadOnlyList? Assets); + + private record LatestReleaseAsset(string Name, string Browser_download_url, long Size); + + public GitHubUpdateChecker(IGitHubReleaseClient gitHubReleaseClient) + { + this.gitHubReleaseClient = gitHubReleaseClient ?? throw new ArgumentNullException(nameof(gitHubReleaseClient)); + } + + public async Task<(Version? latest, string? releaseUrl)> GetLatestVersionAsync( + string owner, + string repo, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(owner)) + { + throw new ArgumentException("Owner is required", nameof(owner)); + } + + if (string.IsNullOrWhiteSpace(repo)) + { + throw new ArgumentException("Repository is required", nameof(repo)); + } + + var json = await this.gitHubReleaseClient.GetLatestReleaseJsonAsync(owner, repo, cancellationToken); + var release = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }); + + if (release is null || release.Draft || release.Prerelease || string.IsNullOrWhiteSpace(release.Tag_name)) + { + return (null, null); + } + + var tag = release.Tag_name.Trim(); + if (tag.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + { + tag = tag[1..]; + } + + var sanitized = tag.Split('-', '+')[0]; + + return Version.TryParse(sanitized, out var version) + ? (version, release.Html_url) + : (null, release.Html_url); + } + + public async Task GetLatestReleaseInfoAsync( + string owner, + string repo, + bool includePrereleases = false, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(owner)) + { + throw new ArgumentException("Owner is required", nameof(owner)); + } + + if (string.IsNullOrWhiteSpace(repo)) + { + throw new ArgumentException("Repository is required", nameof(repo)); + } + + var json = await this.gitHubReleaseClient.GetReleasesJsonAsync(owner, repo, cancellationToken).ConfigureAwait(false); + var releases = JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }); + + if (releases == null || releases.Count == 0) + { + return null; + } + + return releases + .Where(release => !release.Draft) + .Where(release => includePrereleases || !release.Prerelease) + .Select(TryMapRelease) + .Where(release => release != null) + .Cast() + .OrderByDescending(release => release.Version) + .FirstOrDefault(); + } + + private static UpdateReleaseInfo? TryMapRelease(LatestRelease release) + { + if (!SemanticVersion.TryParse(release.Tag_name, out var version) || + string.IsNullOrWhiteSpace(release.Html_url) || + !Uri.TryCreate(release.Html_url, UriKind.Absolute, out var releasePageUrl)) + { + return null; + } + + var assets = (release.Assets ?? Array.Empty()) + .Where(asset => !string.IsNullOrWhiteSpace(asset.Name)) + .Where(asset => Uri.TryCreate(asset.Browser_download_url, UriKind.Absolute, out _)) + .Select(asset => new UpdateAsset( + asset.Name, + new Uri(asset.Browser_download_url, UriKind.Absolute), + asset.Size)) + .ToArray(); + + return new UpdateReleaseInfo(version, release.Tag_name, releasePageUrl, release.Prerelease, assets); + } + } +} + diff --git a/Services/HttpUpdateDownloadClient.cs b/Services/HttpUpdateDownloadClient.cs index a98aba1..b4ee05b 100644 --- a/Services/HttpUpdateDownloadClient.cs +++ b/Services/HttpUpdateDownloadClient.cs @@ -1,44 +1,44 @@ -/* - * ThreadPilot - HTTP downloads for update assets. - */ -namespace ThreadPilot.Services -{ - using System; - using System.IO; - using System.Net.Http; - using System.Threading; - using System.Threading.Tasks; - - public sealed class HttpUpdateDownloadClient : IUpdateDownloadClient - { - private readonly HttpClient httpClient; - - public HttpUpdateDownloadClient(HttpClient httpClient) - { - this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); - } - - public async Task DownloadFileAsync(Uri uri, string destinationPath, CancellationToken cancellationToken = default) - { - using var response = await this.httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken) - .ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - await using var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await using var destination = File.Create(destinationPath); - await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false); - } - - public async Task TryDownloadStringAsync(Uri uri, CancellationToken cancellationToken = default) - { - try - { - return await this.httpClient.GetStringAsync(uri, cancellationToken).ConfigureAwait(false); - } - catch (HttpRequestException) - { - return null; - } - } - } -} +/* + * ThreadPilot - HTTP downloads for update assets. + */ +namespace ThreadPilot.Services +{ + using System; + using System.IO; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + + public sealed class HttpUpdateDownloadClient : IUpdateDownloadClient + { + private readonly HttpClient httpClient; + + public HttpUpdateDownloadClient(HttpClient httpClient) + { + this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public async Task DownloadFileAsync(Uri uri, string destinationPath, CancellationToken cancellationToken = default) + { + using var response = await this.httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + await using var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using var destination = File.Create(destinationPath); + await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false); + } + + public async Task TryDownloadStringAsync(Uri uri, CancellationToken cancellationToken = default) + { + try + { + return await this.httpClient.GetStringAsync(uri, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException) + { + return null; + } + } + } +} diff --git a/Services/IActivityAuditService.cs b/Services/IActivityAuditService.cs index 9ccab1a..c54f6db 100644 --- a/Services/IActivityAuditService.cs +++ b/Services/IActivityAuditService.cs @@ -1,42 +1,42 @@ -namespace ThreadPilot.Services -{ - public enum ActivityAuditSeverity - { - Info, - Success, - Warning, - Error, - } - - public sealed record ActivityAuditEntry - { - public DateTime Timestamp { get; init; } - - public string Category { get; init; } = string.Empty; - - public ActivityAuditSeverity Severity { get; init; } - - public string Message { get; init; } = string.Empty; - - public string? Details { get; init; } - } - - public interface IActivityAuditService - { - event EventHandler? EntryAdded; - - Task LogInfoAsync(string category, string message, string? details = null); - - Task LogSuccessAsync(string category, string message, string? details = null); - - Task LogWarningAsync(string category, string message, string? details = null); - - Task LogErrorAsync(string category, string message, string? details = null); - - Task LogUserActionAsync(string action, string details, string? context = null); - - Task> GetEntriesAsync(DateTime? fromDate = null, DateTime? toDate = null); - - Task ClearDisplayAsync(); - } -} +namespace ThreadPilot.Services +{ + public enum ActivityAuditSeverity + { + Info, + Success, + Warning, + Error, + } + + public sealed record ActivityAuditEntry + { + public DateTime Timestamp { get; init; } + + public string Category { get; init; } = string.Empty; + + public ActivityAuditSeverity Severity { get; init; } + + public string Message { get; init; } = string.Empty; + + public string? Details { get; init; } + } + + public interface IActivityAuditService + { + event EventHandler? EntryAdded; + + Task LogInfoAsync(string category, string message, string? details = null); + + Task LogSuccessAsync(string category, string message, string? details = null); + + Task LogWarningAsync(string category, string message, string? details = null); + + Task LogErrorAsync(string category, string message, string? details = null); + + Task LogUserActionAsync(string action, string details, string? context = null); + + Task> GetEntriesAsync(DateTime? fromDate = null, DateTime? toDate = null); + + Task ClearDisplayAsync(); + } +} diff --git a/Services/IApplicationSettingsService.cs b/Services/IApplicationSettingsService.cs index 7c9aa9f..fb159da 100644 --- a/Services/IApplicationSettingsService.cs +++ b/Services/IApplicationSettingsService.cs @@ -1,101 +1,49 @@ -/* - * 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.Threading.Tasks; - using ThreadPilot.Models; - - /// - /// Service for managing application settings. - /// - public interface IApplicationSettingsService - { - /// - /// Event fired when settings are changed - /// - event EventHandler? SettingsChanged; - - /// - /// Gets the current application settings. - /// - ApplicationSettingsModel Settings { get; } - - /// - /// Loads settings from storage. - /// - Task LoadSettingsAsync(); - - /// - /// Saves current settings to storage. - /// - Task SaveSettingsAsync(); - - /// - /// Updates settings and saves them. - /// - Task UpdateSettingsAsync(ApplicationSettingsModel newSettings); - - /// - /// Resets settings to default values. - /// - Task ResetToDefaultsAsync(); - - /// - /// Gets the settings file path. - /// - string GetSettingsFilePath(); - - /// - /// Validates settings and fixes any invalid values. - /// - void ValidateAndFixSettings(); - - /// - /// Exports settings to a file. - /// - Task ExportSettingsAsync(string filePath); - - /// - /// Imports settings from a file. - /// - Task ImportSettingsAsync(string filePath); - } - - /// - /// Event args for settings changed event. - /// - public class ApplicationSettingsChangedEventArgs : EventArgs - { - public ApplicationSettingsModel OldSettings { get; } - - public ApplicationSettingsModel NewSettings { get; } - - public string[] ChangedProperties { get; } - - public ApplicationSettingsChangedEventArgs( - ApplicationSettingsModel oldSettings, - ApplicationSettingsModel newSettings, - string[] changedProperties) - { - this.OldSettings = oldSettings; - this.NewSettings = newSettings; - this.ChangedProperties = changedProperties; - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Threading.Tasks; + using ThreadPilot.Models; + + public interface IApplicationSettingsService + { + event EventHandler? SettingsChanged; + + ApplicationSettingsModel Settings { get; } + + Task LoadSettingsAsync(); + + Task SaveSettingsAsync(); + + Task UpdateSettingsAsync(ApplicationSettingsModel newSettings); + + Task ResetToDefaultsAsync(); + + string GetSettingsFilePath(); + + void ValidateAndFixSettings(); + + Task ExportSettingsAsync(string filePath); + + Task ImportSettingsAsync(string filePath); + } + + public class ApplicationSettingsChangedEventArgs : EventArgs + { + public ApplicationSettingsModel OldSettings { get; } + + public ApplicationSettingsModel NewSettings { get; } + + public string[] ChangedProperties { get; } + + public ApplicationSettingsChangedEventArgs( + ApplicationSettingsModel oldSettings, + ApplicationSettingsModel newSettings, + string[] changedProperties) + { + this.OldSettings = oldSettings; + this.NewSettings = newSettings; + this.ChangedProperties = changedProperties; + } + } +} + diff --git a/Services/IAutostartService.cs b/Services/IAutostartService.cs index 462c103..3aad691 100644 --- a/Services/IAutostartService.cs +++ b/Services/IAutostartService.cs @@ -1,117 +1,44 @@ -/* - * ThreadPilot - Advanced Windows Process and Power Plan Manager - * Copyright (C) 2025 Prime Build - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, version 3 only. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -namespace ThreadPilot.Services -{ - using System; - using System.Threading.Tasks; - - /// - /// Interface for managing Windows autostart functionality. - /// - public interface IAutostartService - { - /// - /// Occurs when autostart state changes. - /// - event EventHandler? AutostartStatusChanged; - - /// - /// Gets a value indicating whether gets whether the application is currently set to autostart with Windows. - /// - bool IsAutostartEnabled { get; } - - /// - /// Gets the current autostart registry entry path. - /// - string? AutostartPath { get; } - - /// - /// Enables autostart with Windows. - /// - /// Whether to start the application minimized. - /// True if successful, false otherwise. - Task EnableAutostartAsync(bool startMinimized = true); - - /// - /// Disables autostart with Windows. - /// - /// True if successful, false otherwise. - Task DisableAutostartAsync(); - - /// - /// Checks if autostart is currently enabled. - /// - /// True if autostart is enabled, false otherwise. - Task CheckAutostartStatusAsync(); - - /// - /// Updates the autostart entry with new parameters. - /// - /// Whether to start minimized. - /// True if successful, false otherwise. - Task UpdateAutostartAsync(bool startMinimized = true); - - /// - /// Gets the command line arguments for autostart. - /// - /// Whether to include start minimized flag. - /// Command line arguments string. - string GetAutostartArguments(bool startMinimized = true); - } - - /// - /// Event arguments for autostart status changes. - /// - public class AutostartStatusChangedEventArgs : EventArgs - { - /// - /// Gets a value indicating whether autostart is currently enabled. - /// - public bool IsEnabled { get; } - - /// - /// Gets a value indicating whether startup should launch minimized. - /// - public bool StartMinimized { get; } - - /// - /// Gets the registry command value currently used for startup. - /// - public string? RegistryPath { get; } - - /// - /// Gets the error that caused the status update when the operation failed. - /// - public Exception? Error { get; } - - /// - /// Initializes a new instance of the class. - /// - /// Whether autostart is enabled. - /// Whether startup should launch minimized. - /// The autostart registry value when available. - /// The failure that occurred, if any. - public AutostartStatusChangedEventArgs(bool isEnabled, bool startMinimized = false, string? registryPath = null, Exception? error = null) - { - this.IsEnabled = isEnabled; - this.StartMinimized = startMinimized; - this.RegistryPath = registryPath; - this.Error = error; - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Threading.Tasks; + + public interface IAutostartService + { + event EventHandler? AutostartStatusChanged; + + bool IsAutostartEnabled { get; } + + string? AutostartPath { get; } + + Task EnableAutostartAsync(bool startMinimized = true); + + Task DisableAutostartAsync(); + + Task CheckAutostartStatusAsync(); + + Task UpdateAutostartAsync(bool startMinimized = true); + + string GetAutostartArguments(bool startMinimized = true); + } + + public class AutostartStatusChangedEventArgs : EventArgs + { + public bool IsEnabled { get; } + + public bool StartMinimized { get; } + + public string? RegistryPath { get; } + + public Exception? Error { get; } + + public AutostartStatusChangedEventArgs(bool isEnabled, bool startMinimized = false, string? registryPath = null, Exception? error = null) + { + this.IsEnabled = isEnabled; + this.StartMinimized = startMinimized; + this.RegistryPath = registryPath; + this.Error = error; + } + } +} + diff --git a/Services/IConditionalProfileService.cs b/Services/IConditionalProfileService.cs index dca4840..b7176ca 100644 --- a/Services/IConditionalProfileService.cs +++ b/Services/IConditionalProfileService.cs @@ -1,162 +1,77 @@ -/* - * 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.Threading.Tasks; - using ThreadPilot.Models; - - /// - /// Event arguments for profile application events. - /// - public class ProfileApplicationEventArgs : EventArgs - { - public ConditionalProcessProfile Profile { get; set; } = new(); - - public ProcessModel Process { get; set; } = new(); - - public SystemState SystemState { get; set; } = new(); - - public bool WasApplied { get; set; } - - public string Reason { get; set; } = string.Empty; - - public DateTime Timestamp { get; set; } = DateTime.UtcNow; - } - - /// - /// Event arguments for profile conflict events. - /// - public class ProfileConflictEventArgs : EventArgs - { - public List ConflictingProfiles { get; set; } = new(); - - public ProcessModel Process { get; set; } = new(); - - public ConditionalProcessProfile SelectedProfile { get; set; } = new(); - - public string Resolution { get; set; } = string.Empty; - } - - /// - /// Service for managing conditional process profiles. - /// - public interface IConditionalProfileService - { - /// - /// Initialize the conditional profile service. - /// - Task InitializeAsync(); - - /// - /// Add a conditional profile. - /// - Task AddProfileAsync(ConditionalProcessProfile profile); - - /// - /// Remove a conditional profile. - /// - Task RemoveProfileAsync(string profileId); - - /// - /// Update an existing conditional profile. - /// - Task UpdateProfileAsync(ConditionalProcessProfile profile); - - /// - /// Get all conditional profiles. - /// - Task> GetAllProfilesAsync(); - - /// - /// Get profiles for a specific process. - /// - Task> GetProfilesForProcessAsync(string processName); - - /// - /// Evaluate all profiles for a process and return applicable ones. - /// - Task> EvaluateProfilesAsync(ProcessModel process); - - /// - /// Apply the best matching profile for a process. - /// - Task ApplyBestProfileAsync(ProcessModel process); - - /// - /// Get current system state for condition evaluation. - /// - Task GetSystemStateAsync(); - - /// - /// Start automatic profile monitoring and application. - /// - Task StartMonitoringAsync(); - - /// - /// Stop automatic profile monitoring. - /// - Task StopMonitoringAsync(); - - /// - /// Gets a value indicating whether check if monitoring is active. - /// - bool IsMonitoring { get; } - - /// - /// Resolve conflicts when multiple profiles match. - /// - ConditionalProcessProfile ResolveProfileConflict(List conflictingProfiles, ProcessModel process); - - /// - /// Create a default conditional profile template. - /// - ConditionalProcessProfile CreateDefaultProfile(string processName); - - /// - /// Validate a conditional profile. - /// - Task<(bool IsValid, List Errors)> ValidateProfileAsync(ConditionalProcessProfile profile); - - /// - /// Export profiles to JSON. - /// - Task ExportProfilesToJsonAsync(); - - /// - /// Import profiles from JSON. - /// - Task ImportProfilesFromJsonAsync(string json); - - /// - /// Event raised when a profile is automatically applied - /// - event EventHandler? ProfileApplied; - - /// - /// Event raised when profile conflicts are resolved - /// - event EventHandler? ProfileConflictResolved; - - /// - /// Event raised when system state changes significantly - /// - event EventHandler? SystemStateChanged; - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using ThreadPilot.Models; + + public class ProfileApplicationEventArgs : EventArgs + { + public ConditionalProcessProfile Profile { get; set; } = new(); + + public ProcessModel Process { get; set; } = new(); + + public SystemState SystemState { get; set; } = new(); + + public bool WasApplied { get; set; } + + public string Reason { get; set; } = string.Empty; + + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + } + + public class ProfileConflictEventArgs : EventArgs + { + public List ConflictingProfiles { get; set; } = new(); + + public ProcessModel Process { get; set; } = new(); + + public ConditionalProcessProfile SelectedProfile { get; set; } = new(); + + public string Resolution { get; set; } = string.Empty; + } + + public interface IConditionalProfileService + { + Task InitializeAsync(); + + Task AddProfileAsync(ConditionalProcessProfile profile); + + Task RemoveProfileAsync(string profileId); + + Task UpdateProfileAsync(ConditionalProcessProfile profile); + + Task> GetAllProfilesAsync(); + + Task> GetProfilesForProcessAsync(string processName); + + Task> EvaluateProfilesAsync(ProcessModel process); + + Task ApplyBestProfileAsync(ProcessModel process); + + Task GetSystemStateAsync(); + + Task StartMonitoringAsync(); + + Task StopMonitoringAsync(); + + bool IsMonitoring { get; } + + ConditionalProcessProfile ResolveProfileConflict(List conflictingProfiles, ProcessModel process); + + ConditionalProcessProfile CreateDefaultProfile(string processName); + + Task<(bool IsValid, List Errors)> ValidateProfileAsync(ConditionalProcessProfile profile); + + Task ExportProfilesToJsonAsync(); + + Task ImportProfilesFromJsonAsync(string json); + + event EventHandler? ProfileApplied; + + event EventHandler? ProfileConflictResolved; + + event EventHandler? SystemStateChanged; + } +} + diff --git a/Services/ICoreMaskService.cs b/Services/ICoreMaskService.cs index 5ced649..d8f7f66 100644 --- a/Services/ICoreMaskService.cs +++ b/Services/ICoreMaskService.cs @@ -1,120 +1,47 @@ -/* - * 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.Collections.Generic; - using System.Collections.ObjectModel; - using System.Threading.Tasks; - using ThreadPilot.Models; - - /// - /// Service for managing CPU core affinity masks. - /// - public interface ICoreMaskService - { - /// - /// Gets all available core masks. - /// - ObservableCollection AvailableMasks { get; } - - /// - /// Gets the default mask (all cores). - /// - CoreMask? DefaultMask { get; } - - /// - /// Initializes the service and loads masks from storage. - /// - Task InitializeAsync(); - - /// - /// Creates a new core mask. - /// - Task CreateMaskAsync(string name, string description, IEnumerable boolMask); - - /// - /// Updates an existing mask. - /// - Task UpdateMaskAsync(CoreMask mask); - - /// - /// Deletes a mask by ID. - /// - Task DeleteMaskAsync(string maskId); - - /// - /// Gets a mask by ID. - /// - CoreMask? GetMaskById(string maskId); - - /// - /// Gets a mask by name. - /// - CoreMask? GetMaskByName(string name); - - /// - /// Saves all masks to persistent storage. - /// - Task SaveMasksAsync(); - - /// - /// Loads masks from persistent storage. - /// - Task LoadMasksAsync(); - - /// - /// Checks if a mask is referenced by any profile or rule (not necessarily active). - /// - Task IsMaskReferencedByProfilesAsync(string maskId); - - /// - /// Checks if a mask is actively applied to any running process. - /// - Task IsMaskActivelyAppliedAsync(string maskId); - - /// - /// Gets the names of profiles/rules that reference a specific mask. - /// - Task> GetProfilesReferencingMaskAsync(string maskId); - - /// - /// Updates all profiles referencing a mask to use the default "All Cores" mask. - /// - Task UpdateProfilesToDefaultMaskAsync(string maskId); - - /// - /// Creates default masks for the system based on CPU topology. - /// - Task CreateDefaultMasksAsync(); - - /// - /// Gets the "All Cores" baseline mask (cannot be deleted). - /// - CoreMask? GetAllCoresMask(); - - /// - /// Registers that a mask has been applied to a running process. - /// - void RegisterMaskApplication(int processId, string maskId); - - /// - /// Unregisters mask tracking when a process exits or a mask is removed. - /// - void UnregisterMaskApplication(int processId); - } -} - +namespace ThreadPilot.Services +{ + using System.Collections.Generic; + using System.Collections.ObjectModel; + using System.Threading.Tasks; + using ThreadPilot.Models; + + public interface ICoreMaskService + { + ObservableCollection AvailableMasks { get; } + + CoreMask? DefaultMask { get; } + + Task InitializeAsync(); + + Task CreateMaskAsync(string name, string description, IEnumerable boolMask); + + Task UpdateMaskAsync(CoreMask mask); + + Task DeleteMaskAsync(string maskId); + + CoreMask? GetMaskById(string maskId); + + CoreMask? GetMaskByName(string name); + + Task SaveMasksAsync(); + + Task LoadMasksAsync(); + + Task IsMaskReferencedByProfilesAsync(string maskId); + + Task IsMaskActivelyAppliedAsync(string maskId); + + Task> GetProfilesReferencingMaskAsync(string maskId); + + Task UpdateProfilesToDefaultMaskAsync(string maskId); + + Task CreateDefaultMasksAsync(); + + CoreMask? GetAllCoresMask(); + + void RegisterMaskApplication(int processId, string maskId); + + void UnregisterMaskApplication(int processId); + } +} + diff --git a/Services/ICpuPresetGenerator.cs b/Services/ICpuPresetGenerator.cs index c53763a..0c2988f 100644 --- a/Services/ICpuPresetGenerator.cs +++ b/Services/ICpuPresetGenerator.cs @@ -1,27 +1,11 @@ -/* - * 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 ThreadPilot.Models; - - public interface ICpuPresetGenerator - { - IReadOnlyList Generate( - CpuTopologySnapshot topology, - CpuPresetGenerationOptions? options = null); - } -} +namespace ThreadPilot.Services +{ + using ThreadPilot.Models; + + public interface ICpuPresetGenerator + { + IReadOnlyList Generate( + CpuTopologySnapshot topology, + CpuPresetGenerationOptions? options = null); + } +} diff --git a/Services/ICpuTopologyProvider.cs b/Services/ICpuTopologyProvider.cs index 27e5c32..7eb2fdc 100644 --- a/Services/ICpuTopologyProvider.cs +++ b/Services/ICpuTopologyProvider.cs @@ -1,33 +1,11 @@ -/* - * 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.Threading; - using System.Threading.Tasks; - using ThreadPilot.Models; - - /// - /// Provides a topology-aware CPU snapshot without applying runtime affinity changes. - /// - public interface ICpuTopologyProvider - { - /// - /// Gets a current CPU topology snapshot. - /// - Task GetTopologySnapshotAsync(CancellationToken cancellationToken = default); - } -} +namespace ThreadPilot.Services +{ + using System.Threading; + using System.Threading.Tasks; + using ThreadPilot.Models; + + public interface ICpuTopologyProvider + { + Task GetTopologySnapshotAsync(CancellationToken cancellationToken = default); + } +} diff --git a/Services/ICpuTopologyService.cs b/Services/ICpuTopologyService.cs index 333e347..0b38f0c 100644 --- a/Services/ICpuTopologyService.cs +++ b/Services/ICpuTopologyService.cs @@ -1,87 +1,44 @@ -/* - * ThreadPilot - Advanced Windows Process and Power Plan Manager - * Copyright (C) 2025 Prime Build - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, version 3 only. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -namespace ThreadPilot.Services -{ - using System; - using System.Collections.Generic; - using System.Threading.Tasks; - using ThreadPilot.Models; - - /// - /// Service for detecting and managing CPU topology information. - /// - public interface ICpuTopologyService - { - /// - /// Event fired when CPU topology is detected or updated - /// - event EventHandler? TopologyDetected; - - /// - /// Gets the current CPU topology information. - /// - CpuTopologyModel? CurrentTopology { get; } - - /// - /// Detects CPU topology information. - /// - Task DetectTopologyAsync(); - - /// - /// Gets available affinity presets based on current topology. - /// - IEnumerable GetAffinityPresets(); - - /// - /// Validates if an affinity mask is valid for the current system. - /// - bool IsAffinityMaskValid(long affinityMask); - - /// - /// Gets the maximum number of logical cores supported. - /// - int GetMaxLogicalCores(); - - /// - /// Refreshes topology information (useful for hot-plug scenarios). - /// - Task RefreshTopologyAsync(); - } - - /// - /// Event arguments for CPU topology detection. - /// - public class CpuTopologyDetectedEventArgs : EventArgs - { - public CpuTopologyModel Topology { get; } - - public bool DetectionSuccessful { get; } - - public string? ErrorMessage { get; } - - public DateTime DetectionTime { get; } - - public CpuTopologyDetectedEventArgs(CpuTopologyModel topology, bool successful, string? errorMessage = null) - { - this.Topology = topology; - this.DetectionSuccessful = successful; - this.ErrorMessage = errorMessage; - this.DetectionTime = DateTime.Now; - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using ThreadPilot.Models; + + public interface ICpuTopologyService + { + event EventHandler? TopologyDetected; + + CpuTopologyModel? CurrentTopology { get; } + + Task DetectTopologyAsync(); + + IEnumerable GetAffinityPresets(); + + bool IsAffinityMaskValid(long affinityMask); + + int GetMaxLogicalCores(); + + Task RefreshTopologyAsync(); + } + + public class CpuTopologyDetectedEventArgs : EventArgs + { + public CpuTopologyModel Topology { get; } + + public bool DetectionSuccessful { get; } + + public string? ErrorMessage { get; } + + public DateTime DetectionTime { get; } + + public CpuTopologyDetectedEventArgs(CpuTopologyModel topology, bool successful, string? errorMessage = null) + { + this.Topology = topology; + this.DetectionSuccessful = successful; + this.ErrorMessage = errorMessage; + this.DetectionTime = DateTime.Now; + } + } +} + diff --git a/Services/IElevatedTaskService.cs b/Services/IElevatedTaskService.cs index 80d996b..f815f5b 100644 --- a/Services/IElevatedTaskService.cs +++ b/Services/IElevatedTaskService.cs @@ -1,68 +1,21 @@ -/* - * 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.Threading.Tasks; - - /// - /// Service for managing Scheduled Task-based elevation entrypoints. - /// - public interface IElevatedTaskService - { - /// - /// Gets the managed task name used for on-demand elevated launch. - /// - string LaunchTaskName { get; } - - /// - /// Gets the managed task name used for elevated logon autostart. - /// - string AutostartTaskName { get; } - - /// - /// Ensures the managed on-demand elevated launch task exists and targets the current executable. - /// - /// True when the task exists and is up to date; otherwise false. - Task EnsureLaunchTaskAsync(); - - /// - /// Runs the managed on-demand elevated launch task. - /// - /// True if task execution was started successfully; otherwise false. - Task TryRunLaunchTaskAsync(); - - /// - /// Ensures elevated autostart task exists and points to the provided executable command. - /// - /// Absolute executable path. - /// Command-line arguments for autostart launches. - /// True if task was created/updated successfully; otherwise false. - Task EnsureAutostartTaskAsync(string executablePath, string arguments); - - /// - /// Removes the managed elevated autostart task. - /// - /// True when the task is removed (or already absent); otherwise false. - Task RemoveAutostartTaskAsync(); - - /// - /// Checks whether the managed elevated autostart task is currently registered. - /// - /// True when the task exists; otherwise false. - Task IsAutostartTaskRegisteredAsync(); - } -} +namespace ThreadPilot.Services +{ + using System.Threading.Tasks; + + public interface IElevatedTaskService + { + string LaunchTaskName { get; } + + string AutostartTaskName { get; } + + Task EnsureLaunchTaskAsync(); + + Task TryRunLaunchTaskAsync(); + + Task EnsureAutostartTaskAsync(string executablePath, string arguments); + + Task RemoveAutostartTaskAsync(); + + Task IsAutostartTaskRegisteredAsync(); + } +} diff --git a/Services/IElevationService.cs b/Services/IElevationService.cs index 48a244e..4741451 100644 --- a/Services/IElevationService.cs +++ b/Services/IElevationService.cs @@ -1,59 +1,18 @@ -/* - * 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.Threading.Tasks; - - /// - /// Service for managing application elevation and administrator privileges. - /// - public interface IElevationService - { - /// - /// Checks if the current process is running with administrator privileges. - /// - /// True if running as administrator, false otherwise. - bool IsRunningAsAdministrator(); - - /// - /// Requests elevation if the current process is not running as administrator. - /// - /// True if already elevated or elevation was successful, false if elevation failed or was cancelled. - Task RequestElevationIfNeeded(); - - /// - /// Restarts the application with administrator privileges. - /// - /// Command line arguments to pass to the elevated process. - /// True if restart was initiated successfully, false otherwise. - Task RestartWithElevation(string[]? arguments = null); - - /// - /// Validates that the current process has the necessary privileges for the specified operation. - /// - /// The operation that requires validation. - /// True if the operation can be performed, false otherwise. - bool ValidateElevationForOperation(string operation); - - /// - /// Gets the current elevation status as a user-friendly string. - /// - /// Elevation status description. - string GetElevationStatus(); - } -} - +namespace ThreadPilot.Services +{ + using System.Threading.Tasks; + + public interface IElevationService + { + bool IsRunningAsAdministrator(); + + Task RequestElevationIfNeeded(); + + Task RestartWithElevation(string[]? arguments = null); + + bool ValidateElevationForOperation(string operation); + + string GetElevationStatus(); + } +} + diff --git a/Services/IEnhancedLoggingService.cs b/Services/IEnhancedLoggingService.cs index 0325d61..7e1966e 100644 --- a/Services/IEnhancedLoggingService.cs +++ b/Services/IEnhancedLoggingService.cs @@ -1,189 +1,104 @@ -/* - * 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.Threading.Tasks; - using Microsoft.Extensions.Logging; - - /// - /// Enhanced logging service interface for persistent, structured logging. - /// - public interface IEnhancedLoggingService - { - /// - /// Gets the current log file path. - /// - string CurrentLogFilePath { get; } - - /// - /// Gets the log directory path. - /// - string LogDirectoryPath { get; } - - /// - /// Gets a value indicating whether gets whether debug logging is enabled. - /// - bool IsDebugLoggingEnabled { get; } - - /// - /// Event raised when a critical error occurs - /// - event EventHandler? CriticalErrorOccurred; - - /// - /// Initialize the logging service. - /// - Task InitializeAsync(); - - /// - /// Log a power plan change event. - /// - Task LogPowerPlanChangeAsync(string fromPlan, string toPlan, string reason, string? processName = null); - - /// - /// Log a process monitoring event. - /// - Task LogProcessMonitoringEventAsync(string eventType, string processName, int processId, string details); - - /// - /// Log a user action. - /// - Task LogUserActionAsync(string action, string details, string? context = null); - - /// - /// Log a system event. - /// - Task LogSystemEventAsync(string eventType, string message, LogLevel level = LogLevel.Information); - - /// - /// Log an error with structured data. - /// - Task LogErrorAsync(Exception exception, string context, Dictionary? additionalData = null); - - /// - /// Log application startup/shutdown events. - /// - Task LogApplicationLifecycleEventAsync(string eventType, string details); - - /// - /// Get recent log entries. - /// - Task> GetRecentLogEntriesAsync(int count = 100); - - /// - /// Begin a correlated operation scope for better debugging. - /// - IDisposable BeginScope(string operationName, object? parameters = null); - - /// - /// Get the current correlation ID. - /// - string? GetCurrentCorrelationId(); - - /// - /// Get log entries for a specific date range. - /// - Task> GetLogEntriesAsync(DateTime fromDate, DateTime toDate); - - /// - /// Clean up old log files based on retention policy. - /// - Task CleanupOldLogsAsync(); - - /// - /// Get log file statistics. - /// - Task GetLogStatisticsAsync(); - - /// - /// Export logs to a file. - /// - Task ExportLogsAsync(DateTime fromDate, DateTime toDate, string? exportPath = null); - - /// - /// Update logging configuration. - /// - Task UpdateConfigurationAsync(bool enableDebugLogging, int maxFileSizeMb, int retentionDays); - } - - /// - /// Event args for critical errors. - /// - public class CriticalErrorEventArgs : EventArgs - { - public Exception Exception { get; } - - public string Context { get; } - - public DateTime Timestamp { get; } - - public Dictionary AdditionalData { get; } - - public CriticalErrorEventArgs(Exception exception, string context, Dictionary? additionalData = null) - { - this.Exception = exception; - this.Context = context; - this.Timestamp = DateTime.UtcNow; - this.AdditionalData = additionalData ?? new Dictionary(); - } - } - - /// - /// Represents a log entry. - /// - public class LogEntry - { - public DateTime Timestamp { get; set; } - - public LogLevel Level { get; set; } - - public string Category { get; set; } = string.Empty; - - public string Message { get; set; } = string.Empty; - - public string? Exception { get; set; } - - public Dictionary Properties { get; set; } = new(); - - public string? CorrelationId { get; set; } - } - - /// - /// Log file statistics. - /// - public class LogFileStatistics - { - public long CurrentFileSizeBytes { get; set; } - - public int TotalLogFiles { get; set; } - - public long TotalLogSizeBytes { get; set; } - - public DateTime OldestLogDate { get; set; } - - public DateTime NewestLogDate { get; set; } - - public int ErrorCount { get; set; } - - public int WarningCount { get; set; } - - public int InfoCount { get; set; } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + + public interface IEnhancedLoggingService + { + string CurrentLogFilePath { get; } + + string LogDirectoryPath { get; } + + bool IsDebugLoggingEnabled { get; } + + event EventHandler? CriticalErrorOccurred; + + Task InitializeAsync(); + + Task LogPowerPlanChangeAsync(string fromPlan, string toPlan, string reason, string? processName = null); + + Task LogProcessMonitoringEventAsync(string eventType, string processName, int processId, string details); + + Task LogUserActionAsync(string action, string details, string? context = null); + + Task LogSystemEventAsync(string eventType, string message, LogLevel level = LogLevel.Information); + + Task LogErrorAsync(Exception exception, string context, Dictionary? additionalData = null); + + Task LogApplicationLifecycleEventAsync(string eventType, string details); + + Task> GetRecentLogEntriesAsync(int count = 100); + + IDisposable BeginScope(string operationName, object? parameters = null); + + string? GetCurrentCorrelationId(); + + Task> GetLogEntriesAsync(DateTime fromDate, DateTime toDate); + + Task CleanupOldLogsAsync(); + + Task GetLogStatisticsAsync(); + + Task ExportLogsAsync(DateTime fromDate, DateTime toDate, string? exportPath = null); + + Task UpdateConfigurationAsync(bool enableDebugLogging, int maxFileSizeMb, int retentionDays); + } + + public class CriticalErrorEventArgs : EventArgs + { + public Exception Exception { get; } + + public string Context { get; } + + public DateTime Timestamp { get; } + + public Dictionary AdditionalData { get; } + + public CriticalErrorEventArgs(Exception exception, string context, Dictionary? additionalData = null) + { + this.Exception = exception; + this.Context = context; + this.Timestamp = DateTime.UtcNow; + this.AdditionalData = additionalData ?? new Dictionary(); + } + } + + public class LogEntry + { + public DateTime Timestamp { get; set; } + + public LogLevel Level { get; set; } + + public string Category { get; set; } = string.Empty; + + public string Message { get; set; } = string.Empty; + + public string? Exception { get; set; } + + public Dictionary Properties { get; set; } = new(); + + public string? CorrelationId { get; set; } + } + + public class LogFileStatistics + { + public long CurrentFileSizeBytes { get; set; } + + public int TotalLogFiles { get; set; } + + public long TotalLogSizeBytes { get; set; } + + public DateTime OldestLogDate { get; set; } + + public DateTime NewestLogDate { get; set; } + + public int ErrorCount { get; set; } + + public int WarningCount { get; set; } + + public int InfoCount { get; set; } + } +} + diff --git a/Services/IGameBoostService.cs b/Services/IGameBoostService.cs deleted file mode 100644 index e7ee4ec..0000000 --- a/Services/IGameBoostService.cs +++ /dev/null @@ -1,157 +0,0 @@ -/* - * 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 . - */ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using ThreadPilot.Models; - -namespace ThreadPilot.Services -{ - /// - /// Interface for Game Boost mode functionality - /// - public interface IGameBoostService - { - /// - /// Event fired when Game Boost mode is activated - /// - event EventHandler? GameBoostActivated; - - /// - /// Event fired when Game Boost mode is deactivated - /// - event EventHandler? GameBoostDeactivated; - - /// - /// Event fired when a game is detected - /// - event EventHandler? GameDetected; - - /// - /// Gets whether Game Boost mode is currently active - /// - bool IsGameBoostActive { get; } - - /// - /// Gets the currently detected game process - /// - ProcessModel? CurrentGameProcess { get; } - - /// - /// Gets the list of known game executables - /// - IReadOnlyList KnownGameExecutables { get; } - - /// - /// Gets the list of known game executables as a collection - /// - IReadOnlyList GetKnownGameExecutables(); - - /// - /// Enables Game Boost mode - /// - Task EnableGameBoostAsync(); - - /// - /// Disables Game Boost mode - /// - Task DisableGameBoostAsync(); - - /// - /// Manually activates Game Boost for a specific process - /// - /// The process to boost - Task ActivateGameBoostAsync(ProcessModel process); - - /// - /// Manually deactivates Game Boost - /// - Task DeactivateGameBoostAsync(); - - /// - /// Adds a game executable to the known games list - /// - /// Name of the executable (e.g., "game.exe") - Task AddKnownGameAsync(string executableName); - - /// - /// Removes a game executable from the known games list - /// - /// Name of the executable to remove - Task RemoveKnownGameAsync(string executableName); - - /// - /// Checks if a process is considered a game - /// - /// The process to check - bool IsGameProcess(ProcessModel process); - } - - /// - /// Event arguments for Game Boost activation - /// - public class GameBoostActivatedEventArgs : EventArgs - { - public ProcessModel GameProcess { get; } - public string PowerPlanId { get; } - public DateTime ActivatedAt { get; } - - public GameBoostActivatedEventArgs(ProcessModel gameProcess, string powerPlanId) - { - GameProcess = gameProcess; - PowerPlanId = powerPlanId; - ActivatedAt = DateTime.Now; - } - } - - /// - /// Event arguments for Game Boost deactivation - /// - public class GameBoostDeactivatedEventArgs : EventArgs - { - public ProcessModel? GameProcess { get; } - public string? RestoredPowerPlanId { get; } - public DateTime DeactivatedAt { get; } - public TimeSpan Duration { get; } - - public GameBoostDeactivatedEventArgs(ProcessModel? gameProcess, string? restoredPowerPlanId, TimeSpan duration) - { - GameProcess = gameProcess; - RestoredPowerPlanId = restoredPowerPlanId; - DeactivatedAt = DateTime.Now; - Duration = duration; - } - } - - /// - /// Event arguments for game detection - /// - public class GameDetectedEventArgs : EventArgs - { - public ProcessModel GameProcess { get; } - public bool IsKnownGame { get; } - public DateTime DetectedAt { get; } - - public GameDetectedEventArgs(ProcessModel gameProcess, bool isKnownGame) - { - GameProcess = gameProcess; - IsKnownGame = isKnownGame; - DetectedAt = DateTime.Now; - } - } -} - diff --git a/Services/IGameDetectionService.cs b/Services/IGameDetectionService.cs deleted file mode 100644 index 1ba741f..0000000 --- a/Services/IGameDetectionService.cs +++ /dev/null @@ -1,259 +0,0 @@ -/* - * 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 . - */ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Threading.Tasks; -using ThreadPilot.Models; - -namespace ThreadPilot.Services -{ - /// - /// Game classification confidence levels - /// - public enum GameConfidenceLevel - { - VeryLow = 0, // 0-20% - Low = 1, // 20-40% - Medium = 2, // 40-60% - High = 3, // 60-80% - VeryHigh = 4 // 80-100% - } - - /// - /// Game detection result with confidence scoring - /// - public class GameDetectionResult - { - public bool IsGame { get; set; } - public float Confidence { get; set; } - public GameConfidenceLevel ConfidenceLevel => Confidence switch - { - >= 0.8f => GameConfidenceLevel.VeryHigh, - >= 0.6f => GameConfidenceLevel.High, - >= 0.4f => GameConfidenceLevel.Medium, - >= 0.2f => GameConfidenceLevel.Low, - _ => GameConfidenceLevel.VeryLow - }; - public string GameName { get; set; } = string.Empty; - public string DetectionMethod { get; set; } = string.Empty; - public Dictionary Features { get; set; } = new(); - public DateTime DetectionTime { get; set; } = DateTime.UtcNow; - } - - /// - /// Real-time game performance metrics - /// - public class GamePerformanceMetrics - { - public int ProcessId { get; set; } - public string GameName { get; set; } = string.Empty; - public float FrameRate { get; set; } - public double CpuUsage { get; set; } - public long MemoryUsage { get; set; } - public double GpuUsage { get; set; } - public long GpuMemoryUsage { get; set; } - public DateTime Timestamp { get; set; } = DateTime.UtcNow; - public bool IsFullscreen { get; set; } - public string Resolution { get; set; } = string.Empty; - } - - /// - /// Process features for ML classification - /// - public class ProcessFeatures - { - public string ProcessName { get; set; } = string.Empty; - public string ExecutablePath { get; set; } = string.Empty; - public bool HasVisibleWindow { get; set; } - public bool IsFullscreen { get; set; } - public double CpuUsage { get; set; } - public long MemoryUsage { get; set; } - public int ThreadCount { get; set; } - public int HandleCount { get; set; } - public bool HasDirectXDlls { get; set; } - public bool HasOpenGLDlls { get; set; } - public bool HasVulkanDlls { get; set; } - public bool HasAudioDlls { get; set; } - public bool HasNetworkActivity { get; set; } - public string FileDescription { get; set; } = string.Empty; - public string CompanyName { get; set; } = string.Empty; - public bool IsInGamesFolder { get; set; } - public bool HasGameKeywords { get; set; } - public float WindowAspectRatio { get; set; } - public bool IsElevated { get; set; } - } - - /// - /// Service for detecting running games and applying optimal performance profiles - /// - public interface IGameDetectionService - { - /// - /// Detect if a process is a known game - /// - Task DetectGameAsync(ProcessModel process); - - /// - /// Detect if a process is a game using ML classification - /// - Task DetectGameWithMLAsync(ProcessModel process); - - /// - /// Extract features from a process for ML classification - /// - Task ExtractProcessFeaturesAsync(ProcessModel process); - - /// - /// Get real-time performance metrics for a game - /// - Task GetGamePerformanceAsync(ProcessModel process); - - /// - /// Get all currently running games - /// - Task> GetRunningGamesAsync(); - - /// - /// Apply optimal settings for a detected game - /// - Task ApplyGameOptimizationsAsync(ProcessModel process, GameProfile gameProfile); - - /// - /// Check if a process is running through Steam - /// - Task IsSteamGameAsync(ProcessModel process); - - /// - /// Check if a process is running through Epic Games Launcher - /// - Task IsEpicGameAsync(ProcessModel process); - - /// - /// Get game profile for a Steam game - /// - Task GetSteamGameProfileAsync(ProcessModel process); - - /// - /// Get game profile for an Epic Games game - /// - Task GetEpicGameProfileAsync(ProcessModel process); - - /// - /// Add or update a custom game profile - /// - Task AddCustomGameProfileAsync(string processName, GameProfile profile); - - /// - /// Remove a custom game profile - /// - Task RemoveCustomGameProfileAsync(string processName); - - /// - /// Get all available game profiles - /// - Task> GetAllGameProfilesAsync(); - - /// - /// Event raised when a new game is detected - /// - event EventHandler? GameDetected; - - /// - /// Event raised when a game stops running - /// - event EventHandler? GameStopped; - } - - /// - /// Represents a game profile with optimal settings - /// - public class GameProfile - { - public string Name { get; set; } = string.Empty; - public string ProcessName { get; set; } = string.Empty; - public string OptimalCores { get; set; } = "All"; // "All", "Physical", "P-Cores", "E-Cores", "Custom" - public ProcessPriorityClass Priority { get; set; } = ProcessPriorityClass.High; - public string? PowerPlan { get; set; } - public bool DisableFullscreenOptimizations { get; set; } = true; - public bool HighDpiAware { get; set; } = true; - public string? CustomAffinityMask { get; set; } - public Dictionary CustomSettings { get; set; } = new(); - public DateTime LastDetected { get; set; } - public int DetectionCount { get; set; } - public string? SteamAppId { get; set; } - public string? EpicAppId { get; set; } - public GameCategory Category { get; set; } = GameCategory.Unknown; - public string? Description { get; set; } - } - - /// - /// Game categories for better organization - /// - public enum GameCategory - { - Unknown, - FPS, - MOBA, - RTS, - RPG, - Racing, - Sports, - Simulation, - Strategy, - Action, - Adventure, - Indie - } - - /// - /// Event args for game profile detection - /// - public class GameProfileDetectedEventArgs : EventArgs - { - public ProcessModel Process { get; } - public GameProfile GameProfile { get; } - public DateTime DetectedAt { get; } - - public GameProfileDetectedEventArgs(ProcessModel process, GameProfile gameProfile) - { - Process = process; - GameProfile = gameProfile; - DetectedAt = DateTime.UtcNow; - } - } - - /// - /// Event args for game stopped - /// - public class GameStoppedEventArgs : EventArgs - { - public string ProcessName { get; } - public GameProfile GameProfile { get; } - public DateTime StoppedAt { get; } - public TimeSpan PlayDuration { get; } - - public GameStoppedEventArgs(string processName, GameProfile gameProfile, TimeSpan playDuration) - { - ProcessName = processName; - GameProfile = gameProfile; - StoppedAt = DateTime.UtcNow; - PlayDuration = playDuration; - } - } -} - diff --git a/Services/IGameModeService.cs b/Services/IGameModeService.cs index 87fb9bf..fcd29ac 100644 --- a/Services/IGameModeService.cs +++ b/Services/IGameModeService.cs @@ -1,48 +1,14 @@ -/* - * 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.Threading.Tasks; - - /// - /// Service for managing Windows Game Mode settings - /// Game Mode can interfere with CPU affinity settings, particularly on AMD systems. - /// - public interface IGameModeService - { - /// - /// Checks if Windows Game Mode is currently enabled. - /// - /// True if Game Mode is enabled, false otherwise. - Task IsGameModeEnabledAsync(); - - /// - /// Sets Windows Game Mode to enabled or disabled. - /// - /// True to enable Game Mode, false to disable. - /// True if the operation succeeded, false otherwise. - Task SetGameModeAsync(bool enabled); - - /// - /// Disables Game Mode for better CPU affinity control - /// This is a non-intrusive operation that only disables if currently enabled. - /// - /// True if Game Mode was disabled, false if it was already disabled or operation failed. - Task DisableGameModeForAffinityAsync(); - } -} - +namespace ThreadPilot.Services +{ + using System.Threading.Tasks; + + public interface IGameModeService + { + Task IsGameModeEnabledAsync(); + + Task SetGameModeAsync(bool enabled); + + Task DisableGameModeForAffinityAsync(); + } +} + diff --git a/Services/IKeyboardShortcutService.cs b/Services/IKeyboardShortcutService.cs index 19df38a..fa1f151 100644 --- a/Services/IKeyboardShortcutService.cs +++ b/Services/IKeyboardShortcutService.cs @@ -1,164 +1,106 @@ -/* - * 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.Threading.Tasks; - using System.Windows.Input; - - /// - /// Service for managing global keyboard shortcuts. - /// - public interface IKeyboardShortcutService - { - /// - /// Event raised when a registered shortcut is activated - /// - event EventHandler? ShortcutActivated; - - /// - /// Register a global keyboard shortcut. - /// - Task RegisterShortcutAsync(string actionName, Key key, ModifierKeys modifiers); - - /// - /// Unregister a keyboard shortcut. - /// - Task UnregisterShortcutAsync(string actionName); - - /// - /// Update an existing shortcut with new key combination. - /// - Task UpdateShortcutAsync(string actionName, Key key, ModifierKeys modifiers); - - /// - /// Get all registered shortcuts. - /// - Task> GetRegisteredShortcutsAsync(); - - /// - /// Check if a key combination is already registered. - /// - Task IsShortcutRegisteredAsync(Key key, ModifierKeys modifiers); - - /// - /// Load shortcuts from settings. - /// - Task LoadShortcutsFromSettingsAsync(); - - /// - /// Save shortcuts to settings. - /// - Task SaveShortcutsToSettingsAsync(); - - /// - /// Clear all registered shortcuts. - /// - Task ClearAllShortcutsAsync(); - - /// - /// Get the default shortcuts for the application. - /// - Dictionary GetDefaultShortcuts(); - } - - /// - /// Represents a keyboard shortcut. - /// - public class KeyboardShortcut - { - public string ActionName { get; set; } = string.Empty; - - public Key Key { get; set; } - - public ModifierKeys Modifiers { get; set; } - - public string Description { get; set; } = string.Empty; - - public bool IsEnabled { get; set; } = true; - - public bool IsGlobal { get; set; } = true; - - public override string ToString() - { - var parts = new List(); - - if (this.Modifiers.HasFlag(ModifierKeys.Control)) - { - parts.Add("Ctrl"); - } - - if (this.Modifiers.HasFlag(ModifierKeys.Alt)) - { - parts.Add("Alt"); - } - - if (this.Modifiers.HasFlag(ModifierKeys.Shift)) - { - parts.Add("Shift"); - } - - if (this.Modifiers.HasFlag(ModifierKeys.Windows)) - { - parts.Add("Win"); - } - - parts.Add(this.Key.ToString()); - - return string.Join(" + ", parts); - } - } - - /// - /// Event args for shortcut activation. - /// - public class ShortcutActivatedEventArgs : EventArgs - { - public string ActionName { get; } - - public KeyboardShortcut Shortcut { get; } - - public DateTime ActivationTime { get; } - - public ShortcutActivatedEventArgs(string actionName, KeyboardShortcut shortcut) - { - this.ActionName = actionName; - this.Shortcut = shortcut; - this.ActivationTime = DateTime.UtcNow; - } - } - - /// - /// Predefined shortcut actions. - /// - public static class ShortcutActions - { - public const string QuickApply = "QuickApply"; - public const string ToggleMonitoring = "ToggleMonitoring"; - public const string ShowMainWindow = "ShowMainWindow"; - public const string HideToTray = "HideToTray"; - public const string PowerPlanBalanced = "PowerPlanBalanced"; - public const string PowerPlanHighPerformance = "PowerPlanHighPerformance"; - public const string PowerPlanPowerSaver = "PowerPlanPowerSaver"; - public const string RefreshProcessList = "RefreshProcessList"; - public const string OpenSettings = "OpenSettings"; - public const string OpenTweaks = "OpenTweaks"; - public const string ExitApplication = "ExitApplication"; - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using System.Windows.Input; + + public interface IKeyboardShortcutService + { + event EventHandler? ShortcutActivated; + + Task RegisterShortcutAsync(string actionName, Key key, ModifierKeys modifiers); + + Task UnregisterShortcutAsync(string actionName); + + Task UpdateShortcutAsync(string actionName, Key key, ModifierKeys modifiers); + + Task> GetRegisteredShortcutsAsync(); + + Task IsShortcutRegisteredAsync(Key key, ModifierKeys modifiers); + + Task LoadShortcutsFromSettingsAsync(); + + Task SaveShortcutsToSettingsAsync(); + + Task ClearAllShortcutsAsync(); + + Dictionary GetDefaultShortcuts(); + } + + public class KeyboardShortcut + { + public string ActionName { get; set; } = string.Empty; + + public Key Key { get; set; } + + public ModifierKeys Modifiers { get; set; } + + public string Description { get; set; } = string.Empty; + + public bool IsEnabled { get; set; } = true; + + public bool IsGlobal { get; set; } = true; + + public override string ToString() + { + var parts = new List(); + + if (this.Modifiers.HasFlag(ModifierKeys.Control)) + { + parts.Add("Ctrl"); + } + + if (this.Modifiers.HasFlag(ModifierKeys.Alt)) + { + parts.Add("Alt"); + } + + if (this.Modifiers.HasFlag(ModifierKeys.Shift)) + { + parts.Add("Shift"); + } + + if (this.Modifiers.HasFlag(ModifierKeys.Windows)) + { + parts.Add("Win"); + } + + parts.Add(this.Key.ToString()); + + return string.Join(" + ", parts); + } + } + + public class ShortcutActivatedEventArgs : EventArgs + { + public string ActionName { get; } + + public KeyboardShortcut Shortcut { get; } + + public DateTime ActivationTime { get; } + + public ShortcutActivatedEventArgs(string actionName, KeyboardShortcut shortcut) + { + this.ActionName = actionName; + this.Shortcut = shortcut; + this.ActivationTime = DateTime.UtcNow; + } + } + + public static class ShortcutActions + { + public const string QuickApply = "QuickApply"; + public const string ToggleMonitoring = "ToggleMonitoring"; + public const string ShowMainWindow = "ShowMainWindow"; + public const string HideToTray = "HideToTray"; + public const string PowerPlanBalanced = "PowerPlanBalanced"; + public const string PowerPlanHighPerformance = "PowerPlanHighPerformance"; + public const string PowerPlanPowerSaver = "PowerPlanPowerSaver"; + public const string RefreshProcessList = "RefreshProcessList"; + public const string OpenSettings = "OpenSettings"; + public const string OpenTweaks = "OpenTweaks"; + public const string ExitApplication = "ExitApplication"; + } +} + diff --git a/Services/ILocalizationService.cs b/Services/ILocalizationService.cs index 5361e75..bed3d6d 100644 --- a/Services/ILocalizationService.cs +++ b/Services/ILocalizationService.cs @@ -1,44 +1,13 @@ -/* - * ThreadPilot - Advanced Windows Process and Power Plan Manager - * Copyright (C) 2025 Prime Build - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, version 3 only. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -namespace ThreadPilot.Services -{ - /// - /// Service for managing application localization and display language. - /// - public interface ILocalizationService - { - /// - /// Gets the current display language. - /// - string CurrentLanguage { get; } - - /// - /// Event fired when the active language changes. - /// - event EventHandler? LanguageChanged; - - /// - /// Applies the specified display language. - /// - void ApplyLanguage(string? language); - - /// - /// Gets the localized string for the specified key. - /// - string GetString(string key); - } -} +namespace ThreadPilot.Services +{ + public interface ILocalizationService + { + string CurrentLanguage { get; } + + event EventHandler? LanguageChanged; + + void ApplyLanguage(string? language); + + string GetString(string key); + } +} diff --git a/Services/INotificationService.cs b/Services/INotificationService.cs index cf594d7..3feac80 100644 --- a/Services/INotificationService.cs +++ b/Services/INotificationService.cs @@ -1,169 +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.Collections.Generic; - using System.Threading.Tasks; - using ThreadPilot.Models; - - /// - /// Service for managing notifications (balloon tips and toast notifications). - /// - public interface INotificationService - { - /// - /// Event fired when a notification is shown - /// - event EventHandler? NotificationShown; - - /// - /// Event fired when a notification is dismissed - /// - event EventHandler? NotificationDismissed; - - /// - /// Event fired when a notification action is clicked - /// - event EventHandler? NotificationActionClicked; - - /// - /// Gets the notification history. - /// - IReadOnlyList NotificationHistory { get; } - - /// - /// Shows a simple notification. - /// - Task ShowNotificationAsync(string title, string message, NotificationType type = NotificationType.Information); - - /// - /// Shows a notification with custom settings. - /// - Task ShowNotificationAsync(NotificationModel notification); - - /// - /// Shows a balloon tip notification. - /// - Task ShowBalloonTipAsync(string title, string message, NotificationType type = NotificationType.Information, int timeoutMs = 3000); - - /// - /// Shows a Windows toast notification (if available). - /// - Task ShowToastNotificationAsync(string title, string message, NotificationType type = NotificationType.Information); - - /// - /// Shows a notification for power plan changes. - /// - Task ShowPowerPlanChangeNotificationAsync(string oldPlan, string newPlan, string processName = ""); - - /// - /// Shows a notification for process monitoring events. - /// - Task ShowProcessMonitoringNotificationAsync(string message, bool isEnabled); - - /// - /// Shows a notification for CPU affinity changes. - /// - Task ShowCpuAffinityNotificationAsync(string processName, string affinityInfo); - - /// - /// Shows an error notification. - /// - Task ShowErrorNotificationAsync(string title, string message, Exception? exception = null); - - /// - /// Shows a success notification. - /// - Task ShowSuccessNotificationAsync(string title, string message); - - /// - /// Dismisses a specific notification. - /// - Task DismissNotificationAsync(string notificationId); - - /// - /// Dismisses all notifications. - /// - Task DismissAllNotificationsAsync(); - - /// - /// Clears notification history. - /// - Task ClearNotificationHistoryAsync(); - - /// - /// Gets unread notification count. - /// - int GetUnreadNotificationCount(); - - /// - /// Marks all notifications as read. - /// - Task MarkAllNotificationsAsReadAsync(); - - /// - /// Checks if notifications are enabled for the given type. - /// - bool AreNotificationsEnabled(NotificationType type); - - /// - /// Updates notification settings. - /// - void UpdateSettings(ApplicationSettingsModel settings); - - /// - /// Initializes the notification service. - /// - Task InitializeAsync(); - - /// - /// Disposes the notification service. - /// - void Dispose(); - } - - /// - /// Event args for notification events. - /// - public class NotificationEventArgs : EventArgs - { - public NotificationModel Notification { get; } - - public NotificationEventArgs(NotificationModel notification) - { - this.Notification = notification; - } - } - - /// - /// Event args for notification action events. - /// - public class NotificationActionEventArgs : EventArgs - { - public NotificationModel Notification { get; } - - public string ActionCommand { get; } - - public NotificationActionEventArgs(NotificationModel notification, string actionCommand) - { - this.Notification = notification; - this.ActionCommand = actionCommand; - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using ThreadPilot.Models; + + public interface INotificationService + { + event EventHandler? NotificationShown; + + event EventHandler? NotificationDismissed; + + event EventHandler? NotificationActionClicked; + + IReadOnlyList NotificationHistory { get; } + + Task ShowNotificationAsync(string title, string message, NotificationType type = NotificationType.Information); + + Task ShowNotificationAsync(NotificationModel notification); + + Task ShowBalloonTipAsync(string title, string message, NotificationType type = NotificationType.Information, int timeoutMs = 3000); + + Task ShowToastNotificationAsync(string title, string message, NotificationType type = NotificationType.Information); + + Task ShowPowerPlanChangeNotificationAsync(string oldPlan, string newPlan, string processName = ""); + + Task ShowProcessMonitoringNotificationAsync(string message, bool isEnabled); + + Task ShowCpuAffinityNotificationAsync(string processName, string affinityInfo); + + Task ShowErrorNotificationAsync(string title, string message, Exception? exception = null); + + Task ShowSuccessNotificationAsync(string title, string message); + + Task DismissNotificationAsync(string notificationId); + + Task DismissAllNotificationsAsync(); + + Task ClearNotificationHistoryAsync(); + + int GetUnreadNotificationCount(); + + Task MarkAllNotificationsAsReadAsync(); + + bool AreNotificationsEnabled(NotificationType type); + + void UpdateSettings(ApplicationSettingsModel settings); + + Task InitializeAsync(); + + void Dispose(); + } + + public class NotificationEventArgs : EventArgs + { + public NotificationModel Notification { get; } + + public NotificationEventArgs(NotificationModel notification) + { + this.Notification = notification; + } + } + + public class NotificationActionEventArgs : EventArgs + { + public NotificationModel Notification { get; } + + public string ActionCommand { get; } + + public NotificationActionEventArgs(NotificationModel notification, string actionCommand) + { + this.Notification = notification; + this.ActionCommand = actionCommand; + } + } +} + diff --git a/Services/IPerformanceMonitoringService.cs b/Services/IPerformanceMonitoringService.cs index 029203e..bb334d8 100644 --- a/Services/IPerformanceMonitoringService.cs +++ b/Services/IPerformanceMonitoringService.cs @@ -1,240 +1,176 @@ -/* - * 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.Threading.Tasks; - using ThreadPilot.Models; - - /// - /// Service for real-time performance monitoring. - /// - public interface IPerformanceMonitoringService - { - /// - /// Get current system performance metrics. - /// - Task GetSystemMetricsAsync(bool lightweight = false); - - /// - /// Get per-core CPU usage. - /// - Task> GetCpuCoreUsageAsync(); - - /// - /// Get memory usage information. - /// - Task GetMemoryUsageAsync(); - - /// - /// Get top CPU consuming processes. - /// - Task> GetTopCpuProcessesAsync(int count = 10); - - /// - /// Get top memory consuming processes. - /// - Task> GetTopMemoryProcessesAsync(int count = 10); - - /// - /// Start real-time monitoring. - /// - Task StartMonitoringAsync(); - - /// - /// Stop real-time monitoring. - /// - Task StopMonitoringAsync(); - - /// - /// Event raised when performance metrics are updated - /// - event EventHandler? MetricsUpdated; - - /// - /// Get historical performance data. - /// - Task> GetHistoricalDataAsync(TimeSpan duration); - - /// - /// Clear historical performance data. - /// - Task ClearHistoricalDataAsync(); - } - - /// - /// System performance metrics. - /// - public class SystemPerformanceMetrics - { - public DateTime Timestamp { get; set; } = DateTime.UtcNow; - - public double TotalCpuUsage { get; set; } - - public long TotalMemoryUsage { get; set; } - - public long AvailableMemory { get; set; } - - public long TotalMemory { get; set; } - - public double MemoryUsagePercentage { get; set; } - - public int Gen0Collections { get; set; } - - public int Gen1Collections { get; set; } - - public int Gen2Collections { get; set; } - - public int Gen0CollectionsDelta { get; set; } - - public int Gen1CollectionsDelta { get; set; } - - public int Gen2CollectionsDelta { get; set; } - - public long TotalAllocatedBytes { get; set; } - - public long AllocatedBytesDelta { get; set; } - - public long ManagedHeapSizeBytes { get; set; } - - public long GcCommittedBytes { get; set; } - - public double LastGcPauseMs { get; set; } - - public double MaxGcPauseMs { get; set; } - - public int HandleCount { get; set; } - - public long ProcessWorkingSetBytes { get; set; } - - public List CpuCoreUsages { get; set; } = new(); - - public ProcessPerformanceInfo? TopCpuProcess { get; set; } - - public ProcessPerformanceInfo? TopMemoryProcess { get; set; } - - public int ActiveProcessCount { get; set; } - - public int ThreadCount { get; set; } - - public double DiskUsage { get; set; } - - public double NetworkUsage { get; set; } - } - - /// - /// CPU core usage information. - /// - public class CpuCoreUsage - { - public int CoreId { get; set; } - - public string CoreName { get; set; } = string.Empty; - - public double Usage { get; set; } - - public string CoreType { get; set; } = "Unknown"; // P-Core, E-Core, etc. - - public bool IsHyperThreaded { get; set; } - - public int PhysicalCoreId { get; set; } - - public double Frequency { get; set; } - - public double Temperature { get; set; } - } - - /// - /// Memory usage information. - /// - public class MemoryUsageInfo - { - public long TotalPhysicalMemory { get; set; } - - public long AvailablePhysicalMemory { get; set; } - - public long UsedPhysicalMemory { get; set; } - - public double PhysicalMemoryUsagePercentage { get; set; } - - public long TotalVirtualMemory { get; set; } - - public long AvailableVirtualMemory { get; set; } - - public long UsedVirtualMemory { get; set; } - - public double VirtualMemoryUsagePercentage { get; set; } - - public long PageFileSize { get; set; } - - public long PageFileUsage { get; set; } - - public double PageFileUsagePercentage { get; set; } - } - - /// - /// Process performance information. - /// - public class ProcessPerformanceInfo - { - public int ProcessId { get; set; } - - public string ProcessName { get; set; } = string.Empty; - - public string WindowTitle { get; set; } = string.Empty; - - public double CpuUsage { get; set; } - - public long MemoryUsage { get; set; } - - public long VirtualMemoryUsage { get; set; } - - public int ThreadCount { get; set; } - - public int HandleCount { get; set; } - - public DateTime StartTime { get; set; } - - public TimeSpan RunTime { get; set; } - - public string ExecutablePath { get; set; } = string.Empty; - - public bool IsResponding { get; set; } - - public string Priority { get; set; } = string.Empty; - - public IntPtr ProcessorAffinity { get; set; } - } - - /// - /// Event args for performance metrics updates. - /// - public class PerformanceMetricsUpdatedEventArgs : EventArgs - { - public SystemPerformanceMetrics Metrics { get; } - - public DateTime UpdateTime { get; } - - public PerformanceMetricsUpdatedEventArgs(SystemPerformanceMetrics metrics) - { - this.Metrics = metrics; - this.UpdateTime = DateTime.UtcNow; - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using ThreadPilot.Models; + + public interface IPerformanceMonitoringService + { + Task GetSystemMetricsAsync(bool lightweight = false); + + Task> GetCpuCoreUsageAsync(); + + Task GetMemoryUsageAsync(); + + Task> GetTopCpuProcessesAsync(int count = 10); + + Task> GetTopMemoryProcessesAsync(int count = 10); + + Task StartMonitoringAsync(); + + Task StopMonitoringAsync(); + + event EventHandler? MetricsUpdated; + + Task> GetHistoricalDataAsync(TimeSpan duration); + + Task ClearHistoricalDataAsync(); + } + + public class SystemPerformanceMetrics + { + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + public double TotalCpuUsage { get; set; } + + public long TotalMemoryUsage { get; set; } + + public long AvailableMemory { get; set; } + + public long TotalMemory { get; set; } + + public double MemoryUsagePercentage { get; set; } + + public int Gen0Collections { get; set; } + + public int Gen1Collections { get; set; } + + public int Gen2Collections { get; set; } + + public int Gen0CollectionsDelta { get; set; } + + public int Gen1CollectionsDelta { get; set; } + + public int Gen2CollectionsDelta { get; set; } + + public long TotalAllocatedBytes { get; set; } + + public long AllocatedBytesDelta { get; set; } + + public long ManagedHeapSizeBytes { get; set; } + + public long GcCommittedBytes { get; set; } + + public double LastGcPauseMs { get; set; } + + public double MaxGcPauseMs { get; set; } + + public int HandleCount { get; set; } + + public long ProcessWorkingSetBytes { get; set; } + + public List CpuCoreUsages { get; set; } = new(); + + public ProcessPerformanceInfo? TopCpuProcess { get; set; } + + public ProcessPerformanceInfo? TopMemoryProcess { get; set; } + + public int ActiveProcessCount { get; set; } + + public int ThreadCount { get; set; } + + public double DiskUsage { get; set; } + + public double NetworkUsage { get; set; } + } + + public class CpuCoreUsage + { + public int CoreId { get; set; } + + public string CoreName { get; set; } = string.Empty; + + public double Usage { get; set; } + + public string CoreType { get; set; } = "Unknown"; // P-Core, E-Core, etc. + + public bool IsHyperThreaded { get; set; } + + public int PhysicalCoreId { get; set; } + + public double Frequency { get; set; } + + public double Temperature { get; set; } + } + + public class MemoryUsageInfo + { + public long TotalPhysicalMemory { get; set; } + + public long AvailablePhysicalMemory { get; set; } + + public long UsedPhysicalMemory { get; set; } + + public double PhysicalMemoryUsagePercentage { get; set; } + + public long TotalVirtualMemory { get; set; } + + public long AvailableVirtualMemory { get; set; } + + public long UsedVirtualMemory { get; set; } + + public double VirtualMemoryUsagePercentage { get; set; } + + public long PageFileSize { get; set; } + + public long PageFileUsage { get; set; } + + public double PageFileUsagePercentage { get; set; } + } + + public class ProcessPerformanceInfo + { + public int ProcessId { get; set; } + + public string ProcessName { get; set; } = string.Empty; + + public string WindowTitle { get; set; } = string.Empty; + + public double CpuUsage { get; set; } + + public long MemoryUsage { get; set; } + + public long VirtualMemoryUsage { get; set; } + + public int ThreadCount { get; set; } + + public int HandleCount { get; set; } + + public DateTime StartTime { get; set; } + + public TimeSpan RunTime { get; set; } + + public string ExecutablePath { get; set; } = string.Empty; + + public bool IsResponding { get; set; } + + public string Priority { get; set; } = string.Empty; + + public IntPtr ProcessorAffinity { get; set; } + } + + public class PerformanceMetricsUpdatedEventArgs : EventArgs + { + public SystemPerformanceMetrics Metrics { get; } + + public DateTime UpdateTime { get; } + + public PerformanceMetricsUpdatedEventArgs(SystemPerformanceMetrics metrics) + { + this.Metrics = metrics; + this.UpdateTime = DateTime.UtcNow; + } + } +} + diff --git a/Services/IPersistentProcessRuleStore.cs b/Services/IPersistentProcessRuleStore.cs index 80187a3..713b9c5 100644 --- a/Services/IPersistentProcessRuleStore.cs +++ b/Services/IPersistentProcessRuleStore.cs @@ -1,14 +1,14 @@ -/* - * ThreadPilot - persistent process rule store contract. - */ -namespace ThreadPilot.Services -{ - using ThreadPilot.Models; - - public interface IPersistentProcessRuleStore - { - Task> LoadAsync(); - - Task SaveAsync(IReadOnlyList rules); - } -} +/* + * ThreadPilot - persistent process rule store contract. + */ +namespace ThreadPilot.Services +{ + using ThreadPilot.Models; + + public interface IPersistentProcessRuleStore + { + Task> LoadAsync(); + + Task SaveAsync(IReadOnlyList rules); + } +} diff --git a/Services/IPowerPlanService.cs b/Services/IPowerPlanService.cs index 6f50d0e..186e1e5 100644 --- a/Services/IPowerPlanService.cs +++ b/Services/IPowerPlanService.cs @@ -1,152 +1,55 @@ -/* - * 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.ObjectModel; - using System.Threading.Tasks; - using ThreadPilot.Models; - - public interface IPowerPlanService - { - /// - /// Occurs when the active power plan changes. - /// - event EventHandler? PowerPlanChanged; - - /// - /// Retrieves all power plans currently available on the system. - /// - /// Collection of power plans with active-state metadata. - Task> GetPowerPlansAsync(); - - /// - /// Retrieves custom power plans discovered in the managed plans directory. - /// - /// Collection of importable custom power plans. - Task> GetCustomPowerPlansAsync(); - - /// - /// Sets the active plan using a instance. - /// - /// Power plan to activate. - /// when activation succeeds; otherwise . - Task SetActivePowerPlan(PowerPlanModel powerPlan); - - /// - /// Gets the current active power plan. - /// - /// The active power plan, or when unavailable. - Task GetActivePowerPlan(); - - /// - /// Imports a custom power plan from a .pow file. - /// - /// Absolute path to the source .pow file. - /// when import succeeds; otherwise . - Task ImportCustomPowerPlan(string filePath); - - /// - /// Adds a custom .pow file to the managed custom plans library. - /// - /// Absolute path to the source .pow file. - /// when the file is added successfully; otherwise . - Task AddCustomPowerPlanFileAsync(string filePath); - - /// - /// Deletes a non-active Windows power plan by GUID when Windows permits removal. - /// - /// Power plan GUID to delete. - /// when deletion succeeds; otherwise . - Task DeletePowerPlanAsync(string powerPlanGuid); - - /// - /// Sets the active power plan by GUID with duplicate change prevention. - /// - /// Target power plan GUID. - /// Whether to skip redundant changes when already active. - /// when the operation succeeds; otherwise . - Task SetActivePowerPlanByGuidAsync(string powerPlanGuid, bool preventDuplicateChanges = true); - - /// - /// Gets the currently active power plan GUID. - /// - /// Active plan GUID, or when unavailable. - Task GetActivePowerPlanGuidAsync(); - - /// - /// Checks if a power plan with the given GUID exists. - /// - /// Power plan GUID to check. - /// when the plan exists; otherwise . - Task PowerPlanExistsAsync(string powerPlanGuid); - - /// - /// Gets a power plan by GUID. - /// - /// Power plan GUID. - /// Matching plan when found; otherwise . - Task GetPowerPlanByGuidAsync(string powerPlanGuid); - - /// - /// Validates that a power plan change is necessary. - /// - /// Target plan GUID. - /// when a change should be applied; otherwise . - Task IsPowerPlanChangeNeededAsync(string targetPowerPlanGuid); - } - - /// - /// Event arguments for power plan changes. - /// - public class PowerPlanChangedEventArgs : EventArgs - { - /// - /// Gets the previously active power plan. - /// - public PowerPlanModel? PreviousPowerPlan { get; } - - /// - /// Gets the newly active power plan. - /// - public PowerPlanModel? NewPowerPlan { get; } - - /// - /// Gets the local timestamp when the change was recorded. - /// - public DateTime Timestamp { get; } - - /// - /// Gets the optional reason for the power plan transition. - /// - public string? Reason { get; } - - /// - /// Initializes a new instance of the class. - /// - /// Power plan active before the change. - /// Power plan active after the change. - /// Optional reason describing why the change occurred. - public PowerPlanChangedEventArgs(PowerPlanModel? previousPowerPlan, PowerPlanModel? newPowerPlan, string? reason = null) - { - this.PreviousPowerPlan = previousPowerPlan; - this.NewPowerPlan = newPowerPlan; - this.Timestamp = DateTime.Now; - this.Reason = reason; - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.Collections.ObjectModel; + using System.Threading.Tasks; + using ThreadPilot.Models; + + public interface IPowerPlanService + { + event EventHandler? PowerPlanChanged; + + Task> GetPowerPlansAsync(); + + Task> GetCustomPowerPlansAsync(); + + Task SetActivePowerPlan(PowerPlanModel powerPlan); + + Task GetActivePowerPlan(); + + Task ImportCustomPowerPlan(string filePath); + + Task AddCustomPowerPlanFileAsync(string filePath); + + Task DeletePowerPlanAsync(string powerPlanGuid); + + Task SetActivePowerPlanByGuidAsync(string powerPlanGuid, bool preventDuplicateChanges = true); + + Task GetActivePowerPlanGuidAsync(); + + Task PowerPlanExistsAsync(string powerPlanGuid); + + Task GetPowerPlanByGuidAsync(string powerPlanGuid); + + Task IsPowerPlanChangeNeededAsync(string targetPowerPlanGuid); + } + + public class PowerPlanChangedEventArgs : EventArgs + { + public PowerPlanModel? PreviousPowerPlan { get; } + + public PowerPlanModel? NewPowerPlan { get; } + + public DateTime Timestamp { get; } + + public string? Reason { get; } + + public PowerPlanChangedEventArgs(PowerPlanModel? previousPowerPlan, PowerPlanModel? newPowerPlan, string? reason = null) + { + this.PreviousPowerPlan = previousPowerPlan; + this.NewPowerPlan = newPowerPlan; + this.Timestamp = DateTime.Now; + this.Reason = reason; + } + } +} diff --git a/Services/IProcessMemoryPriorityService.cs b/Services/IProcessMemoryPriorityService.cs index c1920b6..c2e6b34 100644 --- a/Services/IProcessMemoryPriorityService.cs +++ b/Services/IProcessMemoryPriorityService.cs @@ -1,14 +1,14 @@ -/* - * ThreadPilot - process memory priority service contract. - */ -namespace ThreadPilot.Services -{ - using ThreadPilot.Models; - - public interface IProcessMemoryPriorityService - { - Task GetMemoryPriorityAsync(ProcessModel process); - - Task SetMemoryPriorityAsync(ProcessModel process, ProcessMemoryPriority priority); - } -} +/* + * ThreadPilot - process memory priority service contract. + */ +namespace ThreadPilot.Services +{ + using ThreadPilot.Models; + + public interface IProcessMemoryPriorityService + { + Task GetMemoryPriorityAsync(ProcessModel process); + + Task SetMemoryPriorityAsync(ProcessModel process, ProcessMemoryPriority priority); + } +} diff --git a/Services/IProcessMonitorManagerService.cs b/Services/IProcessMonitorManagerService.cs index 2340488..0ff418f 100644 --- a/Services/IProcessMonitorManagerService.cs +++ b/Services/IProcessMonitorManagerService.cs @@ -1,148 +1,87 @@ -/* - * 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.Threading.Tasks; - using ThreadPilot.Models; - - /// - /// Main orchestration service for process monitoring and power plan management. - /// - public interface IProcessMonitorManagerService : IDisposable - { - /// - /// Event fired when a process-triggered power plan change occurs - /// - event EventHandler? ProcessPowerPlanChanged; - - /// - /// Event fired when the service status changes - /// - event EventHandler? ServiceStatusChanged; - - /// - /// Gets a value indicating whether gets whether the service is currently running. - /// - bool IsRunning { get; } - - /// - /// Gets the current service status. - /// - string Status { get; } - - /// - /// Gets currently running associated processes. - /// - IEnumerable RunningAssociatedProcesses { get; } - - /// - /// Starts the process monitoring and power plan management service. - /// - Task StartAsync(); - - /// - /// Stops the service. - /// - Task StopAsync(); - - /// - /// Manually triggers a power plan evaluation for all running processes. - /// - Task EvaluateCurrentProcessesAsync(); - - /// - /// Forces a return to the default power plan. - /// - Task ForceDefaultPowerPlanAsync(); - - /// - /// Gets the current active power plan information. - /// - Task GetCurrentActivePowerPlanAsync(); - - /// - /// Refreshes the configuration from the association service. - /// - Task RefreshConfigurationAsync(); - - /// - /// Updates the service settings (polling intervals, etc.) - /// - void UpdateSettings(); - } - - /// - /// Event arguments for process-triggered power plan changes. - /// - public class ProcessPowerPlanChangeEventArgs : EventArgs - { - public ProcessModel Process { get; } - - public ProcessPowerPlanAssociation Association { get; } - - public PowerPlanModel? PreviousPowerPlan { get; } - - public PowerPlanModel? NewPowerPlan { get; } - - public string Action { get; } // "ProcessStarted", "ProcessStopped", "DefaultRestored" - - public DateTime Timestamp { get; } - - public ProcessPowerPlanChangeEventArgs( - ProcessModel process, - ProcessPowerPlanAssociation association, - PowerPlanModel? previousPowerPlan, - PowerPlanModel? newPowerPlan, - string action) - { - this.Process = process; - this.Association = association; - this.PreviousPowerPlan = previousPowerPlan; - this.NewPowerPlan = newPowerPlan; - this.Action = action; - this.Timestamp = DateTime.Now; - } - } - - /// - /// Event arguments for service status changes. - /// - public class ServiceStatusEventArgs : EventArgs - { - public bool IsRunning { get; } - - public string Status { get; } - - public string? Details { get; } - - public Exception? Error { get; } - - public DateTime Timestamp { get; } - - public ServiceStatusEventArgs(bool isRunning, string status, string? details = null, Exception? error = null) - { - this.IsRunning = isRunning; - this.Status = status; - this.Details = details; - this.Error = error; - this.Timestamp = DateTime.Now; - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using ThreadPilot.Models; + + public interface IProcessMonitorManagerService : IDisposable + { + event EventHandler? ProcessPowerPlanChanged; + + event EventHandler? ServiceStatusChanged; + + bool IsRunning { get; } + + string Status { get; } + + IEnumerable RunningAssociatedProcesses { get; } + + Task StartAsync(); + + Task StopAsync(); + + Task EvaluateCurrentProcessesAsync(); + + Task ForceDefaultPowerPlanAsync(); + + Task GetCurrentActivePowerPlanAsync(); + + Task RefreshConfigurationAsync(); + + void UpdateSettings(); + } + + public class ProcessPowerPlanChangeEventArgs : EventArgs + { + public ProcessModel Process { get; } + + public ProcessPowerPlanAssociation Association { get; } + + public PowerPlanModel? PreviousPowerPlan { get; } + + public PowerPlanModel? NewPowerPlan { get; } + + public string Action { get; } // "ProcessStarted", "ProcessStopped", "DefaultRestored" + + public DateTime Timestamp { get; } + + public ProcessPowerPlanChangeEventArgs( + ProcessModel process, + ProcessPowerPlanAssociation association, + PowerPlanModel? previousPowerPlan, + PowerPlanModel? newPowerPlan, + string action) + { + this.Process = process; + this.Association = association; + this.PreviousPowerPlan = previousPowerPlan; + this.NewPowerPlan = newPowerPlan; + this.Action = action; + this.Timestamp = DateTime.Now; + } + } + + public class ServiceStatusEventArgs : EventArgs + { + public bool IsRunning { get; } + + public string Status { get; } + + public string? Details { get; } + + public Exception? Error { get; } + + public DateTime Timestamp { get; } + + public ServiceStatusEventArgs(bool isRunning, string status, string? details = null, Exception? error = null) + { + this.IsRunning = isRunning; + this.Status = status; + this.Details = details; + this.Error = error; + this.Timestamp = DateTime.Now; + } + } +} + diff --git a/Services/IProcessMonitorService.cs b/Services/IProcessMonitorService.cs index e923058..3d0eff2 100644 --- a/Services/IProcessMonitorService.cs +++ b/Services/IProcessMonitorService.cs @@ -1,125 +1,67 @@ -/* - * 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.Threading.Tasks; - using ThreadPilot.Models; - - /// - /// Interface for process monitoring service that uses WMI events with fallback polling. - /// - public interface IProcessMonitorService : IDisposable - { - /// - /// Event fired when a process starts - /// - event EventHandler? ProcessStarted; - - /// - /// Event fired when a process stops - /// - event EventHandler? ProcessStopped; - - /// - /// Event fired when monitoring status changes - /// - event EventHandler? MonitoringStatusChanged; - - /// - /// Gets a value indicating whether gets whether the service is currently monitoring. - /// - bool IsMonitoring { get; } - - /// - /// Gets a value indicating whether gets whether WMI monitoring is available and working. - /// - bool IsWmiAvailable { get; } - - /// - /// Gets a value indicating whether gets whether fallback polling is currently active. - /// - bool IsFallbackPollingActive { get; } - - /// - /// Starts monitoring processes. - /// - Task StartMonitoringAsync(); - - /// - /// Stops monitoring processes. - /// - Task StopMonitoringAsync(); - - /// - /// Gets all currently running processes. - /// - Task> GetRunningProcessesAsync(); - - /// - /// Checks if a specific process is currently running. - /// - Task IsProcessRunningAsync(string executableName); - - /// - /// Updates the service settings (polling intervals, etc.) - /// - void UpdateSettings(); - } - - /// - /// Event arguments for process events. - /// - public class ProcessEventArgs : EventArgs - { - public ProcessModel Process { get; } - - public DateTime Timestamp { get; } - - public ProcessEventArgs(ProcessModel process) - { - this.Process = process; - this.Timestamp = DateTime.Now; - } - } - - /// - /// Event arguments for monitoring status changes. - /// - public class MonitoringStatusEventArgs : EventArgs - { - public bool IsMonitoring { get; } - - public bool IsWmiAvailable { get; } - - public bool IsFallbackPollingActive { get; } - - public string? StatusMessage { get; } - - public Exception? Error { get; } - - public MonitoringStatusEventArgs(bool isMonitoring, bool isWmiAvailable, bool isFallbackPollingActive, string? statusMessage = null, Exception? error = null) - { - this.IsMonitoring = isMonitoring; - this.IsWmiAvailable = isWmiAvailable; - this.IsFallbackPollingActive = isFallbackPollingActive; - this.StatusMessage = statusMessage; - this.Error = error; - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Threading.Tasks; + using ThreadPilot.Models; + + public interface IProcessMonitorService : IDisposable + { + event EventHandler? ProcessStarted; + + event EventHandler? ProcessStopped; + + event EventHandler? MonitoringStatusChanged; + + bool IsMonitoring { get; } + + bool IsWmiAvailable { get; } + + bool IsFallbackPollingActive { get; } + + Task StartMonitoringAsync(); + + Task StopMonitoringAsync(); + + Task> GetRunningProcessesAsync(); + + Task IsProcessRunningAsync(string executableName); + + void UpdateSettings(); + } + + public class ProcessEventArgs : EventArgs + { + public ProcessModel Process { get; } + + public DateTime Timestamp { get; } + + public ProcessEventArgs(ProcessModel process) + { + this.Process = process; + this.Timestamp = DateTime.Now; + } + } + + public class MonitoringStatusEventArgs : EventArgs + { + public bool IsMonitoring { get; } + + public bool IsWmiAvailable { get; } + + public bool IsFallbackPollingActive { get; } + + public string? StatusMessage { get; } + + public Exception? Error { get; } + + public MonitoringStatusEventArgs(bool isMonitoring, bool isWmiAvailable, bool isFallbackPollingActive, string? statusMessage = null, Exception? error = null) + { + this.IsMonitoring = isMonitoring; + this.IsWmiAvailable = isWmiAvailable; + this.IsFallbackPollingActive = isFallbackPollingActive; + this.StatusMessage = statusMessage; + this.Error = error; + } + } +} + diff --git a/Services/IProcessPowerPlanAssociationService.cs b/Services/IProcessPowerPlanAssociationService.cs index e96693f..4e2ec65 100644 --- a/Services/IProcessPowerPlanAssociationService.cs +++ b/Services/IProcessPowerPlanAssociationService.cs @@ -1,139 +1,63 @@ -/* - * 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.Threading.Tasks; - using ThreadPilot.Models; - - /// - /// Interface for managing process-power plan associations. - /// - public interface IProcessPowerPlanAssociationService - { - /// - /// Event fired when configuration changes - /// - event EventHandler? ConfigurationChanged; - - /// - /// Gets the current configuration. - /// - ProcessMonitorConfiguration Configuration { get; } - - /// - /// Loads configuration from persistent storage. - /// - Task LoadConfigurationAsync(); - - /// - /// Saves configuration to persistent storage. - /// - Task SaveConfigurationAsync(); - - /// - /// Gets all associations. - /// - Task> GetAssociationsAsync(); - - /// - /// Gets enabled associations only. - /// - Task> GetEnabledAssociationsAsync(); - - /// - /// Adds a new association. - /// - Task AddAssociationAsync(ProcessPowerPlanAssociation association); - - /// - /// Updates an existing association. - /// - Task UpdateAssociationAsync(ProcessPowerPlanAssociation association); - - /// - /// Removes an association. - /// - Task RemoveAssociationAsync(string associationId); - - /// - /// Finds the best matching association for a process. - /// - Task FindMatchingAssociationAsync(ProcessModel process); - - /// - /// Finds association by executable name. - /// - Task FindAssociationByExecutableAsync(string executableName); - - /// - /// Sets the default power plan. - /// - Task SetDefaultPowerPlanAsync(string powerPlanGuid, string powerPlanName); - - /// - /// Gets the default power plan. - /// - Task<(string Guid, string Name)> GetDefaultPowerPlanAsync(); - - /// - /// Validates the current configuration. - /// - Task> ValidateConfigurationAsync(); - - /// - /// Resets configuration to defaults. - /// - Task ResetConfigurationAsync(); - - /// - /// Exports configuration to a file. - /// - Task ExportConfigurationAsync(string filePath); - - /// - /// Imports configuration from a file. - /// - Task ImportConfigurationAsync(string filePath); - - /// - /// Replaces the current configuration and persists it. - /// - Task ReplaceConfigurationAsync(ProcessMonitorConfiguration configuration); - } - - /// - /// Event arguments for configuration changes. - /// - public class ConfigurationChangedEventArgs : EventArgs - { - public string ChangeType { get; } - - public ProcessPowerPlanAssociation? Association { get; } - - public string? Details { get; } - - public ConfigurationChangedEventArgs(string changeType, ProcessPowerPlanAssociation? association = null, string? details = null) - { - this.ChangeType = changeType; - this.Association = association; - this.Details = details; - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using ThreadPilot.Models; + + public interface IProcessPowerPlanAssociationService + { + event EventHandler? ConfigurationChanged; + + ProcessMonitorConfiguration Configuration { get; } + + Task LoadConfigurationAsync(); + + Task SaveConfigurationAsync(); + + Task> GetAssociationsAsync(); + + Task> GetEnabledAssociationsAsync(); + + Task AddAssociationAsync(ProcessPowerPlanAssociation association); + + Task UpdateAssociationAsync(ProcessPowerPlanAssociation association); + + Task RemoveAssociationAsync(string associationId); + + Task FindMatchingAssociationAsync(ProcessModel process); + + Task FindAssociationByExecutableAsync(string executableName); + + Task SetDefaultPowerPlanAsync(string powerPlanGuid, string powerPlanName); + + Task<(string Guid, string Name)> GetDefaultPowerPlanAsync(); + + Task> ValidateConfigurationAsync(); + + Task ResetConfigurationAsync(); + + Task ExportConfigurationAsync(string filePath); + + Task ImportConfigurationAsync(string filePath); + + Task ReplaceConfigurationAsync(ProcessMonitorConfiguration configuration); + } + + public class ConfigurationChangedEventArgs : EventArgs + { + public string ChangeType { get; } + + public ProcessPowerPlanAssociation? Association { get; } + + public string? Details { get; } + + public ConfigurationChangedEventArgs(string changeType, ProcessPowerPlanAssociation? association = null, string? details = null) + { + this.ChangeType = changeType; + this.Association = association; + this.Details = details; + } + } +} + diff --git a/Services/IProcessService.cs b/Services/IProcessService.cs index 78baf5e..3c95f9b 100644 --- a/Services/IProcessService.cs +++ b/Services/IProcessService.cs @@ -1,128 +1,59 @@ -/* - * 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.Collections.Generic; - using System.Collections.ObjectModel; - using System.Diagnostics; - using System.Threading.Tasks; - using ThreadPilot.Models; - - public interface IProcessService - { - Task> GetProcessesAsync(); - - Task SetProcessorAffinity(ProcessModel process, long affinityMask); - - Task SetProcessorAffinity(ProcessModel process, CpuSelection selection); - - Task SetProcessPriority(ProcessModel process, ProcessPriorityClass priority); - - Task SaveProcessProfile(string profileName, ProcessModel process); - - Task LoadProcessProfile(string profileName, ProcessModel process); - - Task RefreshProcessInfo(ProcessModel process); - - /// - /// Gets a process by its ID. - /// - Task GetProcessByIdAsync(int processId); - - /// - /// Gets processes by executable name. - /// - Task> GetProcessesByNameAsync(string executableName); - - /// - /// Checks if a process with the given name is currently running. - /// - Task IsProcessRunningAsync(string executableName); - - /// - /// Gets all running processes with their executable paths. - /// - Task> GetProcessesWithPathsAsync(); - - /// - /// Gets only active applications with visible windows (user-facing applications). - /// - Task> GetActiveApplicationsAsync(); - - /// - /// Creates a ProcessModel from a System.Diagnostics.Process. - /// - ProcessModel CreateProcessModel(Process process); - - /// - /// Checks if a specific process is still running. - /// - Task IsProcessStillRunning(ProcessModel process); - - /// - /// Sets the idle server state for a process (disables/enables idle functionality). - /// - Task SetIdleServerStateAsync(ProcessModel process, bool enableIdleServer); - - /// - /// Sets registry-based priority enforcement for a process. - /// - Task SetRegistryPriorityAsync(ProcessModel process, bool enable, ProcessPriorityClass priority); - - /// - /// Enables or disables the use of Windows CPU Sets for affinity management. - /// - void SetUseCpuSets(bool useCpuSets); - - /// - /// Gets whether CPU Sets are currently enabled for affinity management. - /// - bool GetUseCpuSets(); - - /// - /// Clears the CPU Set for a process (allows it to run on all cores). - /// - Task ClearProcessCpuSetAsync(ProcessModel process); - - /// - /// Clears all applied CPU masks/affinities from all tracked processes - /// Processes return to using all cores (used on application exit). - /// - Task ClearAllAppliedMasksAsync(); - - /// - /// Resets all modified process priorities to Normal - /// (used on application exit). - /// - Task ResetAllProcessPrioritiesAsync(); - - /// - /// Registers that a mask has been applied to a process (for tracking). - /// - void TrackAppliedMask(int processId, string maskId); - - /// - /// Registers that a priority has been changed for a process (for tracking). - /// - void TrackPriorityChange(int processId, ProcessPriorityClass originalPriority); - - /// - /// Unregisters tracking when a process exits. - /// - void UntrackProcess(int processId); - } -} +namespace ThreadPilot.Services +{ + using System.Collections.Generic; + using System.Collections.ObjectModel; + using System.Diagnostics; + using System.Threading.Tasks; + using ThreadPilot.Models; + + public interface IProcessService + { + Task> GetProcessesAsync(); + + Task SetProcessorAffinity(ProcessModel process, long affinityMask); + + Task SetProcessorAffinity(ProcessModel process, CpuSelection selection); + + Task SetProcessPriority(ProcessModel process, ProcessPriorityClass priority); + + Task SaveProcessProfile(string profileName, ProcessModel process); + + Task LoadProcessProfile(string profileName, ProcessModel process); + + Task RefreshProcessInfo(ProcessModel process); + + Task GetProcessByIdAsync(int processId); + + Task> GetProcessesByNameAsync(string executableName); + + Task IsProcessRunningAsync(string executableName); + + Task> GetProcessesWithPathsAsync(); + + Task> GetActiveApplicationsAsync(); + + ProcessModel CreateProcessModel(Process process); + + Task IsProcessStillRunning(ProcessModel process); + + Task SetIdleServerStateAsync(ProcessModel process, bool enableIdleServer); + + Task SetRegistryPriorityAsync(ProcessModel process, bool enable, ProcessPriorityClass priority); + + void SetUseCpuSets(bool useCpuSets); + + bool GetUseCpuSets(); + + Task ClearProcessCpuSetAsync(ProcessModel process); + + Task ClearAllAppliedMasksAsync(); + + Task ResetAllProcessPrioritiesAsync(); + + void TrackAppliedMask(int processId, string maskId); + + void TrackPriorityChange(int processId, ProcessPriorityClass originalPriority); + + void UntrackProcess(int processId); + } +} diff --git a/Services/IRetryPolicyService.cs b/Services/IRetryPolicyService.cs deleted file mode 100644 index c7feca4..0000000 --- a/Services/IRetryPolicyService.cs +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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.Threading.Tasks; - - /// - /// Retry policy configuration. - /// - public class RetryPolicy - { - public int MaxAttempts { get; set; } = 3; - - public TimeSpan InitialDelay { get; set; } = TimeSpan.FromMilliseconds(100); - - public TimeSpan MaxDelay { get; set; } = TimeSpan.FromSeconds(5); - - public double BackoffMultiplier { get; set; } = 2.0; - - public Func? ShouldRetry { get; set; } - } - - /// - /// Service for implementing retry policies with exponential backoff. - /// - public interface IRetryPolicyService - { - /// - /// Execute an operation with retry policy. - /// - Task ExecuteAsync(Func> operation, RetryPolicy? policy = null); - - /// - /// Execute an operation with retry policy (no return value). - /// - Task ExecuteAsync(Func operation, RetryPolicy? policy = null); - - /// - /// Create a default retry policy for process operations. - /// - RetryPolicy CreateProcessOperationPolicy(); - - /// - /// Create a default retry policy for WMI operations. - /// - RetryPolicy CreateWmiOperationPolicy(); - - /// - /// Create a default retry policy for file operations. - /// - RetryPolicy CreateFileOperationPolicy(); - } -} - diff --git a/Services/ISecurityService.cs b/Services/ISecurityService.cs index abd66c7..9118e1a 100644 --- a/Services/ISecurityService.cs +++ b/Services/ISecurityService.cs @@ -1,71 +1,20 @@ -/* - * 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.Diagnostics; - - /// - /// Service for security validation and auditing of elevated operations. - /// - public interface ISecurityService - { - /// - /// Validates that the specified operation is allowed to be performed with elevated privileges. - /// - /// The operation to validate. - /// True if the operation is allowed, false otherwise. - bool ValidateElevatedOperation(string operation); - - /// - /// Audits an elevated action for security logging. - /// - /// The action that was performed. - /// The target of the action (process name, power plan, etc.) - /// Whether the action was successful. - Task AuditElevatedAction(string action, string target, bool success); - - /// - /// Validates that a process operation is safe to perform. - /// - /// The name of the process. - /// The operation to perform. - /// True if the operation is safe, false otherwise. - bool ValidateProcessOperation(string processName, string operation); - - /// - /// Validates that a power plan operation is safe to perform. - /// - /// The power plan GUID. - /// The operation to perform. - /// True if the operation is safe, false otherwise. - bool ValidatePowerPlanOperation(string powerPlanId, string operation); - - /// - /// Gets the list of allowed elevated operations. - /// - /// Array of allowed operation names. - string[] GetAllowedElevatedOperations(); - - /// - /// Determines whether a process is protected and should be excluded from optimization operations. - /// - /// Target process. - /// True if process is protected; otherwise false. - bool IsProtected(Process process); - } -} - +namespace ThreadPilot.Services +{ + using System.Diagnostics; + + public interface ISecurityService + { + bool ValidateElevatedOperation(string operation); + + Task AuditElevatedAction(string action, string target, bool success); + + bool ValidateProcessOperation(string processName, string operation); + + bool ValidatePowerPlanOperation(string powerPlanId, string operation); + + string[] GetAllowedElevatedOperations(); + + bool IsProtected(Process process); + } +} + diff --git a/Services/ISelfResourceManagementService.cs b/Services/ISelfResourceManagementService.cs index dc7e7f6..81ed518 100644 --- a/Services/ISelfResourceManagementService.cs +++ b/Services/ISelfResourceManagementService.cs @@ -1,25 +1,9 @@ -/* - * 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 interface ISelfResourceManagementService - { - void ApplyLowImpactMode(bool limitAffinity); - - void RestoreForegroundMode(); - } -} +namespace ThreadPilot.Services +{ + public interface ISelfResourceManagementService + { + void ApplyLowImpactMode(bool limitAffinity); + + void RestoreForegroundMode(); + } +} diff --git a/Services/IServiceDisposalCoordinator.cs b/Services/IServiceDisposalCoordinator.cs deleted file mode 100644 index 0474721..0000000 --- a/Services/IServiceDisposalCoordinator.cs +++ /dev/null @@ -1,53 +0,0 @@ -/* - * 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.Threading.Tasks; - - /// - /// Interface for coordinating proper disposal of services. - /// - public interface IServiceDisposalCoordinator : IDisposable - { - /// - /// Register a service for coordinated disposal. - /// - void RegisterService(string serviceName, IDisposable service, int priority = 0); - - /// - /// Register an async disposable service. - /// - void RegisterAsyncService(string serviceName, IAsyncDisposable service, int priority = 0); - - /// - /// Register a custom disposal action. - /// - void RegisterDisposalAction(string actionName, Func disposalAction, int priority = 0); - - /// - /// Dispose all registered services in priority order. - /// - Task DisposeAllAsync(); - - /// - /// Gets a value indicating whether get disposal status. - /// - bool IsDisposed { get; } - } -} - diff --git a/Services/IServiceHealthMonitor.cs b/Services/IServiceHealthMonitor.cs deleted file mode 100644 index a3077a9..0000000 --- a/Services/IServiceHealthMonitor.cs +++ /dev/null @@ -1,104 +0,0 @@ -/* - * 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.Threading.Tasks; - - /// - /// Service health status enumeration. - /// - public enum ServiceHealthStatus - { - Healthy, - Degraded, - Unhealthy, - Critical, - } - - /// - /// Service health check result. - /// - public class ServiceHealthResult - { - public string ServiceName { get; set; } = string.Empty; - - public ServiceHealthStatus Status { get; set; } - - public string Description { get; set; } = string.Empty; - - public TimeSpan ResponseTime { get; set; } - - public DateTime CheckTime { get; set; } - - public Exception? Exception { get; set; } - - public Dictionary Data { get; set; } = new(); - } - - /// - /// Interface for monitoring service health and lifecycle. - /// - public interface IServiceHealthMonitor - { - /// - /// Register a service for health monitoring. - /// - void RegisterService(string serviceName, Func> healthCheck); - - /// - /// Unregister a service from health monitoring. - /// - void UnregisterService(string serviceName); - - /// - /// Perform health check on a specific service. - /// - Task CheckServiceHealthAsync(string serviceName); - - /// - /// Perform health check on all registered services. - /// - Task> CheckAllServicesHealthAsync(); - - /// - /// Get the current health status of all services. - /// - Dictionary GetCurrentHealthStatus(); - - /// - /// Event raised when a service health status changes - /// - event EventHandler? ServiceHealthChanged; - } - - /// - /// Event arguments for service health changes. - /// - public class ServiceHealthChangedEventArgs : EventArgs - { - public string ServiceName { get; set; } = string.Empty; - - public ServiceHealthStatus PreviousStatus { get; set; } - - public ServiceHealthStatus CurrentStatus { get; set; } - - public ServiceHealthResult HealthResult { get; set; } = new(); - } -} - diff --git a/Services/ISmartNotificationService.cs b/Services/ISmartNotificationService.cs index cf90272..656565c 100644 --- a/Services/ISmartNotificationService.cs +++ b/Services/ISmartNotificationService.cs @@ -1,225 +1,137 @@ -/* - * 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.Threading.Tasks; - using ThreadPilot.Models; - - /// - /// Notification categories for throttling. - /// - public enum NotificationCategory - { - System, - Process, - Performance, - PowerPlan, - Error, - Warning, - Information, - UserAction, - } - - /// - /// Smart notification with metadata. - /// - public class SmartNotification - { - public string Id { get; set; } = Guid.NewGuid().ToString(); - - public string Title { get; set; } = string.Empty; - - public string Message { get; set; } = string.Empty; - - public NotificationPriority Priority { get; set; } = NotificationPriority.Normal; - - public NotificationCategory Category { get; set; } = NotificationCategory.Information; - - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - public DateTime? ScheduledFor { get; set; } - - public TimeSpan? ExpiresAfter { get; set; } - - public Dictionary Metadata { get; set; } = new(); - - public string DeduplicationKey { get; set; } = string.Empty; - - public bool IsPersistent { get; set; } = false; - - public int RetryCount { get; set; } = 0; - - public int MaxRetries { get; set; } = 3; - } - - /// - /// Notification throttling configuration. - /// - public class NotificationThrottleConfig - { - public NotificationCategory Category { get; set; } - - public TimeSpan MinInterval { get; set; } = TimeSpan.FromSeconds(30); - - public int MaxPerHour { get; set; } = 10; - - public int MaxPerDay { get; set; } = 50; - - public bool EnableDeduplication { get; set; } = true; - - public TimeSpan DeduplicationWindow { get; set; } = TimeSpan.FromMinutes(5); - } - - /// - /// User notification preferences. - /// - public class NotificationPreferences - { - public bool IsEnabled { get; set; } = true; - - public bool DoNotDisturbMode { get; set; } = false; - - public TimeSpan DoNotDisturbStart { get; set; } = TimeSpan.FromHours(22); // 10 PM - - public TimeSpan DoNotDisturbEnd { get; set; } = TimeSpan.FromHours(8); // 8 AM - - public NotificationPriority MinimumPriority { get; set; } = NotificationPriority.Normal; - - public Dictionary CategoryEnabled { get; set; } = new(); - - public Dictionary ThrottleConfigs { get; set; } = new(); - - public bool ShowOnlyWhenMinimized { get; set; } = false; - - public bool PlaySounds { get; set; } = true; - - public int DefaultDisplayDuration { get; set; } = 5000; // milliseconds - } - - /// - /// Event arguments for smart notification events. - /// - public class SmartNotificationEventArgs : EventArgs - { - public SmartNotification Notification { get; set; } = new(); - - public string Reason { get; set; } = string.Empty; - - public DateTime Timestamp { get; set; } = DateTime.UtcNow; - } - - /// - /// Smart notification service with throttling and priority queuing. - /// - public interface ISmartNotificationService - { - /// - /// Initialize the smart notification service. - /// - Task InitializeAsync(); - - /// - /// Send a smart notification. - /// - Task SendNotificationAsync(SmartNotification notification); - - /// - /// Send a simple notification. - /// - Task SendNotificationAsync(string title, string message, - NotificationPriority priority = NotificationPriority.Normal, - NotificationCategory category = NotificationCategory.Information); - - /// - /// Schedule a notification for later delivery. - /// - Task ScheduleNotificationAsync(SmartNotification notification, DateTime deliveryTime); - - /// - /// Cancel a scheduled notification. - /// - Task CancelNotificationAsync(string notificationId); - - /// - /// Get pending notifications. - /// - Task> GetPendingNotificationsAsync(); - - /// - /// Get notification history. - /// - Task> GetNotificationHistoryAsync(TimeSpan? period = null); - - /// - /// Clear notification history. - /// - Task ClearHistoryAsync(); - - /// - /// Update user preferences. - /// - Task UpdatePreferencesAsync(NotificationPreferences preferences); - - /// - /// Get current user preferences. - /// - Task GetPreferencesAsync(); - - /// - /// Enable/disable Do Not Disturb mode. - /// - Task SetDoNotDisturbAsync(bool enabled, TimeSpan? duration = null); - - /// - /// Check if Do Not Disturb is currently active. - /// - bool IsDoNotDisturbActive(); - - /// - /// Get notification statistics. - /// - Task> GetStatisticsAsync(); - - /// - /// Test notification delivery. - /// - Task TestNotificationAsync(); - - /// - /// Event raised when a notification is sent - /// - event EventHandler? NotificationSent; - - /// - /// Event raised when a notification is throttled - /// - event EventHandler? NotificationThrottled; - - /// - /// Event raised when a notification is deduplicated - /// - event EventHandler? NotificationDeduplicated; - - /// - /// Event raised when Do Not Disturb mode changes - /// - event EventHandler? DoNotDisturbChanged; - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using ThreadPilot.Models; + + public enum NotificationCategory + { + System, + Process, + Performance, + PowerPlan, + Error, + Warning, + Information, + UserAction, + } + + public class SmartNotification + { + public string Id { get; set; } = Guid.NewGuid().ToString(); + + public string Title { get; set; } = string.Empty; + + public string Message { get; set; } = string.Empty; + + public NotificationPriority Priority { get; set; } = NotificationPriority.Normal; + + public NotificationCategory Category { get; set; } = NotificationCategory.Information; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? ScheduledFor { get; set; } + + public TimeSpan? ExpiresAfter { get; set; } + + public Dictionary Metadata { get; set; } = new(); + + public string DeduplicationKey { get; set; } = string.Empty; + + public bool IsPersistent { get; set; } = false; + + public int RetryCount { get; set; } = 0; + + public int MaxRetries { get; set; } = 3; + } + + public class NotificationThrottleConfig + { + public NotificationCategory Category { get; set; } + + public TimeSpan MinInterval { get; set; } = TimeSpan.FromSeconds(30); + + public int MaxPerHour { get; set; } = 10; + + public int MaxPerDay { get; set; } = 50; + + public bool EnableDeduplication { get; set; } = true; + + public TimeSpan DeduplicationWindow { get; set; } = TimeSpan.FromMinutes(5); + } + + public class NotificationPreferences + { + public bool IsEnabled { get; set; } = true; + + public bool DoNotDisturbMode { get; set; } = false; + + public TimeSpan DoNotDisturbStart { get; set; } = TimeSpan.FromHours(22); // 10 PM + + public TimeSpan DoNotDisturbEnd { get; set; } = TimeSpan.FromHours(8); // 8 AM + + public NotificationPriority MinimumPriority { get; set; } = NotificationPriority.Normal; + + public Dictionary CategoryEnabled { get; set; } = new(); + + public Dictionary ThrottleConfigs { get; set; } = new(); + + public bool ShowOnlyWhenMinimized { get; set; } = false; + + public bool PlaySounds { get; set; } = true; + + public int DefaultDisplayDuration { get; set; } = 5000; // milliseconds + } + + public class SmartNotificationEventArgs : EventArgs + { + public SmartNotification Notification { get; set; } = new(); + + public string Reason { get; set; } = string.Empty; + + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + } + + public interface ISmartNotificationService + { + Task InitializeAsync(); + + Task SendNotificationAsync(SmartNotification notification); + + Task SendNotificationAsync(string title, string message, + NotificationPriority priority = NotificationPriority.Normal, + NotificationCategory category = NotificationCategory.Information); + + Task ScheduleNotificationAsync(SmartNotification notification, DateTime deliveryTime); + + Task CancelNotificationAsync(string notificationId); + + Task> GetPendingNotificationsAsync(); + + Task> GetNotificationHistoryAsync(TimeSpan? period = null); + + Task ClearHistoryAsync(); + + Task UpdatePreferencesAsync(NotificationPreferences preferences); + + Task GetPreferencesAsync(); + + Task SetDoNotDisturbAsync(bool enabled, TimeSpan? duration = null); + + bool IsDoNotDisturbActive(); + + Task> GetStatisticsAsync(); + + Task TestNotificationAsync(); + + event EventHandler? NotificationSent; + + event EventHandler? NotificationThrottled; + + event EventHandler? NotificationDeduplicated; + + event EventHandler? DoNotDisturbChanged; + } +} + diff --git a/Services/ISystemTrayService.cs b/Services/ISystemTrayService.cs index aee8195..590e618 100644 --- a/Services/ISystemTrayService.cs +++ b/Services/ISystemTrayService.cs @@ -1,202 +1,99 @@ -/* - * 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.Threading.Tasks; - using ThreadPilot.Models; - - /// - /// Service for managing system tray functionality. - /// - public interface ISystemTrayService : IDisposable - { - /// - /// Event fired when quick apply is requested from tray - /// - event EventHandler? QuickApplyRequested; - - /// - /// Event fired when show main window is requested from tray - /// - event EventHandler? ShowMainWindowRequested; - - /// - /// Event fired when exit is requested from tray - /// - event EventHandler? ExitRequested; - - /// - /// Event fired when monitoring enable/disable is requested from tray - /// - event EventHandler? MonitoringToggleRequested; - - /// - /// Event fired when settings are requested from tray - /// - event EventHandler? SettingsRequested; - - /// - /// Event fired when power plan change is requested from tray - /// - event EventHandler? PowerPlanChangeRequested; - - /// - /// Event fired when profile application is requested from tray - /// - event EventHandler? ProfileApplicationRequested; - - /// - /// Event fired when performance dashboard is requested from tray - /// - event EventHandler? PerformanceDashboardRequested; - - /// - /// Event fired when process management dashboard is requested from tray - /// - event EventHandler? DashboardRequested; - - /// - /// Initializes the system tray icon. - /// - void Initialize(); - - /// - /// Shows the system tray icon. - /// - void Show(); - - /// - /// Hides the system tray icon. - /// - void Hide(); - - /// - /// Updates the tray icon tooltip. - /// - void UpdateTooltip(string tooltip); - - /// - /// Shows a balloon tip notification. - /// - void ShowBalloonTip(string title, string text, int timeoutMs = 3000); - - /// - /// Updates the context menu with current process information. - /// - void UpdateContextMenu(string? selectedProcessName = null, bool hasSelection = false); - - /// - /// Updates the monitoring status in the context menu. - /// - void UpdateMonitoringStatus(bool isMonitoring, bool isWmiAvailable = true); - - /// - /// Updates the tray icon based on application state. - /// - void UpdateTrayIcon(TrayIconState state); - - /// - /// Shows a notification through the tray icon. - /// - void ShowTrayNotification(string title, string message, NotificationType type = NotificationType.Information, int timeoutMs = 3000); - - /// - /// Updates settings for the tray service. - /// - void UpdateSettings(ApplicationSettingsModel settings); - - /// - /// Applies theme to the tray context menu. - /// - void ApplyTheme(bool useDarkTheme); - - /// - /// Updates the available power plans in the context menu. - /// - void UpdatePowerPlans(IEnumerable powerPlans, PowerPlanModel? activePlan); - - /// - /// Updates the available profiles in the context menu. - /// - void UpdateProfiles(IEnumerable profileNames); - - /// - /// Updates the current system status in the tray. - /// - void UpdateSystemStatus(string currentPowerPlan); - - /// - /// Updates the current system status in the tray with diagnostics metrics. - /// - void UpdateSystemStatus(string currentPowerPlan, double cpuUsage, double memoryUsage); - } - - /// - /// Event args for monitoring toggle events. - /// - public class MonitoringToggleEventArgs : EventArgs - { - public bool EnableMonitoring { get; } - - public MonitoringToggleEventArgs(bool enableMonitoring) - { - this.EnableMonitoring = enableMonitoring; - } - } - - /// - /// Event args for power plan change requests. - /// - public class PowerPlanChangeRequestedEventArgs : EventArgs - { - public string PowerPlanGuid { get; } - - public string PowerPlanName { get; } - - public PowerPlanChangeRequestedEventArgs(string powerPlanGuid, string powerPlanName) - { - this.PowerPlanGuid = powerPlanGuid; - this.PowerPlanName = powerPlanName; - } - } - - /// - /// Event args for profile application requests. - /// - public class ProfileApplicationRequestedEventArgs : EventArgs - { - public string ProfileName { get; } - - public ProfileApplicationRequestedEventArgs(string profileName) - { - this.ProfileName = profileName; - } - } - - /// - /// Tray icon states. - /// - public enum TrayIconState - { - Normal, - Monitoring, - Error, - Disabled, - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Threading.Tasks; + using ThreadPilot.Models; + + public interface ISystemTrayService : IDisposable + { + event EventHandler? QuickApplyRequested; + + event EventHandler? ShowMainWindowRequested; + + event EventHandler? ExitRequested; + + event EventHandler? MonitoringToggleRequested; + + event EventHandler? SettingsRequested; + + event EventHandler? PowerPlanChangeRequested; + + event EventHandler? ProfileApplicationRequested; + + event EventHandler? PerformanceDashboardRequested; + + event EventHandler? DashboardRequested; + + void Initialize(); + + void Show(); + + void Hide(); + + void UpdateTooltip(string tooltip); + + void ShowBalloonTip(string title, string text, int timeoutMs = 3000); + + void UpdateContextMenu(string? selectedProcessName = null, bool hasSelection = false); + + void UpdateMonitoringStatus(bool isMonitoring, bool isWmiAvailable = true); + + void UpdateTrayIcon(TrayIconState state); + + void ShowTrayNotification(string title, string message, NotificationType type = NotificationType.Information, int timeoutMs = 3000); + + void UpdateSettings(ApplicationSettingsModel settings); + + void ApplyTheme(bool useDarkTheme); + + void UpdatePowerPlans(IEnumerable powerPlans, PowerPlanModel? activePlan); + + void UpdateProfiles(IEnumerable profileNames); + + void UpdateSystemStatus(string currentPowerPlan); + + void UpdateSystemStatus(string currentPowerPlan, double cpuUsage, double memoryUsage); + } + + public class MonitoringToggleEventArgs : EventArgs + { + public bool EnableMonitoring { get; } + + public MonitoringToggleEventArgs(bool enableMonitoring) + { + this.EnableMonitoring = enableMonitoring; + } + } + + public class PowerPlanChangeRequestedEventArgs : EventArgs + { + public string PowerPlanGuid { get; } + + public string PowerPlanName { get; } + + public PowerPlanChangeRequestedEventArgs(string powerPlanGuid, string powerPlanName) + { + this.PowerPlanGuid = powerPlanGuid; + this.PowerPlanName = powerPlanName; + } + } + + public class ProfileApplicationRequestedEventArgs : EventArgs + { + public string ProfileName { get; } + + public ProfileApplicationRequestedEventArgs(string profileName) + { + this.ProfileName = profileName; + } + } + + public enum TrayIconState + { + Normal, + Monitoring, + Error, + Disabled, + } +} + diff --git a/Services/ISystemTweaksService.cs b/Services/ISystemTweaksService.cs index 9c3c082..2a8c6d3 100644 --- a/Services/ISystemTweaksService.cs +++ b/Services/ISystemTweaksService.cs @@ -1,166 +1,84 @@ -/* - * 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.Threading.Tasks; - - /// - /// Service for managing Windows system tweaks and optimizations. - /// - public interface ISystemTweaksService - { - /// - /// Event raised when a tweak status changes - /// - event EventHandler? TweakStatusChanged; - - /// - /// Get the current status of Core Parking. - /// - Task GetCoreParkingStatusAsync(); - - /// - /// Enable or disable Core Parking. - /// - Task SetCoreParkingAsync(bool enabled); - - /// - /// Get the current status of C-States. - /// - Task GetCStatesStatusAsync(); - - /// - /// Enable or disable C-States. - /// - Task SetCStatesAsync(bool enabled); - - /// - /// Get the current status of SysMain service. - /// - Task GetSysMainStatusAsync(); - - /// - /// Enable or disable SysMain service. - /// - Task SetSysMainAsync(bool enabled); - - /// - /// Get the current status of Prefetch feature. - /// - Task GetPrefetchStatusAsync(); - - /// - /// Enable or disable Prefetch feature. - /// - Task SetPrefetchAsync(bool enabled); - - /// - /// Get the current status of Power Throttling. - /// - Task GetPowerThrottlingStatusAsync(); - - /// - /// Enable or disable Power Throttling. - /// - Task SetPowerThrottlingAsync(bool enabled); - - /// - /// Get the current status of HPET (High Precision Event Timer). - /// - Task GetHpetStatusAsync(); - - /// - /// Enable or disable HPET. - /// - Task SetHpetAsync(bool enabled); - - /// - /// Get the current status of High Scheduling Category for gaming. - /// - Task GetHighSchedulingCategoryStatusAsync(); - - /// - /// Enable or disable High Scheduling Category for gaming. - /// - Task SetHighSchedulingCategoryAsync(bool enabled); - - /// - /// Get the current status of Menu Show Delay. - /// - Task GetMenuShowDelayStatusAsync(); - - /// - /// Enable or disable Menu Show Delay. - /// - Task SetMenuShowDelayAsync(bool enabled); - - /// - /// Refresh all tweak statuses. - /// - Task RefreshAllStatusesAsync(); - } - - /// - /// Represents the status of a system tweak. - /// - public class TweakStatus - { - public bool IsEnabled { get; set; } - - public bool IsAvailable { get; set; } - - public string? ErrorMessage { get; set; } - - public string? Description { get; set; } - } - - /// - /// Event args for tweak status changes. - /// - public class TweakStatusChangedEventArgs : EventArgs - { - public string TweakName { get; } - - public TweakStatus Status { get; } - - public DateTime ChangeTime { get; } - - public TweakStatusChangedEventArgs(string tweakName, TweakStatus status) - { - this.TweakName = tweakName; - this.Status = status; - this.ChangeTime = DateTime.UtcNow; - } - } - - /// - /// Enumeration of available system tweaks. - /// - public enum SystemTweak - { - CoreParking, - CStates, - SysMain, - Prefetch, - PowerThrottling, - Hpet, - HighSchedulingCategory, - MenuShowDelay, - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Threading.Tasks; + + public interface ISystemTweaksService + { + event EventHandler? TweakStatusChanged; + + Task GetCoreParkingStatusAsync(); + + Task SetCoreParkingAsync(bool enabled); + + Task GetCStatesStatusAsync(); + + Task SetCStatesAsync(bool enabled); + + Task GetSysMainStatusAsync(); + + Task SetSysMainAsync(bool enabled); + + Task GetPrefetchStatusAsync(); + + Task SetPrefetchAsync(bool enabled); + + Task GetPowerThrottlingStatusAsync(); + + Task SetPowerThrottlingAsync(bool enabled); + + Task GetHpetStatusAsync(); + + Task SetHpetAsync(bool enabled); + + Task GetHighSchedulingCategoryStatusAsync(); + + Task SetHighSchedulingCategoryAsync(bool enabled); + + Task GetMenuShowDelayStatusAsync(); + + Task SetMenuShowDelayAsync(bool enabled); + + Task RefreshAllStatusesAsync(); + } + + public class TweakStatus + { + public bool IsEnabled { get; set; } + + public bool IsAvailable { get; set; } + + public string? ErrorMessage { get; set; } + + public string? Description { get; set; } + } + + public class TweakStatusChangedEventArgs : EventArgs + { + public string TweakName { get; } + + public TweakStatus Status { get; } + + public DateTime ChangeTime { get; } + + public TweakStatusChangedEventArgs(string tweakName, TweakStatus status) + { + this.TweakName = tweakName; + this.Status = status; + this.ChangeTime = DateTime.UtcNow; + } + } + + public enum SystemTweak + { + CoreParking, + CStates, + SysMain, + Prefetch, + PowerThrottling, + Hpet, + HighSchedulingCategory, + MenuShowDelay, + } +} + diff --git a/Services/IThemeService.cs b/Services/IThemeService.cs index 2b1970f..fbbec87 100644 --- a/Services/IThemeService.cs +++ b/Services/IThemeService.cs @@ -1,29 +1,13 @@ -/* - * 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; - - public interface IThemeService - { - bool IsDarkTheme { get; } - - void ApplyTheme(bool useDarkTheme); - - bool GetSystemUsesDarkTheme(); - } -} +namespace ThreadPilot.Services +{ + using System; + + public interface IThemeService + { + bool IsDarkTheme { get; } + + void ApplyTheme(bool useDarkTheme); + + bool GetSystemUsesDarkTheme(); + } +} diff --git a/Services/IVirtualizedProcessService.cs b/Services/IVirtualizedProcessService.cs index 0af09fb..583fc3a 100644 --- a/Services/IVirtualizedProcessService.cs +++ b/Services/IVirtualizedProcessService.cs @@ -1,135 +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 System.Collections.Generic; - using System.Threading.Tasks; - using ThreadPilot.Models; - - /// - /// Configuration for virtualized process loading. - /// - public class VirtualizedProcessConfig - { - public int BatchSize { get; set; } = 50; - - public int PreloadBatches { get; set; } = 2; - - public bool EnableBackgroundLoading { get; set; } = true; - - public TimeSpan RefreshInterval { get; set; } = TimeSpan.FromSeconds(5); - } - - /// - /// Result of a batch loading operation. - /// - public class ProcessBatchResult - { - public List Processes { get; set; } = new(); - - public int BatchIndex { get; set; } - - public int TotalBatches { get; set; } - - public int TotalProcessCount { get; set; } - - public bool HasMoreBatches { get; set; } - - public TimeSpan LoadTime { get; set; } - } - - /// - /// Event arguments for batch loading progress. - /// - public class BatchLoadProgressEventArgs : EventArgs - { - public int LoadedBatches { get; set; } - - public int TotalBatches { get; set; } - - public int LoadedProcesses { get; set; } - - public int TotalProcesses { get; set; } - - public double ProgressPercentage => this.TotalBatches > 0 ? (double)this.LoadedBatches / this.TotalBatches * 100 : 0; - - public string StatusMessage { get; set; } = string.Empty; - } - - /// - /// Service for virtualized process loading with batch support. - /// - public interface IVirtualizedProcessService - { - /// - /// Gets or sets configuration for virtualized loading. - /// - VirtualizedProcessConfig Configuration { get; set; } - - /// - /// Initialize the virtualized process service. - /// - Task InitializeAsync(); - - /// - /// Get the total number of processes available. - /// - Task GetTotalProcessCountAsync(bool activeApplicationsOnly = false); - - /// - /// Load a specific batch of processes. - /// - Task LoadProcessBatchAsync(int batchIndex, bool activeApplicationsOnly = false); - - /// - /// Load multiple batches starting from a specific index. - /// - Task> LoadProcessBatchesAsync(int startBatchIndex, int batchCount, bool activeApplicationsOnly = false); - - /// - /// Preload the next batch in background. - /// - Task PreloadNextBatchAsync(int currentBatchIndex, bool activeApplicationsOnly = false); - - /// - /// Search processes across all batches. - /// - Task> SearchProcessesAsync(string searchTerm, bool activeApplicationsOnly = false); - - /// - /// Refresh a specific batch. - /// - Task RefreshBatchAsync(int batchIndex, bool activeApplicationsOnly = false); - - /// - /// Clear all cached batches. - /// - void ClearCache(); - - /// - /// Event raised when batch loading progress changes - /// - event EventHandler? BatchLoadProgress; - - /// - /// Event raised when background preloading completes - /// - event EventHandler? BackgroundBatchLoaded; - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using ThreadPilot.Models; + + public class VirtualizedProcessConfig + { + public int BatchSize { get; set; } = 50; + + public int PreloadBatches { get; set; } = 2; + + public bool EnableBackgroundLoading { get; set; } = true; + + public TimeSpan RefreshInterval { get; set; } = TimeSpan.FromSeconds(5); + } + + public class ProcessBatchResult + { + public List Processes { get; set; } = new(); + + public int BatchIndex { get; set; } + + public int TotalBatches { get; set; } + + public int TotalProcessCount { get; set; } + + public bool HasMoreBatches { get; set; } + + public TimeSpan LoadTime { get; set; } + } + + public class BatchLoadProgressEventArgs : EventArgs + { + public int LoadedBatches { get; set; } + + public int TotalBatches { get; set; } + + public int LoadedProcesses { get; set; } + + public int TotalProcesses { get; set; } + + public double ProgressPercentage => this.TotalBatches > 0 ? (double)this.LoadedBatches / this.TotalBatches * 100 : 0; + + public string StatusMessage { get; set; } = string.Empty; + } + + public interface IVirtualizedProcessService + { + VirtualizedProcessConfig Configuration { get; set; } + + Task InitializeAsync(); + + Task GetTotalProcessCountAsync(bool activeApplicationsOnly = false); + + Task LoadProcessBatchAsync(int batchIndex, bool activeApplicationsOnly = false); + + Task> LoadProcessBatchesAsync(int startBatchIndex, int batchCount, bool activeApplicationsOnly = false); + + Task PreloadNextBatchAsync(int currentBatchIndex, bool activeApplicationsOnly = false); + + Task> SearchProcessesAsync(string searchTerm, bool activeApplicationsOnly = false); + + Task RefreshBatchAsync(int batchIndex, bool activeApplicationsOnly = false); + + void ClearCache(); + + event EventHandler? BatchLoadProgress; + + event EventHandler? BackgroundBatchLoaded; + } +} + diff --git a/Services/KeyboardShortcutService.cs b/Services/KeyboardShortcutService.cs index 15134d8..44f0dd5 100644 --- a/Services/KeyboardShortcutService.cs +++ b/Services/KeyboardShortcutService.cs @@ -1,387 +1,368 @@ -/* - * 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.Linq; - using System.Runtime.InteropServices; - using System.Threading.Tasks; - using System.Windows.Input; - using System.Windows.Interop; - using Microsoft.Extensions.Logging; - - /// - /// Service for managing global keyboard shortcuts using Windows API. - /// - public class KeyboardShortcutService : IKeyboardShortcutService, IDisposable - { - private readonly ILogger logger; - private readonly IApplicationSettingsService settingsService; - private readonly Dictionary registeredShortcuts = new(); - private readonly Dictionary hotkeyIdToAction = new(); - private int nextHotkeyId = 1; - private IntPtr windowHandle = IntPtr.Zero; - private HwndSource? hwndSource; - private bool disposed; - - // Windows API constants - private const int WMHOTKEY = 0x0312; - private const int MODALT = 0x0001; - private const int MODCONTROL = 0x0002; - private const int MODSHIFT = 0x0004; - private const int MODWIN = 0x0008; - - // Windows API functions - [DllImport("user32.dll")] - private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); - - [DllImport("user32.dll")] - private static extern bool UnregisterHotKey(IntPtr hWnd, int id); - - public event EventHandler? ShortcutActivated; - - public KeyboardShortcutService( - ILogger logger, - IApplicationSettingsService settingsService) - { - this.logger = logger; - this.settingsService = settingsService; - } - - public async Task RegisterShortcutAsync(string actionName, Key key, ModifierKeys modifiers) - { - try - { - if (string.IsNullOrEmpty(actionName)) - { - return false; - } - - // Check if shortcut is already registered - if (await this.IsShortcutRegisteredAsync(key, modifiers)) - { - this.logger.LogWarning("Shortcut {Key}+{Modifiers} is already registered", key, modifiers); - return false; - } - - // Unregister existing shortcut for this action if it exists - if (this.registeredShortcuts.ContainsKey(actionName)) - { - await this.UnregisterShortcutAsync(actionName); - } - - var shortcut = new KeyboardShortcut - { - ActionName = actionName, - Key = key, - Modifiers = modifiers, - Description = this.GetActionDescription(actionName), - IsEnabled = true, - IsGlobal = true, - }; - - // Register with Windows API - var hotkeyId = this.nextHotkeyId++; - var winModifiers = this.ConvertToWinModifiers(modifiers); - var virtualKey = KeyInterop.VirtualKeyFromKey(key); - - if (RegisterHotKey(this.windowHandle, hotkeyId, winModifiers, (uint)virtualKey)) - { - this.registeredShortcuts[actionName] = shortcut; - this.hotkeyIdToAction[hotkeyId] = actionName; - - this.logger.LogInformation( - "Registered global shortcut {Shortcut} for action {Action}", - shortcut.ToString(), actionName); - return true; - } - else - { - this.logger.LogError( - "Failed to register global shortcut {Shortcut} for action {Action}", - shortcut.ToString(), actionName); - return false; - } - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error registering shortcut for action {Action}", actionName); - return false; - } - } - - public async Task UnregisterShortcutAsync(string actionName) - { - try - { - if (!this.registeredShortcuts.TryGetValue(actionName, out var shortcut)) - { - return false; - } - - // Find the hotkey ID - var hotkeyId = this.hotkeyIdToAction.FirstOrDefault(kvp => kvp.Value == actionName).Key; - if (hotkeyId == 0) - { - return false; - } - - // Unregister from Windows API - if (UnregisterHotKey(this.windowHandle, hotkeyId)) - { - this.registeredShortcuts.Remove(actionName); - this.hotkeyIdToAction.Remove(hotkeyId); - - this.logger.LogInformation( - "Unregistered shortcut {Shortcut} for action {Action}", - shortcut.ToString(), actionName); - return true; - } - else - { - this.logger.LogError( - "Failed to unregister shortcut {Shortcut} for action {Action}", - shortcut.ToString(), actionName); - return false; - } - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error unregistering shortcut for action {Action}", actionName); - return false; - } - } - - public async Task UpdateShortcutAsync(string actionName, Key key, ModifierKeys modifiers) - { - // Unregister existing shortcut and register new one - await this.UnregisterShortcutAsync(actionName); - return await this.RegisterShortcutAsync(actionName, key, modifiers); - } - - public async Task> GetRegisteredShortcutsAsync() - { - return new Dictionary(this.registeredShortcuts); - } - - public async Task IsShortcutRegisteredAsync(Key key, ModifierKeys modifiers) - { - return this.registeredShortcuts.Values.Any(s => s.Key == key && s.Modifiers == modifiers); - } - - public async Task LoadShortcutsFromSettingsAsync() - { - try - { - var settings = this.settingsService.Settings; - if (settings.KeyboardShortcuts != null) - { - foreach (var shortcutSetting in settings.KeyboardShortcuts) - { - if (shortcutSetting.IsEnabled) - { - await this.RegisterShortcutAsync(shortcutSetting.ActionName, shortcutSetting.Key, shortcutSetting.Modifiers); - } - } - } - else - { - // Load default shortcuts if none are configured - await this.LoadDefaultShortcutsAsync(); - } - - this.logger.LogInformation("Loaded {Count} keyboard shortcuts from settings", this.registeredShortcuts.Count); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error loading shortcuts from settings"); - } - } - - public async Task SaveShortcutsToSettingsAsync() - { - try - { - var settings = this.settingsService.Settings; - settings.KeyboardShortcuts = this.registeredShortcuts.Values.ToList(); - await this.settingsService.UpdateSettingsAsync(settings); - - this.logger.LogInformation("Saved {Count} keyboard shortcuts to settings", this.registeredShortcuts.Count); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error saving shortcuts to settings"); - } - } - - public async Task ClearAllShortcutsAsync() - { - var actions = this.registeredShortcuts.Keys.ToList(); - foreach (var action in actions) - { - await this.UnregisterShortcutAsync(action); - } - } - - public Dictionary GetDefaultShortcuts() - { - return new Dictionary - { - [ShortcutActions.ShowMainWindow] = new KeyboardShortcut - { - ActionName = ShortcutActions.ShowMainWindow, - Key = Key.T, - Modifiers = ModifierKeys.Control | ModifierKeys.Shift, - Description = "Show/Hide main window", - IsEnabled = true, - IsGlobal = true, - }, - [ShortcutActions.ToggleMonitoring] = new KeyboardShortcut - { - ActionName = ShortcutActions.ToggleMonitoring, - Key = Key.M, - Modifiers = ModifierKeys.Control | ModifierKeys.Shift, - Description = "Toggle process monitoring", - IsEnabled = true, - IsGlobal = true, - }, - [ShortcutActions.PowerPlanHighPerformance] = new KeyboardShortcut - { - ActionName = ShortcutActions.PowerPlanHighPerformance, - Key = Key.H, - Modifiers = ModifierKeys.Control | ModifierKeys.Shift, - Description = "Switch to High Performance power plan", - IsEnabled = true, - IsGlobal = true, - }, - [ShortcutActions.OpenTweaks] = new KeyboardShortcut - { - ActionName = ShortcutActions.OpenTweaks, - Key = Key.W, - Modifiers = ModifierKeys.Control | ModifierKeys.Shift, - Description = "Open System Tweaks tab", - IsEnabled = true, - IsGlobal = true - }, - }; - } - - public void SetWindowHandle(IntPtr windowHandle) - { - this.windowHandle = windowHandle; - - // Set up message hook for hotkey messages - if (this.windowHandle != nint.Zero) - { - this.hwndSource = HwndSource.FromHwnd(this.windowHandle); - if (this.hwndSource != null) - { - this.hwndSource.AddHook(this.WndProc); - } - } - } - - private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) - { - if (msg == WMHOTKEY) - { - var hotkeyId = wParam.ToInt32(); - if (this.hotkeyIdToAction.TryGetValue(hotkeyId, out var actionName) && - this.registeredShortcuts.TryGetValue(actionName, out var shortcut)) - { - this.ShortcutActivated?.Invoke(this, new ShortcutActivatedEventArgs(actionName, shortcut)); - handled = true; - } - } - - return IntPtr.Zero; - } - - private async Task LoadDefaultShortcutsAsync() - { - var defaultShortcuts = this.GetDefaultShortcuts(); - foreach (var shortcut in defaultShortcuts.Values) - { - await this.RegisterShortcutAsync(shortcut.ActionName, shortcut.Key, shortcut.Modifiers); - } - } - - private uint ConvertToWinModifiers(ModifierKeys modifiers) - { - uint winModifiers = 0; - - if (modifiers.HasFlag(ModifierKeys.Alt)) - { - winModifiers |= MODALT; - } - - if (modifiers.HasFlag(ModifierKeys.Control)) - { - winModifiers |= MODCONTROL; - } - - if (modifiers.HasFlag(ModifierKeys.Shift)) - { - winModifiers |= MODSHIFT; - } - - if (modifiers.HasFlag(ModifierKeys.Windows)) - { - winModifiers |= MODWIN; - } - - return winModifiers; - } - - private string GetActionDescription(string actionName) - { - return actionName switch - { - ShortcutActions.QuickApply => "Quick apply current settings", - ShortcutActions.ToggleMonitoring => "Toggle process monitoring", - ShortcutActions.ShowMainWindow => "Show/Hide main window", - ShortcutActions.HideToTray => "Hide to system tray", - ShortcutActions.PowerPlanBalanced => "Switch to Balanced power plan", - ShortcutActions.PowerPlanHighPerformance => "Switch to High Performance power plan", - ShortcutActions.PowerPlanPowerSaver => "Switch to Power Saver power plan", - ShortcutActions.RefreshProcessList => "Refresh process list", - ShortcutActions.OpenSettings => "Open Settings tab", - ShortcutActions.OpenTweaks => "Open System Tweaks tab", - ShortcutActions.ExitApplication => "Exit application", - _ => actionName, - }; - } - - public void Dispose() - { - if (!this.disposed) - { - this.ClearAllShortcutsAsync().Wait(); - - if (this.hwndSource != null) - { - this.hwndSource.RemoveHook(this.WndProc); - this.hwndSource = null; - } - - this.disposed = true; - } - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Runtime.InteropServices; + using System.Threading.Tasks; + using System.Windows.Input; + using System.Windows.Interop; + using Microsoft.Extensions.Logging; + + public class KeyboardShortcutService : IKeyboardShortcutService, IDisposable + { + private readonly ILogger logger; + private readonly IApplicationSettingsService settingsService; + private readonly Dictionary registeredShortcuts = new(); + private readonly Dictionary hotkeyIdToAction = new(); + private int nextHotkeyId = 1; + private IntPtr windowHandle = IntPtr.Zero; + private HwndSource? hwndSource; + private bool disposed; + + // Windows API constants + private const int WMHOTKEY = 0x0312; + private const int MODALT = 0x0001; + private const int MODCONTROL = 0x0002; + private const int MODSHIFT = 0x0004; + private const int MODWIN = 0x0008; + + // Windows API functions + [DllImport("user32.dll")] + private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); + + [DllImport("user32.dll")] + private static extern bool UnregisterHotKey(IntPtr hWnd, int id); + + public event EventHandler? ShortcutActivated; + + public KeyboardShortcutService( + ILogger logger, + IApplicationSettingsService settingsService) + { + this.logger = logger; + this.settingsService = settingsService; + } + + public async Task RegisterShortcutAsync(string actionName, Key key, ModifierKeys modifiers) + { + try + { + if (string.IsNullOrEmpty(actionName)) + { + return false; + } + + // Check if shortcut is already registered + if (await this.IsShortcutRegisteredAsync(key, modifiers)) + { + this.logger.LogWarning("Shortcut {Key}+{Modifiers} is already registered", key, modifiers); + return false; + } + + // Unregister existing shortcut for this action if it exists + if (this.registeredShortcuts.ContainsKey(actionName)) + { + await this.UnregisterShortcutAsync(actionName); + } + + var shortcut = new KeyboardShortcut + { + ActionName = actionName, + Key = key, + Modifiers = modifiers, + Description = this.GetActionDescription(actionName), + IsEnabled = true, + IsGlobal = true, + }; + + // Register with Windows API + var hotkeyId = this.nextHotkeyId++; + var winModifiers = this.ConvertToWinModifiers(modifiers); + var virtualKey = KeyInterop.VirtualKeyFromKey(key); + + if (RegisterHotKey(this.windowHandle, hotkeyId, winModifiers, (uint)virtualKey)) + { + this.registeredShortcuts[actionName] = shortcut; + this.hotkeyIdToAction[hotkeyId] = actionName; + + this.logger.LogInformation( + "Registered global shortcut {Shortcut} for action {Action}", + shortcut.ToString(), actionName); + return true; + } + else + { + this.logger.LogError( + "Failed to register global shortcut {Shortcut} for action {Action}", + shortcut.ToString(), actionName); + return false; + } + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error registering shortcut for action {Action}", actionName); + return false; + } + } + + public async Task UnregisterShortcutAsync(string actionName) + { + try + { + if (!this.registeredShortcuts.TryGetValue(actionName, out var shortcut)) + { + return false; + } + + // Find the hotkey ID + var hotkeyId = this.hotkeyIdToAction.FirstOrDefault(kvp => kvp.Value == actionName).Key; + if (hotkeyId == 0) + { + return false; + } + + // Unregister from Windows API + if (UnregisterHotKey(this.windowHandle, hotkeyId)) + { + this.registeredShortcuts.Remove(actionName); + this.hotkeyIdToAction.Remove(hotkeyId); + + this.logger.LogInformation( + "Unregistered shortcut {Shortcut} for action {Action}", + shortcut.ToString(), actionName); + return true; + } + else + { + this.logger.LogError( + "Failed to unregister shortcut {Shortcut} for action {Action}", + shortcut.ToString(), actionName); + return false; + } + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error unregistering shortcut for action {Action}", actionName); + return false; + } + } + + public async Task UpdateShortcutAsync(string actionName, Key key, ModifierKeys modifiers) + { + // Unregister existing shortcut and register new one + await this.UnregisterShortcutAsync(actionName); + return await this.RegisterShortcutAsync(actionName, key, modifiers); + } + + public async Task> GetRegisteredShortcutsAsync() + { + return new Dictionary(this.registeredShortcuts); + } + + public async Task IsShortcutRegisteredAsync(Key key, ModifierKeys modifiers) + { + return this.registeredShortcuts.Values.Any(s => s.Key == key && s.Modifiers == modifiers); + } + + public async Task LoadShortcutsFromSettingsAsync() + { + try + { + var settings = this.settingsService.Settings; + if (settings.KeyboardShortcuts != null) + { + foreach (var shortcutSetting in settings.KeyboardShortcuts) + { + if (shortcutSetting.IsEnabled) + { + await this.RegisterShortcutAsync(shortcutSetting.ActionName, shortcutSetting.Key, shortcutSetting.Modifiers); + } + } + } + else + { + // Load default shortcuts if none are configured + await this.LoadDefaultShortcutsAsync(); + } + + this.logger.LogInformation("Loaded {Count} keyboard shortcuts from settings", this.registeredShortcuts.Count); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error loading shortcuts from settings"); + } + } + + public async Task SaveShortcutsToSettingsAsync() + { + try + { + var settings = this.settingsService.Settings; + settings.KeyboardShortcuts = this.registeredShortcuts.Values.ToList(); + await this.settingsService.UpdateSettingsAsync(settings); + + this.logger.LogInformation("Saved {Count} keyboard shortcuts to settings", this.registeredShortcuts.Count); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error saving shortcuts to settings"); + } + } + + public async Task ClearAllShortcutsAsync() + { + var actions = this.registeredShortcuts.Keys.ToList(); + foreach (var action in actions) + { + await this.UnregisterShortcutAsync(action); + } + } + + public Dictionary GetDefaultShortcuts() + { + return new Dictionary + { + [ShortcutActions.ShowMainWindow] = new KeyboardShortcut + { + ActionName = ShortcutActions.ShowMainWindow, + Key = Key.T, + Modifiers = ModifierKeys.Control | ModifierKeys.Shift, + Description = "Show/Hide main window", + IsEnabled = true, + IsGlobal = true, + }, + [ShortcutActions.ToggleMonitoring] = new KeyboardShortcut + { + ActionName = ShortcutActions.ToggleMonitoring, + Key = Key.M, + Modifiers = ModifierKeys.Control | ModifierKeys.Shift, + Description = "Toggle process monitoring", + IsEnabled = true, + IsGlobal = true, + }, + [ShortcutActions.PowerPlanHighPerformance] = new KeyboardShortcut + { + ActionName = ShortcutActions.PowerPlanHighPerformance, + Key = Key.H, + Modifiers = ModifierKeys.Control | ModifierKeys.Shift, + Description = "Switch to High Performance power plan", + IsEnabled = true, + IsGlobal = true, + }, + [ShortcutActions.OpenTweaks] = new KeyboardShortcut + { + ActionName = ShortcutActions.OpenTweaks, + Key = Key.W, + Modifiers = ModifierKeys.Control | ModifierKeys.Shift, + Description = "Open System Tweaks tab", + IsEnabled = true, + IsGlobal = true + }, + }; + } + + public void SetWindowHandle(IntPtr windowHandle) + { + this.windowHandle = windowHandle; + + // Set up message hook for hotkey messages + if (this.windowHandle != nint.Zero) + { + this.hwndSource = HwndSource.FromHwnd(this.windowHandle); + if (this.hwndSource != null) + { + this.hwndSource.AddHook(this.WndProc); + } + } + } + + private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + if (msg == WMHOTKEY) + { + var hotkeyId = wParam.ToInt32(); + if (this.hotkeyIdToAction.TryGetValue(hotkeyId, out var actionName) && + this.registeredShortcuts.TryGetValue(actionName, out var shortcut)) + { + this.ShortcutActivated?.Invoke(this, new ShortcutActivatedEventArgs(actionName, shortcut)); + handled = true; + } + } + + return IntPtr.Zero; + } + + private async Task LoadDefaultShortcutsAsync() + { + var defaultShortcuts = this.GetDefaultShortcuts(); + foreach (var shortcut in defaultShortcuts.Values) + { + await this.RegisterShortcutAsync(shortcut.ActionName, shortcut.Key, shortcut.Modifiers); + } + } + + private uint ConvertToWinModifiers(ModifierKeys modifiers) + { + uint winModifiers = 0; + + if (modifiers.HasFlag(ModifierKeys.Alt)) + { + winModifiers |= MODALT; + } + + if (modifiers.HasFlag(ModifierKeys.Control)) + { + winModifiers |= MODCONTROL; + } + + if (modifiers.HasFlag(ModifierKeys.Shift)) + { + winModifiers |= MODSHIFT; + } + + if (modifiers.HasFlag(ModifierKeys.Windows)) + { + winModifiers |= MODWIN; + } + + return winModifiers; + } + + private string GetActionDescription(string actionName) + { + return actionName switch + { + ShortcutActions.QuickApply => "Quick apply current settings", + ShortcutActions.ToggleMonitoring => "Toggle process monitoring", + ShortcutActions.ShowMainWindow => "Show/Hide main window", + ShortcutActions.HideToTray => "Hide to system tray", + ShortcutActions.PowerPlanBalanced => "Switch to Balanced power plan", + ShortcutActions.PowerPlanHighPerformance => "Switch to High Performance power plan", + ShortcutActions.PowerPlanPowerSaver => "Switch to Power Saver power plan", + ShortcutActions.RefreshProcessList => "Refresh process list", + ShortcutActions.OpenSettings => "Open Settings tab", + ShortcutActions.OpenTweaks => "Open System Tweaks tab", + ShortcutActions.ExitApplication => "Exit application", + _ => actionName, + }; + } + + public void Dispose() + { + if (!this.disposed) + { + this.ClearAllShortcutsAsync().Wait(); + + if (this.hwndSource != null) + { + this.hwndSource.RemoveHook(this.WndProc); + this.hwndSource = null; + } + + this.disposed = true; + } + } + } +} + diff --git a/Services/LocalizationService.cs b/Services/LocalizationService.cs index 9e3270b..78b4ef3 100644 --- a/Services/LocalizationService.cs +++ b/Services/LocalizationService.cs @@ -1,269 +1,250 @@ -/* - * ThreadPilot - Advanced Windows Process and Power Plan Manager - * Copyright (C) 2025 Prime Build - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, version 3 only. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -namespace ThreadPilot.Services -{ - using System; - using System.Collections.Generic; - using System.Windows; - using Microsoft.Extensions.Logging; - - /// - /// Service for managing application localization and display language. - /// - public class LocalizationService : ILocalizationService - { - public const string DefaultLanguage = "en-US"; - public const string SimplifiedChineseLanguage = "zh-CN"; - - private const string EnUsDictionaryPath = "Locales/en-US.xaml"; - private const string ZhCnDictionaryPath = "Locales/zh-CN.xaml"; - - private readonly ILogger logger; - private readonly IReadOnlyDictionary? englishStrings; - private readonly IReadOnlyDictionary? chineseStrings; - private ResourceDictionary? activeLocaleDictionary; - private ResourceDictionary? englishFallbackDictionary; - private Uri? activeLocaleUri; - - public string CurrentLanguage { get; private set; } = DefaultLanguage; - - public event EventHandler? LanguageChanged; - - public LocalizationService(ILogger logger) - : this(logger, englishStrings: null, chineseStrings: null) - { - } - - public LocalizationService( - ILogger logger, - IReadOnlyDictionary? englishStrings, - IReadOnlyDictionary? chineseStrings) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.englishStrings = englishStrings; - this.chineseStrings = chineseStrings; - } - - public static string NormalizeLanguage(string? language) - { - if (string.Equals(language, SimplifiedChineseLanguage, StringComparison.OrdinalIgnoreCase)) - { - return SimplifiedChineseLanguage; - } - - return DefaultLanguage; - } - - public void ApplyLanguage(string? language) - { - var normalizedLanguage = NormalizeLanguage(language); - var targetUri = new Uri(GetDictionaryPath(normalizedLanguage), UriKind.Relative); - - this.CurrentLanguage = normalizedLanguage; - - var appResources = System.Windows.Application.Current?.Resources; - if (appResources == null) - { - this.activeLocaleUri = targetUri; - this.LanguageChanged?.Invoke(this, normalizedLanguage); - return; - } - - try - { - this.ApplyLanguageDictionary(appResources, targetUri); - this.activeLocaleUri = targetUri; - this.logger.LogInformation("Applied display language {Language}", normalizedLanguage); - this.LanguageChanged?.Invoke(this, normalizedLanguage); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to apply language {Language}", normalizedLanguage); - } - } - - public string GetString(string key) - { - if (string.IsNullOrWhiteSpace(key)) - { - return string.Empty; - } - - if (this.TryGetStringFromOverrides(this.CurrentLanguage, key, out var localized)) - { - return localized; - } - - if (this.TryGetStringFromApplicationResources(key, out localized)) - { - return localized; - } - - if (this.activeLocaleDictionary != null && TryGetString(this.activeLocaleDictionary, key, out localized)) - { - return localized; - } - - if (this.CurrentLanguage != DefaultLanguage && - this.TryGetStringFromOverrides(DefaultLanguage, key, out localized)) - { - return localized; - } - - if (this.CurrentLanguage != DefaultLanguage && - this.TryGetStringFromEnglishFallbackDictionary(key, out localized)) - { - return localized; - } - - return key; - } - - private void ApplyLanguageDictionary(ResourceDictionary appResources, Uri targetUri) - { - ResourceDictionary? matchingDictionary = null; - for (var i = appResources.MergedDictionaries.Count - 1; i >= 0; i--) - { - var dictionary = appResources.MergedDictionaries[i]; - var source = dictionary.Source?.OriginalString; - if (IsLocaleDictionary(source)) - { - if (matchingDictionary == null && - string.Equals(source, targetUri.OriginalString, StringComparison.OrdinalIgnoreCase)) - { - matchingDictionary = dictionary; - continue; - } - - appResources.MergedDictionaries.RemoveAt(i); - } - } - - if (matchingDictionary != null) - { - appResources.MergedDictionaries.Remove(matchingDictionary); - appResources.MergedDictionaries.Insert(0, matchingDictionary); - this.activeLocaleDictionary = matchingDictionary; - } - else - { - var nextDictionary = new ResourceDictionary { Source = targetUri }; - appResources.MergedDictionaries.Insert(0, nextDictionary); - this.activeLocaleDictionary = nextDictionary; - } - } - - private static string GetDictionaryPath(string language) - { - return language == SimplifiedChineseLanguage ? ZhCnDictionaryPath : EnUsDictionaryPath; - } - - private static bool IsLocaleDictionary(string? source) - { - return !string.IsNullOrWhiteSpace(source) && - (source.EndsWith(EnUsDictionaryPath, StringComparison.OrdinalIgnoreCase) || - source.EndsWith(ZhCnDictionaryPath, StringComparison.OrdinalIgnoreCase)); - } - - private static bool TryGetString(ResourceDictionary dictionary, string key, out string value) - { - if (dictionary.Contains(key) && dictionary[key] is string text && !string.IsNullOrEmpty(text)) - { - value = text; - return true; - } - - value = string.Empty; - return false; - } - - private bool TryGetStringFromOverrides(string language, string key, out string value) - { - var source = language == SimplifiedChineseLanguage ? this.chineseStrings : this.englishStrings; - if (source != null && source.TryGetValue(key, out var text) && !string.IsNullOrEmpty(text)) - { - value = text; - return true; - } - - value = string.Empty; - return false; - } - - private bool TryGetStringFromApplicationResources(string key, out string value) - { - value = string.Empty; - var app = System.Windows.Application.Current; - if (app == null) - { - return false; - } - - try - { - if (app.Dispatcher.CheckAccess()) - { - return TryGetApplicationResourceValue(app, key, out value); - } - - var found = false; - var dispatcherValue = string.Empty; - app.Dispatcher.Invoke(() => - { - found = TryGetApplicationResourceValue(app, key, out dispatcherValue); - }); - value = dispatcherValue; - return found; - } - catch (Exception ex) - { - this.logger.LogDebug(ex, "Failed to read localized resource {Key}", key); - return false; - } - } - - private static bool TryGetApplicationResourceValue(System.Windows.Application app, string key, out string value) - { - if (app.Resources.Contains(key) && app.Resources[key] is string text && !string.IsNullOrEmpty(text)) - { - value = text; - return true; - } - - value = string.Empty; - return false; - } - - private bool TryGetStringFromEnglishFallbackDictionary(string key, out string value) - { - value = string.Empty; - try - { - this.englishFallbackDictionary ??= new ResourceDictionary - { - Source = new Uri(EnUsDictionaryPath, UriKind.Relative), - }; - return TryGetString(this.englishFallbackDictionary, key, out value); - } - catch (Exception ex) - { - this.logger.LogDebug(ex, "Failed to load English fallback localization dictionary"); - return false; - } - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Windows; + using Microsoft.Extensions.Logging; + + public class LocalizationService : ILocalizationService + { + public const string DefaultLanguage = "en-US"; + public const string SimplifiedChineseLanguage = "zh-CN"; + + private const string EnUsDictionaryPath = "Locales/en-US.xaml"; + private const string ZhCnDictionaryPath = "Locales/zh-CN.xaml"; + + private readonly ILogger logger; + private readonly IReadOnlyDictionary? englishStrings; + private readonly IReadOnlyDictionary? chineseStrings; + private ResourceDictionary? activeLocaleDictionary; + private ResourceDictionary? englishFallbackDictionary; + private Uri? activeLocaleUri; + + public string CurrentLanguage { get; private set; } = DefaultLanguage; + + public event EventHandler? LanguageChanged; + + public LocalizationService(ILogger logger) + : this(logger, englishStrings: null, chineseStrings: null) + { + } + + public LocalizationService( + ILogger logger, + IReadOnlyDictionary? englishStrings, + IReadOnlyDictionary? chineseStrings) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.englishStrings = englishStrings; + this.chineseStrings = chineseStrings; + } + + public static string NormalizeLanguage(string? language) + { + if (string.Equals(language, SimplifiedChineseLanguage, StringComparison.OrdinalIgnoreCase)) + { + return SimplifiedChineseLanguage; + } + + return DefaultLanguage; + } + + public void ApplyLanguage(string? language) + { + var normalizedLanguage = NormalizeLanguage(language); + var targetUri = new Uri(GetDictionaryPath(normalizedLanguage), UriKind.Relative); + + this.CurrentLanguage = normalizedLanguage; + + var appResources = System.Windows.Application.Current?.Resources; + if (appResources == null) + { + this.activeLocaleUri = targetUri; + this.LanguageChanged?.Invoke(this, normalizedLanguage); + return; + } + + try + { + this.ApplyLanguageDictionary(appResources, targetUri); + this.activeLocaleUri = targetUri; + this.logger.LogInformation("Applied display language {Language}", normalizedLanguage); + this.LanguageChanged?.Invoke(this, normalizedLanguage); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to apply language {Language}", normalizedLanguage); + } + } + + public string GetString(string key) + { + if (string.IsNullOrWhiteSpace(key)) + { + return string.Empty; + } + + if (this.TryGetStringFromOverrides(this.CurrentLanguage, key, out var localized)) + { + return localized; + } + + if (this.TryGetStringFromApplicationResources(key, out localized)) + { + return localized; + } + + if (this.activeLocaleDictionary != null && TryGetString(this.activeLocaleDictionary, key, out localized)) + { + return localized; + } + + if (this.CurrentLanguage != DefaultLanguage && + this.TryGetStringFromOverrides(DefaultLanguage, key, out localized)) + { + return localized; + } + + if (this.CurrentLanguage != DefaultLanguage && + this.TryGetStringFromEnglishFallbackDictionary(key, out localized)) + { + return localized; + } + + return key; + } + + private void ApplyLanguageDictionary(ResourceDictionary appResources, Uri targetUri) + { + ResourceDictionary? matchingDictionary = null; + for (var i = appResources.MergedDictionaries.Count - 1; i >= 0; i--) + { + var dictionary = appResources.MergedDictionaries[i]; + var source = dictionary.Source?.OriginalString; + if (IsLocaleDictionary(source)) + { + if (matchingDictionary == null && + string.Equals(source, targetUri.OriginalString, StringComparison.OrdinalIgnoreCase)) + { + matchingDictionary = dictionary; + continue; + } + + appResources.MergedDictionaries.RemoveAt(i); + } + } + + if (matchingDictionary != null) + { + appResources.MergedDictionaries.Remove(matchingDictionary); + appResources.MergedDictionaries.Insert(0, matchingDictionary); + this.activeLocaleDictionary = matchingDictionary; + } + else + { + var nextDictionary = new ResourceDictionary { Source = targetUri }; + appResources.MergedDictionaries.Insert(0, nextDictionary); + this.activeLocaleDictionary = nextDictionary; + } + } + + private static string GetDictionaryPath(string language) + { + return language == SimplifiedChineseLanguage ? ZhCnDictionaryPath : EnUsDictionaryPath; + } + + private static bool IsLocaleDictionary(string? source) + { + return !string.IsNullOrWhiteSpace(source) && + (source.EndsWith(EnUsDictionaryPath, StringComparison.OrdinalIgnoreCase) || + source.EndsWith(ZhCnDictionaryPath, StringComparison.OrdinalIgnoreCase)); + } + + private static bool TryGetString(ResourceDictionary dictionary, string key, out string value) + { + if (dictionary.Contains(key) && dictionary[key] is string text && !string.IsNullOrEmpty(text)) + { + value = text; + return true; + } + + value = string.Empty; + return false; + } + + private bool TryGetStringFromOverrides(string language, string key, out string value) + { + var source = language == SimplifiedChineseLanguage ? this.chineseStrings : this.englishStrings; + if (source != null && source.TryGetValue(key, out var text) && !string.IsNullOrEmpty(text)) + { + value = text; + return true; + } + + value = string.Empty; + return false; + } + + private bool TryGetStringFromApplicationResources(string key, out string value) + { + value = string.Empty; + var app = System.Windows.Application.Current; + if (app == null) + { + return false; + } + + try + { + if (app.Dispatcher.CheckAccess()) + { + return TryGetApplicationResourceValue(app, key, out value); + } + + var found = false; + var dispatcherValue = string.Empty; + app.Dispatcher.Invoke(() => + { + found = TryGetApplicationResourceValue(app, key, out dispatcherValue); + }); + value = dispatcherValue; + return found; + } + catch (Exception ex) + { + this.logger.LogDebug(ex, "Failed to read localized resource {Key}", key); + return false; + } + } + + private static bool TryGetApplicationResourceValue(System.Windows.Application app, string key, out string value) + { + if (app.Resources.Contains(key) && app.Resources[key] is string text && !string.IsNullOrEmpty(text)) + { + value = text; + return true; + } + + value = string.Empty; + return false; + } + + private bool TryGetStringFromEnglishFallbackDictionary(string key, out string value) + { + value = string.Empty; + try + { + this.englishFallbackDictionary ??= new ResourceDictionary + { + Source = new Uri(EnUsDictionaryPath, UriKind.Relative), + }; + return TryGetString(this.englishFallbackDictionary, key, out value); + } + catch (Exception ex) + { + this.logger.LogDebug(ex, "Failed to load English fallback localization dictionary"); + return false; + } + } + } +} diff --git a/Services/LogFileManager.cs b/Services/LogFileManager.cs index a9d3fef..8e6116a 100644 --- a/Services/LogFileManager.cs +++ b/Services/LogFileManager.cs @@ -1,458 +1,415 @@ -/* - * 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.IO; - using System.Linq; - using System.Threading; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - - /// - /// Manages log file operations including rotation, cleanup, and concurrent access. - /// - public class LogFileManager : IDisposable - { - private readonly ILogger logger; - private readonly string logDirectory; - private readonly SemaphoreSlim fileLock = new(1, 1); - private readonly ReaderWriterLockSlim configLock = new(); - private bool disposed; - - // Configuration - private int maxFileSizeMb = 10; - private int retentionDays = 7; - private int maxLogFiles = 50; - - public string LogDirectory => this.logDirectory; - - public string CurrentLogFilePath { get; private set; } - - public LogFileManager(ILogger logger, string? logDirectory = null) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - - this.logDirectory = logDirectory ?? Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "ThreadPilot", - "Logs"); - - this.CurrentLogFilePath = Path.Combine(this.logDirectory, "ThreadPilot.log"); - } - - /// - /// Initialize the log file manager. - /// - public async Task InitializeAsync() - { - await this.fileLock.WaitAsync(); - try - { - // Ensure log directory exists - Directory.CreateDirectory(this.logDirectory); - - // Create current log file if it doesn't exist - if (!File.Exists(this.CurrentLogFilePath)) - { - await this.CreateNewLogFileAsync(); - } - - this.logger.LogInformation("Log file manager initialized. Directory: {LogDirectory}", this.logDirectory); - } - finally - { - this.fileLock.Release(); - } - } - - /// - /// Write log entries to the current log file with automatic rotation. - /// - public async Task WriteLogEntriesAsync(IEnumerable logLines) - { - await this.fileLock.WaitAsync(); - try - { - // Check if rotation is needed - await this.CheckAndRotateLogFileAsync(); - - // Write entries - await File.AppendAllLinesAsync(this.CurrentLogFilePath, logLines); - } - finally - { - this.fileLock.Release(); - } - } - - /// - /// Write a single log entry. - /// - public async Task WriteLogEntryAsync(string logLine) - { - await this.WriteLogEntriesAsync(new[] { logLine }); - } - - /// - /// Read log entries from all log files within date range. - /// - public async Task> ReadLogEntriesAsync(DateTime fromDate, DateTime toDate, int maxEntries = 1000) - { - await this.fileLock.WaitAsync(); - try - { - var allEntries = new List<(DateTime timestamp, string line)>(); - var logFiles = this.GetLogFiles(); - - foreach (var logFile in logFiles) - { - var entries = await this.ReadLogEntriesFromFileAsync(logFile, fromDate, toDate); - allEntries.AddRange(entries); - } - - return allEntries - .OrderByDescending(e => e.timestamp) - .Take(maxEntries) - .Select(e => e.line) - .ToList(); - } - finally - { - this.fileLock.Release(); - } - } - - /// - /// Get log file statistics. - /// - public async Task GetStatisticsAsync() - { - await this.fileLock.WaitAsync(); - try - { - var stats = new LogFileStatistics(); - var logFiles = this.GetLogFiles(); - - stats.TotalLogFiles = logFiles.Count; - - foreach (var logFile in logFiles) - { - var fileInfo = new FileInfo(logFile); - stats.TotalLogSizeBytes += fileInfo.Length; - - if (Path.GetFileName(logFile) == "ThreadPilot.log") - { - stats.CurrentFileSizeBytes = fileInfo.Length; - } - - if (stats.OldestLogDate == default || fileInfo.CreationTime < stats.OldestLogDate) - { - stats.OldestLogDate = fileInfo.CreationTime; - } - - if (fileInfo.CreationTime > stats.NewestLogDate) - { - stats.NewestLogDate = fileInfo.CreationTime; - } - } - - // Count log levels by reading recent entries - await this.CountLogLevelsAsync(stats); - - return stats; - } - finally - { - this.fileLock.Release(); - } - } - - /// - /// Clean up old log files based on retention policy. - /// - public async Task CleanupOldLogsAsync() - { - await this.fileLock.WaitAsync(); - try - { - this.configLock.EnterReadLock(); - var retentionDate = DateTime.UtcNow.AddDays(-this.retentionDays); - var maxFiles = this.maxLogFiles; - this.configLock.ExitReadLock(); - - var logFiles = this.GetLogFiles() - .Where(f => Path.GetFileName(f) != "ThreadPilot.log") // Don't delete current log - .OrderBy(f => new FileInfo(f).CreationTime) - .ToList(); - - var deletedCount = 0; - - // Delete files older than retention period - foreach (var logFile in logFiles.ToList()) - { - var fileInfo = new FileInfo(logFile); - if (fileInfo.CreationTime < retentionDate) - { - try - { - File.Delete(logFile); - logFiles.Remove(logFile); - deletedCount++; - this.logger.LogDebug("Deleted old log file: {LogFile}", logFile); - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to delete old log file: {LogFile}", logFile); - } - } - } - - // Delete excess files if we have too many - if (logFiles.Count > maxFiles) - { - var excessFiles = logFiles.Take(logFiles.Count - maxFiles); - foreach (var logFile in excessFiles) - { - try - { - File.Delete(logFile); - deletedCount++; - this.logger.LogDebug("Deleted excess log file: {LogFile}", logFile); - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to delete excess log file: {LogFile}", logFile); - } - } - } - - if (deletedCount > 0) - { - this.logger.LogInformation("Cleaned up {DeletedCount} old log files", deletedCount); - } - } - finally - { - this.fileLock.Release(); - } - } - - /// - /// Export logs to a specified file. - /// - public async Task ExportLogsAsync(DateTime fromDate, DateTime toDate, string? exportPath = null) - { - exportPath ??= Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.Desktop), - $"ThreadPilot_Logs_{DateTime.Now:yyyyMMdd_HHmmss}.txt"); - - var logEntries = await this.ReadLogEntriesAsync(fromDate, toDate, int.MaxValue); - - var exportContent = new List - { - $"# ThreadPilot Log Export", - $"# Export Date: {DateTime.Now:yyyy-MM-dd HH:mm:ss}", - $"# Date Range: {fromDate:yyyy-MM-dd} to {toDate:yyyy-MM-dd}", - $"# Total Entries: {logEntries.Count}", - string.Empty, - }; - - exportContent.AddRange(logEntries); - - await File.WriteAllLinesAsync(exportPath, exportContent); - return exportPath; - } - - /// - /// Update configuration. - /// - public void UpdateConfiguration(int maxFileSizeMb, int retentionDays, int maxLogFiles = 50) - { - this.configLock.EnterWriteLock(); - try - { - this.maxFileSizeMb = maxFileSizeMb; - this.retentionDays = retentionDays; - this.maxLogFiles = maxLogFiles; - } - finally - { - this.configLock.ExitWriteLock(); - } - } - - private async Task CheckAndRotateLogFileAsync() - { - var fileInfo = new FileInfo(this.CurrentLogFilePath); - - this.configLock.EnterReadLock(); - var maxSizeBytes = this.maxFileSizeMb * 1024 * 1024; - this.configLock.ExitReadLock(); - - if (fileInfo.Exists && fileInfo.Length > maxSizeBytes) - { - await this.RotateLogFileAsync(); - } - } - - private async Task RotateLogFileAsync() - { - var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); - var rotatedPath = Path.Combine(this.logDirectory, $"ThreadPilot_{timestamp}.log"); - - try - { - // Move current log to rotated name - File.Move(this.CurrentLogFilePath, rotatedPath); - - // Create new current log file - await this.CreateNewLogFileAsync(); - - this.logger.LogInformation("Log file rotated: {RotatedPath}", rotatedPath); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to rotate log file"); - throw; - } - } - - private async Task CreateNewLogFileAsync() - { - var header = new[] - { - $"# ThreadPilot Log File", - $"# Created: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC", - $"# Version: {System.Reflection.Assembly.GetExecutingAssembly().GetName().Version}", - $"# Machine: {Environment.MachineName}", - string.Empty, - }; - - await File.WriteAllLinesAsync(this.CurrentLogFilePath, header); - } - - private List GetLogFiles() - { - return Directory.GetFiles(this.logDirectory, "*.log") - .OrderByDescending(f => new FileInfo(f).CreationTime) - .ToList(); - } - - private async Task> ReadLogEntriesFromFileAsync(string filePath, DateTime fromDate, DateTime toDate) - { - var entries = new List<(DateTime timestamp, string line)>(); - - try - { - var lines = await File.ReadAllLinesAsync(filePath); - foreach (var line in lines) - { - if (line.StartsWith("#") || string.IsNullOrWhiteSpace(line)) - { - continue; - } - - // Try to extract timestamp from JSON log entry - if (this.TryExtractTimestamp(line, out var timestamp)) - { - if (timestamp >= fromDate && timestamp <= toDate) - { - entries.Add((timestamp, line)); - } - } - } - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to read log entries from file: {FilePath}", filePath); - } - - return entries; - } - - private bool TryExtractTimestamp(string logLine, out DateTime timestamp) - { - timestamp = default; - - try - { - // Look for timestamp in JSON format: "timestamp":"2024-01-01 12:00:00.000" - var timestampStart = logLine.IndexOf("\"timestamp\":\""); - if (timestampStart >= 0) - { - timestampStart += 13; // Length of "timestamp":"" - var timestampEnd = logLine.IndexOf("\"", timestampStart); - if (timestampEnd > timestampStart) - { - var timestampStr = logLine.Substring(timestampStart, timestampEnd - timestampStart); - return DateTime.TryParse(timestampStr, out timestamp); - } - } - } - catch - { - // Ignore parsing errors - } - - return false; - } - - private async Task CountLogLevelsAsync(LogFileStatistics stats) - { - try - { - // Read recent entries to count log levels - var recentEntries = await this.ReadLogEntriesAsync(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow, 1000); - - foreach (var entry in recentEntries) - { - if (entry.Contains("\"level\":\"Error\"")) - { - stats.ErrorCount++; - } - else if (entry.Contains("\"level\":\"Warning\"")) - { - stats.WarningCount++; - } - else if (entry.Contains("\"level\":\"Information\"")) - { - stats.InfoCount++; - } - } - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to count log levels"); - } - } - - public void Dispose() - { - if (this.disposed) - { - return; - } - - this.fileLock?.Dispose(); - this.configLock?.Dispose(); - this.disposed = true; - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + + public class LogFileManager : IDisposable + { + private readonly ILogger logger; + private readonly string logDirectory; + private readonly SemaphoreSlim fileLock = new(1, 1); + private readonly ReaderWriterLockSlim configLock = new(); + private bool disposed; + + // Configuration + private int maxFileSizeMb = 10; + private int retentionDays = 7; + private int maxLogFiles = 50; + + public string LogDirectory => this.logDirectory; + + public string CurrentLogFilePath { get; private set; } + + public LogFileManager(ILogger logger, string? logDirectory = null) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + this.logDirectory = logDirectory ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ThreadPilot", + "Logs"); + + this.CurrentLogFilePath = Path.Combine(this.logDirectory, "ThreadPilot.log"); + } + + public async Task InitializeAsync() + { + await this.fileLock.WaitAsync(); + try + { + // Ensure log directory exists + Directory.CreateDirectory(this.logDirectory); + + // Create current log file if it doesn't exist + if (!File.Exists(this.CurrentLogFilePath)) + { + await this.CreateNewLogFileAsync(); + } + + this.logger.LogInformation("Log file manager initialized. Directory: {LogDirectory}", this.logDirectory); + } + finally + { + this.fileLock.Release(); + } + } + + public async Task WriteLogEntriesAsync(IEnumerable logLines) + { + await this.fileLock.WaitAsync(); + try + { + // Check if rotation is needed + await this.CheckAndRotateLogFileAsync(); + + // Write entries + await File.AppendAllLinesAsync(this.CurrentLogFilePath, logLines); + } + finally + { + this.fileLock.Release(); + } + } + + public async Task WriteLogEntryAsync(string logLine) + { + await this.WriteLogEntriesAsync(new[] { logLine }); + } + + public async Task> ReadLogEntriesAsync(DateTime fromDate, DateTime toDate, int maxEntries = 1000) + { + await this.fileLock.WaitAsync(); + try + { + var allEntries = new List<(DateTime timestamp, string line)>(); + var logFiles = this.GetLogFiles(); + + foreach (var logFile in logFiles) + { + var entries = await this.ReadLogEntriesFromFileAsync(logFile, fromDate, toDate); + allEntries.AddRange(entries); + } + + return allEntries + .OrderByDescending(e => e.timestamp) + .Take(maxEntries) + .Select(e => e.line) + .ToList(); + } + finally + { + this.fileLock.Release(); + } + } + + public async Task GetStatisticsAsync() + { + await this.fileLock.WaitAsync(); + try + { + var stats = new LogFileStatistics(); + var logFiles = this.GetLogFiles(); + + stats.TotalLogFiles = logFiles.Count; + + foreach (var logFile in logFiles) + { + var fileInfo = new FileInfo(logFile); + stats.TotalLogSizeBytes += fileInfo.Length; + + if (Path.GetFileName(logFile) == "ThreadPilot.log") + { + stats.CurrentFileSizeBytes = fileInfo.Length; + } + + if (stats.OldestLogDate == default || fileInfo.CreationTime < stats.OldestLogDate) + { + stats.OldestLogDate = fileInfo.CreationTime; + } + + if (fileInfo.CreationTime > stats.NewestLogDate) + { + stats.NewestLogDate = fileInfo.CreationTime; + } + } + + // Count log levels by reading recent entries + await this.CountLogLevelsAsync(stats); + + return stats; + } + finally + { + this.fileLock.Release(); + } + } + + public async Task CleanupOldLogsAsync() + { + await this.fileLock.WaitAsync(); + try + { + this.configLock.EnterReadLock(); + var retentionDate = DateTime.UtcNow.AddDays(-this.retentionDays); + var maxFiles = this.maxLogFiles; + this.configLock.ExitReadLock(); + + var logFiles = this.GetLogFiles() + .Where(f => Path.GetFileName(f) != "ThreadPilot.log") // Don't delete current log + .OrderBy(f => new FileInfo(f).CreationTime) + .ToList(); + + var deletedCount = 0; + + // Delete files older than retention period + foreach (var logFile in logFiles.ToList()) + { + var fileInfo = new FileInfo(logFile); + if (fileInfo.CreationTime < retentionDate) + { + try + { + File.Delete(logFile); + logFiles.Remove(logFile); + deletedCount++; + this.logger.LogDebug("Deleted old log file: {LogFile}", logFile); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to delete old log file: {LogFile}", logFile); + } + } + } + + // Delete excess files if we have too many + if (logFiles.Count > maxFiles) + { + var excessFiles = logFiles.Take(logFiles.Count - maxFiles); + foreach (var logFile in excessFiles) + { + try + { + File.Delete(logFile); + deletedCount++; + this.logger.LogDebug("Deleted excess log file: {LogFile}", logFile); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to delete excess log file: {LogFile}", logFile); + } + } + } + + if (deletedCount > 0) + { + this.logger.LogInformation("Cleaned up {DeletedCount} old log files", deletedCount); + } + } + finally + { + this.fileLock.Release(); + } + } + + public async Task ExportLogsAsync(DateTime fromDate, DateTime toDate, string? exportPath = null) + { + exportPath ??= Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Desktop), + $"ThreadPilot_Logs_{DateTime.Now:yyyyMMdd_HHmmss}.txt"); + + var logEntries = await this.ReadLogEntriesAsync(fromDate, toDate, int.MaxValue); + + var exportContent = new List + { + $"# ThreadPilot Log Export", + $"# Export Date: {DateTime.Now:yyyy-MM-dd HH:mm:ss}", + $"# Date Range: {fromDate:yyyy-MM-dd} to {toDate:yyyy-MM-dd}", + $"# Total Entries: {logEntries.Count}", + string.Empty, + }; + + exportContent.AddRange(logEntries); + + await File.WriteAllLinesAsync(exportPath, exportContent); + return exportPath; + } + + public void UpdateConfiguration(int maxFileSizeMb, int retentionDays, int maxLogFiles = 50) + { + this.configLock.EnterWriteLock(); + try + { + this.maxFileSizeMb = maxFileSizeMb; + this.retentionDays = retentionDays; + this.maxLogFiles = maxLogFiles; + } + finally + { + this.configLock.ExitWriteLock(); + } + } + + private async Task CheckAndRotateLogFileAsync() + { + var fileInfo = new FileInfo(this.CurrentLogFilePath); + + this.configLock.EnterReadLock(); + var maxSizeBytes = this.maxFileSizeMb * 1024 * 1024; + this.configLock.ExitReadLock(); + + if (fileInfo.Exists && fileInfo.Length > maxSizeBytes) + { + await this.RotateLogFileAsync(); + } + } + + private async Task RotateLogFileAsync() + { + var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); + var rotatedPath = Path.Combine(this.logDirectory, $"ThreadPilot_{timestamp}.log"); + + try + { + // Move current log to rotated name + File.Move(this.CurrentLogFilePath, rotatedPath); + + // Create new current log file + await this.CreateNewLogFileAsync(); + + this.logger.LogInformation("Log file rotated: {RotatedPath}", rotatedPath); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to rotate log file"); + throw; + } + } + + private async Task CreateNewLogFileAsync() + { + var header = new[] + { + $"# ThreadPilot Log File", + $"# Created: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC", + $"# Version: {System.Reflection.Assembly.GetExecutingAssembly().GetName().Version}", + $"# Machine: {Environment.MachineName}", + string.Empty, + }; + + await File.WriteAllLinesAsync(this.CurrentLogFilePath, header); + } + + private List GetLogFiles() + { + return Directory.GetFiles(this.logDirectory, "*.log") + .OrderByDescending(f => new FileInfo(f).CreationTime) + .ToList(); + } + + private async Task> ReadLogEntriesFromFileAsync(string filePath, DateTime fromDate, DateTime toDate) + { + var entries = new List<(DateTime timestamp, string line)>(); + + try + { + var lines = await File.ReadAllLinesAsync(filePath); + foreach (var line in lines) + { + if (line.StartsWith("#") || string.IsNullOrWhiteSpace(line)) + { + continue; + } + + // Try to extract timestamp from JSON log entry + if (this.TryExtractTimestamp(line, out var timestamp)) + { + if (timestamp >= fromDate && timestamp <= toDate) + { + entries.Add((timestamp, line)); + } + } + } + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to read log entries from file: {FilePath}", filePath); + } + + return entries; + } + + private bool TryExtractTimestamp(string logLine, out DateTime timestamp) + { + timestamp = default; + + try + { + // Look for timestamp in JSON format: "timestamp":"2024-01-01 12:00:00.000" + var timestampStart = logLine.IndexOf("\"timestamp\":\""); + if (timestampStart >= 0) + { + timestampStart += 13; // Length of "timestamp":"" + var timestampEnd = logLine.IndexOf("\"", timestampStart); + if (timestampEnd > timestampStart) + { + var timestampStr = logLine.Substring(timestampStart, timestampEnd - timestampStart); + return DateTime.TryParse(timestampStr, out timestamp); + } + } + } + catch + { + // Ignore parsing errors + } + + return false; + } + + private async Task CountLogLevelsAsync(LogFileStatistics stats) + { + try + { + // Read recent entries to count log levels + var recentEntries = await this.ReadLogEntriesAsync(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow, 1000); + + foreach (var entry in recentEntries) + { + if (entry.Contains("\"level\":\"Error\"")) + { + stats.ErrorCount++; + } + else if (entry.Contains("\"level\":\"Warning\"")) + { + stats.WarningCount++; + } + else if (entry.Contains("\"level\":\"Information\"")) + { + stats.InfoCount++; + } + } + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to count log levels"); + } + } + + public void Dispose() + { + if (this.disposed) + { + return; + } + + this.fileLock?.Dispose(); + this.configLock?.Dispose(); + this.disposed = true; + } + } +} + diff --git a/Services/NotificationService.cs b/Services/NotificationService.cs index e6e7081..4bfc5e2 100644 --- a/Services/NotificationService.cs +++ b/Services/NotificationService.cs @@ -1,534 +1,515 @@ -/* - * 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 System.Threading.Tasks; - using System.Windows.Forms; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - - /// - /// Service for managing notifications with balloon tips and toast support. - /// - public class NotificationService : INotificationService, IDisposable - { - private const int NotificationDisplayDurationMs = 2000; - - private readonly ILogger logger; - private readonly IApplicationSettingsService settingsService; - private readonly ISystemTrayService systemTrayService; - private readonly ILocalizationService localizationService; - private readonly List notificationHistory; - private ApplicationSettingsModel settings; - private bool disposed = false; - - public event EventHandler? NotificationShown; - - public event EventHandler? NotificationDismissed; - - public event EventHandler? NotificationActionClicked; - - public IReadOnlyList NotificationHistory => this.notificationHistory.AsReadOnly(); - - public NotificationService( - ILogger logger, - IApplicationSettingsService settingsService, - ISystemTrayService systemTrayService, - ILocalizationService localizationService) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); - this.systemTrayService = systemTrayService ?? throw new ArgumentNullException(nameof(systemTrayService)); - this.localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); - - this.notificationHistory = new List(); - this.settings = this.settingsService.Settings; - - // Subscribe to settings changes - this.settingsService.SettingsChanged += this.OnSettingsChanged; - } - - public async Task InitializeAsync() - { - try - { - this.logger.LogInformation("Initializing notification service"); - - // Load settings - this.settings = this.settingsService.Settings; - - this.logger.LogInformation("Notification service initialized successfully"); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to initialize notification service"); - throw; - } - } - - public async Task ShowNotificationAsync(string title, string message, NotificationType type = NotificationType.Information) - { - var notification = new NotificationModel(title, message, type) - { - DurationMs = NotificationDisplayDurationMs, - Category = "General", - SourceService = "NotificationService", - }; - - await this.ShowNotificationAsync(notification); - } - - public async Task ShowNotificationAsync(NotificationModel notification) - { - if (notification == null) - { - return; - } - - try - { - notification.Title = this.TryGetLocalizedNotificationString(notification.Title); - notification.Message = this.TryGetLocalizedNotificationString(notification.Message); - - // Check if notifications are enabled - if (!this.AreNotificationsEnabled(notification.Type)) - { - this.logger.LogDebug("Notifications disabled for type {Type}", notification.Type); - return; - } - - // Add to history - notification.DurationMs = NotificationDisplayDurationMs; - this.AddToHistory(notification); - - // Show balloon tip if enabled - if (this.settings.EnableBalloonNotifications) - { - await this.ShowBalloonTipInternalAsync(notification); - } - - // Show toast notification if enabled and available - if (this.settings.EnableToastNotifications) - { - await this.ShowToastNotificationInternalAsync(notification); - } - - // Fire event - this.NotificationShown?.Invoke(this, new NotificationEventArgs(notification)); - - this.logger.LogDebug("Notification shown: {Title}", notification.Title); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error showing notification: {Title}", notification.Title); - } - } - - public async Task ShowBalloonTipAsync(string title, string message, NotificationType type = NotificationType.Information, int timeoutMs = 3000) - { - var notification = new NotificationModel(title, message, type) - { - DurationMs = NotificationDisplayDurationMs, - Category = "BalloonTip", - SourceService = "NotificationService", - }; - - if (this.settings.EnableBalloonNotifications && this.AreNotificationsEnabled(type)) - { - this.AddToHistory(notification); - await this.ShowBalloonTipInternalAsync(notification); - this.NotificationShown?.Invoke(this, new NotificationEventArgs(notification)); - } - } - - public async Task ShowToastNotificationAsync(string title, string message, NotificationType type = NotificationType.Information) - { - var notification = new NotificationModel(title, message, type) - { - Category = "Toast", - SourceService = "NotificationService", - }; - - if (this.settings.EnableToastNotifications && this.AreNotificationsEnabled(type)) - { - this.AddToHistory(notification); - await this.ShowToastNotificationInternalAsync(notification); - this.NotificationShown?.Invoke(this, new NotificationEventArgs(notification)); - } - } - - public async Task ShowPowerPlanChangeNotificationAsync(string oldPlan, string newPlan, string processName = "") - { - if (!this.settings.EnablePowerPlanChangeNotifications) - { - return; - } - - var title = this.GetLocalizedString("Notification_PowerPlanChangedTitle"); - var message = string.IsNullOrEmpty(processName) - ? string.Format( - this.GetLocalizedString("Notification_PowerPlanChangedFormat"), - oldPlan, - newPlan) - : string.Format( - this.GetLocalizedString("Notification_PowerPlanChangedProcessFormat"), - newPlan, - processName); - - var notification = new NotificationModel(title, message, NotificationType.PowerPlanChange) - { - Category = "PowerPlan", - SourceService = "PowerPlanService", - Priority = NotificationPriority.Normal, - }; - - await this.ShowNotificationAsync(notification); - } - - public async Task ShowProcessMonitoringNotificationAsync(string message, bool isEnabled) - { - if (!this.settings.EnableProcessMonitoringNotifications) - { - return; - } - - var title = isEnabled - ? this.GetLocalizedString("Notification_ProcessMonitoringEnabled") - : this.GetLocalizedString("Notification_ProcessMonitoringDisabled"); - var type = isEnabled ? NotificationType.Success : NotificationType.Warning; - - var notification = new NotificationModel(title, message, type) - { - Category = "ProcessMonitoring", - SourceService = "ProcessMonitorService", - Priority = NotificationPriority.Normal, - }; - - await this.ShowNotificationAsync(notification); - } - - public async Task ShowCpuAffinityNotificationAsync(string processName, string affinityInfo) - { - var title = this.GetLocalizedString("Notification_CpuAffinityAppliedTitle"); - var message = string.Format( - this.GetLocalizedString("Notification_CpuAffinityAppliedFormat"), - processName, - affinityInfo); - - var notification = new NotificationModel( - title, - message, - NotificationType.CpuAffinity) - { - Category = "CpuAffinity", - SourceService = "ProcessService", - Priority = NotificationPriority.Normal, - }; - - await this.ShowNotificationAsync(notification); - } - - public async Task ShowErrorNotificationAsync(string title, string message, Exception? exception = null) - { - if (!this.settings.EnableErrorNotifications) - { - return; - } - - var fullMessage = exception != null ? $"{message}\n\nError: {exception.Message}" : message; - - var notification = new NotificationModel(title, fullMessage, NotificationType.Error) - { - Category = "Error", - SourceService = "System", - Priority = NotificationPriority.High, - IsPersistent = true, - }; - - await this.ShowNotificationAsync(notification); - } - - public async Task ShowSuccessNotificationAsync(string title, string message) - { - if (!this.settings.EnableSuccessNotifications) - { - return; - } - - var notification = new NotificationModel(title, message, NotificationType.Success) - { - Category = "Success", - SourceService = "System", - Priority = NotificationPriority.Normal, - }; - - await this.ShowNotificationAsync(notification); - } - - public async Task DismissNotificationAsync(string notificationId) - { - var notification = this.notificationHistory.FirstOrDefault(n => n.Id == notificationId); - if (notification != null) - { - this.NotificationDismissed?.Invoke(this, new NotificationEventArgs(notification)); - this.logger.LogDebug("Notification dismissed: {Id}", notificationId); - } - await Task.CompletedTask; - } - - public async Task DismissAllNotificationsAsync() - { - foreach (var notification in this.notificationHistory.ToList()) - { - this.NotificationDismissed?.Invoke(this, new NotificationEventArgs(notification)); - } - this.logger.LogDebug("All notifications dismissed"); - await Task.CompletedTask; - } - - public async Task ClearNotificationHistoryAsync() - { - this.notificationHistory.Clear(); - this.logger.LogInformation("Notification history cleared"); - await Task.CompletedTask; - } - - public int GetUnreadNotificationCount() - { - return this.notificationHistory.Count(n => !n.IsRead); - } - - public async Task MarkAllNotificationsAsReadAsync() - { - foreach (var notification in this.notificationHistory) - { - notification.MarkAsRead(); - } - this.logger.LogDebug("All notifications marked as read"); - await Task.CompletedTask; - } - - public bool AreNotificationsEnabled(NotificationType type) - { - if (!this.settings.EnableNotifications) - { - return false; - } - - if (this.settings.NotificationLevel == NotificationLevelProfile.Silent) - { - return false; - } - - if (this.settings.NotificationLevel == NotificationLevelProfile.WarningsAndErrorsOnly && - type != NotificationType.Warning && - type != NotificationType.Error) - { - return false; - } - - return type switch - { - NotificationType.PowerPlanChange => this.settings.EnablePowerPlanChangeNotifications, - NotificationType.ProcessMonitoring => this.settings.EnableProcessMonitoringNotifications, - NotificationType.Error => this.settings.EnableErrorNotifications, - NotificationType.Success => this.settings.EnableSuccessNotifications, - _ => true, - }; - } - - public void UpdateSettings(ApplicationSettingsModel settings) - { - this.settings = settings ?? throw new ArgumentNullException(nameof(settings)); - this.logger.LogDebug("Notification settings updated"); - } - - private void AddToHistory(NotificationModel notification) - { - if (!this.settings.EnableNotificationHistory) - { - return; - } - - this.notificationHistory.Insert(0, notification); - - // Trim history if it exceeds max items - while (this.notificationHistory.Count > this.settings.MaxNotificationHistoryItems) - { - this.notificationHistory.RemoveAt(this.notificationHistory.Count - 1); - } - } - - private async Task ShowBalloonTipInternalAsync(NotificationModel notification) - { - try - { - // Use the system tray service to show the actual balloon tip - this.systemTrayService.ShowTrayNotification( - notification.Title, - notification.Message, - notification.Type, - notification.DurationMs); - - this.logger.LogDebug("Balloon tip shown via system tray: {Title} - {Message}", notification.Title, notification.Message); - await Task.CompletedTask; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error showing balloon tip"); - } - } - - private async Task ShowToastNotificationInternalAsync(NotificationModel notification) - { - try - { - // Toast notifications would require Windows 10+ and additional setup - // For now, we'll just log it - this.logger.LogDebug("Toast notification: {Title} - {Message}", notification.Title, notification.Message); - await Task.CompletedTask; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error showing toast notification"); - } - } - - private void OnSettingsChanged(object? sender, ApplicationSettingsChangedEventArgs e) - { - this.UpdateSettings(e.NewSettings); - } - - private string GetLocalizedString(string key) - { - var localized = this.localizationService.GetString(key); - return string.IsNullOrEmpty(localized) ? key : localized; - } - - private string TryGetLocalizedNotificationString(string input) - { - if (string.IsNullOrEmpty(input)) - { - return input; - } - - var key = input switch - { - "Game Boost Activated" => "Notification_GameBoostActivatedTitle", - "Game Boost Deactivated" => "Notification_GameBoostDeactivatedTitle", - "Process Monitor Error" => "Notification_ProcessMonitorErrorTitle", - "Affinity blocked" => "Notification_AffinityBlockedTitle", - "Affinity applied" => "Notification_AffinityAppliedTitle", - "Affinity adjusted" => "Notification_AffinityAdjustedTitle", - "Affinity failed" => "Notification_AffinityFailedTitle", - "Affinity error" => "Notification_AffinityErrorTitle", - "Priority blocked" => "Notification_PriorityBlockedTitle", - "Priority warning" => "Notification_PriorityWarningTitle", - "Priority applied" => "Notification_PriorityAppliedTitle", - "Priority adjusted" => "Notification_PriorityAdjustedTitle", - "Priority error" => "Notification_PriorityErrorTitle", - "Keyboard Shortcut" => "Notification_KeyboardShortcutTitle", - "ThreadPilot Started" => "Notification_ThreadPilotStartedTitle", - "Startup Error" => "Notification_StartupErrorTitle", - "Automation Monitoring Error" => "Notification_AutomationMonitoringErrorTitle", - "Settings Saved" => "Notification_SettingsSavedTitle", - "Settings Saved with Warnings" => "Notification_SettingsSavedWarningsTitle", - "Settings Error" => "Notification_SettingsErrorTitle", - "Test Notification" => "SettingsView_TestNotification", - _ => null, - }; - - if (key != null) - { - var localized = this.GetLocalizedString(key); - if (!string.Equals(localized, key, StringComparison.Ordinal)) - { - return localized; - } - } - - const string GameBoostActivatedPrefix = "Game Boost mode activated for "; - if (input.StartsWith(GameBoostActivatedPrefix, StringComparison.OrdinalIgnoreCase)) - { - var processName = input[GameBoostActivatedPrefix.Length..]; - var format = this.GetLocalizedString("Notification_GameBoostActivatedFormat"); - if (!string.Equals(format, "Notification_GameBoostActivatedFormat", StringComparison.Ordinal)) - { - return string.Format(format, processName); - } - } - - const string GameBoostDeactivatedPrefix = "Game Boost mode deactivated after "; - if (input.StartsWith(GameBoostDeactivatedPrefix, StringComparison.OrdinalIgnoreCase)) - { - var duration = input[GameBoostDeactivatedPrefix.Length..]; - var format = this.GetLocalizedString("Notification_GameBoostDeactivatedFormat"); - if (!string.Equals(format, "Notification_GameBoostDeactivatedFormat", StringComparison.Ordinal)) - { - return string.Format(format, duration); - } - } - - key = input switch - { - "Toggle monitoring shortcut activated" => "Notification_ToggleMonitoringShortcut", - "High Performance power plan shortcut activated" => "Notification_HighPerformanceShortcut", - "Refresh process list shortcut activated" => "Notification_RefreshProcessListShortcut", - "Process monitoring and power plan management is now active" => "Notification_ThreadPilotStartedMessage", - "Failed to start process monitoring manager" => "Notification_ProcessMonitoringStartFailed", - "Application settings have been saved successfully" => "Notification_SettingsSavedMessage", - "Failed to save settings" => "Notification_SettingsSaveFailed", - "This is a test notification to verify your settings are working correctly." => "Notification_TestNotificationMessage", - _ => null, - }; - - if (key != null) - { - var localized = this.GetLocalizedString(key); - if (!string.Equals(localized, key, StringComparison.Ordinal)) - { - return localized; - } - } - - return input; - } - - public void Dispose() - { - if (this.disposed) - { - return; - } - - try - { - this.settingsService.SettingsChanged -= this.OnSettingsChanged; - this.disposed = true; - this.logger.LogInformation("Notification service disposed"); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error disposing notification service"); - } - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Collections.ObjectModel; + using System.Linq; + using System.Threading.Tasks; + using System.Windows.Forms; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public class NotificationService : INotificationService, IDisposable + { + private const int NotificationDisplayDurationMs = 2000; + + private readonly ILogger logger; + private readonly IApplicationSettingsService settingsService; + private readonly ISystemTrayService systemTrayService; + private readonly ILocalizationService localizationService; + private readonly List notificationHistory; + private ApplicationSettingsModel settings; + private bool disposed = false; + + public event EventHandler? NotificationShown; + + public event EventHandler? NotificationDismissed; + + public event EventHandler? NotificationActionClicked; + + public IReadOnlyList NotificationHistory => this.notificationHistory.AsReadOnly(); + + public NotificationService( + ILogger logger, + IApplicationSettingsService settingsService, + ISystemTrayService systemTrayService, + ILocalizationService localizationService) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + this.systemTrayService = systemTrayService ?? throw new ArgumentNullException(nameof(systemTrayService)); + this.localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); + + this.notificationHistory = new List(); + this.settings = this.settingsService.Settings; + + // Subscribe to settings changes + this.settingsService.SettingsChanged += this.OnSettingsChanged; + } + + public async Task InitializeAsync() + { + try + { + this.logger.LogInformation("Initializing notification service"); + + // Load settings + this.settings = this.settingsService.Settings; + + this.logger.LogInformation("Notification service initialized successfully"); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to initialize notification service"); + throw; + } + } + + public async Task ShowNotificationAsync(string title, string message, NotificationType type = NotificationType.Information) + { + var notification = new NotificationModel(title, message, type) + { + DurationMs = NotificationDisplayDurationMs, + Category = "General", + SourceService = "NotificationService", + }; + + await this.ShowNotificationAsync(notification); + } + + public async Task ShowNotificationAsync(NotificationModel notification) + { + if (notification == null) + { + return; + } + + try + { + notification.Title = this.TryGetLocalizedNotificationString(notification.Title); + notification.Message = this.TryGetLocalizedNotificationString(notification.Message); + + // Check if notifications are enabled + if (!this.AreNotificationsEnabled(notification.Type)) + { + this.logger.LogDebug("Notifications disabled for type {Type}", notification.Type); + return; + } + + // Add to history + notification.DurationMs = NotificationDisplayDurationMs; + this.AddToHistory(notification); + + // Show balloon tip if enabled + if (this.settings.EnableBalloonNotifications) + { + await this.ShowBalloonTipInternalAsync(notification); + } + + // Show toast notification if enabled and available + if (this.settings.EnableToastNotifications) + { + await this.ShowToastNotificationInternalAsync(notification); + } + + // Fire event + this.NotificationShown?.Invoke(this, new NotificationEventArgs(notification)); + + this.logger.LogDebug("Notification shown: {Title}", notification.Title); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error showing notification: {Title}", notification.Title); + } + } + + public async Task ShowBalloonTipAsync(string title, string message, NotificationType type = NotificationType.Information, int timeoutMs = 3000) + { + var notification = new NotificationModel(title, message, type) + { + DurationMs = NotificationDisplayDurationMs, + Category = "BalloonTip", + SourceService = "NotificationService", + }; + + if (this.settings.EnableBalloonNotifications && this.AreNotificationsEnabled(type)) + { + this.AddToHistory(notification); + await this.ShowBalloonTipInternalAsync(notification); + this.NotificationShown?.Invoke(this, new NotificationEventArgs(notification)); + } + } + + public async Task ShowToastNotificationAsync(string title, string message, NotificationType type = NotificationType.Information) + { + var notification = new NotificationModel(title, message, type) + { + Category = "Toast", + SourceService = "NotificationService", + }; + + if (this.settings.EnableToastNotifications && this.AreNotificationsEnabled(type)) + { + this.AddToHistory(notification); + await this.ShowToastNotificationInternalAsync(notification); + this.NotificationShown?.Invoke(this, new NotificationEventArgs(notification)); + } + } + + public async Task ShowPowerPlanChangeNotificationAsync(string oldPlan, string newPlan, string processName = "") + { + if (!this.settings.EnablePowerPlanChangeNotifications) + { + return; + } + + var title = this.GetLocalizedString("Notification_PowerPlanChangedTitle"); + var message = string.IsNullOrEmpty(processName) + ? string.Format( + this.GetLocalizedString("Notification_PowerPlanChangedFormat"), + oldPlan, + newPlan) + : string.Format( + this.GetLocalizedString("Notification_PowerPlanChangedProcessFormat"), + newPlan, + processName); + + var notification = new NotificationModel(title, message, NotificationType.PowerPlanChange) + { + Category = "PowerPlan", + SourceService = "PowerPlanService", + Priority = NotificationPriority.Normal, + }; + + await this.ShowNotificationAsync(notification); + } + + public async Task ShowProcessMonitoringNotificationAsync(string message, bool isEnabled) + { + if (!this.settings.EnableProcessMonitoringNotifications) + { + return; + } + + var title = isEnabled + ? this.GetLocalizedString("Notification_ProcessMonitoringEnabled") + : this.GetLocalizedString("Notification_ProcessMonitoringDisabled"); + var type = isEnabled ? NotificationType.Success : NotificationType.Warning; + + var notification = new NotificationModel(title, message, type) + { + Category = "ProcessMonitoring", + SourceService = "ProcessMonitorService", + Priority = NotificationPriority.Normal, + }; + + await this.ShowNotificationAsync(notification); + } + + public async Task ShowCpuAffinityNotificationAsync(string processName, string affinityInfo) + { + var title = this.GetLocalizedString("Notification_CpuAffinityAppliedTitle"); + var message = string.Format( + this.GetLocalizedString("Notification_CpuAffinityAppliedFormat"), + processName, + affinityInfo); + + var notification = new NotificationModel( + title, + message, + NotificationType.CpuAffinity) + { + Category = "CpuAffinity", + SourceService = "ProcessService", + Priority = NotificationPriority.Normal, + }; + + await this.ShowNotificationAsync(notification); + } + + public async Task ShowErrorNotificationAsync(string title, string message, Exception? exception = null) + { + if (!this.settings.EnableErrorNotifications) + { + return; + } + + var fullMessage = exception != null ? $"{message}\n\nError: {exception.Message}" : message; + + var notification = new NotificationModel(title, fullMessage, NotificationType.Error) + { + Category = "Error", + SourceService = "System", + Priority = NotificationPriority.High, + IsPersistent = true, + }; + + await this.ShowNotificationAsync(notification); + } + + public async Task ShowSuccessNotificationAsync(string title, string message) + { + if (!this.settings.EnableSuccessNotifications) + { + return; + } + + var notification = new NotificationModel(title, message, NotificationType.Success) + { + Category = "Success", + SourceService = "System", + Priority = NotificationPriority.Normal, + }; + + await this.ShowNotificationAsync(notification); + } + + public async Task DismissNotificationAsync(string notificationId) + { + var notification = this.notificationHistory.FirstOrDefault(n => n.Id == notificationId); + if (notification != null) + { + this.NotificationDismissed?.Invoke(this, new NotificationEventArgs(notification)); + this.logger.LogDebug("Notification dismissed: {Id}", notificationId); + } + await Task.CompletedTask; + } + + public async Task DismissAllNotificationsAsync() + { + foreach (var notification in this.notificationHistory.ToList()) + { + this.NotificationDismissed?.Invoke(this, new NotificationEventArgs(notification)); + } + this.logger.LogDebug("All notifications dismissed"); + await Task.CompletedTask; + } + + public async Task ClearNotificationHistoryAsync() + { + this.notificationHistory.Clear(); + this.logger.LogInformation("Notification history cleared"); + await Task.CompletedTask; + } + + public int GetUnreadNotificationCount() + { + return this.notificationHistory.Count(n => !n.IsRead); + } + + public async Task MarkAllNotificationsAsReadAsync() + { + foreach (var notification in this.notificationHistory) + { + notification.MarkAsRead(); + } + this.logger.LogDebug("All notifications marked as read"); + await Task.CompletedTask; + } + + public bool AreNotificationsEnabled(NotificationType type) + { + if (!this.settings.EnableNotifications) + { + return false; + } + + if (this.settings.NotificationLevel == NotificationLevelProfile.Silent) + { + return false; + } + + if (this.settings.NotificationLevel == NotificationLevelProfile.WarningsAndErrorsOnly && + type != NotificationType.Warning && + type != NotificationType.Error) + { + return false; + } + + return type switch + { + NotificationType.PowerPlanChange => this.settings.EnablePowerPlanChangeNotifications, + NotificationType.ProcessMonitoring => this.settings.EnableProcessMonitoringNotifications, + NotificationType.Error => this.settings.EnableErrorNotifications, + NotificationType.Success => this.settings.EnableSuccessNotifications, + _ => true, + }; + } + + public void UpdateSettings(ApplicationSettingsModel settings) + { + this.settings = settings ?? throw new ArgumentNullException(nameof(settings)); + this.logger.LogDebug("Notification settings updated"); + } + + private void AddToHistory(NotificationModel notification) + { + if (!this.settings.EnableNotificationHistory) + { + return; + } + + this.notificationHistory.Insert(0, notification); + + // Trim history if it exceeds max items + while (this.notificationHistory.Count > this.settings.MaxNotificationHistoryItems) + { + this.notificationHistory.RemoveAt(this.notificationHistory.Count - 1); + } + } + + private async Task ShowBalloonTipInternalAsync(NotificationModel notification) + { + try + { + // Use the system tray service to show the actual balloon tip + this.systemTrayService.ShowTrayNotification( + notification.Title, + notification.Message, + notification.Type, + notification.DurationMs); + + this.logger.LogDebug("Balloon tip shown via system tray: {Title} - {Message}", notification.Title, notification.Message); + await Task.CompletedTask; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error showing balloon tip"); + } + } + + private async Task ShowToastNotificationInternalAsync(NotificationModel notification) + { + try + { + // Toast notifications would require Windows 10+ and additional setup + // For now, we'll just log it + this.logger.LogDebug("Toast notification: {Title} - {Message}", notification.Title, notification.Message); + await Task.CompletedTask; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error showing toast notification"); + } + } + + private void OnSettingsChanged(object? sender, ApplicationSettingsChangedEventArgs e) + { + this.UpdateSettings(e.NewSettings); + } + + private string GetLocalizedString(string key) + { + var localized = this.localizationService.GetString(key); + return string.IsNullOrEmpty(localized) ? key : localized; + } + + private string TryGetLocalizedNotificationString(string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + + var key = input switch + { + "Game Boost Activated" => "Notification_GameBoostActivatedTitle", + "Game Boost Deactivated" => "Notification_GameBoostDeactivatedTitle", + "Process Monitor Error" => "Notification_ProcessMonitorErrorTitle", + "Affinity blocked" => "Notification_AffinityBlockedTitle", + "Affinity applied" => "Notification_AffinityAppliedTitle", + "Affinity adjusted" => "Notification_AffinityAdjustedTitle", + "Affinity failed" => "Notification_AffinityFailedTitle", + "Affinity error" => "Notification_AffinityErrorTitle", + "Priority blocked" => "Notification_PriorityBlockedTitle", + "Priority warning" => "Notification_PriorityWarningTitle", + "Priority applied" => "Notification_PriorityAppliedTitle", + "Priority adjusted" => "Notification_PriorityAdjustedTitle", + "Priority error" => "Notification_PriorityErrorTitle", + "Keyboard Shortcut" => "Notification_KeyboardShortcutTitle", + "ThreadPilot Started" => "Notification_ThreadPilotStartedTitle", + "Startup Error" => "Notification_StartupErrorTitle", + "Automation Monitoring Error" => "Notification_AutomationMonitoringErrorTitle", + "Settings Saved" => "Notification_SettingsSavedTitle", + "Settings Saved with Warnings" => "Notification_SettingsSavedWarningsTitle", + "Settings Error" => "Notification_SettingsErrorTitle", + "Test Notification" => "SettingsView_TestNotification", + _ => null, + }; + + if (key != null) + { + var localized = this.GetLocalizedString(key); + if (!string.Equals(localized, key, StringComparison.Ordinal)) + { + return localized; + } + } + + const string GameBoostActivatedPrefix = "Game Boost mode activated for "; + if (input.StartsWith(GameBoostActivatedPrefix, StringComparison.OrdinalIgnoreCase)) + { + var processName = input[GameBoostActivatedPrefix.Length..]; + var format = this.GetLocalizedString("Notification_GameBoostActivatedFormat"); + if (!string.Equals(format, "Notification_GameBoostActivatedFormat", StringComparison.Ordinal)) + { + return string.Format(format, processName); + } + } + + const string GameBoostDeactivatedPrefix = "Game Boost mode deactivated after "; + if (input.StartsWith(GameBoostDeactivatedPrefix, StringComparison.OrdinalIgnoreCase)) + { + var duration = input[GameBoostDeactivatedPrefix.Length..]; + var format = this.GetLocalizedString("Notification_GameBoostDeactivatedFormat"); + if (!string.Equals(format, "Notification_GameBoostDeactivatedFormat", StringComparison.Ordinal)) + { + return string.Format(format, duration); + } + } + + key = input switch + { + "Toggle monitoring shortcut activated" => "Notification_ToggleMonitoringShortcut", + "High Performance power plan shortcut activated" => "Notification_HighPerformanceShortcut", + "Refresh process list shortcut activated" => "Notification_RefreshProcessListShortcut", + "Process monitoring and power plan management is now active" => "Notification_ThreadPilotStartedMessage", + "Failed to start process monitoring manager" => "Notification_ProcessMonitoringStartFailed", + "Application settings have been saved successfully" => "Notification_SettingsSavedMessage", + "Failed to save settings" => "Notification_SettingsSaveFailed", + "This is a test notification to verify your settings are working correctly." => "Notification_TestNotificationMessage", + _ => null, + }; + + if (key != null) + { + var localized = this.GetLocalizedString(key); + if (!string.Equals(localized, key, StringComparison.Ordinal)) + { + return localized; + } + } + + return input; + } + + public void Dispose() + { + if (this.disposed) + { + return; + } + + try + { + this.settingsService.SettingsChanged -= this.OnSettingsChanged; + this.disposed = true; + this.logger.LogInformation("Notification service disposed"); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error disposing notification service"); + } + } + } +} diff --git a/Services/PassiveProcessErrorThrottle.cs b/Services/PassiveProcessErrorThrottle.cs index 75ab89b..77e6102 100644 --- a/Services/PassiveProcessErrorThrottle.cs +++ b/Services/PassiveProcessErrorThrottle.cs @@ -1,70 +1,54 @@ -/* - * 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; - } - } -} +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/PerformanceMonitoringService.cs b/Services/PerformanceMonitoringService.cs index 1bd2896..7f7d2ef 100644 --- a/Services/PerformanceMonitoringService.cs +++ b/Services/PerformanceMonitoringService.cs @@ -1,741 +1,722 @@ -/* - * 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.Diagnostics; - using System.Linq; - using System.Management; - using System.Threading; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - - /// - /// Service for real-time performance monitoring. - /// - public class PerformanceMonitoringService : IPerformanceMonitoringService, IDisposable - { - private readonly ILogger logger; - private readonly IProcessService processService; - private readonly ICpuTopologyService cpuTopologyService; - private readonly IApplicationSettingsService settingsService; - private readonly IEnhancedLoggingService enhancedLoggingService; - private readonly Queue historicalData; - private readonly object counterInitializationLock = new(); - private PerformanceCounter? totalCpuCounter; - private PerformanceCounter? memoryCounter; - private readonly List cpuCoreCounters; - private System.Threading.Timer? monitoringTimer; - private readonly object totalMemoryCacheLock = new(); - private readonly TimeSpan totalPhysicalMemoryCacheDuration = TimeSpan.FromMinutes(5); - private long cachedTotalPhysicalMemory; - private DateTime totalPhysicalMemoryCacheUtc = DateTime.MinValue; - private readonly object processCountCacheLock = new(); - private readonly TimeSpan processCountCacheDuration = TimeSpan.FromSeconds(5); - private int cachedProcessCount; - private DateTime processCountCacheUtc = DateTime.MinValue; - private readonly object runtimeTelemetryLock = new(); - private int isMonitoringTickInProgress; - private bool runtimeTelemetryInitialized; - private int previousGen0Collections; - private int previousGen1Collections; - private int previousGen2Collections; - private long previousTotalAllocatedBytes; - private double maxObservedGcPauseMs; - private DateTime lastGcPauseAlertUtc = DateTime.MinValue; - private bool isMonitoring; - private bool disposed; - - private static readonly TimeSpan GcPauseAlertCooldown = TimeSpan.FromMinutes(1); - private static readonly TimeSpan WmiQueryTimeout = TimeSpan.FromSeconds(5); - private const int HistoricalDataCapacity = 1000; - private const double Gen2PauseAlertThresholdMs = 100; - - public event EventHandler? MetricsUpdated; - - public PerformanceMonitoringService( - ILogger logger, - IProcessService processService, - ICpuTopologyService cpuTopologyService, - IApplicationSettingsService settingsService, - IEnhancedLoggingService enhancedLoggingService) - { - this.logger = logger; - this.processService = processService; - this.cpuTopologyService = cpuTopologyService; - this.settingsService = settingsService; - this.enhancedLoggingService = enhancedLoggingService; - this.historicalData = new Queue(HistoricalDataCapacity); - this.cpuCoreCounters = new List(); - } - - public async Task GetSystemMetricsAsync(bool lightweight = false) - { - try - { - var metrics = new SystemPerformanceMetrics - { - Timestamp = DateTime.UtcNow, - TotalCpuUsage = await this.GetTotalCpuUsageAsync().ConfigureAwait(false), - AvailableMemory = await this.GetAvailableMemoryAsync().ConfigureAwait(false), - }; - - // Calculate memory percentage - metrics.TotalMemory = await this.GetTotalPhysicalMemoryAsync().ConfigureAwait(false); - metrics.TotalMemoryUsage = Math.Max(0, metrics.TotalMemory - metrics.AvailableMemory); - metrics.MemoryUsagePercentage = metrics.TotalMemory > 0 - ? ((double)(metrics.TotalMemory - metrics.AvailableMemory) / metrics.TotalMemory) * 100 - : 0; - - this.PopulateRuntimeTelemetry(metrics); - - if (!lightweight) - { - metrics.CpuCoreUsages = await this.GetCpuCoreUsageAsync().ConfigureAwait(false); - metrics.ActiveProcessCount = await this.GetActiveProcessCountAsync().ConfigureAwait(false); - - // Get top processes - var topCpuProcesses = await this.GetTopCpuProcessesAsync(1).ConfigureAwait(false); - metrics.TopCpuProcess = topCpuProcesses.FirstOrDefault(); - - var topMemoryProcesses = await this.GetTopMemoryProcessesAsync(1).ConfigureAwait(false); - metrics.TopMemoryProcess = topMemoryProcesses.FirstOrDefault(); - - // Store in historical data - if (this.historicalData.Count >= HistoricalDataCapacity) - { - this.historicalData.Dequeue(); - } - - this.historicalData.Enqueue(metrics); - } - - return metrics; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error getting system metrics"); - return new SystemPerformanceMetrics(); - } - } - - public async Task> GetCpuCoreUsageAsync() - { - var coreUsages = new List(); - - try - { - this.EnsureCpuCoreCountersInitialized(); - var topology = await this.cpuTopologyService.DetectTopologyAsync().ConfigureAwait(false); - - for (int i = 0; i < this.cpuCoreCounters.Count; i++) - { - var counter = this.cpuCoreCounters[i]; - var usage = counter.NextValue(); - - var coreUsage = new CpuCoreUsage - { - CoreId = i, - CoreName = $"Core {i}", - Usage = usage, - CoreType = DetermineCoreType(i, topology), - IsHyperThreaded = IsHyperThreadedCore(i, topology), - PhysicalCoreId = GetPhysicalCoreId(i, topology), - }; - - coreUsages.Add(coreUsage); - } - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error getting CPU core usage"); - } - - return coreUsages; - } - - public async Task GetMemoryUsageAsync() - { - try - { - this.EnsureSystemCountersInitialized(); - var memoryInfo = new MemoryUsageInfo(); - - // Get physical memory info - var scope = CreateCimv2ScopeWithTimeout(); - using var searcher = new ManagementObjectSearcher(scope, new ObjectQuery("SELECT TotalPhysicalMemory FROM Win32_ComputerSystem")); - foreach (var obj in searcher.Get()) - { - memoryInfo.TotalPhysicalMemory = Convert.ToInt64(obj["TotalPhysicalMemory"]); - } - - // Get available memory - memoryInfo.AvailablePhysicalMemory = (long)(this.memoryCounter?.NextValue() ?? 0) * 1024 * 1024; // Convert MB to bytes - memoryInfo.UsedPhysicalMemory = memoryInfo.TotalPhysicalMemory - memoryInfo.AvailablePhysicalMemory; - memoryInfo.PhysicalMemoryUsagePercentage = memoryInfo.TotalPhysicalMemory > 0 - ? ((double)memoryInfo.UsedPhysicalMemory / memoryInfo.TotalPhysicalMemory) * 100 - : 0; - - // Get virtual memory info - using var memSearcher = new ManagementObjectSearcher(scope, new ObjectQuery("SELECT TotalVirtualMemorySize, FreeVirtualMemory FROM Win32_OperatingSystem")); - foreach (var obj in memSearcher.Get()) - { - memoryInfo.TotalVirtualMemory = Convert.ToInt64(obj["TotalVirtualMemorySize"]) * 1024; // Convert KB to bytes - memoryInfo.AvailableVirtualMemory = Convert.ToInt64(obj["FreeVirtualMemory"]) * 1024; - } - - memoryInfo.UsedVirtualMemory = memoryInfo.TotalVirtualMemory - memoryInfo.AvailableVirtualMemory; - memoryInfo.VirtualMemoryUsagePercentage = memoryInfo.TotalVirtualMemory > 0 - ? ((double)memoryInfo.UsedVirtualMemory / memoryInfo.TotalVirtualMemory) * 100 - : 0; - - return memoryInfo; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error getting memory usage"); - return new MemoryUsageInfo(); - } - } - - public async Task> GetTopCpuProcessesAsync(int count = 10) - { - try - { - var processes = await this.processService.GetProcessesAsync().ConfigureAwait(false); - return processes - .OrderByDescending(p => p.CpuUsage) - .Take(count) - .Select(p => new ProcessPerformanceInfo - { - ProcessId = p.ProcessId, - ProcessName = p.Name, - WindowTitle = p.MainWindowTitle, - CpuUsage = p.CpuUsage, - MemoryUsage = p.MemoryUsage, - ThreadCount = GetThreadCountSafe(p.ProcessId), - ExecutablePath = p.ExecutablePath ?? string.Empty, - Priority = p.Priority.ToString(), - }) - .ToList(); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error getting top CPU processes"); - return new List(); - } - } - - public async Task> GetTopMemoryProcessesAsync(int count = 10) - { - try - { - var processes = await this.processService.GetProcessesAsync().ConfigureAwait(false); - return processes - .OrderByDescending(p => p.MemoryUsage) - .Take(count) - .Select(p => new ProcessPerformanceInfo - { - ProcessId = p.ProcessId, - ProcessName = p.Name, - WindowTitle = p.MainWindowTitle, - CpuUsage = p.CpuUsage, - MemoryUsage = p.MemoryUsage, - ThreadCount = GetThreadCountSafe(p.ProcessId), - ExecutablePath = p.ExecutablePath ?? string.Empty, - Priority = p.Priority.ToString(), - }) - .ToList(); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error getting top memory processes"); - return new List(); - } - } - - public async Task StartMonitoringAsync() - { - if (this.isMonitoring) - { - return; - } - - this.logger.LogInformation("Starting performance monitoring"); - this.isMonitoring = true; - Interlocked.Exchange(ref this.isMonitoringTickInProgress, 0); - - // PERFORMANCE OPTIMIZATION: Increased interval from 1s to 2s for better performance - this.monitoringTimer = new System.Threading.Timer( - async _ => - { - if (Interlocked.Exchange(ref this.isMonitoringTickInProgress, 1) == 1) - { - return; - } - - try - { - var metrics = await this.GetSystemMetricsAsync().ConfigureAwait(false); - await this.EmitGcDiagnosticsIfNeededAsync(metrics).ConfigureAwait(false); - this.MetricsUpdated?.Invoke(this, new PerformanceMetricsUpdatedEventArgs(metrics)); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error during performance monitoring update"); - } - finally - { - Interlocked.Exchange(ref this.isMonitoringTickInProgress, 0); - } - }, null, TimeSpan.Zero, TimeSpan.FromSeconds(2)); - } - - public Task StopMonitoringAsync() - { - if (!this.isMonitoring) - { - return Task.CompletedTask; - } - - this.logger.LogInformation("Stopping performance monitoring"); - this.isMonitoring = false; - Interlocked.Exchange(ref this.isMonitoringTickInProgress, 0); - - this.monitoringTimer?.Dispose(); - this.monitoringTimer = null; - return Task.CompletedTask; - } - - public Task> GetHistoricalDataAsync(TimeSpan duration) - { - var cutoffTime = DateTime.UtcNow - duration; - var data = this.historicalData.Where(m => m.Timestamp >= cutoffTime).ToList(); - return Task.FromResult(data); - } - - public Task ClearHistoricalDataAsync() - { - this.historicalData.Clear(); - this.logger.LogInformation("Historical performance data cleared"); - return Task.CompletedTask; - } - - private void InitializeCpuCoreCounters() - { - var tempCounters = new List(); - - try - { - var coreCount = Environment.ProcessorCount; - for (int i = 0; i < coreCount; i++) - { - tempCounters.Add(this.CreatePrimedCounter("Processor", "% Processor Time", i.ToString())); - } - - this.cpuCoreCounters.Clear(); - this.cpuCoreCounters.AddRange(tempCounters); - - this.logger.LogInformation("Initialized {CoreCount} CPU core performance counters", coreCount); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error initializing CPU core counters"); - foreach (var counter in tempCounters) - { - try - { - counter.Dispose(); - } - catch - { - // Best effort cleanup for partially initialized counters. - } - } - - throw; - } - } - - private void EnsureSystemCountersInitialized() - { - if (this.totalCpuCounter != null && this.memoryCounter != null) - { - return; - } - - lock (this.counterInitializationLock) - { - if (this.totalCpuCounter != null && this.memoryCounter != null) - { - return; - } - - PerformanceCounter? totalCpu = null; - PerformanceCounter? memory = null; - - try - { - totalCpu = this.CreatePrimedCounter("Processor", "% Processor Time", "_Total"); - memory = this.CreatePrimedCounter("Memory", "Available MBytes"); - - this.totalCpuCounter = totalCpu; - this.memoryCounter = memory; - } - catch - { - totalCpu?.Dispose(); - memory?.Dispose(); - throw; - } - } - } - - private void EnsureCpuCoreCountersInitialized() - { - if (this.cpuCoreCounters.Count > 0) - { - return; - } - - lock (this.counterInitializationLock) - { - if (this.cpuCoreCounters.Count > 0) - { - return; - } - - this.InitializeCpuCoreCounters(); - } - } - - private PerformanceCounter CreatePrimedCounter(string categoryName, string counterName, string? instanceName = null) - { - try - { - var counter = string.IsNullOrWhiteSpace(instanceName) - ? new PerformanceCounter(categoryName, counterName) - : new PerformanceCounter(categoryName, counterName, instanceName); - - _ = counter.NextValue(); - return counter; - } - catch (Exception ex) - { - this.logger.LogError( - ex, - "Failed to initialize PerformanceCounter category '{Category}' counter '{Counter}' instance '{Instance}'", - categoryName, - counterName, - instanceName ?? ""); - throw; - } - } - - private async Task GetTotalCpuUsageAsync() - { - try - { - this.EnsureSystemCountersInitialized(); - return this.totalCpuCounter?.NextValue() ?? 0; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error getting total CPU usage"); - return 0; - } - } - - private async Task GetAvailableMemoryAsync() - { - try - { - this.EnsureSystemCountersInitialized(); - return (long)(this.memoryCounter?.NextValue() ?? 0) * 1024 * 1024; // Convert MB to bytes - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error getting available memory"); - return 0; - } - } - - private async Task GetTotalPhysicalMemoryAsync() - { - var now = DateTime.UtcNow; - - lock (this.totalMemoryCacheLock) - { - if (this.cachedTotalPhysicalMemory > 0 && - (now - this.totalPhysicalMemoryCacheUtc) < this.totalPhysicalMemoryCacheDuration) - { - return this.cachedTotalPhysicalMemory; - } - } - - try - { - var scope = CreateCimv2ScopeWithTimeout(); - using var searcher = new ManagementObjectSearcher(scope, new ObjectQuery("SELECT TotalPhysicalMemory FROM Win32_ComputerSystem")); - foreach (var obj in searcher.Get()) - { - var totalMemory = Convert.ToInt64(obj["TotalPhysicalMemory"]); - - lock (this.totalMemoryCacheLock) - { - this.cachedTotalPhysicalMemory = totalMemory; - this.totalPhysicalMemoryCacheUtc = DateTime.UtcNow; - } - - return totalMemory; - } - - return 0; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error getting total physical memory"); - return 0; - } - } - - private async Task GetActiveProcessCountAsync() - { - var now = DateTime.UtcNow; - lock (this.processCountCacheLock) - { - if ((now - this.processCountCacheUtc) < this.processCountCacheDuration) - { - return this.cachedProcessCount; - } - } - - try - { - var scope = CreateCimv2ScopeWithTimeout(); - using var searcher = new ManagementObjectSearcher(scope, new ObjectQuery("SELECT Count(*) AS Count FROM Win32_Process")); - var result = searcher.Get().Cast().FirstOrDefault(); - var countValue = result?["Count"]; - var count = countValue != null ? Convert.ToInt32(countValue) : 0; - - lock (this.processCountCacheLock) - { - this.cachedProcessCount = count; - this.processCountCacheUtc = now; - } - - return count; - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to read process count via WMI, falling back to Process.GetProcesses"); - return Process.GetProcesses().Length; - } - } - - private static ManagementScope CreateCimv2ScopeWithTimeout() - { - var options = new ConnectionOptions { Timeout = WmiQueryTimeout }; - var scope = new ManagementScope(@"\\.\root\cimv2", options); - scope.Connect(); - return scope; - } - - private static int GetThreadCountSafe(int processId) - { - try - { - using var process = Process.GetProcessById(processId); - return process.Threads.Count; - } - catch - { - return 0; - } - } - - private void PopulateRuntimeTelemetry(SystemPerformanceMetrics metrics) - { - try - { - var gen0Collections = GC.CollectionCount(0); - var gen1Collections = GC.CollectionCount(1); - var gen2Collections = GC.CollectionCount(2); - var totalAllocatedBytes = GC.GetTotalAllocatedBytes(); - var gcInfo = GC.GetGCMemoryInfo(); - var lastGcPauseMs = GetLastGcPauseMilliseconds(gcInfo); - - metrics.Gen0Collections = gen0Collections; - metrics.Gen1Collections = gen1Collections; - metrics.Gen2Collections = gen2Collections; - metrics.TotalAllocatedBytes = totalAllocatedBytes; - metrics.ManagedHeapSizeBytes = gcInfo.HeapSizeBytes; - metrics.GcCommittedBytes = gcInfo.TotalCommittedBytes; - metrics.LastGcPauseMs = lastGcPauseMs; - - lock (this.runtimeTelemetryLock) - { - if (this.runtimeTelemetryInitialized) - { - metrics.Gen0CollectionsDelta = Math.Max(0, gen0Collections - this.previousGen0Collections); - metrics.Gen1CollectionsDelta = Math.Max(0, gen1Collections - this.previousGen1Collections); - metrics.Gen2CollectionsDelta = Math.Max(0, gen2Collections - this.previousGen2Collections); - metrics.AllocatedBytesDelta = Math.Max(0, totalAllocatedBytes - this.previousTotalAllocatedBytes); - } - else - { - metrics.Gen0CollectionsDelta = 0; - metrics.Gen1CollectionsDelta = 0; - metrics.Gen2CollectionsDelta = 0; - metrics.AllocatedBytesDelta = 0; - this.runtimeTelemetryInitialized = true; - } - - this.previousGen0Collections = gen0Collections; - this.previousGen1Collections = gen1Collections; - this.previousGen2Collections = gen2Collections; - this.previousTotalAllocatedBytes = totalAllocatedBytes; - - this.maxObservedGcPauseMs = Math.Max(this.maxObservedGcPauseMs, lastGcPauseMs); - metrics.MaxGcPauseMs = this.maxObservedGcPauseMs; - } - - using var currentProcess = Process.GetCurrentProcess(); - metrics.HandleCount = currentProcess.HandleCount; - metrics.ProcessWorkingSetBytes = currentProcess.WorkingSet64; - } - catch (Exception ex) - { - this.logger.LogDebug(ex, "Failed to collect runtime GC telemetry sample"); - } - } - - private async Task EmitGcDiagnosticsIfNeededAsync(SystemPerformanceMetrics metrics) - { - try - { - if (!this.settingsService.Settings.EnablePerformanceCounters) - { - return; - } - - if (metrics.Gen2CollectionsDelta <= 0 || metrics.LastGcPauseMs < Gen2PauseAlertThresholdMs) - { - return; - } - - var now = DateTime.UtcNow; - if ((now - this.lastGcPauseAlertUtc) < GcPauseAlertCooldown) - { - return; - } - - this.lastGcPauseAlertUtc = now; - - this.logger.LogWarning( - "Gen2 GC pause alert: LastPauseMs={LastPauseMs}, Gen2Delta={Gen2Delta}, HeapBytes={HeapBytes}, AllocDeltaBytes={AllocDeltaBytes}", - metrics.LastGcPauseMs, - metrics.Gen2CollectionsDelta, - metrics.ManagedHeapSizeBytes, - metrics.AllocatedBytesDelta); - - await this.enhancedLoggingService.LogSystemEventAsync( - LogEventTypes.Performance.SlowOperation, - $"Gen2 GC pause {metrics.LastGcPauseMs:F2}ms (delta={metrics.Gen2CollectionsDelta}, heap={metrics.ManagedHeapSizeBytes} bytes)", - LogLevel.Warning).ConfigureAwait(false); - } - catch (Exception ex) - { - this.logger.LogDebug(ex, "Failed to emit GC diagnostics alert"); - } - } - - private static double GetLastGcPauseMilliseconds(GCMemoryInfo gcInfo) - { - var pauseDurations = gcInfo.PauseDurations; - if (pauseDurations.Length == 0) - { - return 0; - } - - return pauseDurations[pauseDurations.Length - 1].TotalMilliseconds; - } - - private static string DetermineCoreType(int coreId, CpuTopologyModel? topology) - { - if (topology?.HasIntelHybrid == true) - { - // Intel hybrid architecture - if (coreId < topology.PerformanceCores.Count()) - { - return "P-Core"; - } - else - { - return "E-Core"; - } - } - - return "Standard"; - } - - private static bool IsHyperThreadedCore(int coreId, CpuTopologyModel? topology) - { - if (topology?.HasHyperThreading != true) - { - return false; - } - - // Simplified logic - in reality this would be more complex - return coreId >= topology.TotalPhysicalCores; - } - - private static int GetPhysicalCoreId(int coreId, CpuTopologyModel? topology) - { - if (topology?.HasHyperThreading == true) - { - return coreId / 2; // Simplified - assumes 2 threads per core - } - - return coreId; - } - - public void Dispose() - { - if (this.disposed) - { - return; - } - - this.monitoringTimer?.Dispose(); - Interlocked.Exchange(ref this.isMonitoringTickInProgress, 0); - this.totalCpuCounter?.Dispose(); - this.memoryCounter?.Dispose(); - - foreach (var counter in this.cpuCoreCounters) - { - counter?.Dispose(); - } - - this.cpuCoreCounters.Clear(); - this.disposed = true; - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Linq; + using System.Management; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public class PerformanceMonitoringService : IPerformanceMonitoringService, IDisposable + { + private readonly ILogger logger; + private readonly IProcessService processService; + private readonly ICpuTopologyService cpuTopologyService; + private readonly IApplicationSettingsService settingsService; + private readonly IEnhancedLoggingService enhancedLoggingService; + private readonly Queue historicalData; + private readonly object counterInitializationLock = new(); + private PerformanceCounter? totalCpuCounter; + private PerformanceCounter? memoryCounter; + private readonly List cpuCoreCounters; + private System.Threading.Timer? monitoringTimer; + private readonly object totalMemoryCacheLock = new(); + private readonly TimeSpan totalPhysicalMemoryCacheDuration = TimeSpan.FromMinutes(5); + private long cachedTotalPhysicalMemory; + private DateTime totalPhysicalMemoryCacheUtc = DateTime.MinValue; + private readonly object processCountCacheLock = new(); + private readonly TimeSpan processCountCacheDuration = TimeSpan.FromSeconds(5); + private int cachedProcessCount; + private DateTime processCountCacheUtc = DateTime.MinValue; + private readonly object runtimeTelemetryLock = new(); + private int isMonitoringTickInProgress; + private bool runtimeTelemetryInitialized; + private int previousGen0Collections; + private int previousGen1Collections; + private int previousGen2Collections; + private long previousTotalAllocatedBytes; + private double maxObservedGcPauseMs; + private DateTime lastGcPauseAlertUtc = DateTime.MinValue; + private bool isMonitoring; + private bool disposed; + + private static readonly TimeSpan GcPauseAlertCooldown = TimeSpan.FromMinutes(1); + private static readonly TimeSpan WmiQueryTimeout = TimeSpan.FromSeconds(5); + private const int HistoricalDataCapacity = 1000; + private const double Gen2PauseAlertThresholdMs = 100; + + public event EventHandler? MetricsUpdated; + + public PerformanceMonitoringService( + ILogger logger, + IProcessService processService, + ICpuTopologyService cpuTopologyService, + IApplicationSettingsService settingsService, + IEnhancedLoggingService enhancedLoggingService) + { + this.logger = logger; + this.processService = processService; + this.cpuTopologyService = cpuTopologyService; + this.settingsService = settingsService; + this.enhancedLoggingService = enhancedLoggingService; + this.historicalData = new Queue(HistoricalDataCapacity); + this.cpuCoreCounters = new List(); + } + + public async Task GetSystemMetricsAsync(bool lightweight = false) + { + try + { + var metrics = new SystemPerformanceMetrics + { + Timestamp = DateTime.UtcNow, + TotalCpuUsage = await this.GetTotalCpuUsageAsync().ConfigureAwait(false), + AvailableMemory = await this.GetAvailableMemoryAsync().ConfigureAwait(false), + }; + + // Calculate memory percentage + metrics.TotalMemory = await this.GetTotalPhysicalMemoryAsync().ConfigureAwait(false); + metrics.TotalMemoryUsage = Math.Max(0, metrics.TotalMemory - metrics.AvailableMemory); + metrics.MemoryUsagePercentage = metrics.TotalMemory > 0 + ? ((double)(metrics.TotalMemory - metrics.AvailableMemory) / metrics.TotalMemory) * 100 + : 0; + + this.PopulateRuntimeTelemetry(metrics); + + if (!lightweight) + { + metrics.CpuCoreUsages = await this.GetCpuCoreUsageAsync().ConfigureAwait(false); + metrics.ActiveProcessCount = await this.GetActiveProcessCountAsync().ConfigureAwait(false); + + // Get top processes + var topCpuProcesses = await this.GetTopCpuProcessesAsync(1).ConfigureAwait(false); + metrics.TopCpuProcess = topCpuProcesses.FirstOrDefault(); + + var topMemoryProcesses = await this.GetTopMemoryProcessesAsync(1).ConfigureAwait(false); + metrics.TopMemoryProcess = topMemoryProcesses.FirstOrDefault(); + + // Store in historical data + if (this.historicalData.Count >= HistoricalDataCapacity) + { + this.historicalData.Dequeue(); + } + + this.historicalData.Enqueue(metrics); + } + + return metrics; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error getting system metrics"); + return new SystemPerformanceMetrics(); + } + } + + public async Task> GetCpuCoreUsageAsync() + { + var coreUsages = new List(); + + try + { + this.EnsureCpuCoreCountersInitialized(); + var topology = await this.cpuTopologyService.DetectTopologyAsync().ConfigureAwait(false); + + for (int i = 0; i < this.cpuCoreCounters.Count; i++) + { + var counter = this.cpuCoreCounters[i]; + var usage = counter.NextValue(); + + var coreUsage = new CpuCoreUsage + { + CoreId = i, + CoreName = $"Core {i}", + Usage = usage, + CoreType = DetermineCoreType(i, topology), + IsHyperThreaded = IsHyperThreadedCore(i, topology), + PhysicalCoreId = GetPhysicalCoreId(i, topology), + }; + + coreUsages.Add(coreUsage); + } + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error getting CPU core usage"); + } + + return coreUsages; + } + + public async Task GetMemoryUsageAsync() + { + try + { + this.EnsureSystemCountersInitialized(); + var memoryInfo = new MemoryUsageInfo(); + + // Get physical memory info + var scope = CreateCimv2ScopeWithTimeout(); + using var searcher = new ManagementObjectSearcher(scope, new ObjectQuery("SELECT TotalPhysicalMemory FROM Win32_ComputerSystem")); + foreach (var obj in searcher.Get()) + { + memoryInfo.TotalPhysicalMemory = Convert.ToInt64(obj["TotalPhysicalMemory"]); + } + + // Get available memory + memoryInfo.AvailablePhysicalMemory = (long)(this.memoryCounter?.NextValue() ?? 0) * 1024 * 1024; // Convert MB to bytes + memoryInfo.UsedPhysicalMemory = memoryInfo.TotalPhysicalMemory - memoryInfo.AvailablePhysicalMemory; + memoryInfo.PhysicalMemoryUsagePercentage = memoryInfo.TotalPhysicalMemory > 0 + ? ((double)memoryInfo.UsedPhysicalMemory / memoryInfo.TotalPhysicalMemory) * 100 + : 0; + + // Get virtual memory info + using var memSearcher = new ManagementObjectSearcher(scope, new ObjectQuery("SELECT TotalVirtualMemorySize, FreeVirtualMemory FROM Win32_OperatingSystem")); + foreach (var obj in memSearcher.Get()) + { + memoryInfo.TotalVirtualMemory = Convert.ToInt64(obj["TotalVirtualMemorySize"]) * 1024; // Convert KB to bytes + memoryInfo.AvailableVirtualMemory = Convert.ToInt64(obj["FreeVirtualMemory"]) * 1024; + } + + memoryInfo.UsedVirtualMemory = memoryInfo.TotalVirtualMemory - memoryInfo.AvailableVirtualMemory; + memoryInfo.VirtualMemoryUsagePercentage = memoryInfo.TotalVirtualMemory > 0 + ? ((double)memoryInfo.UsedVirtualMemory / memoryInfo.TotalVirtualMemory) * 100 + : 0; + + return memoryInfo; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error getting memory usage"); + return new MemoryUsageInfo(); + } + } + + public async Task> GetTopCpuProcessesAsync(int count = 10) + { + try + { + var processes = await this.processService.GetProcessesAsync().ConfigureAwait(false); + return processes + .OrderByDescending(p => p.CpuUsage) + .Take(count) + .Select(p => new ProcessPerformanceInfo + { + ProcessId = p.ProcessId, + ProcessName = p.Name, + WindowTitle = p.MainWindowTitle, + CpuUsage = p.CpuUsage, + MemoryUsage = p.MemoryUsage, + ThreadCount = GetThreadCountSafe(p.ProcessId), + ExecutablePath = p.ExecutablePath ?? string.Empty, + Priority = p.Priority.ToString(), + }) + .ToList(); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error getting top CPU processes"); + return new List(); + } + } + + public async Task> GetTopMemoryProcessesAsync(int count = 10) + { + try + { + var processes = await this.processService.GetProcessesAsync().ConfigureAwait(false); + return processes + .OrderByDescending(p => p.MemoryUsage) + .Take(count) + .Select(p => new ProcessPerformanceInfo + { + ProcessId = p.ProcessId, + ProcessName = p.Name, + WindowTitle = p.MainWindowTitle, + CpuUsage = p.CpuUsage, + MemoryUsage = p.MemoryUsage, + ThreadCount = GetThreadCountSafe(p.ProcessId), + ExecutablePath = p.ExecutablePath ?? string.Empty, + Priority = p.Priority.ToString(), + }) + .ToList(); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error getting top memory processes"); + return new List(); + } + } + + public async Task StartMonitoringAsync() + { + if (this.isMonitoring) + { + return; + } + + this.logger.LogInformation("Starting performance monitoring"); + this.isMonitoring = true; + Interlocked.Exchange(ref this.isMonitoringTickInProgress, 0); + + // PERFORMANCE OPTIMIZATION: Increased interval from 1s to 2s for better performance + this.monitoringTimer = new System.Threading.Timer( + async _ => + { + if (Interlocked.Exchange(ref this.isMonitoringTickInProgress, 1) == 1) + { + return; + } + + try + { + var metrics = await this.GetSystemMetricsAsync().ConfigureAwait(false); + await this.EmitGcDiagnosticsIfNeededAsync(metrics).ConfigureAwait(false); + this.MetricsUpdated?.Invoke(this, new PerformanceMetricsUpdatedEventArgs(metrics)); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error during performance monitoring update"); + } + finally + { + Interlocked.Exchange(ref this.isMonitoringTickInProgress, 0); + } + }, null, TimeSpan.Zero, TimeSpan.FromSeconds(2)); + } + + public Task StopMonitoringAsync() + { + if (!this.isMonitoring) + { + return Task.CompletedTask; + } + + this.logger.LogInformation("Stopping performance monitoring"); + this.isMonitoring = false; + Interlocked.Exchange(ref this.isMonitoringTickInProgress, 0); + + this.monitoringTimer?.Dispose(); + this.monitoringTimer = null; + return Task.CompletedTask; + } + + public Task> GetHistoricalDataAsync(TimeSpan duration) + { + var cutoffTime = DateTime.UtcNow - duration; + var data = this.historicalData.Where(m => m.Timestamp >= cutoffTime).ToList(); + return Task.FromResult(data); + } + + public Task ClearHistoricalDataAsync() + { + this.historicalData.Clear(); + this.logger.LogInformation("Historical performance data cleared"); + return Task.CompletedTask; + } + + private void InitializeCpuCoreCounters() + { + var tempCounters = new List(); + + try + { + var coreCount = Environment.ProcessorCount; + for (int i = 0; i < coreCount; i++) + { + tempCounters.Add(this.CreatePrimedCounter("Processor", "% Processor Time", i.ToString())); + } + + this.cpuCoreCounters.Clear(); + this.cpuCoreCounters.AddRange(tempCounters); + + this.logger.LogInformation("Initialized {CoreCount} CPU core performance counters", coreCount); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error initializing CPU core counters"); + foreach (var counter in tempCounters) + { + try + { + counter.Dispose(); + } + catch + { + // Best effort cleanup for partially initialized counters. + } + } + + throw; + } + } + + private void EnsureSystemCountersInitialized() + { + if (this.totalCpuCounter != null && this.memoryCounter != null) + { + return; + } + + lock (this.counterInitializationLock) + { + if (this.totalCpuCounter != null && this.memoryCounter != null) + { + return; + } + + PerformanceCounter? totalCpu = null; + PerformanceCounter? memory = null; + + try + { + totalCpu = this.CreatePrimedCounter("Processor", "% Processor Time", "_Total"); + memory = this.CreatePrimedCounter("Memory", "Available MBytes"); + + this.totalCpuCounter = totalCpu; + this.memoryCounter = memory; + } + catch + { + totalCpu?.Dispose(); + memory?.Dispose(); + throw; + } + } + } + + private void EnsureCpuCoreCountersInitialized() + { + if (this.cpuCoreCounters.Count > 0) + { + return; + } + + lock (this.counterInitializationLock) + { + if (this.cpuCoreCounters.Count > 0) + { + return; + } + + this.InitializeCpuCoreCounters(); + } + } + + private PerformanceCounter CreatePrimedCounter(string categoryName, string counterName, string? instanceName = null) + { + try + { + var counter = string.IsNullOrWhiteSpace(instanceName) + ? new PerformanceCounter(categoryName, counterName) + : new PerformanceCounter(categoryName, counterName, instanceName); + + _ = counter.NextValue(); + return counter; + } + catch (Exception ex) + { + this.logger.LogError( + ex, + "Failed to initialize PerformanceCounter category '{Category}' counter '{Counter}' instance '{Instance}'", + categoryName, + counterName, + instanceName ?? ""); + throw; + } + } + + private async Task GetTotalCpuUsageAsync() + { + try + { + this.EnsureSystemCountersInitialized(); + return this.totalCpuCounter?.NextValue() ?? 0; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error getting total CPU usage"); + return 0; + } + } + + private async Task GetAvailableMemoryAsync() + { + try + { + this.EnsureSystemCountersInitialized(); + return (long)(this.memoryCounter?.NextValue() ?? 0) * 1024 * 1024; // Convert MB to bytes + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error getting available memory"); + return 0; + } + } + + private async Task GetTotalPhysicalMemoryAsync() + { + var now = DateTime.UtcNow; + + lock (this.totalMemoryCacheLock) + { + if (this.cachedTotalPhysicalMemory > 0 && + (now - this.totalPhysicalMemoryCacheUtc) < this.totalPhysicalMemoryCacheDuration) + { + return this.cachedTotalPhysicalMemory; + } + } + + try + { + var scope = CreateCimv2ScopeWithTimeout(); + using var searcher = new ManagementObjectSearcher(scope, new ObjectQuery("SELECT TotalPhysicalMemory FROM Win32_ComputerSystem")); + foreach (var obj in searcher.Get()) + { + var totalMemory = Convert.ToInt64(obj["TotalPhysicalMemory"]); + + lock (this.totalMemoryCacheLock) + { + this.cachedTotalPhysicalMemory = totalMemory; + this.totalPhysicalMemoryCacheUtc = DateTime.UtcNow; + } + + return totalMemory; + } + + return 0; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error getting total physical memory"); + return 0; + } + } + + private async Task GetActiveProcessCountAsync() + { + var now = DateTime.UtcNow; + lock (this.processCountCacheLock) + { + if ((now - this.processCountCacheUtc) < this.processCountCacheDuration) + { + return this.cachedProcessCount; + } + } + + try + { + var scope = CreateCimv2ScopeWithTimeout(); + using var searcher = new ManagementObjectSearcher(scope, new ObjectQuery("SELECT Count(*) AS Count FROM Win32_Process")); + var result = searcher.Get().Cast().FirstOrDefault(); + var countValue = result?["Count"]; + var count = countValue != null ? Convert.ToInt32(countValue) : 0; + + lock (this.processCountCacheLock) + { + this.cachedProcessCount = count; + this.processCountCacheUtc = now; + } + + return count; + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to read process count via WMI, falling back to Process.GetProcesses"); + return Process.GetProcesses().Length; + } + } + + private static ManagementScope CreateCimv2ScopeWithTimeout() + { + var options = new ConnectionOptions { Timeout = WmiQueryTimeout }; + var scope = new ManagementScope(@"\\.\root\cimv2", options); + scope.Connect(); + return scope; + } + + private static int GetThreadCountSafe(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + return process.Threads.Count; + } + catch + { + return 0; + } + } + + private void PopulateRuntimeTelemetry(SystemPerformanceMetrics metrics) + { + try + { + var gen0Collections = GC.CollectionCount(0); + var gen1Collections = GC.CollectionCount(1); + var gen2Collections = GC.CollectionCount(2); + var totalAllocatedBytes = GC.GetTotalAllocatedBytes(); + var gcInfo = GC.GetGCMemoryInfo(); + var lastGcPauseMs = GetLastGcPauseMilliseconds(gcInfo); + + metrics.Gen0Collections = gen0Collections; + metrics.Gen1Collections = gen1Collections; + metrics.Gen2Collections = gen2Collections; + metrics.TotalAllocatedBytes = totalAllocatedBytes; + metrics.ManagedHeapSizeBytes = gcInfo.HeapSizeBytes; + metrics.GcCommittedBytes = gcInfo.TotalCommittedBytes; + metrics.LastGcPauseMs = lastGcPauseMs; + + lock (this.runtimeTelemetryLock) + { + if (this.runtimeTelemetryInitialized) + { + metrics.Gen0CollectionsDelta = Math.Max(0, gen0Collections - this.previousGen0Collections); + metrics.Gen1CollectionsDelta = Math.Max(0, gen1Collections - this.previousGen1Collections); + metrics.Gen2CollectionsDelta = Math.Max(0, gen2Collections - this.previousGen2Collections); + metrics.AllocatedBytesDelta = Math.Max(0, totalAllocatedBytes - this.previousTotalAllocatedBytes); + } + else + { + metrics.Gen0CollectionsDelta = 0; + metrics.Gen1CollectionsDelta = 0; + metrics.Gen2CollectionsDelta = 0; + metrics.AllocatedBytesDelta = 0; + this.runtimeTelemetryInitialized = true; + } + + this.previousGen0Collections = gen0Collections; + this.previousGen1Collections = gen1Collections; + this.previousGen2Collections = gen2Collections; + this.previousTotalAllocatedBytes = totalAllocatedBytes; + + this.maxObservedGcPauseMs = Math.Max(this.maxObservedGcPauseMs, lastGcPauseMs); + metrics.MaxGcPauseMs = this.maxObservedGcPauseMs; + } + + using var currentProcess = Process.GetCurrentProcess(); + metrics.HandleCount = currentProcess.HandleCount; + metrics.ProcessWorkingSetBytes = currentProcess.WorkingSet64; + } + catch (Exception ex) + { + this.logger.LogDebug(ex, "Failed to collect runtime GC telemetry sample"); + } + } + + private async Task EmitGcDiagnosticsIfNeededAsync(SystemPerformanceMetrics metrics) + { + try + { + if (!this.settingsService.Settings.EnablePerformanceCounters) + { + return; + } + + if (metrics.Gen2CollectionsDelta <= 0 || metrics.LastGcPauseMs < Gen2PauseAlertThresholdMs) + { + return; + } + + var now = DateTime.UtcNow; + if ((now - this.lastGcPauseAlertUtc) < GcPauseAlertCooldown) + { + return; + } + + this.lastGcPauseAlertUtc = now; + + this.logger.LogWarning( + "Gen2 GC pause alert: LastPauseMs={LastPauseMs}, Gen2Delta={Gen2Delta}, HeapBytes={HeapBytes}, AllocDeltaBytes={AllocDeltaBytes}", + metrics.LastGcPauseMs, + metrics.Gen2CollectionsDelta, + metrics.ManagedHeapSizeBytes, + metrics.AllocatedBytesDelta); + + await this.enhancedLoggingService.LogSystemEventAsync( + LogEventTypes.Performance.SlowOperation, + $"Gen2 GC pause {metrics.LastGcPauseMs:F2}ms (delta={metrics.Gen2CollectionsDelta}, heap={metrics.ManagedHeapSizeBytes} bytes)", + LogLevel.Warning).ConfigureAwait(false); + } + catch (Exception ex) + { + this.logger.LogDebug(ex, "Failed to emit GC diagnostics alert"); + } + } + + private static double GetLastGcPauseMilliseconds(GCMemoryInfo gcInfo) + { + var pauseDurations = gcInfo.PauseDurations; + if (pauseDurations.Length == 0) + { + return 0; + } + + return pauseDurations[pauseDurations.Length - 1].TotalMilliseconds; + } + + private static string DetermineCoreType(int coreId, CpuTopologyModel? topology) + { + if (topology?.HasIntelHybrid == true) + { + // Intel hybrid architecture + if (coreId < topology.PerformanceCores.Count()) + { + return "P-Core"; + } + else + { + return "E-Core"; + } + } + + return "Standard"; + } + + private static bool IsHyperThreadedCore(int coreId, CpuTopologyModel? topology) + { + if (topology?.HasHyperThreading != true) + { + return false; + } + + // Simplified logic - in reality this would be more complex + return coreId >= topology.TotalPhysicalCores; + } + + private static int GetPhysicalCoreId(int coreId, CpuTopologyModel? topology) + { + if (topology?.HasHyperThreading == true) + { + return coreId / 2; // Simplified - assumes 2 threads per core + } + + return coreId; + } + + public void Dispose() + { + if (this.disposed) + { + return; + } + + this.monitoringTimer?.Dispose(); + Interlocked.Exchange(ref this.isMonitoringTickInProgress, 0); + this.totalCpuCounter?.Dispose(); + this.memoryCounter?.Dispose(); + + foreach (var counter in this.cpuCoreCounters) + { + counter?.Dispose(); + } + + this.cpuCoreCounters.Clear(); + this.disposed = true; + } + } +} + diff --git a/Services/PersistentProcessRuleJsonStore.cs b/Services/PersistentProcessRuleJsonStore.cs index c592ec4..ef88a26 100644 --- a/Services/PersistentProcessRuleJsonStore.cs +++ b/Services/PersistentProcessRuleJsonStore.cs @@ -1,69 +1,69 @@ -/* - * ThreadPilot - JSON-backed persistent process rule store. - */ -namespace ThreadPilot.Services -{ - using System.IO; - using System.Text.Json; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - - public sealed class PersistentProcessRuleJsonStore : IPersistentProcessRuleStore - { - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - }; - - private readonly Func filePathProvider; - private readonly ILogger? logger; - - public PersistentProcessRuleJsonStore(ILogger? logger = null) - : this(() => StoragePaths.PersistentRulesFilePath, logger) - { - } - - internal PersistentProcessRuleJsonStore( - Func filePathProvider, - ILogger? logger = null) - { - this.filePathProvider = filePathProvider ?? throw new ArgumentNullException(nameof(filePathProvider)); - this.logger = logger; - } - - public async Task> LoadAsync() - { - var filePath = this.filePathProvider(); - if (!File.Exists(filePath)) - { - return []; - } - - try - { - var json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); - return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; - } - catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) - { - this.logger?.LogWarning(ex, "Could not load persistent process rules from {FilePath}", filePath); - return []; - } - } - - public async Task SaveAsync(IReadOnlyList rules) - { - ArgumentNullException.ThrowIfNull(rules); - - var filePath = this.filePathProvider(); - var directory = Path.GetDirectoryName(filePath); - if (!string.IsNullOrWhiteSpace(directory)) - { - Directory.CreateDirectory(directory); - } - - var json = JsonSerializer.Serialize(rules, JsonOptions); - await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false); - } - } -} +/* + * ThreadPilot - JSON-backed persistent process rule store. + */ +namespace ThreadPilot.Services +{ + using System.IO; + using System.Text.Json; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public sealed class PersistentProcessRuleJsonStore : IPersistentProcessRuleStore + { + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + }; + + private readonly Func filePathProvider; + private readonly ILogger? logger; + + public PersistentProcessRuleJsonStore(ILogger? logger = null) + : this(() => StoragePaths.PersistentRulesFilePath, logger) + { + } + + internal PersistentProcessRuleJsonStore( + Func filePathProvider, + ILogger? logger = null) + { + this.filePathProvider = filePathProvider ?? throw new ArgumentNullException(nameof(filePathProvider)); + this.logger = logger; + } + + public async Task> LoadAsync() + { + var filePath = this.filePathProvider(); + if (!File.Exists(filePath)) + { + return []; + } + + try + { + var json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); + return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + { + this.logger?.LogWarning(ex, "Could not load persistent process rules from {FilePath}", filePath); + return []; + } + } + + public async Task SaveAsync(IReadOnlyList rules) + { + ArgumentNullException.ThrowIfNull(rules); + + var filePath = this.filePathProvider(); + var directory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + var json = JsonSerializer.Serialize(rules, JsonOptions); + await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false); + } + } +} diff --git a/Services/PersistentProcessRuleMatcher.cs b/Services/PersistentProcessRuleMatcher.cs index 69955cd..bf69ecb 100644 --- a/Services/PersistentProcessRuleMatcher.cs +++ b/Services/PersistentProcessRuleMatcher.cs @@ -1,58 +1,58 @@ -/* - * ThreadPilot - persistent process rule matcher. - */ -namespace ThreadPilot.Services -{ - using System.IO; - using ThreadPilot.Models; - - public interface IPersistentProcessRuleMatcher - { - bool IsMatch(PersistentProcessRule rule, ProcessModel process); - } - - public sealed class PersistentProcessRuleMatcher : IPersistentProcessRuleMatcher - { - public bool IsMatch(PersistentProcessRule rule, ProcessModel process) - { - ArgumentNullException.ThrowIfNull(rule); - ArgumentNullException.ThrowIfNull(process); - - if (!rule.IsEnabled) - { - return false; - } - - var rulePath = NormalizePath(rule.ExecutablePath); - if (!string.IsNullOrWhiteSpace(rulePath)) - { - var processPath = NormalizePath(process.ExecutablePath); - return !string.IsNullOrWhiteSpace(processPath) && - string.Equals(rulePath, processPath, StringComparison.OrdinalIgnoreCase); - } - - return !string.IsNullOrWhiteSpace(rule.ProcessName) && - string.Equals(rule.ProcessName.Trim(), process.Name?.Trim(), StringComparison.OrdinalIgnoreCase); - } - - private static string? NormalizePath(string? path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return null; - } - - var trimmed = path.Trim(); - try - { - trimmed = Path.GetFullPath(trimmed); - } - catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) - { - // Keep matching best-effort for inaccessible or malformed process paths. - } - - return trimmed.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - } - } -} +/* + * ThreadPilot - persistent process rule matcher. + */ +namespace ThreadPilot.Services +{ + using System.IO; + using ThreadPilot.Models; + + public interface IPersistentProcessRuleMatcher + { + bool IsMatch(PersistentProcessRule rule, ProcessModel process); + } + + public sealed class PersistentProcessRuleMatcher : IPersistentProcessRuleMatcher + { + public bool IsMatch(PersistentProcessRule rule, ProcessModel process) + { + ArgumentNullException.ThrowIfNull(rule); + ArgumentNullException.ThrowIfNull(process); + + if (!rule.IsEnabled) + { + return false; + } + + var rulePath = NormalizePath(rule.ExecutablePath); + if (!string.IsNullOrWhiteSpace(rulePath)) + { + var processPath = NormalizePath(process.ExecutablePath); + return !string.IsNullOrWhiteSpace(processPath) && + string.Equals(rulePath, processPath, StringComparison.OrdinalIgnoreCase); + } + + return !string.IsNullOrWhiteSpace(rule.ProcessName) && + string.Equals(rule.ProcessName.Trim(), process.Name?.Trim(), StringComparison.OrdinalIgnoreCase); + } + + private static string? NormalizePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + var trimmed = path.Trim(); + try + { + trimmed = Path.GetFullPath(trimmed); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + // Keep matching best-effort for inaccessible or malformed process paths. + } + + return trimmed.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + } +} diff --git a/Services/PersistentRuleAutoApplyService.cs b/Services/PersistentRuleAutoApplyService.cs index 11d9161..4e4cc92 100644 --- a/Services/PersistentRuleAutoApplyService.cs +++ b/Services/PersistentRuleAutoApplyService.cs @@ -1,328 +1,328 @@ -/* - * ThreadPilot - persistent rule runtime auto-apply coordinator. - */ -namespace ThreadPilot.Services -{ - using System.Collections.Concurrent; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - - public interface IPersistentRuleAutoApplyService - { - Task> ApplyForDiscoveredProcessesAsync( - IEnumerable processes, - CancellationToken cancellationToken = default); - - Task> ApplyForProcessStartAsync( - ProcessModel process, - CancellationToken cancellationToken = default); - - void MarkProcessExited(int processId); - } - - public sealed record PersistentRuleAutoApplyResult - { - public bool Success { get; init; } - - public string RuleId { get; init; } = string.Empty; - - public int ProcessId { get; init; } - - public string ProcessName { get; init; } = string.Empty; - - public string? ErrorCode { get; init; } - - public string UserMessage { get; init; } = string.Empty; - - public string TechnicalMessage { get; init; } = string.Empty; - - public bool IsAccessDenied { get; init; } - - public bool IsAntiCheatLikely { get; init; } - - public bool IsProcessExited { get; init; } - - public static PersistentRuleAutoApplyResult FromApplyResult(PersistentRuleApplyResult result) => - new() - { - Success = result.Success, - RuleId = result.RuleId, - ProcessId = result.ProcessId, - ProcessName = result.ProcessName, - ErrorCode = result.ErrorCode, - UserMessage = result.IsAntiCheatLikely - ? ProcessOperationUserMessages.PersistentRulesProtectedProcessWarning - : result.UserMessage, - TechnicalMessage = result.TechnicalMessage, - IsAccessDenied = result.IsAccessDenied, - IsAntiCheatLikely = result.IsAntiCheatLikely, - IsProcessExited = result.IsProcessExited, - }; - } - - public sealed class PersistentRuleAutoApplyService : IPersistentRuleAutoApplyService - { - private static readonly TimeSpan DefaultCooldown = TimeSpan.FromSeconds(30); - - private readonly IPersistentProcessRuleStore ruleStore; - private readonly IPersistentProcessRuleMatcher matcher; - private readonly IPersistentRulesEngine rulesEngine; - private readonly IApplicationSettingsService settingsService; - private readonly ILogger logger; - private readonly IActivityAuditService? activityAuditService; - private readonly Func nowProvider; - private readonly TimeSpan cooldown; - private readonly ConcurrentDictionary recentAttempts = new(); - - public PersistentRuleAutoApplyService( - IPersistentProcessRuleStore ruleStore, - IPersistentProcessRuleMatcher matcher, - IPersistentRulesEngine rulesEngine, - IApplicationSettingsService settingsService, - ILogger logger, - IActivityAuditService? activityAuditService = null) - : this(ruleStore, matcher, rulesEngine, settingsService, logger, () => DateTimeOffset.UtcNow, DefaultCooldown, activityAuditService) - { - } - - public PersistentRuleAutoApplyService( - IPersistentProcessRuleStore ruleStore, - IPersistentProcessRuleMatcher matcher, - IPersistentRulesEngine rulesEngine, - IApplicationSettingsService settingsService, - ILogger logger, - Func nowProvider, - TimeSpan cooldown, - IActivityAuditService? activityAuditService = null) - { - this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore)); - this.matcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); - this.rulesEngine = rulesEngine ?? throw new ArgumentNullException(nameof(rulesEngine)); - this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.nowProvider = nowProvider ?? throw new ArgumentNullException(nameof(nowProvider)); - this.cooldown = cooldown <= TimeSpan.Zero ? DefaultCooldown : cooldown; - this.activityAuditService = activityAuditService; - } - - public async Task> ApplyForDiscoveredProcessesAsync( - IEnumerable processes, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(processes); - - var snapshot = processes - .Where(IsProcessEligible) - .GroupBy(process => process.ProcessId) - .Select(group => group.First()) - .ToList(); - this.ClearAttemptsForMissingProcesses(snapshot.Select(process => process.ProcessId).ToHashSet()); - - if (!this.IsEnabled() || snapshot.Count == 0) - { - return Array.Empty(); - } - - var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false); - if (rules.Count == 0) - { - return Array.Empty(); - } - - var results = new List(); - foreach (var process in snapshot) - { - cancellationToken.ThrowIfCancellationRequested(); - results.AddRange(await this.ApplyForProcessAsync(process, rules, cancellationToken).ConfigureAwait(false)); - } - - return results; - } - - public async Task> ApplyForProcessStartAsync( - ProcessModel process, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(process); - - if (!this.IsEnabled() || !IsProcessEligible(process)) - { - return Array.Empty(); - } - - var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false); - return await this.ApplyForProcessAsync(process, rules, cancellationToken).ConfigureAwait(false); - } - - public void MarkProcessExited(int processId) - { - foreach (var key in this.recentAttempts.Keys.Where(key => key.ProcessId == processId)) - { - this.recentAttempts.TryRemove(key, out _); - } - } - - private async Task> ApplyForProcessAsync( - ProcessModel process, - IReadOnlyList rules, - CancellationToken cancellationToken) - { - var now = this.nowProvider(); - var candidates = rules - .Where(rule => rule.IsEnabled && this.matcher.IsMatch(rule, process)) - .ToList(); - - if (candidates.Count == 0) - { - return Array.Empty(); - } - - var selectedRules = candidates - .Where(rule => this.TryRecordAttempt(process.ProcessId, rule, now)) - .ToList(); - - if (selectedRules.Count == 0) - { - this.logger.LogDebug( - "Persistent rule auto-apply suppressed by cooldown for process {ProcessName} (PID: {ProcessId})", - process.Name, - process.ProcessId); - return Array.Empty(); - } - - var selectedSignatures = selectedRules - .Select(GetRuleSignature) - .ToHashSet(StringComparer.Ordinal); - - try - { - // Runtime auto-apply only runs while ThreadPilot is open; it does not use registry, - // IFEO, services, or protected-process bypass techniques. - var applyResults = await this.rulesEngine - .ApplyMatchingRulesAsync( - process, - rule => selectedSignatures.Contains(GetRuleSignature(rule)), - cancellationToken) - .ConfigureAwait(false); - - var results = applyResults.Select(PersistentRuleAutoApplyResult.FromApplyResult).ToList(); - foreach (var result in results) - { - await this.LogResultAsync(result).ConfigureAwait(false); - } - - return results; - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - this.logger.LogWarning( - ex, - "Persistent rule auto-apply failed for process {ProcessName} (PID: {ProcessId})", - process.Name, - process.ProcessId); - - return selectedRules - .Select(rule => new PersistentRuleAutoApplyResult - { - Success = false, - RuleId = rule.Id, - ProcessId = process.ProcessId, - ProcessName = process.Name, - UserMessage = "ThreadPilot could not apply the saved rule.", - TechnicalMessage = ex.Message, - }) - .ToList(); - } - } - - private bool TryRecordAttempt(int processId, PersistentProcessRule rule, DateTimeOffset now) - { - var key = new RuleAttemptKey(processId, GetRuleSignature(rule)); - if (this.recentAttempts.TryGetValue(key, out var lastAttempt) && - now - lastAttempt < this.cooldown) - { - return false; - } - - this.recentAttempts[key] = now; - return true; - } - - private void ClearAttemptsForMissingProcesses(HashSet currentProcessIds) - { - foreach (var key in this.recentAttempts.Keys.Where(key => !currentProcessIds.Contains(key.ProcessId))) - { - this.recentAttempts.TryRemove(key, out _); - } - } - - private async Task LogResultAsync(PersistentRuleAutoApplyResult result) - { - if (result.Success) - { - this.logger.LogInformation( - "Applied saved persistent rule {RuleId} to process {ProcessName} (PID: {ProcessId})", - result.RuleId, - result.ProcessName, - result.ProcessId); - await this.LogActivityResultAsync(result).ConfigureAwait(false); - return; - } - - var logLevel = result.IsAccessDenied || result.IsAntiCheatLikely || result.IsProcessExited - ? LogLevel.Debug - : LogLevel.Warning; - this.logger.Log( - logLevel, - "Persistent rule {RuleId} was not applied to process {ProcessName} (PID: {ProcessId}): {Message}", - result.RuleId, - result.ProcessName, - result.ProcessId, - result.UserMessage); - await this.LogActivityResultAsync(result).ConfigureAwait(false); - } - - private async Task LogActivityResultAsync(PersistentRuleAutoApplyResult result) - { - if (this.activityAuditService == null) - { - return; - } - - var action = result.Success - ? "PersistentRuleAutoApplied" - : "PersistentRuleAutoApplyFailed"; - var message = result.Success - ? $"Auto-applied saved rule for {result.ProcessName}." - : $"Failed to auto-apply saved rule for {result.ProcessName}: {result.UserMessage}"; - - try - { - await this.activityAuditService - .LogUserActionAsync( - action, - message, - $"Rule: {result.RuleId}, PID: {result.ProcessId}") - .ConfigureAwait(false); - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to write persistent rule activity audit entry"); - } - } - - private bool IsEnabled() => - this.settingsService.Settings.ApplyPersistentRulesOnProcessStart; - - private static bool IsProcessEligible(ProcessModel process) => - process.ProcessId > 0 && !string.IsNullOrWhiteSpace(process.Name); - - private static string GetRuleSignature(PersistentProcessRule rule) => - string.Join( - "|", - string.IsNullOrWhiteSpace(rule.Id) ? rule.Name : rule.Id, - rule.UpdatedAt.ToUniversalTime().Ticks); - - private readonly record struct RuleAttemptKey(int ProcessId, string RuleSignature); - } -} +/* + * ThreadPilot - persistent rule runtime auto-apply coordinator. + */ +namespace ThreadPilot.Services +{ + using System.Collections.Concurrent; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public interface IPersistentRuleAutoApplyService + { + Task> ApplyForDiscoveredProcessesAsync( + IEnumerable processes, + CancellationToken cancellationToken = default); + + Task> ApplyForProcessStartAsync( + ProcessModel process, + CancellationToken cancellationToken = default); + + void MarkProcessExited(int processId); + } + + public sealed record PersistentRuleAutoApplyResult + { + public bool Success { get; init; } + + public string RuleId { get; init; } = string.Empty; + + public int ProcessId { get; init; } + + public string ProcessName { get; init; } = string.Empty; + + public string? ErrorCode { get; init; } + + public string UserMessage { get; init; } = string.Empty; + + public string TechnicalMessage { get; init; } = string.Empty; + + public bool IsAccessDenied { get; init; } + + public bool IsAntiCheatLikely { get; init; } + + public bool IsProcessExited { get; init; } + + public static PersistentRuleAutoApplyResult FromApplyResult(PersistentRuleApplyResult result) => + new() + { + Success = result.Success, + RuleId = result.RuleId, + ProcessId = result.ProcessId, + ProcessName = result.ProcessName, + ErrorCode = result.ErrorCode, + UserMessage = result.IsAntiCheatLikely + ? ProcessOperationUserMessages.PersistentRulesProtectedProcessWarning + : result.UserMessage, + TechnicalMessage = result.TechnicalMessage, + IsAccessDenied = result.IsAccessDenied, + IsAntiCheatLikely = result.IsAntiCheatLikely, + IsProcessExited = result.IsProcessExited, + }; + } + + public sealed class PersistentRuleAutoApplyService : IPersistentRuleAutoApplyService + { + private static readonly TimeSpan DefaultCooldown = TimeSpan.FromSeconds(30); + + private readonly IPersistentProcessRuleStore ruleStore; + private readonly IPersistentProcessRuleMatcher matcher; + private readonly IPersistentRulesEngine rulesEngine; + private readonly IApplicationSettingsService settingsService; + private readonly ILogger logger; + private readonly IActivityAuditService? activityAuditService; + private readonly Func nowProvider; + private readonly TimeSpan cooldown; + private readonly ConcurrentDictionary recentAttempts = new(); + + public PersistentRuleAutoApplyService( + IPersistentProcessRuleStore ruleStore, + IPersistentProcessRuleMatcher matcher, + IPersistentRulesEngine rulesEngine, + IApplicationSettingsService settingsService, + ILogger logger, + IActivityAuditService? activityAuditService = null) + : this(ruleStore, matcher, rulesEngine, settingsService, logger, () => DateTimeOffset.UtcNow, DefaultCooldown, activityAuditService) + { + } + + public PersistentRuleAutoApplyService( + IPersistentProcessRuleStore ruleStore, + IPersistentProcessRuleMatcher matcher, + IPersistentRulesEngine rulesEngine, + IApplicationSettingsService settingsService, + ILogger logger, + Func nowProvider, + TimeSpan cooldown, + IActivityAuditService? activityAuditService = null) + { + this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore)); + this.matcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); + this.rulesEngine = rulesEngine ?? throw new ArgumentNullException(nameof(rulesEngine)); + this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.nowProvider = nowProvider ?? throw new ArgumentNullException(nameof(nowProvider)); + this.cooldown = cooldown <= TimeSpan.Zero ? DefaultCooldown : cooldown; + this.activityAuditService = activityAuditService; + } + + public async Task> ApplyForDiscoveredProcessesAsync( + IEnumerable processes, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(processes); + + var snapshot = processes + .Where(IsProcessEligible) + .GroupBy(process => process.ProcessId) + .Select(group => group.First()) + .ToList(); + this.ClearAttemptsForMissingProcesses(snapshot.Select(process => process.ProcessId).ToHashSet()); + + if (!this.IsEnabled() || snapshot.Count == 0) + { + return Array.Empty(); + } + + var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false); + if (rules.Count == 0) + { + return Array.Empty(); + } + + var results = new List(); + foreach (var process in snapshot) + { + cancellationToken.ThrowIfCancellationRequested(); + results.AddRange(await this.ApplyForProcessAsync(process, rules, cancellationToken).ConfigureAwait(false)); + } + + return results; + } + + public async Task> ApplyForProcessStartAsync( + ProcessModel process, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(process); + + if (!this.IsEnabled() || !IsProcessEligible(process)) + { + return Array.Empty(); + } + + var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false); + return await this.ApplyForProcessAsync(process, rules, cancellationToken).ConfigureAwait(false); + } + + public void MarkProcessExited(int processId) + { + foreach (var key in this.recentAttempts.Keys.Where(key => key.ProcessId == processId)) + { + this.recentAttempts.TryRemove(key, out _); + } + } + + private async Task> ApplyForProcessAsync( + ProcessModel process, + IReadOnlyList rules, + CancellationToken cancellationToken) + { + var now = this.nowProvider(); + var candidates = rules + .Where(rule => rule.IsEnabled && this.matcher.IsMatch(rule, process)) + .ToList(); + + if (candidates.Count == 0) + { + return Array.Empty(); + } + + var selectedRules = candidates + .Where(rule => this.TryRecordAttempt(process.ProcessId, rule, now)) + .ToList(); + + if (selectedRules.Count == 0) + { + this.logger.LogDebug( + "Persistent rule auto-apply suppressed by cooldown for process {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + return Array.Empty(); + } + + var selectedSignatures = selectedRules + .Select(GetRuleSignature) + .ToHashSet(StringComparer.Ordinal); + + try + { + // Runtime auto-apply only runs while ThreadPilot is open; it does not use registry, + // IFEO, services, or protected-process bypass techniques. + var applyResults = await this.rulesEngine + .ApplyMatchingRulesAsync( + process, + rule => selectedSignatures.Contains(GetRuleSignature(rule)), + cancellationToken) + .ConfigureAwait(false); + + var results = applyResults.Select(PersistentRuleAutoApplyResult.FromApplyResult).ToList(); + foreach (var result in results) + { + await this.LogResultAsync(result).ConfigureAwait(false); + } + + return results; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogWarning( + ex, + "Persistent rule auto-apply failed for process {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + + return selectedRules + .Select(rule => new PersistentRuleAutoApplyResult + { + Success = false, + RuleId = rule.Id, + ProcessId = process.ProcessId, + ProcessName = process.Name, + UserMessage = "ThreadPilot could not apply the saved rule.", + TechnicalMessage = ex.Message, + }) + .ToList(); + } + } + + private bool TryRecordAttempt(int processId, PersistentProcessRule rule, DateTimeOffset now) + { + var key = new RuleAttemptKey(processId, GetRuleSignature(rule)); + if (this.recentAttempts.TryGetValue(key, out var lastAttempt) && + now - lastAttempt < this.cooldown) + { + return false; + } + + this.recentAttempts[key] = now; + return true; + } + + private void ClearAttemptsForMissingProcesses(HashSet currentProcessIds) + { + foreach (var key in this.recentAttempts.Keys.Where(key => !currentProcessIds.Contains(key.ProcessId))) + { + this.recentAttempts.TryRemove(key, out _); + } + } + + private async Task LogResultAsync(PersistentRuleAutoApplyResult result) + { + if (result.Success) + { + this.logger.LogInformation( + "Applied saved persistent rule {RuleId} to process {ProcessName} (PID: {ProcessId})", + result.RuleId, + result.ProcessName, + result.ProcessId); + await this.LogActivityResultAsync(result).ConfigureAwait(false); + return; + } + + var logLevel = result.IsAccessDenied || result.IsAntiCheatLikely || result.IsProcessExited + ? LogLevel.Debug + : LogLevel.Warning; + this.logger.Log( + logLevel, + "Persistent rule {RuleId} was not applied to process {ProcessName} (PID: {ProcessId}): {Message}", + result.RuleId, + result.ProcessName, + result.ProcessId, + result.UserMessage); + await this.LogActivityResultAsync(result).ConfigureAwait(false); + } + + private async Task LogActivityResultAsync(PersistentRuleAutoApplyResult result) + { + if (this.activityAuditService == null) + { + return; + } + + var action = result.Success + ? "PersistentRuleAutoApplied" + : "PersistentRuleAutoApplyFailed"; + var message = result.Success + ? $"Auto-applied saved rule for {result.ProcessName}." + : $"Failed to auto-apply saved rule for {result.ProcessName}: {result.UserMessage}"; + + try + { + await this.activityAuditService + .LogUserActionAsync( + action, + message, + $"Rule: {result.RuleId}, PID: {result.ProcessId}") + .ConfigureAwait(false); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to write persistent rule activity audit entry"); + } + } + + private bool IsEnabled() => + this.settingsService.Settings.ApplyPersistentRulesOnProcessStart; + + private static bool IsProcessEligible(ProcessModel process) => + process.ProcessId > 0 && !string.IsNullOrWhiteSpace(process.Name); + + private static string GetRuleSignature(PersistentProcessRule rule) => + string.Join( + "|", + string.IsNullOrWhiteSpace(rule.Id) ? rule.Name : rule.Id, + rule.UpdatedAt.ToUniversalTime().Ticks); + + private readonly record struct RuleAttemptKey(int ProcessId, string RuleSignature); + } +} diff --git a/Services/PersistentRulesEngine.cs b/Services/PersistentRulesEngine.cs index d854f98..ddebb51 100644 --- a/Services/PersistentRulesEngine.cs +++ b/Services/PersistentRulesEngine.cs @@ -1,305 +1,305 @@ -/* - * ThreadPilot - persistent rules engine foundation. - */ -namespace ThreadPilot.Services -{ - using System.Diagnostics; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - - public interface IPersistentRulesEngine - { - Task> ApplyMatchingRulesAsync( - ProcessModel process, - Predicate? ruleFilter = null, - CancellationToken cancellationToken = default); - } - - public sealed class PersistentRulesEngine : IPersistentRulesEngine - { - private const string MissingAffinityErrorCode = "PersistentRuleMissingAffinity"; - private const string MissingMemoryPriorityErrorCode = "PersistentRuleMissingMemoryPriority"; - private const string MissingPriorityErrorCode = "PersistentRuleMissingPriority"; - private const string MemoryPriorityApplyFailedErrorCode = "MemoryPriorityApplyFailed"; - private const string NoActionsErrorCode = "PersistentRuleNoActions"; - private const string PriorityApplyFailedErrorCode = "PriorityApplyFailed"; - private const string RealtimePriorityBlockedErrorCode = "RealtimePriorityBlocked"; - - private readonly IPersistentProcessRuleStore ruleStore; - private readonly IPersistentProcessRuleMatcher matcher; - private readonly IAffinityApplyService affinityApplyService; - private readonly IProcessService processService; - private readonly IProcessMemoryPriorityService memoryPriorityService; - private readonly ILogger logger; - - public PersistentRulesEngine( - IPersistentProcessRuleStore ruleStore, - IPersistentProcessRuleMatcher matcher, - IAffinityApplyService affinityApplyService, - IProcessService processService, - IProcessMemoryPriorityService memoryPriorityService, - ILogger logger) - { - this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore)); - this.matcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); - this.affinityApplyService = affinityApplyService ?? throw new ArgumentNullException(nameof(affinityApplyService)); - this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); - this.memoryPriorityService = memoryPriorityService ?? throw new ArgumentNullException(nameof(memoryPriorityService)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task> ApplyMatchingRulesAsync( - ProcessModel process, - Predicate? ruleFilter = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(process); - - var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false); - var results = new List(); - - foreach (var rule in rules.Where(rule => - (ruleFilter == null || ruleFilter(rule)) && - this.matcher.IsMatch(rule, process))) - { - cancellationToken.ThrowIfCancellationRequested(); - results.Add(await this.ApplyRuleAsync(rule, process, cancellationToken).ConfigureAwait(false)); - } - - return results; - } - - private async Task ApplyRuleAsync( - PersistentProcessRule rule, - ProcessModel process, - CancellationToken cancellationToken) - { - var result = CreateSuccessResult(rule, process); - var success = true; - - if (!rule.ApplyAffinityOnStart && !rule.ApplyPriorityOnStart && !rule.ApplyMemoryPriorityOnStart) - { - return MarkRuleConfigurationFailure( - result, - rule, - NoActionsErrorCode, - "This saved rule has no actions to apply."); - } - - if (rule.ApplyAffinityOnStart) - { - if (rule.CpuSelection == null && !rule.LegacyAffinityMask.HasValue) - { - success = false; - result = MarkRuleConfigurationFailure( - result, - rule, - MissingAffinityErrorCode, - "This saved rule has no affinity selection to apply."); - } - else - { - var affinityResult = await this.ApplyAffinityAsync(rule, process).ConfigureAwait(false); - if (affinityResult.Success) - { - result = result with { AffinityApplied = true }; - } - else - { - success = false; - result = MergeAffinityFailure(result, affinityResult); - } - } - } - - if (rule.ApplyPriorityOnStart && !result.IsProcessExited) - { - if (!rule.Priority.HasValue) - { - success = false; - result = MarkRuleConfigurationFailure( - result, - rule, - MissingPriorityErrorCode, - "This saved rule has no priority value to apply."); - } - else - { - cancellationToken.ThrowIfCancellationRequested(); - try - { - await this.processService.SetProcessPriority(process, rule.Priority.Value).ConfigureAwait(false); - result = result with { PriorityApplied = true }; - } - catch (Exception ex) - { - success = false; - result = this.MergePriorityFailure(result, ex); - } - } - } - - if (rule.ApplyMemoryPriorityOnStart && !result.IsProcessExited) - { - if (!rule.MemoryPriority.HasValue) - { - success = false; - result = MarkRuleConfigurationFailure( - result, - rule, - MissingMemoryPriorityErrorCode, - "This saved rule has no memory priority value to apply."); - } - else - { - cancellationToken.ThrowIfCancellationRequested(); - var memoryPriorityResult = await this.memoryPriorityService - .SetMemoryPriorityAsync(process, rule.MemoryPriority.Value) - .ConfigureAwait(false); - if (memoryPriorityResult.Success) - { - result = result with { MemoryPriorityApplied = true }; - } - else - { - success = false; - result = this.MergeMemoryPriorityFailure(result, memoryPriorityResult); - } - } - } - - return result with - { - Success = success, - UserMessage = success ? "Persistent rule applied." : result.UserMessage, - TechnicalMessage = success ? $"Persistent rule '{rule.Name}' applied to process {process.Name}." : result.TechnicalMessage, - }; - } - - private PersistentRuleApplyResult MergeMemoryPriorityFailure( - PersistentRuleApplyResult result, - ProcessOperationResult memoryPriorityResult) - { - this.logger.LogWarning( - "Persistent rule memory priority apply failed for rule {RuleId} on process {ProcessName} (PID: {ProcessId}): {Message}", - result.RuleId, - result.ProcessName, - result.ProcessId, - memoryPriorityResult.TechnicalMessage); - - return result with - { - ErrorCode = string.IsNullOrWhiteSpace(memoryPriorityResult.ErrorCode) - ? MemoryPriorityApplyFailedErrorCode - : memoryPriorityResult.ErrorCode, - UserMessage = string.IsNullOrWhiteSpace(memoryPriorityResult.UserMessage) - ? "ThreadPilot could not apply the saved memory priority rule." - : memoryPriorityResult.UserMessage, - TechnicalMessage = memoryPriorityResult.TechnicalMessage, - IsAccessDenied = result.IsAccessDenied || memoryPriorityResult.IsAccessDenied, - IsAntiCheatLikely = result.IsAntiCheatLikely || memoryPriorityResult.IsAntiCheatLikely, - IsProcessExited = result.IsProcessExited || memoryPriorityResult.IsProcessExited, - }; - } - - private Task ApplyAffinityAsync(PersistentProcessRule rule, ProcessModel process) - { - if (rule.CpuSelection != null) - { - return this.affinityApplyService.ApplyAsync(process, rule.CpuSelection); - } - - if (rule.LegacyAffinityMask.HasValue) - { - return this.affinityApplyService.ApplyAsync(process, rule.LegacyAffinityMask.Value); - } - - return Task.FromResult(AffinityApplyResult.Succeeded(0, process.ProcessorAffinity)); - } - - private static PersistentRuleApplyResult MarkRuleConfigurationFailure( - PersistentRuleApplyResult result, - PersistentProcessRule rule, - string errorCode, - string userMessage) => - result with - { - Success = false, - ErrorCode = errorCode, - UserMessage = userMessage, - TechnicalMessage = $"Persistent rule '{rule.Name}' ({rule.Id}) is incomplete: {userMessage}", - }; - - private PersistentRuleApplyResult MergePriorityFailure(PersistentRuleApplyResult result, Exception ex) - { - this.logger.LogWarning( - ex, - "Persistent rule priority apply failed for rule {RuleId} on process {ProcessName} (PID: {ProcessId})", - result.RuleId, - result.ProcessName, - result.ProcessId); - - var isProcessExited = AffinityApplyExceptionClassifier.IsProcessExited(ex); - var isAccessDenied = AffinityApplyExceptionClassifier.IsAccessDenied(ex); - var isAntiCheatLikely = AffinityApplyExceptionClassifier.IsAntiCheatLikely(ex); - var isRealtimeBlocked = string.Equals( - ex.Message, - ProcessOperationUserMessages.RealtimePriorityBlocked, - StringComparison.Ordinal); - - return result with - { - ErrorCode = isRealtimeBlocked - ? RealtimePriorityBlockedErrorCode - : isProcessExited - ? AffinityApplyErrorCodes.ProcessExited - : isAntiCheatLikely - ? AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely - : isAccessDenied - ? AffinityApplyErrorCodes.AccessDenied - : PriorityApplyFailedErrorCode, - UserMessage = isRealtimeBlocked - ? ProcessOperationUserMessages.RealtimePriorityBlocked - : isProcessExited - ? ProcessOperationUserMessages.ProcessExited - : isAntiCheatLikely - ? ProcessOperationUserMessages.PersistentRulesProtectedProcessWarning - : isAccessDenied - ? ProcessOperationUserMessages.AccessDenied - : "ThreadPilot could not apply the saved priority rule.", - TechnicalMessage = ex.Message, - IsAccessDenied = result.IsAccessDenied || isAccessDenied, - IsAntiCheatLikely = result.IsAntiCheatLikely || isAntiCheatLikely, - IsProcessExited = result.IsProcessExited || isProcessExited, - }; - } - - private static PersistentRuleApplyResult CreateSuccessResult(PersistentProcessRule rule, ProcessModel process) => - new() - { - Success = true, - RuleId = rule.Id, - ProcessId = process.ProcessId, - ProcessName = process.Name, - UserMessage = "Persistent rule applied.", - TechnicalMessage = $"Persistent rule '{rule.Name}' matched process {process.Name}.", - }; - - private static PersistentRuleApplyResult MergeAffinityFailure( - PersistentRuleApplyResult result, - AffinityApplyResult affinityResult) => - result with - { - ErrorCode = affinityResult.ErrorCode, - UserMessage = affinityResult.IsAntiCheatLikely - ? ProcessOperationUserMessages.PersistentRulesProtectedProcessWarning - : affinityResult.UserMessage, - TechnicalMessage = affinityResult.TechnicalMessage, - IsAccessDenied = result.IsAccessDenied || affinityResult.IsAccessDenied, - IsAntiCheatLikely = result.IsAntiCheatLikely || affinityResult.IsAntiCheatLikely, - IsProcessExited = result.IsProcessExited || - affinityResult.ErrorCode == AffinityApplyErrorCodes.ProcessExited || - affinityResult.FailureReason == AffinityApplyFailureReason.ProcessTerminated, - }; - } -} +/* + * ThreadPilot - persistent rules engine foundation. + */ +namespace ThreadPilot.Services +{ + using System.Diagnostics; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public interface IPersistentRulesEngine + { + Task> ApplyMatchingRulesAsync( + ProcessModel process, + Predicate? ruleFilter = null, + CancellationToken cancellationToken = default); + } + + public sealed class PersistentRulesEngine : IPersistentRulesEngine + { + private const string MissingAffinityErrorCode = "PersistentRuleMissingAffinity"; + private const string MissingMemoryPriorityErrorCode = "PersistentRuleMissingMemoryPriority"; + private const string MissingPriorityErrorCode = "PersistentRuleMissingPriority"; + private const string MemoryPriorityApplyFailedErrorCode = "MemoryPriorityApplyFailed"; + private const string NoActionsErrorCode = "PersistentRuleNoActions"; + private const string PriorityApplyFailedErrorCode = "PriorityApplyFailed"; + private const string RealtimePriorityBlockedErrorCode = "RealtimePriorityBlocked"; + + private readonly IPersistentProcessRuleStore ruleStore; + private readonly IPersistentProcessRuleMatcher matcher; + private readonly IAffinityApplyService affinityApplyService; + private readonly IProcessService processService; + private readonly IProcessMemoryPriorityService memoryPriorityService; + private readonly ILogger logger; + + public PersistentRulesEngine( + IPersistentProcessRuleStore ruleStore, + IPersistentProcessRuleMatcher matcher, + IAffinityApplyService affinityApplyService, + IProcessService processService, + IProcessMemoryPriorityService memoryPriorityService, + ILogger logger) + { + this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore)); + this.matcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); + this.affinityApplyService = affinityApplyService ?? throw new ArgumentNullException(nameof(affinityApplyService)); + this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); + this.memoryPriorityService = memoryPriorityService ?? throw new ArgumentNullException(nameof(memoryPriorityService)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task> ApplyMatchingRulesAsync( + ProcessModel process, + Predicate? ruleFilter = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(process); + + var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false); + var results = new List(); + + foreach (var rule in rules.Where(rule => + (ruleFilter == null || ruleFilter(rule)) && + this.matcher.IsMatch(rule, process))) + { + cancellationToken.ThrowIfCancellationRequested(); + results.Add(await this.ApplyRuleAsync(rule, process, cancellationToken).ConfigureAwait(false)); + } + + return results; + } + + private async Task ApplyRuleAsync( + PersistentProcessRule rule, + ProcessModel process, + CancellationToken cancellationToken) + { + var result = CreateSuccessResult(rule, process); + var success = true; + + if (!rule.ApplyAffinityOnStart && !rule.ApplyPriorityOnStart && !rule.ApplyMemoryPriorityOnStart) + { + return MarkRuleConfigurationFailure( + result, + rule, + NoActionsErrorCode, + "This saved rule has no actions to apply."); + } + + if (rule.ApplyAffinityOnStart) + { + if (rule.CpuSelection == null && !rule.LegacyAffinityMask.HasValue) + { + success = false; + result = MarkRuleConfigurationFailure( + result, + rule, + MissingAffinityErrorCode, + "This saved rule has no affinity selection to apply."); + } + else + { + var affinityResult = await this.ApplyAffinityAsync(rule, process).ConfigureAwait(false); + if (affinityResult.Success) + { + result = result with { AffinityApplied = true }; + } + else + { + success = false; + result = MergeAffinityFailure(result, affinityResult); + } + } + } + + if (rule.ApplyPriorityOnStart && !result.IsProcessExited) + { + if (!rule.Priority.HasValue) + { + success = false; + result = MarkRuleConfigurationFailure( + result, + rule, + MissingPriorityErrorCode, + "This saved rule has no priority value to apply."); + } + else + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await this.processService.SetProcessPriority(process, rule.Priority.Value).ConfigureAwait(false); + result = result with { PriorityApplied = true }; + } + catch (Exception ex) + { + success = false; + result = this.MergePriorityFailure(result, ex); + } + } + } + + if (rule.ApplyMemoryPriorityOnStart && !result.IsProcessExited) + { + if (!rule.MemoryPriority.HasValue) + { + success = false; + result = MarkRuleConfigurationFailure( + result, + rule, + MissingMemoryPriorityErrorCode, + "This saved rule has no memory priority value to apply."); + } + else + { + cancellationToken.ThrowIfCancellationRequested(); + var memoryPriorityResult = await this.memoryPriorityService + .SetMemoryPriorityAsync(process, rule.MemoryPriority.Value) + .ConfigureAwait(false); + if (memoryPriorityResult.Success) + { + result = result with { MemoryPriorityApplied = true }; + } + else + { + success = false; + result = this.MergeMemoryPriorityFailure(result, memoryPriorityResult); + } + } + } + + return result with + { + Success = success, + UserMessage = success ? "Persistent rule applied." : result.UserMessage, + TechnicalMessage = success ? $"Persistent rule '{rule.Name}' applied to process {process.Name}." : result.TechnicalMessage, + }; + } + + private PersistentRuleApplyResult MergeMemoryPriorityFailure( + PersistentRuleApplyResult result, + ProcessOperationResult memoryPriorityResult) + { + this.logger.LogWarning( + "Persistent rule memory priority apply failed for rule {RuleId} on process {ProcessName} (PID: {ProcessId}): {Message}", + result.RuleId, + result.ProcessName, + result.ProcessId, + memoryPriorityResult.TechnicalMessage); + + return result with + { + ErrorCode = string.IsNullOrWhiteSpace(memoryPriorityResult.ErrorCode) + ? MemoryPriorityApplyFailedErrorCode + : memoryPriorityResult.ErrorCode, + UserMessage = string.IsNullOrWhiteSpace(memoryPriorityResult.UserMessage) + ? "ThreadPilot could not apply the saved memory priority rule." + : memoryPriorityResult.UserMessage, + TechnicalMessage = memoryPriorityResult.TechnicalMessage, + IsAccessDenied = result.IsAccessDenied || memoryPriorityResult.IsAccessDenied, + IsAntiCheatLikely = result.IsAntiCheatLikely || memoryPriorityResult.IsAntiCheatLikely, + IsProcessExited = result.IsProcessExited || memoryPriorityResult.IsProcessExited, + }; + } + + private Task ApplyAffinityAsync(PersistentProcessRule rule, ProcessModel process) + { + if (rule.CpuSelection != null) + { + return this.affinityApplyService.ApplyAsync(process, rule.CpuSelection); + } + + if (rule.LegacyAffinityMask.HasValue) + { + return this.affinityApplyService.ApplyAsync(process, rule.LegacyAffinityMask.Value); + } + + return Task.FromResult(AffinityApplyResult.Succeeded(0, process.ProcessorAffinity)); + } + + private static PersistentRuleApplyResult MarkRuleConfigurationFailure( + PersistentRuleApplyResult result, + PersistentProcessRule rule, + string errorCode, + string userMessage) => + result with + { + Success = false, + ErrorCode = errorCode, + UserMessage = userMessage, + TechnicalMessage = $"Persistent rule '{rule.Name}' ({rule.Id}) is incomplete: {userMessage}", + }; + + private PersistentRuleApplyResult MergePriorityFailure(PersistentRuleApplyResult result, Exception ex) + { + this.logger.LogWarning( + ex, + "Persistent rule priority apply failed for rule {RuleId} on process {ProcessName} (PID: {ProcessId})", + result.RuleId, + result.ProcessName, + result.ProcessId); + + var isProcessExited = AffinityApplyExceptionClassifier.IsProcessExited(ex); + var isAccessDenied = AffinityApplyExceptionClassifier.IsAccessDenied(ex); + var isAntiCheatLikely = AffinityApplyExceptionClassifier.IsAntiCheatLikely(ex); + var isRealtimeBlocked = string.Equals( + ex.Message, + ProcessOperationUserMessages.RealtimePriorityBlocked, + StringComparison.Ordinal); + + return result with + { + ErrorCode = isRealtimeBlocked + ? RealtimePriorityBlockedErrorCode + : isProcessExited + ? AffinityApplyErrorCodes.ProcessExited + : isAntiCheatLikely + ? AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely + : isAccessDenied + ? AffinityApplyErrorCodes.AccessDenied + : PriorityApplyFailedErrorCode, + UserMessage = isRealtimeBlocked + ? ProcessOperationUserMessages.RealtimePriorityBlocked + : isProcessExited + ? ProcessOperationUserMessages.ProcessExited + : isAntiCheatLikely + ? ProcessOperationUserMessages.PersistentRulesProtectedProcessWarning + : isAccessDenied + ? ProcessOperationUserMessages.AccessDenied + : "ThreadPilot could not apply the saved priority rule.", + TechnicalMessage = ex.Message, + IsAccessDenied = result.IsAccessDenied || isAccessDenied, + IsAntiCheatLikely = result.IsAntiCheatLikely || isAntiCheatLikely, + IsProcessExited = result.IsProcessExited || isProcessExited, + }; + } + + private static PersistentRuleApplyResult CreateSuccessResult(PersistentProcessRule rule, ProcessModel process) => + new() + { + Success = true, + RuleId = rule.Id, + ProcessId = process.ProcessId, + ProcessName = process.Name, + UserMessage = "Persistent rule applied.", + TechnicalMessage = $"Persistent rule '{rule.Name}' matched process {process.Name}.", + }; + + private static PersistentRuleApplyResult MergeAffinityFailure( + PersistentRuleApplyResult result, + AffinityApplyResult affinityResult) => + result with + { + ErrorCode = affinityResult.ErrorCode, + UserMessage = affinityResult.IsAntiCheatLikely + ? ProcessOperationUserMessages.PersistentRulesProtectedProcessWarning + : affinityResult.UserMessage, + TechnicalMessage = affinityResult.TechnicalMessage, + IsAccessDenied = result.IsAccessDenied || affinityResult.IsAccessDenied, + IsAntiCheatLikely = result.IsAntiCheatLikely || affinityResult.IsAntiCheatLikely, + IsProcessExited = result.IsProcessExited || + affinityResult.ErrorCode == AffinityApplyErrorCodes.ProcessExited || + affinityResult.FailureReason == AffinityApplyFailureReason.ProcessTerminated, + }; + } +} diff --git a/Services/PowerPlanService.cs b/Services/PowerPlanService.cs index af308f3..422f606 100644 --- a/Services/PowerPlanService.cs +++ b/Services/PowerPlanService.cs @@ -1,554 +1,533 @@ -/* - * 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.IO; - using System.Linq; - using System.Text.RegularExpressions; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - using ThreadPilot.Services.Abstractions; - - public class PowerPlanService : IPowerPlanService - { - private static readonly Lazy powerPlansPath = new(GetPowerPlansPath); - private static readonly string powerCfgExecutablePath = Path.Combine(Environment.SystemDirectory, "powercfg.exe"); - private static readonly TimeSpan powerCfgTimeout = TimeSpan.FromSeconds(20); - private static readonly Regex powerSchemeRegex = new(@"Power Scheme GUID: (.*?) \((.*?)\)", RegexOptions.Multiline | RegexOptions.Compiled); - private static readonly Regex pathTraversalRegex = new(@"(^|[\\/])\.\.([\\/]|$)", RegexOptions.Compiled); - - private static string PowerPlansPath => powerPlansPath.Value; - - private readonly object lockObject = new(); - private readonly ILogger logger; - private readonly IEnhancedLoggingService enhancedLogger; - private readonly IProcessRunner processRunner; - private readonly Func powerPlansPathProvider; - private string? lastActivePowerPlanGuid; - - public event EventHandler? PowerPlanChanged; - - public PowerPlanService(ILogger logger, IEnhancedLoggingService enhancedLogger) - : this(logger, enhancedLogger, new SystemProcessRunner(), null) - { - } - - public PowerPlanService(ILogger logger, IEnhancedLoggingService enhancedLogger, IProcessRunner processRunner) - : this(logger, enhancedLogger, processRunner, null) - { - } - - public PowerPlanService( - ILogger logger, - IEnhancedLoggingService enhancedLogger, - IProcessRunner processRunner, - Func? powerPlansPathProvider) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.enhancedLogger = enhancedLogger ?? throw new ArgumentNullException(nameof(enhancedLogger)); - this.processRunner = processRunner ?? throw new ArgumentNullException(nameof(processRunner)); - this.powerPlansPathProvider = powerPlansPathProvider ?? (() => PowerPlansPath); - } - - /// - /// Gets the power plans path using smart detection: - /// - Portable mode: Powerplans folder next to EXE - /// - Installed mode: %AppData%\ThreadPilot\Powerplans. - /// - private static string GetPowerPlansPath() - { - StoragePaths.EnsureAppDataDirectories(); - - // Check portable mode first (Powerplans folder next to EXE) - var exeDir = AppContext.BaseDirectory; - var portablePath = Path.Combine(exeDir, "Powerplans"); - if (Directory.Exists(portablePath) && Directory.EnumerateFiles(portablePath, "*.pow", SearchOption.AllDirectories).Any()) - { - return portablePath; - } - - // Installed mode: use AppData - var appDataPath = StoragePaths.PowerPlansDirectory; - - // Ensure directory exists - if (!Directory.Exists(appDataPath)) - { - Directory.CreateDirectory(appDataPath); - - // If portable path exists but was empty, or we have bundled plans to copy - // Copy any .pow files from portable location to AppData - if (Directory.Exists(portablePath)) - { - foreach (var file in Directory.EnumerateFiles(portablePath, "*.pow", SearchOption.AllDirectories)) - { - var relativePath = Path.GetRelativePath(portablePath, file); - var destFile = Path.Combine(appDataPath, relativePath); - if (!File.Exists(destFile)) - { - try - { - Directory.CreateDirectory(Path.GetDirectoryName(destFile) ?? appDataPath); - File.Copy(file, destFile); - } - catch - { - // Ignore copy errors, plans may not be available - } - } - } - } - } - - return appDataPath; - } - - public async Task> GetPowerPlansAsync() - { - var powerPlans = new ObservableCollection(); - var activePlan = await this.GetActivePowerPlan().ConfigureAwait(false); - - var result = await this.RunPowerCfgAsync("/list").ConfigureAwait(false); - var matches = powerSchemeRegex.Matches(result.StandardOutput); - - foreach (Match match in matches) - { - var guid = match.Groups[1].Value.Trim(); - var name = match.Groups[2].Value.Trim(); - - var plan = new PowerPlanModel - { - Guid = guid, - Name = name, - IsActive = guid == activePlan?.Guid, - IsCustomPlan = false, - }; - - powerPlans.Add(plan); - } - - return powerPlans; - } - - public async Task> GetCustomPowerPlansAsync() - { - var customPlans = new ObservableCollection(); - var powerPlansPath = this.powerPlansPathProvider(); - if (!Directory.Exists(powerPlansPath)) - { - return customPlans; - } - - foreach (var file in Directory.EnumerateFiles(powerPlansPath, "*.pow", SearchOption.AllDirectories)) - { - customPlans.Add(new PowerPlanModel - { - Name = Path.GetFileNameWithoutExtension(file), - FilePath = file, - IsCustomPlan = true, - }); - } - - return await Task.FromResult(customPlans).ConfigureAwait(false); - } - - public async Task SetActivePowerPlan(PowerPlanModel powerPlan) - { - return await this.SetActivePowerPlanByGuidAsync(powerPlan.Guid, false).ConfigureAwait(false); - } - - public async Task SetActivePowerPlanByGuidAsync(string powerPlanGuid, bool preventDuplicateChanges = true) - { - if (!Guid.TryParse(powerPlanGuid, out _)) - { - this.logger.LogWarning("Rejected invalid power plan GUID: {PowerPlanGuid}", powerPlanGuid); - return false; - } - - try - { - // Check if change is needed when duplicate prevention is enabled - if (preventDuplicateChanges) - { - var isChangeNeeded = await this.IsPowerPlanChangeNeededAsync(powerPlanGuid).ConfigureAwait(false); - if (!isChangeNeeded) - { - this.logger.LogDebug("Power plan change skipped - already active: {PowerPlanGuid}", powerPlanGuid); - return true; // No change needed, consider it successful - } - } - - var previousPowerPlan = await this.GetActivePowerPlan().ConfigureAwait(false); - var targetPowerPlan = await this.GetPowerPlanByGuidAsync(powerPlanGuid).ConfigureAwait(false); - - this.logger.LogInformation( - "Attempting to change power plan from '{FromPlan}' to '{ToPlan}'", - previousPowerPlan?.Name ?? "Unknown", targetPowerPlan?.Name ?? "Unknown"); - - await this.enhancedLogger.LogPowerPlanChangeAsync( - previousPowerPlan?.Name ?? "Unknown", - targetPowerPlan?.Name ?? "Unknown", - "Manual power plan change requested").ConfigureAwait(false); - - var result = await this.RunPowerCfgAsync("/setactive", powerPlanGuid).ConfigureAwait(false); - var success = result.ExitCode == 0; - - if (success) - { - lock (this.lockObject) - { - this.lastActivePowerPlanGuid = powerPlanGuid; - } - - var newPowerPlan = await this.GetPowerPlanByGuidAsync(powerPlanGuid).ConfigureAwait(false); - - this.logger.LogInformation("Power plan successfully changed to '{PowerPlan}'", newPowerPlan?.Name ?? "Unknown"); - - await this.enhancedLogger.LogPowerPlanChangeAsync( - previousPowerPlan?.Name ?? "Unknown", - newPowerPlan?.Name ?? "Unknown", - "Manual power plan change completed").ConfigureAwait(false); - - this.PowerPlanChanged?.Invoke(this, new PowerPlanChangedEventArgs( - previousPowerPlan, newPowerPlan, "Manual power plan change")); - } - else - { - this.logger.LogWarning( - "Failed to change power plan to '{PowerPlanGuid}' - powercfg exit code: {ExitCode}, stderr: {StdErr}", - powerPlanGuid, - result.ExitCode, - result.StandardError); - - await this.enhancedLogger.LogSystemEventAsync( - LogEventTypes.PowerPlan.ChangeFailed, - $"Failed to change power plan to '{targetPowerPlan?.Name ?? powerPlanGuid}' - Exit code: {result.ExitCode}", - Microsoft.Extensions.Logging.LogLevel.Warning).ConfigureAwait(false); - } - - return success; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Exception occurred while changing power plan to '{PowerPlanGuid}'", powerPlanGuid); - - await this.enhancedLogger.LogErrorAsync(ex, "PowerPlanService.SetActivePowerPlanByGuidAsync", - new Dictionary - { - ["PowerPlanGuid"] = powerPlanGuid, - ["PreventDuplicateChanges"] = preventDuplicateChanges, - }).ConfigureAwait(false); - - return false; - } - } - - public async Task GetActivePowerPlan() - { - try - { - var result = await this.RunPowerCfgAsync("/getactivescheme").ConfigureAwait(false); - var match = powerSchemeRegex.Match(result.StandardOutput); - - if (match.Success) - { - return new PowerPlanModel - { - Guid = match.Groups[1].Value.Trim(), - Name = match.Groups[2].Value.Trim(), - IsActive = true, - }; - } - - this.logger.LogWarning("Could not parse active power plan from powercfg output."); - return null; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to read active power plan."); - return null; - } - } - - public async Task ImportCustomPowerPlan(string filePath) - { - if (!this.TryNormalizePowerPlanPath(filePath, out var normalizedPath, out var validationError)) - { - this.logger.LogWarning("Rejected power plan import path '{FilePath}': {ValidationError}", filePath, validationError); - return false; - } - - try - { - var result = await this.RunPowerCfgAsync("/import", normalizedPath).ConfigureAwait(false); - - if (result.ExitCode != 0) - { - this.logger.LogWarning( - "Power plan import failed for '{Path}' with exit code {ExitCode}. stderr: {StdErr}", - normalizedPath, - result.ExitCode, - result.StandardError); - - return false; - } - - this.logger.LogInformation("Imported custom power plan from '{Path}'", normalizedPath); - return true; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Exception occurred while importing custom power plan from '{Path}'", normalizedPath); - await this.enhancedLogger.LogErrorAsync(ex, "PowerPlanService.ImportCustomPowerPlan", - new Dictionary { ["Path"] = normalizedPath }).ConfigureAwait(false); - return false; - } - } - - public async Task AddCustomPowerPlanFileAsync(string filePath) - { - if (!this.TryNormalizePowerPlanPath(filePath, out var normalizedPath, out var validationError)) - { - this.logger.LogWarning("Rejected custom power plan file '{FilePath}': {ValidationError}", filePath, validationError); - return false; - } - - try - { - var powerPlansPath = this.powerPlansPathProvider(); - Directory.CreateDirectory(powerPlansPath); - - var fileName = Path.GetFileName(normalizedPath); - var destinationPath = Path.Combine(powerPlansPath, fileName); - - // If user selects a file already in the managed folder, treat as success. - if (string.Equals(normalizedPath, destinationPath, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (File.Exists(destinationPath)) - { - var baseName = Path.GetFileNameWithoutExtension(fileName); - var extension = Path.GetExtension(fileName); - var suffix = 1; - - do - { - destinationPath = Path.Combine(powerPlansPath, $"{baseName}_{suffix}{extension}"); - suffix++; - } - while (File.Exists(destinationPath)); - } - - File.Copy(normalizedPath, destinationPath, false); - - this.logger.LogInformation( - "Added custom power plan file '{SourcePath}' as '{DestinationPath}'", - normalizedPath, - destinationPath); - - await Task.CompletedTask.ConfigureAwait(false); - return true; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to add custom power plan file '{Path}'", normalizedPath); - await this.enhancedLogger.LogErrorAsync(ex, "PowerPlanService.AddCustomPowerPlanFileAsync", - new Dictionary { ["Path"] = normalizedPath }).ConfigureAwait(false); - return false; - } - } - - public async Task DeletePowerPlanAsync(string powerPlanGuid) - { - if (!Guid.TryParse(powerPlanGuid, out _)) - { - this.logger.LogWarning("Rejected invalid power plan GUID for delete: {PowerPlanGuid}", powerPlanGuid); - return false; - } - - try - { - var activePlan = await this.GetActivePowerPlan().ConfigureAwait(false); - if (string.Equals(activePlan?.Guid, powerPlanGuid, StringComparison.OrdinalIgnoreCase)) - { - this.logger.LogWarning("Blocked deletion of active power plan: {PowerPlanGuid}", powerPlanGuid); - await this.enhancedLogger.LogSystemEventAsync( - LogEventTypes.PowerPlan.ChangeFailed, - $"Blocked deletion of active power plan '{activePlan?.Name ?? powerPlanGuid}'", - Microsoft.Extensions.Logging.LogLevel.Warning).ConfigureAwait(false); - return false; - } - - var targetPlan = await this.GetPowerPlanByGuidAsync(powerPlanGuid).ConfigureAwait(false); - var result = await this.RunPowerCfgAsync("/delete", powerPlanGuid).ConfigureAwait(false); - var success = result.ExitCode == 0; - - if (success) - { - this.logger.LogInformation("Deleted power plan '{PowerPlan}' ({PowerPlanGuid})", targetPlan?.Name ?? "Unknown", powerPlanGuid); - await this.enhancedLogger.LogUserActionAsync( - "PowerPlanDeleted", - $"Deleted power plan {targetPlan?.Name ?? powerPlanGuid}", - $"Guid: {powerPlanGuid}").ConfigureAwait(false); - } - else - { - this.logger.LogWarning( - "Failed to delete power plan '{PowerPlanGuid}' - powercfg exit code: {ExitCode}, stderr: {StdErr}", - powerPlanGuid, - result.ExitCode, - result.StandardError); - - await this.enhancedLogger.LogSystemEventAsync( - LogEventTypes.PowerPlan.ChangeFailed, - $"Failed to delete power plan '{targetPlan?.Name ?? powerPlanGuid}' - Exit code: {result.ExitCode}", - Microsoft.Extensions.Logging.LogLevel.Warning).ConfigureAwait(false); - } - - return success; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Exception occurred while deleting power plan '{PowerPlanGuid}'", powerPlanGuid); - await this.enhancedLogger.LogErrorAsync(ex, "PowerPlanService.DeletePowerPlanAsync", - new Dictionary { ["PowerPlanGuid"] = powerPlanGuid }).ConfigureAwait(false); - return false; - } - } - - public async Task GetActivePowerPlanGuidAsync() - { - var activePlan = await this.GetActivePowerPlan().ConfigureAwait(false); - return activePlan?.Guid; - } - - public async Task PowerPlanExistsAsync(string powerPlanGuid) - { - if (!Guid.TryParse(powerPlanGuid, out _)) - { - return false; - } - - var powerPlans = await this.GetPowerPlansAsync().ConfigureAwait(false); - return powerPlans.Any(p => string.Equals(p.Guid, powerPlanGuid, StringComparison.OrdinalIgnoreCase)); - } - - public async Task GetPowerPlanByGuidAsync(string powerPlanGuid) - { - if (!Guid.TryParse(powerPlanGuid, out _)) - { - return null; - } - - var powerPlans = await this.GetPowerPlansAsync().ConfigureAwait(false); - return powerPlans.FirstOrDefault(p => string.Equals(p.Guid, powerPlanGuid, StringComparison.OrdinalIgnoreCase)); - } - - public async Task IsPowerPlanChangeNeededAsync(string targetPowerPlanGuid) - { - try - { - var currentGuid = await this.GetActivePowerPlanGuidAsync().ConfigureAwait(false); - - // Check if the target power plan is already active - if (string.Equals(currentGuid, targetPowerPlanGuid, StringComparison.OrdinalIgnoreCase)) - { - return false; // No change needed - } - - // Check if we recently set this power plan (to prevent rapid switching) - lock (this.lockObject) - { - if (string.Equals(this.lastActivePowerPlanGuid, targetPowerPlanGuid, StringComparison.OrdinalIgnoreCase)) - { - return false; // We recently set this plan - } - } - - return true; // Change is needed - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Could not determine if power plan change is needed for '{PowerPlanGuid}'", targetPowerPlanGuid); - return true; // If we can't determine, assume change is needed - } - } - - private bool TryNormalizePowerPlanPath(string filePath, out string normalizedPath, out string error) - { - normalizedPath = string.Empty; - - if (string.IsNullOrWhiteSpace(filePath)) - { - error = "Path is empty."; - return false; - } - - if (pathTraversalRegex.IsMatch(filePath)) - { - error = "Path traversal segments are not allowed."; - return false; - } - - if (!Path.IsPathFullyQualified(filePath)) - { - error = "Path must be absolute."; - return false; - } - - try - { - normalizedPath = Path.GetFullPath(filePath); - } - catch (Exception ex) - { - error = $"Invalid file path: {ex.Message}"; - return false; - } - - if (!string.Equals(Path.GetExtension(normalizedPath), ".pow", StringComparison.OrdinalIgnoreCase)) - { - error = "Only .pow files are supported."; - return false; - } - - if (!File.Exists(normalizedPath)) - { - error = "File does not exist."; - return false; - } - - var fileInfo = new FileInfo(normalizedPath); - if (fileInfo.Length > 10 * 1024 * 1024) - { - error = "Power plan file size exceeds 10 MB limit."; - return false; - } - - error = string.Empty; - return true; - } - - private Task RunPowerCfgAsync(params string[] arguments) => - this.processRunner.RunAsync(powerCfgExecutablePath, arguments, powerCfgTimeout); - } -} +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Collections.ObjectModel; + using System.IO; + using System.Linq; + using System.Text.RegularExpressions; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + using ThreadPilot.Services.Abstractions; + + public class PowerPlanService : IPowerPlanService + { + private static readonly Lazy powerPlansPath = new(GetPowerPlansPath); + private static readonly string powerCfgExecutablePath = Path.Combine(Environment.SystemDirectory, "powercfg.exe"); + private static readonly TimeSpan powerCfgTimeout = TimeSpan.FromSeconds(20); + private static readonly Regex powerSchemeRegex = new(@"Power Scheme GUID: (.*?) \((.*?)\)", RegexOptions.Multiline | RegexOptions.Compiled); + private static readonly Regex pathTraversalRegex = new(@"(^|[\\/])\.\.([\\/]|$)", RegexOptions.Compiled); + + private static string PowerPlansPath => powerPlansPath.Value; + + private readonly object lockObject = new(); + private readonly ILogger logger; + private readonly IEnhancedLoggingService enhancedLogger; + private readonly IProcessRunner processRunner; + private readonly Func powerPlansPathProvider; + private string? lastActivePowerPlanGuid; + + public event EventHandler? PowerPlanChanged; + + public PowerPlanService(ILogger logger, IEnhancedLoggingService enhancedLogger) + : this(logger, enhancedLogger, new SystemProcessRunner(), null) + { + } + + public PowerPlanService(ILogger logger, IEnhancedLoggingService enhancedLogger, IProcessRunner processRunner) + : this(logger, enhancedLogger, processRunner, null) + { + } + + public PowerPlanService( + ILogger logger, + IEnhancedLoggingService enhancedLogger, + IProcessRunner processRunner, + Func? powerPlansPathProvider) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.enhancedLogger = enhancedLogger ?? throw new ArgumentNullException(nameof(enhancedLogger)); + this.processRunner = processRunner ?? throw new ArgumentNullException(nameof(processRunner)); + this.powerPlansPathProvider = powerPlansPathProvider ?? (() => PowerPlansPath); + } + + private static string GetPowerPlansPath() + { + StoragePaths.EnsureAppDataDirectories(); + + // Check portable mode first (Powerplans folder next to EXE) + var exeDir = AppContext.BaseDirectory; + var portablePath = Path.Combine(exeDir, "Powerplans"); + if (Directory.Exists(portablePath) && Directory.EnumerateFiles(portablePath, "*.pow", SearchOption.AllDirectories).Any()) + { + return portablePath; + } + + // Installed mode: use AppData + var appDataPath = StoragePaths.PowerPlansDirectory; + + // Ensure directory exists + if (!Directory.Exists(appDataPath)) + { + Directory.CreateDirectory(appDataPath); + + // If portable path exists but was empty, or we have bundled plans to copy + // Copy any .pow files from portable location to AppData + if (Directory.Exists(portablePath)) + { + foreach (var file in Directory.EnumerateFiles(portablePath, "*.pow", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(portablePath, file); + var destFile = Path.Combine(appDataPath, relativePath); + if (!File.Exists(destFile)) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(destFile) ?? appDataPath); + File.Copy(file, destFile); + } + catch + { + // Ignore copy errors, plans may not be available + } + } + } + } + } + + return appDataPath; + } + + public async Task> GetPowerPlansAsync() + { + var powerPlans = new ObservableCollection(); + var activePlan = await this.GetActivePowerPlan().ConfigureAwait(false); + + var result = await this.RunPowerCfgAsync("/list").ConfigureAwait(false); + var matches = powerSchemeRegex.Matches(result.StandardOutput); + + foreach (Match match in matches) + { + var guid = match.Groups[1].Value.Trim(); + var name = match.Groups[2].Value.Trim(); + + var plan = new PowerPlanModel + { + Guid = guid, + Name = name, + IsActive = guid == activePlan?.Guid, + IsCustomPlan = false, + }; + + powerPlans.Add(plan); + } + + return powerPlans; + } + + public async Task> GetCustomPowerPlansAsync() + { + var customPlans = new ObservableCollection(); + var powerPlansPath = this.powerPlansPathProvider(); + if (!Directory.Exists(powerPlansPath)) + { + return customPlans; + } + + foreach (var file in Directory.EnumerateFiles(powerPlansPath, "*.pow", SearchOption.AllDirectories)) + { + customPlans.Add(new PowerPlanModel + { + Name = Path.GetFileNameWithoutExtension(file), + FilePath = file, + IsCustomPlan = true, + }); + } + + return await Task.FromResult(customPlans).ConfigureAwait(false); + } + + public async Task SetActivePowerPlan(PowerPlanModel powerPlan) + { + return await this.SetActivePowerPlanByGuidAsync(powerPlan.Guid, false).ConfigureAwait(false); + } + + public async Task SetActivePowerPlanByGuidAsync(string powerPlanGuid, bool preventDuplicateChanges = true) + { + if (!Guid.TryParse(powerPlanGuid, out _)) + { + this.logger.LogWarning("Rejected invalid power plan GUID: {PowerPlanGuid}", powerPlanGuid); + return false; + } + + try + { + // Check if change is needed when duplicate prevention is enabled + if (preventDuplicateChanges) + { + var isChangeNeeded = await this.IsPowerPlanChangeNeededAsync(powerPlanGuid).ConfigureAwait(false); + if (!isChangeNeeded) + { + this.logger.LogDebug("Power plan change skipped - already active: {PowerPlanGuid}", powerPlanGuid); + return true; // No change needed, consider it successful + } + } + + var previousPowerPlan = await this.GetActivePowerPlan().ConfigureAwait(false); + var targetPowerPlan = await this.GetPowerPlanByGuidAsync(powerPlanGuid).ConfigureAwait(false); + + this.logger.LogInformation( + "Attempting to change power plan from '{FromPlan}' to '{ToPlan}'", + previousPowerPlan?.Name ?? "Unknown", targetPowerPlan?.Name ?? "Unknown"); + + await this.enhancedLogger.LogPowerPlanChangeAsync( + previousPowerPlan?.Name ?? "Unknown", + targetPowerPlan?.Name ?? "Unknown", + "Manual power plan change requested").ConfigureAwait(false); + + var result = await this.RunPowerCfgAsync("/setactive", powerPlanGuid).ConfigureAwait(false); + var success = result.ExitCode == 0; + + if (success) + { + lock (this.lockObject) + { + this.lastActivePowerPlanGuid = powerPlanGuid; + } + + var newPowerPlan = await this.GetPowerPlanByGuidAsync(powerPlanGuid).ConfigureAwait(false); + + this.logger.LogInformation("Power plan successfully changed to '{PowerPlan}'", newPowerPlan?.Name ?? "Unknown"); + + await this.enhancedLogger.LogPowerPlanChangeAsync( + previousPowerPlan?.Name ?? "Unknown", + newPowerPlan?.Name ?? "Unknown", + "Manual power plan change completed").ConfigureAwait(false); + + this.PowerPlanChanged?.Invoke(this, new PowerPlanChangedEventArgs( + previousPowerPlan, newPowerPlan, "Manual power plan change")); + } + else + { + this.logger.LogWarning( + "Failed to change power plan to '{PowerPlanGuid}' - powercfg exit code: {ExitCode}, stderr: {StdErr}", + powerPlanGuid, + result.ExitCode, + result.StandardError); + + await this.enhancedLogger.LogSystemEventAsync( + LogEventTypes.PowerPlan.ChangeFailed, + $"Failed to change power plan to '{targetPowerPlan?.Name ?? powerPlanGuid}' - Exit code: {result.ExitCode}", + Microsoft.Extensions.Logging.LogLevel.Warning).ConfigureAwait(false); + } + + return success; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Exception occurred while changing power plan to '{PowerPlanGuid}'", powerPlanGuid); + + await this.enhancedLogger.LogErrorAsync(ex, "PowerPlanService.SetActivePowerPlanByGuidAsync", + new Dictionary + { + ["PowerPlanGuid"] = powerPlanGuid, + ["PreventDuplicateChanges"] = preventDuplicateChanges, + }).ConfigureAwait(false); + + return false; + } + } + + public async Task GetActivePowerPlan() + { + try + { + var result = await this.RunPowerCfgAsync("/getactivescheme").ConfigureAwait(false); + var match = powerSchemeRegex.Match(result.StandardOutput); + + if (match.Success) + { + return new PowerPlanModel + { + Guid = match.Groups[1].Value.Trim(), + Name = match.Groups[2].Value.Trim(), + IsActive = true, + }; + } + + this.logger.LogWarning("Could not parse active power plan from powercfg output."); + return null; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to read active power plan."); + return null; + } + } + + public async Task ImportCustomPowerPlan(string filePath) + { + if (!this.TryNormalizePowerPlanPath(filePath, out var normalizedPath, out var validationError)) + { + this.logger.LogWarning("Rejected power plan import path '{FilePath}': {ValidationError}", filePath, validationError); + return false; + } + + try + { + var result = await this.RunPowerCfgAsync("/import", normalizedPath).ConfigureAwait(false); + + if (result.ExitCode != 0) + { + this.logger.LogWarning( + "Power plan import failed for '{Path}' with exit code {ExitCode}. stderr: {StdErr}", + normalizedPath, + result.ExitCode, + result.StandardError); + + return false; + } + + this.logger.LogInformation("Imported custom power plan from '{Path}'", normalizedPath); + return true; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Exception occurred while importing custom power plan from '{Path}'", normalizedPath); + await this.enhancedLogger.LogErrorAsync(ex, "PowerPlanService.ImportCustomPowerPlan", + new Dictionary { ["Path"] = normalizedPath }).ConfigureAwait(false); + return false; + } + } + + public async Task AddCustomPowerPlanFileAsync(string filePath) + { + if (!this.TryNormalizePowerPlanPath(filePath, out var normalizedPath, out var validationError)) + { + this.logger.LogWarning("Rejected custom power plan file '{FilePath}': {ValidationError}", filePath, validationError); + return false; + } + + try + { + var powerPlansPath = this.powerPlansPathProvider(); + Directory.CreateDirectory(powerPlansPath); + + var fileName = Path.GetFileName(normalizedPath); + var destinationPath = Path.Combine(powerPlansPath, fileName); + + // If user selects a file already in the managed folder, treat as success. + if (string.Equals(normalizedPath, destinationPath, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (File.Exists(destinationPath)) + { + var baseName = Path.GetFileNameWithoutExtension(fileName); + var extension = Path.GetExtension(fileName); + var suffix = 1; + + do + { + destinationPath = Path.Combine(powerPlansPath, $"{baseName}_{suffix}{extension}"); + suffix++; + } + while (File.Exists(destinationPath)); + } + + File.Copy(normalizedPath, destinationPath, false); + + this.logger.LogInformation( + "Added custom power plan file '{SourcePath}' as '{DestinationPath}'", + normalizedPath, + destinationPath); + + await Task.CompletedTask.ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to add custom power plan file '{Path}'", normalizedPath); + await this.enhancedLogger.LogErrorAsync(ex, "PowerPlanService.AddCustomPowerPlanFileAsync", + new Dictionary { ["Path"] = normalizedPath }).ConfigureAwait(false); + return false; + } + } + + public async Task DeletePowerPlanAsync(string powerPlanGuid) + { + if (!Guid.TryParse(powerPlanGuid, out _)) + { + this.logger.LogWarning("Rejected invalid power plan GUID for delete: {PowerPlanGuid}", powerPlanGuid); + return false; + } + + try + { + var activePlan = await this.GetActivePowerPlan().ConfigureAwait(false); + if (string.Equals(activePlan?.Guid, powerPlanGuid, StringComparison.OrdinalIgnoreCase)) + { + this.logger.LogWarning("Blocked deletion of active power plan: {PowerPlanGuid}", powerPlanGuid); + await this.enhancedLogger.LogSystemEventAsync( + LogEventTypes.PowerPlan.ChangeFailed, + $"Blocked deletion of active power plan '{activePlan?.Name ?? powerPlanGuid}'", + Microsoft.Extensions.Logging.LogLevel.Warning).ConfigureAwait(false); + return false; + } + + var targetPlan = await this.GetPowerPlanByGuidAsync(powerPlanGuid).ConfigureAwait(false); + var result = await this.RunPowerCfgAsync("/delete", powerPlanGuid).ConfigureAwait(false); + var success = result.ExitCode == 0; + + if (success) + { + this.logger.LogInformation("Deleted power plan '{PowerPlan}' ({PowerPlanGuid})", targetPlan?.Name ?? "Unknown", powerPlanGuid); + await this.enhancedLogger.LogUserActionAsync( + "PowerPlanDeleted", + $"Deleted power plan {targetPlan?.Name ?? powerPlanGuid}", + $"Guid: {powerPlanGuid}").ConfigureAwait(false); + } + else + { + this.logger.LogWarning( + "Failed to delete power plan '{PowerPlanGuid}' - powercfg exit code: {ExitCode}, stderr: {StdErr}", + powerPlanGuid, + result.ExitCode, + result.StandardError); + + await this.enhancedLogger.LogSystemEventAsync( + LogEventTypes.PowerPlan.ChangeFailed, + $"Failed to delete power plan '{targetPlan?.Name ?? powerPlanGuid}' - Exit code: {result.ExitCode}", + Microsoft.Extensions.Logging.LogLevel.Warning).ConfigureAwait(false); + } + + return success; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Exception occurred while deleting power plan '{PowerPlanGuid}'", powerPlanGuid); + await this.enhancedLogger.LogErrorAsync(ex, "PowerPlanService.DeletePowerPlanAsync", + new Dictionary { ["PowerPlanGuid"] = powerPlanGuid }).ConfigureAwait(false); + return false; + } + } + + public async Task GetActivePowerPlanGuidAsync() + { + var activePlan = await this.GetActivePowerPlan().ConfigureAwait(false); + return activePlan?.Guid; + } + + public async Task PowerPlanExistsAsync(string powerPlanGuid) + { + if (!Guid.TryParse(powerPlanGuid, out _)) + { + return false; + } + + var powerPlans = await this.GetPowerPlansAsync().ConfigureAwait(false); + return powerPlans.Any(p => string.Equals(p.Guid, powerPlanGuid, StringComparison.OrdinalIgnoreCase)); + } + + public async Task GetPowerPlanByGuidAsync(string powerPlanGuid) + { + if (!Guid.TryParse(powerPlanGuid, out _)) + { + return null; + } + + var powerPlans = await this.GetPowerPlansAsync().ConfigureAwait(false); + return powerPlans.FirstOrDefault(p => string.Equals(p.Guid, powerPlanGuid, StringComparison.OrdinalIgnoreCase)); + } + + public async Task IsPowerPlanChangeNeededAsync(string targetPowerPlanGuid) + { + try + { + var currentGuid = await this.GetActivePowerPlanGuidAsync().ConfigureAwait(false); + + // Check if the target power plan is already active + if (string.Equals(currentGuid, targetPowerPlanGuid, StringComparison.OrdinalIgnoreCase)) + { + return false; // No change needed + } + + // Check if we recently set this power plan (to prevent rapid switching) + lock (this.lockObject) + { + if (string.Equals(this.lastActivePowerPlanGuid, targetPowerPlanGuid, StringComparison.OrdinalIgnoreCase)) + { + return false; // We recently set this plan + } + } + + return true; // Change is needed + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Could not determine if power plan change is needed for '{PowerPlanGuid}'", targetPowerPlanGuid); + return true; // If we can't determine, assume change is needed + } + } + + private bool TryNormalizePowerPlanPath(string filePath, out string normalizedPath, out string error) + { + normalizedPath = string.Empty; + + if (string.IsNullOrWhiteSpace(filePath)) + { + error = "Path is empty."; + return false; + } + + if (pathTraversalRegex.IsMatch(filePath)) + { + error = "Path traversal segments are not allowed."; + return false; + } + + if (!Path.IsPathFullyQualified(filePath)) + { + error = "Path must be absolute."; + return false; + } + + try + { + normalizedPath = Path.GetFullPath(filePath); + } + catch (Exception ex) + { + error = $"Invalid file path: {ex.Message}"; + return false; + } + + if (!string.Equals(Path.GetExtension(normalizedPath), ".pow", StringComparison.OrdinalIgnoreCase)) + { + error = "Only .pow files are supported."; + return false; + } + + if (!File.Exists(normalizedPath)) + { + error = "File does not exist."; + return false; + } + + var fileInfo = new FileInfo(normalizedPath); + if (fileInfo.Length > 10 * 1024 * 1024) + { + error = "Power plan file size exceeds 10 MB limit."; + return false; + } + + error = string.Empty; + return true; + } + + private Task RunPowerCfgAsync(params string[] arguments) => + this.processRunner.RunAsync(powerCfgExecutablePath, arguments, powerCfgTimeout); + } +} diff --git a/Services/PowerPlanTransitionGate.cs b/Services/PowerPlanTransitionGate.cs index 41f8b08..8c91e11 100644 --- a/Services/PowerPlanTransitionGate.cs +++ b/Services/PowerPlanTransitionGate.cs @@ -1,78 +1,62 @@ -/* - * 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(); - } - } - } -} +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/ProcessAffinityApplyCoordinator.cs b/Services/ProcessAffinityApplyCoordinator.cs index 66cf0c0..3d58dfd 100644 --- a/Services/ProcessAffinityApplyCoordinator.cs +++ b/Services/ProcessAffinityApplyCoordinator.cs @@ -1,178 +1,178 @@ -/* - * ThreadPilot - process tab affinity apply coordination. - */ -namespace ThreadPilot.Services -{ - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - - public interface IProcessAffinityApplyCoordinator - { - Task ApplyCoreMaskAsync( - ProcessModel process, - CoreMask coreMask, - CancellationToken cancellationToken = default); - - Task ApplyCoreSelectionAsync( - ProcessModel process, - IReadOnlyList boolMask, - string selectionReason, - CancellationToken cancellationToken = default); - } - - public sealed class ProcessAffinityApplyCoordinator : IProcessAffinityApplyCoordinator - { - private readonly IAffinityApplyService affinityApplyService; - private readonly ICpuTopologyProvider? cpuTopologyProvider; - private readonly CpuSelectionMigrationService cpuSelectionMigrationService; - private readonly ILogger logger; - - public ProcessAffinityApplyCoordinator( - IAffinityApplyService affinityApplyService, - ICpuTopologyProvider? cpuTopologyProvider, - CpuSelectionMigrationService cpuSelectionMigrationService, - ILogger logger) - { - this.affinityApplyService = affinityApplyService ?? throw new ArgumentNullException(nameof(affinityApplyService)); - this.cpuTopologyProvider = cpuTopologyProvider; - this.cpuSelectionMigrationService = cpuSelectionMigrationService ?? throw new ArgumentNullException(nameof(cpuSelectionMigrationService)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public Task ApplyCoreMaskAsync( - ProcessModel process, - CoreMask coreMask, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(coreMask); - - if (HasSelectionPayload(coreMask.CpuSelection)) - { - return this.affinityApplyService.ApplyAsync(process, coreMask.CpuSelection!); - } - - return this.ApplyCoreSelectionAsync( - process, - coreMask.BoolMask.ToList(), - $"Manual Process tab mask '{coreMask.Name}'", - cancellationToken); - } - - public async Task ApplyCoreSelectionAsync( - ProcessModel process, - IReadOnlyList boolMask, - string selectionReason, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(process); - ArgumentNullException.ThrowIfNull(boolMask); - - if (boolMask.Count == 0 || !boolMask.Any(selected => selected)) - { - return AffinityApplyResult.Failed( - AffinityApplyErrorCodes.InvalidSelection, - ProcessOperationUserMessages.InvalidTopology, - "Manual CPU selection is empty.", - isInvalidTopology: true, - failureReason: AffinityApplyFailureReason.InvalidMask); - } - - var migratedSelection = await this.TryMigrateToCpuSelectionAsync( - boolMask, - selectionReason, - cancellationToken).ConfigureAwait(false); - if (migratedSelection != null) - { - return await this.affinityApplyService.ApplyAsync(process, migratedSelection).ConfigureAwait(false); - } - - if (!TryBuildSafeLegacyMask(boolMask, out var legacyMask, out var legacyFailure)) - { - return legacyFailure; - } - - return await this.affinityApplyService.ApplyAsync(process, legacyMask).ConfigureAwait(false); - } - - private async Task TryMigrateToCpuSelectionAsync( - IReadOnlyList boolMask, - string selectionReason, - CancellationToken cancellationToken) - { - if (this.cpuTopologyProvider == null) - { - return null; - } - - try - { - var topology = await this.cpuTopologyProvider.GetTopologySnapshotAsync(cancellationToken).ConfigureAwait(false); - var migrated = this.cpuSelectionMigrationService.MigrateFromLegacyCoreMask(boolMask, topology); - if (!HasSelectionPayload(migrated.Selection)) - { - return null; - } - - return migrated.Selection with - { - Metadata = migrated.Selection.Metadata with - { - SelectionReason = string.IsNullOrWhiteSpace(selectionReason) - ? migrated.Selection.Metadata.SelectionReason - : selectionReason, - }, - }; - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - this.logger.LogDebug(ex, "Could not migrate manual Process tab CPU selection to CpuSelection"); - return null; - } - } - - private static bool HasSelectionPayload(CpuSelection? selection) => - selection != null && - (selection.CpuSetIds.Count > 0 || selection.LogicalProcessors.Count > 0); - - private static bool TryBuildSafeLegacyMask( - IReadOnlyList boolMask, - out long legacyMask, - out AffinityApplyResult failure) - { - legacyMask = 0; - failure = default!; - - if (boolMask.Count > 64) - { - failure = AffinityApplyResult.Failed( - AffinityApplyErrorCodes.LegacyFallbackUnsafe, - ProcessOperationUserMessages.LegacyFallbackBlocked, - "Manual CPU selection exceeds the legacy single-group 64-bit affinity mask.", - isLegacyFallbackBlocked: true, - failureReason: AffinityApplyFailureReason.InvalidMask); - return false; - } - - for (var bit = 0; bit < boolMask.Count; bit++) - { - if (boolMask[bit]) - { - legacyMask |= 1L << bit; - } - } - - if (legacyMask == 0) - { - failure = AffinityApplyResult.Failed( - AffinityApplyErrorCodes.InvalidSelection, - ProcessOperationUserMessages.InvalidTopology, - "Manual CPU selection does not contain any enabled CPUs.", - isInvalidTopology: true, - failureReason: AffinityApplyFailureReason.InvalidMask); - return false; - } - - return true; - } - } -} +/* + * ThreadPilot - process tab affinity apply coordination. + */ +namespace ThreadPilot.Services +{ + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public interface IProcessAffinityApplyCoordinator + { + Task ApplyCoreMaskAsync( + ProcessModel process, + CoreMask coreMask, + CancellationToken cancellationToken = default); + + Task ApplyCoreSelectionAsync( + ProcessModel process, + IReadOnlyList boolMask, + string selectionReason, + CancellationToken cancellationToken = default); + } + + public sealed class ProcessAffinityApplyCoordinator : IProcessAffinityApplyCoordinator + { + private readonly IAffinityApplyService affinityApplyService; + private readonly ICpuTopologyProvider? cpuTopologyProvider; + private readonly CpuSelectionMigrationService cpuSelectionMigrationService; + private readonly ILogger logger; + + public ProcessAffinityApplyCoordinator( + IAffinityApplyService affinityApplyService, + ICpuTopologyProvider? cpuTopologyProvider, + CpuSelectionMigrationService cpuSelectionMigrationService, + ILogger logger) + { + this.affinityApplyService = affinityApplyService ?? throw new ArgumentNullException(nameof(affinityApplyService)); + this.cpuTopologyProvider = cpuTopologyProvider; + this.cpuSelectionMigrationService = cpuSelectionMigrationService ?? throw new ArgumentNullException(nameof(cpuSelectionMigrationService)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task ApplyCoreMaskAsync( + ProcessModel process, + CoreMask coreMask, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(coreMask); + + if (HasSelectionPayload(coreMask.CpuSelection)) + { + return this.affinityApplyService.ApplyAsync(process, coreMask.CpuSelection!); + } + + return this.ApplyCoreSelectionAsync( + process, + coreMask.BoolMask.ToList(), + $"Manual Process tab mask '{coreMask.Name}'", + cancellationToken); + } + + public async Task ApplyCoreSelectionAsync( + ProcessModel process, + IReadOnlyList boolMask, + string selectionReason, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(process); + ArgumentNullException.ThrowIfNull(boolMask); + + if (boolMask.Count == 0 || !boolMask.Any(selected => selected)) + { + return AffinityApplyResult.Failed( + AffinityApplyErrorCodes.InvalidSelection, + ProcessOperationUserMessages.InvalidTopology, + "Manual CPU selection is empty.", + isInvalidTopology: true, + failureReason: AffinityApplyFailureReason.InvalidMask); + } + + var migratedSelection = await this.TryMigrateToCpuSelectionAsync( + boolMask, + selectionReason, + cancellationToken).ConfigureAwait(false); + if (migratedSelection != null) + { + return await this.affinityApplyService.ApplyAsync(process, migratedSelection).ConfigureAwait(false); + } + + if (!TryBuildSafeLegacyMask(boolMask, out var legacyMask, out var legacyFailure)) + { + return legacyFailure; + } + + return await this.affinityApplyService.ApplyAsync(process, legacyMask).ConfigureAwait(false); + } + + private async Task TryMigrateToCpuSelectionAsync( + IReadOnlyList boolMask, + string selectionReason, + CancellationToken cancellationToken) + { + if (this.cpuTopologyProvider == null) + { + return null; + } + + try + { + var topology = await this.cpuTopologyProvider.GetTopologySnapshotAsync(cancellationToken).ConfigureAwait(false); + var migrated = this.cpuSelectionMigrationService.MigrateFromLegacyCoreMask(boolMask, topology); + if (!HasSelectionPayload(migrated.Selection)) + { + return null; + } + + return migrated.Selection with + { + Metadata = migrated.Selection.Metadata with + { + SelectionReason = string.IsNullOrWhiteSpace(selectionReason) + ? migrated.Selection.Metadata.SelectionReason + : selectionReason, + }, + }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogDebug(ex, "Could not migrate manual Process tab CPU selection to CpuSelection"); + return null; + } + } + + private static bool HasSelectionPayload(CpuSelection? selection) => + selection != null && + (selection.CpuSetIds.Count > 0 || selection.LogicalProcessors.Count > 0); + + private static bool TryBuildSafeLegacyMask( + IReadOnlyList boolMask, + out long legacyMask, + out AffinityApplyResult failure) + { + legacyMask = 0; + failure = default!; + + if (boolMask.Count > 64) + { + failure = AffinityApplyResult.Failed( + AffinityApplyErrorCodes.LegacyFallbackUnsafe, + ProcessOperationUserMessages.LegacyFallbackBlocked, + "Manual CPU selection exceeds the legacy single-group 64-bit affinity mask.", + isLegacyFallbackBlocked: true, + failureReason: AffinityApplyFailureReason.InvalidMask); + return false; + } + + for (var bit = 0; bit < boolMask.Count; bit++) + { + if (boolMask[bit]) + { + legacyMask |= 1L << bit; + } + } + + if (legacyMask == 0) + { + failure = AffinityApplyResult.Failed( + AffinityApplyErrorCodes.InvalidSelection, + ProcessOperationUserMessages.InvalidTopology, + "Manual CPU selection does not contain any enabled CPUs.", + isInvalidTopology: true, + failureReason: AffinityApplyFailureReason.InvalidMask); + return false; + } + + return true; + } + } +} diff --git a/Services/ProcessClassifier.cs b/Services/ProcessClassifier.cs index 2d737af..d1e050c 100644 --- a/Services/ProcessClassifier.cs +++ b/Services/ProcessClassifier.cs @@ -1,78 +1,62 @@ -/* - * 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; - } - } -} +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 3c4871e..73209e5 100644 --- a/Services/ProcessFilterService.cs +++ b/Services/ProcessFilterService.cs @@ -1,109 +1,84 @@ -/* - * 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.Linq; - using ThreadPilot.Models; - - /// - /// Filter options used for process-list querying from the ViewModel. - /// - public sealed class ProcessFilterCriteria - { - public string SearchText { get; init; } = string.Empty; - - public bool HideSystemProcesses { get; init; } - - public bool HideIdleProcesses { get; init; } - - public string SortMode { get; init; } = "CpuUsage"; - } - - /// - /// Service for filtering and sorting process collections. - /// - public class ProcessFilterService - { - private static readonly string[] SystemProcessNames = - { - "System", "Registry", "smss.exe", "csrss.exe", "wininit.exe", "winlogon.exe", - "services.exe", "lsass.exe", "svchost.exe", "spoolsv.exe", "explorer.exe", - "dwm.exe", "audiodg.exe", "conhost.exe", "dllhost.exe", "rundll32.exe", - "taskhostw.exe", "SearchIndexer.exe", "WmiPrvSE.exe", "MsMpEng.exe", - "SecurityHealthService.exe", "SecurityHealthSystray.exe", - }; - - /// - /// Applies filter criteria and returns sorted process results. - /// - public IReadOnlyList FilterAndSort(IEnumerable source, ProcessFilterCriteria criteria) - { - ArgumentNullException.ThrowIfNull(source); - ArgumentNullException.ThrowIfNull(criteria); - - var filtered = source; - - if (!string.IsNullOrWhiteSpace(criteria.SearchText)) - { - filtered = filtered.Where(p => p.Name.Contains(criteria.SearchText, StringComparison.OrdinalIgnoreCase)); - } - - if (criteria.HideSystemProcesses) - { - filtered = filtered.Where(p => !this.IsSystemProcess(p)); - } - - if (criteria.HideIdleProcesses) - { - filtered = filtered.Where(p => p.CpuUsage > 0.1); - } - - var sorted = criteria.SortMode switch - { - "CpuUsage" => filtered.OrderByDescending(p => p.CpuUsage), - "MemoryUsage" => filtered.OrderByDescending(p => p.MemoryUsage), - "Name" => filtered.OrderBy(p => p.Name), - "ProcessId" => filtered.OrderBy(p => p.ProcessId), - _ => filtered.OrderByDescending(p => p.CpuUsage), - }; - - return sorted.ToList(); - } - - public bool IsSystemProcess(ProcessModel process) - { - if (process == null) - { - return false; - } - - var processName = NormalizeProcessName(process.Name); - - return SystemProcessNames.Any(sp => processName.Equals(NormalizeProcessName(sp), StringComparison.OrdinalIgnoreCase)) || - processName.StartsWith("system", StringComparison.OrdinalIgnoreCase); - } - - private static string NormalizeProcessName(string processName) - { - return processName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) - ? processName[..^4] - : processName; - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Linq; + using ThreadPilot.Models; + + public sealed class ProcessFilterCriteria + { + public string SearchText { get; init; } = string.Empty; + + public bool HideSystemProcesses { get; init; } + + public bool HideIdleProcesses { get; init; } + + public string SortMode { get; init; } = "CpuUsage"; + } + + public class ProcessFilterService + { + private static readonly string[] SystemProcessNames = + { + "System", "Registry", "smss.exe", "csrss.exe", "wininit.exe", "winlogon.exe", + "services.exe", "lsass.exe", "svchost.exe", "spoolsv.exe", "explorer.exe", + "dwm.exe", "audiodg.exe", "conhost.exe", "dllhost.exe", "rundll32.exe", + "taskhostw.exe", "SearchIndexer.exe", "WmiPrvSE.exe", "MsMpEng.exe", + "SecurityHealthService.exe", "SecurityHealthSystray.exe", + }; + + public IReadOnlyList FilterAndSort(IEnumerable source, ProcessFilterCriteria criteria) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(criteria); + + var filtered = source; + + if (!string.IsNullOrWhiteSpace(criteria.SearchText)) + { + filtered = filtered.Where(p => p.Name.Contains(criteria.SearchText, StringComparison.OrdinalIgnoreCase)); + } + + if (criteria.HideSystemProcesses) + { + filtered = filtered.Where(p => !this.IsSystemProcess(p)); + } + + if (criteria.HideIdleProcesses) + { + filtered = filtered.Where(p => p.CpuUsage > 0.1); + } + + var sorted = criteria.SortMode switch + { + "CpuUsage" => filtered.OrderByDescending(p => p.CpuUsage), + "MemoryUsage" => filtered.OrderByDescending(p => p.MemoryUsage), + "Name" => filtered.OrderBy(p => p.Name), + "ProcessId" => filtered.OrderBy(p => p.ProcessId), + _ => filtered.OrderByDescending(p => p.CpuUsage), + }; + + return sorted.ToList(); + } + + public bool IsSystemProcess(ProcessModel process) + { + if (process == null) + { + return false; + } + + var processName = NormalizeProcessName(process.Name); + + return SystemProcessNames.Any(sp => processName.Equals(NormalizeProcessName(sp), StringComparison.OrdinalIgnoreCase)) || + processName.StartsWith("system", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeProcessName(string processName) + { + return processName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ? processName[..^4] + : processName; + } + } +} diff --git a/Services/ProcessListDeltaUpdater.cs b/Services/ProcessListDeltaUpdater.cs index 0ced855..2523b2c 100644 --- a/Services/ProcessListDeltaUpdater.cs +++ b/Services/ProcessListDeltaUpdater.cs @@ -1,103 +1,84 @@ -/* - * 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; - } - } -} +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); + + 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/ProcessManagement/IProcessManagementService.cs b/Services/ProcessManagement/IProcessManagementService.cs deleted file mode 100644 index 0246590..0000000 --- a/Services/ProcessManagement/IProcessManagementService.cs +++ /dev/null @@ -1,123 +0,0 @@ -/* - * 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.ProcessManagement -{ - using System; - using System.Collections.Generic; - using System.Diagnostics; - using System.Threading.Tasks; - using ThreadPilot.Models; - - /// - /// Unified interface for process management operations. - /// - public interface IProcessManagementService - { - /// - /// Event fired when a process starts - /// - event EventHandler? ProcessStarted; - - /// - /// Event fired when a process stops - /// - event EventHandler? ProcessStopped; - - /// - /// Event fired when process monitoring status changes - /// - event EventHandler? MonitoringStatusChanged; - - /// - /// Gets a value indicating whether gets whether process monitoring is currently active. - /// - bool IsMonitoringActive { get; } - - /// - /// Gets all currently running processes. - /// - Task> GetRunningProcessesAsync(); - - /// - /// Gets a specific process by ID. - /// - Task GetProcessByIdAsync(int processId); - - /// - /// Gets processes by executable name. - /// - Task> GetProcessesByNameAsync(string executableName); - - /// - /// Start monitoring for process events. - /// - Task StartMonitoringAsync(); - - /// - /// Stop monitoring for process events. - /// - Task StopMonitoringAsync(); - - /// - /// Set processor affinity for a process. - /// - Task SetProcessorAffinityAsync(ProcessModel process, long affinityMask); - - /// - /// Set priority for a process. - /// - Task SetProcessPriorityAsync(ProcessModel process, ProcessPriorityClass priority); - - /// - /// Refresh process information. - /// - Task RefreshProcessInfoAsync(ProcessModel process); - } - - /// - /// Event args for process events. - /// - public class ProcessEventArgs : EventArgs - { - public ProcessModel Process { get; } - - public DateTime Timestamp { get; } - - public ProcessEventArgs(ProcessModel process) - { - this.Process = process ?? throw new ArgumentNullException(nameof(process)); - this.Timestamp = DateTime.Now; - } - } - - /// - /// Event args for monitoring status changes. - /// - public class MonitoringStatusChangedEventArgs : EventArgs - { - public bool IsActive { get; } - - public string? Reason { get; } - - public MonitoringStatusChangedEventArgs(bool isActive, string? reason = null) - { - this.IsActive = isActive; - this.Reason = reason; - } - } -} - diff --git a/Services/ProcessMemoryPriorityService.cs b/Services/ProcessMemoryPriorityService.cs index 6ea3e7a..33805ec 100644 --- a/Services/ProcessMemoryPriorityService.cs +++ b/Services/ProcessMemoryPriorityService.cs @@ -1,242 +1,242 @@ -/* - * ThreadPilot - process memory priority service. - */ -namespace ThreadPilot.Services -{ - using System.ComponentModel; - using System.Runtime.InteropServices; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - using ThreadPilot.Platforms.Windows; - - public sealed class ProcessMemoryPriorityService : IProcessMemoryPriorityService - { - public const string UnsupportedUserMessage = - "Memory priority is not supported on this Windows version or process."; - - private const string InvalidMemoryPriorityUserMessage = - "This memory priority value is not supported."; - - private const string InvalidProcessErrorCode = "InvalidProcess"; - private const string UnsupportedErrorCode = "Unsupported"; - private const string InvalidPriorityErrorCode = "InvalidMemoryPriority"; - - private static readonly uint MemoryPriorityInformationSize = - (uint)Marshal.SizeOf(); - - private readonly IProcessMemoryPriorityNativeApi nativeApi; - private readonly ILogger logger; - - public ProcessMemoryPriorityService( - IProcessMemoryPriorityNativeApi nativeApi, - ILogger logger) - { - this.nativeApi = nativeApi ?? throw new ArgumentNullException(nameof(nativeApi)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public Task GetMemoryPriorityAsync(ProcessModel process) - { - if (!this.nativeApi.IsSupported || !IsValidProcess(process)) - { - return Task.FromResult(null); - } - - try - { - using var handle = this.nativeApi.OpenProcess( - ProcessAccessFlags.PROCESS_QUERY_LIMITED_INFORMATION, - inheritHandle: false, - (uint)process.ProcessId); - - if (handle.IsInvalid) - { - this.logger.LogDebug( - "OpenProcess failed while reading memory priority for process {ProcessName} (PID: {ProcessId}): {Error}", - process.Name, - process.ProcessId, - this.nativeApi.GetLastWin32Error()); - return Task.FromResult(null); - } - - var information = default(MemoryPriorityInformation); - if (!this.nativeApi.GetProcessInformation( - handle, - ProcessInformationClass.ProcessMemoryPriority, - ref information, - MemoryPriorityInformationSize)) - { - this.logger.LogDebug( - "GetProcessInformation(ProcessMemoryPriority) failed for process {ProcessName} (PID: {ProcessId}): {Error}", - process.Name, - process.ProcessId, - this.nativeApi.GetLastWin32Error()); - return Task.FromResult(null); - } - - return Task.FromResult(FromWindowsMemoryPriority(information.MemoryPriority)); - } - catch (Exception ex) when (IsUnsupported(ex) || AffinityApplyExceptionClassifier.IsAccessDenied(ex) || AffinityApplyExceptionClassifier.IsProcessExited(ex)) - { - this.logger.LogDebug( - ex, - "Could not read memory priority for process {ProcessName} (PID: {ProcessId})", - process.Name, - process.ProcessId); - return Task.FromResult(null); - } - } - - public Task SetMemoryPriorityAsync(ProcessModel process, ProcessMemoryPriority priority) - { - if (!IsValidProcess(process)) - { - return Task.FromResult(ProcessOperationResult.Failed( - InvalidProcessErrorCode, - ProcessOperationUserMessages.ProcessExited, - "Process is null or has an invalid PID.")); - } - - if (!IsDefinedPriority(priority)) - { - return Task.FromResult(ProcessOperationResult.Failed( - InvalidPriorityErrorCode, - InvalidMemoryPriorityUserMessage, - $"Memory priority value '{priority}' is not supported.")); - } - - if (!this.nativeApi.IsSupported) - { - return Task.FromResult(Unsupported("The Windows process memory priority APIs are unavailable.")); - } - - try - { - using var handle = this.nativeApi.OpenProcess( - ProcessAccessFlags.PROCESS_SET_INFORMATION, - inheritHandle: false, - (uint)process.ProcessId); - - if (handle.IsInvalid) - { - return Task.FromResult(this.FromLastError( - "OpenProcess failed before SetProcessInformation(ProcessMemoryPriority).")); - } - - var information = new MemoryPriorityInformation - { - MemoryPriority = ToWindowsMemoryPriority(priority), - }; - - if (!this.nativeApi.SetProcessInformation( - handle, - ProcessInformationClass.ProcessMemoryPriority, - ref information, - MemoryPriorityInformationSize)) - { - return Task.FromResult(this.FromLastError( - "SetProcessInformation(ProcessMemoryPriority) failed.")); - } - - return Task.FromResult(ProcessOperationResult.Succeeded( - "Memory priority applied.", - $"Process {process.Name} (PID: {process.ProcessId}) memory priority set to {priority}.")); - } - catch (Exception ex) when (IsUnsupported(ex)) - { - return Task.FromResult(Unsupported(ex.Message)); - } - catch (Exception ex) when (AffinityApplyExceptionClassifier.IsProcessExited(ex)) - { - return Task.FromResult(ProcessOperationResult.Failed( - AffinityApplyErrorCodes.ProcessExited, - ProcessOperationUserMessages.ProcessExited, - ex.Message, - isProcessExited: true)); - } - catch (Exception ex) when (AffinityApplyExceptionClassifier.IsAccessDenied(ex)) - { - var antiCheatLikely = AffinityApplyExceptionClassifier.IsAntiCheatLikely(ex); - return Task.FromResult(ProcessOperationResult.Failed( - antiCheatLikely - ? AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely - : AffinityApplyErrorCodes.AccessDenied, - antiCheatLikely - ? ProcessOperationUserMessages.AntiCheatProtectedLikely - : ProcessOperationUserMessages.AccessDenied, - ex.Message, - isAccessDenied: true, - isAntiCheatLikely: antiCheatLikely)); - } - catch (Exception ex) - { - this.logger.LogWarning( - ex, - "Memory priority apply failed for process {ProcessName} (PID: {ProcessId})", - process.Name, - process.ProcessId); - - return Task.FromResult(ProcessOperationResult.Failed( - AffinityApplyErrorCodes.NativeApplyFailed, - "ThreadPilot could not apply the memory priority change.", - ex.Message)); - } - } - - private static bool IsValidProcess(ProcessModel? process) => - process != null && process.ProcessId > 0; - - private static bool IsDefinedPriority(ProcessMemoryPriority priority) => - priority is ProcessMemoryPriority.VeryLow or - ProcessMemoryPriority.Low or - ProcessMemoryPriority.Medium or - ProcessMemoryPriority.BelowNormal or - ProcessMemoryPriority.Normal; - - private static uint ToWindowsMemoryPriority(ProcessMemoryPriority priority) => - IsDefinedPriority(priority) - ? (uint)priority - : throw new ArgumentOutOfRangeException(nameof(priority), priority, "Unsupported memory priority value."); - - private static ProcessMemoryPriority? FromWindowsMemoryPriority(uint priority) => - priority is >= (uint)ProcessMemoryPriority.VeryLow and <= (uint)ProcessMemoryPriority.Normal - ? (ProcessMemoryPriority)priority - : null; - - private static bool IsUnsupported(Exception ex) => - ex is EntryPointNotFoundException || - ex is DllNotFoundException || - (ex is Win32Exception win32Exception && win32Exception.NativeErrorCode == 50); - - private static ProcessOperationResult Unsupported(string technicalMessage) => - ProcessOperationResult.Failed( - UnsupportedErrorCode, - UnsupportedUserMessage, - technicalMessage); - - private ProcessOperationResult FromLastError(string context) - { - var error = this.nativeApi.GetLastWin32Error(); - var technicalMessage = $"{context} Win32 error {error}."; - - return error switch - { - 5 => ProcessOperationResult.Failed( - AffinityApplyErrorCodes.AccessDenied, - ProcessOperationUserMessages.AccessDenied, - technicalMessage, - isAccessDenied: true), - 50 => Unsupported(technicalMessage), - 87 => ProcessOperationResult.Failed( - AffinityApplyErrorCodes.ProcessExited, - ProcessOperationUserMessages.ProcessExited, - technicalMessage, - isProcessExited: true), - _ => ProcessOperationResult.Failed( - AffinityApplyErrorCodes.NativeApplyFailed, - "ThreadPilot could not apply the memory priority change.", - technicalMessage), - }; - } - } -} +/* + * ThreadPilot - process memory priority service. + */ +namespace ThreadPilot.Services +{ + using System.ComponentModel; + using System.Runtime.InteropServices; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + using ThreadPilot.Platforms.Windows; + + public sealed class ProcessMemoryPriorityService : IProcessMemoryPriorityService + { + public const string UnsupportedUserMessage = + "Memory priority is not supported on this Windows version or process."; + + private const string InvalidMemoryPriorityUserMessage = + "This memory priority value is not supported."; + + private const string InvalidProcessErrorCode = "InvalidProcess"; + private const string UnsupportedErrorCode = "Unsupported"; + private const string InvalidPriorityErrorCode = "InvalidMemoryPriority"; + + private static readonly uint MemoryPriorityInformationSize = + (uint)Marshal.SizeOf(); + + private readonly IProcessMemoryPriorityNativeApi nativeApi; + private readonly ILogger logger; + + public ProcessMemoryPriorityService( + IProcessMemoryPriorityNativeApi nativeApi, + ILogger logger) + { + this.nativeApi = nativeApi ?? throw new ArgumentNullException(nameof(nativeApi)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task GetMemoryPriorityAsync(ProcessModel process) + { + if (!this.nativeApi.IsSupported || !IsValidProcess(process)) + { + return Task.FromResult(null); + } + + try + { + using var handle = this.nativeApi.OpenProcess( + ProcessAccessFlags.PROCESS_QUERY_LIMITED_INFORMATION, + inheritHandle: false, + (uint)process.ProcessId); + + if (handle.IsInvalid) + { + this.logger.LogDebug( + "OpenProcess failed while reading memory priority for process {ProcessName} (PID: {ProcessId}): {Error}", + process.Name, + process.ProcessId, + this.nativeApi.GetLastWin32Error()); + return Task.FromResult(null); + } + + var information = default(MemoryPriorityInformation); + if (!this.nativeApi.GetProcessInformation( + handle, + ProcessInformationClass.ProcessMemoryPriority, + ref information, + MemoryPriorityInformationSize)) + { + this.logger.LogDebug( + "GetProcessInformation(ProcessMemoryPriority) failed for process {ProcessName} (PID: {ProcessId}): {Error}", + process.Name, + process.ProcessId, + this.nativeApi.GetLastWin32Error()); + return Task.FromResult(null); + } + + return Task.FromResult(FromWindowsMemoryPriority(information.MemoryPriority)); + } + catch (Exception ex) when (IsUnsupported(ex) || AffinityApplyExceptionClassifier.IsAccessDenied(ex) || AffinityApplyExceptionClassifier.IsProcessExited(ex)) + { + this.logger.LogDebug( + ex, + "Could not read memory priority for process {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + return Task.FromResult(null); + } + } + + public Task SetMemoryPriorityAsync(ProcessModel process, ProcessMemoryPriority priority) + { + if (!IsValidProcess(process)) + { + return Task.FromResult(ProcessOperationResult.Failed( + InvalidProcessErrorCode, + ProcessOperationUserMessages.ProcessExited, + "Process is null or has an invalid PID.")); + } + + if (!IsDefinedPriority(priority)) + { + return Task.FromResult(ProcessOperationResult.Failed( + InvalidPriorityErrorCode, + InvalidMemoryPriorityUserMessage, + $"Memory priority value '{priority}' is not supported.")); + } + + if (!this.nativeApi.IsSupported) + { + return Task.FromResult(Unsupported("The Windows process memory priority APIs are unavailable.")); + } + + try + { + using var handle = this.nativeApi.OpenProcess( + ProcessAccessFlags.PROCESS_SET_INFORMATION, + inheritHandle: false, + (uint)process.ProcessId); + + if (handle.IsInvalid) + { + return Task.FromResult(this.FromLastError( + "OpenProcess failed before SetProcessInformation(ProcessMemoryPriority).")); + } + + var information = new MemoryPriorityInformation + { + MemoryPriority = ToWindowsMemoryPriority(priority), + }; + + if (!this.nativeApi.SetProcessInformation( + handle, + ProcessInformationClass.ProcessMemoryPriority, + ref information, + MemoryPriorityInformationSize)) + { + return Task.FromResult(this.FromLastError( + "SetProcessInformation(ProcessMemoryPriority) failed.")); + } + + return Task.FromResult(ProcessOperationResult.Succeeded( + "Memory priority applied.", + $"Process {process.Name} (PID: {process.ProcessId}) memory priority set to {priority}.")); + } + catch (Exception ex) when (IsUnsupported(ex)) + { + return Task.FromResult(Unsupported(ex.Message)); + } + catch (Exception ex) when (AffinityApplyExceptionClassifier.IsProcessExited(ex)) + { + return Task.FromResult(ProcessOperationResult.Failed( + AffinityApplyErrorCodes.ProcessExited, + ProcessOperationUserMessages.ProcessExited, + ex.Message, + isProcessExited: true)); + } + catch (Exception ex) when (AffinityApplyExceptionClassifier.IsAccessDenied(ex)) + { + var antiCheatLikely = AffinityApplyExceptionClassifier.IsAntiCheatLikely(ex); + return Task.FromResult(ProcessOperationResult.Failed( + antiCheatLikely + ? AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely + : AffinityApplyErrorCodes.AccessDenied, + antiCheatLikely + ? ProcessOperationUserMessages.AntiCheatProtectedLikely + : ProcessOperationUserMessages.AccessDenied, + ex.Message, + isAccessDenied: true, + isAntiCheatLikely: antiCheatLikely)); + } + catch (Exception ex) + { + this.logger.LogWarning( + ex, + "Memory priority apply failed for process {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + + return Task.FromResult(ProcessOperationResult.Failed( + AffinityApplyErrorCodes.NativeApplyFailed, + "ThreadPilot could not apply the memory priority change.", + ex.Message)); + } + } + + private static bool IsValidProcess(ProcessModel? process) => + process != null && process.ProcessId > 0; + + private static bool IsDefinedPriority(ProcessMemoryPriority priority) => + priority is ProcessMemoryPriority.VeryLow or + ProcessMemoryPriority.Low or + ProcessMemoryPriority.Medium or + ProcessMemoryPriority.BelowNormal or + ProcessMemoryPriority.Normal; + + private static uint ToWindowsMemoryPriority(ProcessMemoryPriority priority) => + IsDefinedPriority(priority) + ? (uint)priority + : throw new ArgumentOutOfRangeException(nameof(priority), priority, "Unsupported memory priority value."); + + private static ProcessMemoryPriority? FromWindowsMemoryPriority(uint priority) => + priority is >= (uint)ProcessMemoryPriority.VeryLow and <= (uint)ProcessMemoryPriority.Normal + ? (ProcessMemoryPriority)priority + : null; + + private static bool IsUnsupported(Exception ex) => + ex is EntryPointNotFoundException || + ex is DllNotFoundException || + (ex is Win32Exception win32Exception && win32Exception.NativeErrorCode == 50); + + private static ProcessOperationResult Unsupported(string technicalMessage) => + ProcessOperationResult.Failed( + UnsupportedErrorCode, + UnsupportedUserMessage, + technicalMessage); + + private ProcessOperationResult FromLastError(string context) + { + var error = this.nativeApi.GetLastWin32Error(); + var technicalMessage = $"{context} Win32 error {error}."; + + return error switch + { + 5 => ProcessOperationResult.Failed( + AffinityApplyErrorCodes.AccessDenied, + ProcessOperationUserMessages.AccessDenied, + technicalMessage, + isAccessDenied: true), + 50 => Unsupported(technicalMessage), + 87 => ProcessOperationResult.Failed( + AffinityApplyErrorCodes.ProcessExited, + ProcessOperationUserMessages.ProcessExited, + technicalMessage, + isProcessExited: true), + _ => ProcessOperationResult.Failed( + AffinityApplyErrorCodes.NativeApplyFailed, + "ThreadPilot could not apply the memory priority change.", + technicalMessage), + }; + } + } +} diff --git a/Services/ProcessMonitorManagerService.cs b/Services/ProcessMonitorManagerService.cs index 51ae8b2..1c53cc8 100644 --- a/Services/ProcessMonitorManagerService.cs +++ b/Services/ProcessMonitorManagerService.cs @@ -1,906 +1,883 @@ -/* - * 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; - using System.Collections.Generic; - using System.Diagnostics; - using System.Linq; - using System.Threading; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - - /// - /// Main orchestration service that coordinates process monitoring and power plan management. - /// - public class ProcessMonitorManagerService : IProcessMonitorManagerService - { - private readonly IProcessMonitorService processMonitorService; - private readonly IProcessPowerPlanAssociationService associationService; - private readonly IPowerPlanService powerPlanService; - private readonly INotificationService notificationService; - private readonly IApplicationSettingsService settingsService; - private readonly IProcessService processService; - private readonly ICoreMaskService coreMaskService; - private readonly IAffinityApplyService affinityApplyService; - private readonly IPersistentRuleAutoApplyService persistentRuleAutoApplyService; - private readonly PowerPlanTransitionGate powerPlanTransitionGate; - private readonly ILogger logger; - private readonly IEnhancedLoggingService enhancedLogger; - private readonly object lockObject = new(); - - private readonly ConcurrentDictionary runningAssociatedProcesses = new(); - private readonly System.Threading.Timer delayTimer; - private readonly SemaphoreSlim powerPlanChangeSemaphore = new(1, 1); - private readonly SemaphoreSlim stateMutationSemaphore = new(1, 1); - - private bool isRunning; - private string status = "Stopped"; - private bool disposed; - private ProcessMonitorConfiguration? configuration; - private int pendingPowerPlanReevaluation; - - public event EventHandler? ProcessPowerPlanChanged; - - public event EventHandler? ServiceStatusChanged; - - public bool IsRunning => this.isRunning; - - public string Status => this.status; - - public IEnumerable RunningAssociatedProcesses => this.runningAssociatedProcesses.Values.ToList(); - - public ProcessMonitorManagerService( - IProcessMonitorService processMonitorService, - IProcessPowerPlanAssociationService associationService, - IPowerPlanService powerPlanService, - INotificationService notificationService, - IApplicationSettingsService settingsService, - IProcessService processService, - ICoreMaskService coreMaskService, - IAffinityApplyService affinityApplyService, - IPersistentRuleAutoApplyService persistentRuleAutoApplyService, - PowerPlanTransitionGate powerPlanTransitionGate, - ILogger logger, - IEnhancedLoggingService enhancedLogger) - { - this.processMonitorService = processMonitorService ?? throw new ArgumentNullException(nameof(processMonitorService)); - this.associationService = associationService ?? throw new ArgumentNullException(nameof(associationService)); - this.powerPlanService = powerPlanService ?? throw new ArgumentNullException(nameof(powerPlanService)); - this.notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService)); - 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.persistentRuleAutoApplyService = persistentRuleAutoApplyService ?? throw new ArgumentNullException(nameof(persistentRuleAutoApplyService)); - this.powerPlanTransitionGate = powerPlanTransitionGate ?? throw new ArgumentNullException(nameof(powerPlanTransitionGate)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.enhancedLogger = enhancedLogger ?? throw new ArgumentNullException(nameof(enhancedLogger)); - - // Initialize delay timer (used for delayed power plan changes) - this.delayTimer = new System.Threading.Timer(this.DelayedPowerPlanChangeCallback, null, Timeout.Infinite, Timeout.Infinite); - - // Subscribe to events - this.processMonitorService.ProcessStarted += this.OnProcessStarted; - this.processMonitorService.ProcessStopped += this.OnProcessStopped; - this.processMonitorService.MonitoringStatusChanged += this.OnMonitoringStatusChanged; - this.associationService.ConfigurationChanged += this.OnConfigurationChanged; - } - - public async Task StartAsync() - { - if (this.disposed) - { - throw new ObjectDisposedException(nameof(ProcessMonitorManagerService)); - } - - await this.stateMutationSemaphore.WaitAsync(); - try - { - if (this.isRunning) - { - return; - } - - this.logger.LogInformation("Starting Process Monitor Manager Service"); - await this.enhancedLogger.LogSystemEventAsync( - LogEventTypes.System.ServiceStarted, - "Process Monitor Manager Service starting"); - this.SetStatus(true, "Starting..."); - - // Load configuration - await this.associationService.LoadConfigurationAsync(); - this.configuration = this.associationService.Configuration; - this.logger.LogInformation( - "Configuration loaded with {AssociationCount} associations", - this.configuration.Associations.Count); - - await this.enhancedLogger.LogSystemEventAsync( - LogEventTypes.System.ConfigurationLoaded, - $"Process monitoring configuration loaded with {this.configuration.Associations.Count} associations"); - - // Start process monitoring - await this.processMonitorService.StartMonitoringAsync(); - this.logger.LogInformation("Process monitoring started"); - - Interlocked.Exchange(ref this.pendingPowerPlanReevaluation, 0); - - await this.enhancedLogger.LogProcessMonitoringEventAsync( - LogEventTypes.ProcessMonitoring.MonitoringStarted, - "ProcessMonitorService", 0, "WMI-based process monitoring started"); - - this.isRunning = true; - this.SetStatus(true, "Running"); - - this.logger.LogInformation("Process Monitor Manager Service started successfully"); - - await this.enhancedLogger.LogSystemEventAsync( - LogEventTypes.System.ServiceStarted, - "Process Monitor Manager Service started successfully"); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to start Process Monitor Manager Service"); - await this.enhancedLogger.LogErrorAsync(ex, "ProcessMonitorManagerService.StartAsync", - new Dictionary { ["ServiceName"] = "ProcessMonitorManagerService" }); - this.isRunning = false; - this.SetStatus(false, "Failed to start", $"Error: {ex.Message}", ex); - throw; - } - finally - { - this.stateMutationSemaphore.Release(); - } - - // Evaluate current processes after startup lock is released - await this.EvaluateCurrentProcessesAsync(); - } - - public async Task StopAsync() - { - await this.stateMutationSemaphore.WaitAsync(); - try - { - if (!this.isRunning) - { - return; - } - - this.SetStatus(false, "Stopping..."); - - // Mark as stopped early to prevent new event handling while shutting down - this.isRunning = false; - Interlocked.Exchange(ref this.pendingPowerPlanReevaluation, 0); - - // Stop process monitoring - await this.processMonitorService.StopMonitoringAsync(); - - // Clear running processes - foreach (var processId in this.runningAssociatedProcesses.Keys) - { - this.coreMaskService.UnregisterMaskApplication(processId); - this.processService.UntrackProcess(processId); - this.persistentRuleAutoApplyService.MarkProcessExited(processId); - } - this.runningAssociatedProcesses.Clear(); - - // Restore default power plan if configured - if (this.configuration?.DefaultPowerPlanGuid != null) - { - await this.ForceDefaultPowerPlanAsync(); - } - - this.SetStatus(false, "Stopped"); - } - catch (Exception ex) - { - this.SetStatus(false, "Error stopping", $"Error: {ex.Message}", ex); - throw; - } - finally - { - this.stateMutationSemaphore.Release(); - } - } - - public async Task EvaluateCurrentProcessesAsync() - { - if (!this.isRunning || this.configuration == null) - { - return; - } - - try - { - var currentProcesses = (await this.processMonitorService.GetRunningProcessesAsync()).ToList(); - await this.ApplyPersistentRulesForDiscoveredProcessesAsync(currentProcesses); - var associatedProcesses = new List(); - var currentProcessIds = new HashSet(currentProcesses.Select(p => p.ProcessId)); - - // Remove stale tracked processes that are no longer running - foreach (var trackedPid in this.runningAssociatedProcesses.Keys) - { - if (!currentProcessIds.Contains(trackedPid) && this.runningAssociatedProcesses.TryRemove(trackedPid, out _)) - { - this.coreMaskService.UnregisterMaskApplication(trackedPid); - this.processService.UntrackProcess(trackedPid); - this.persistentRuleAutoApplyService.MarkProcessExited(trackedPid); - } - } - - // Find all currently running processes that have associations - foreach (var process in currentProcesses) - { - var association = this.configuration.FindMatchingAssociation(process); - if (association != null) - { - associatedProcesses.Add(process); - this.runningAssociatedProcesses[process.ProcessId] = process; - } - } - - // Determine which power plan should be active - await this.DeterminePowerPlanAsync(associatedProcesses); - } - catch (Exception ex) - { - this.SetStatus(this.isRunning, "Error evaluating processes", $"Error: {ex.Message}", ex); - } - } - - public async Task ForceDefaultPowerPlanAsync() - { - if (this.configuration?.DefaultPowerPlanGuid == null) - { - return; - } - - try - { - 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); - - if (success) - { - var newPowerPlan = await this.powerPlanService.GetPowerPlanByGuidAsync(this.configuration.DefaultPowerPlanGuid); - // Note: We don't have a specific process for this event, so we'll use a dummy one - var dummyProcess = new ProcessModel { Name = "System", ProcessId = -1 }; - var dummyAssociation = new ProcessPowerPlanAssociation("System", this.configuration.DefaultPowerPlanGuid, this.configuration.DefaultPowerPlanName); - - this.ProcessPowerPlanChanged?.Invoke(this, new ProcessPowerPlanChangeEventArgs( - dummyProcess, dummyAssociation, currentPowerPlan, newPowerPlan, "DefaultRestored")); - - // Show notification for default power plan restoration - await this.notificationService.ShowPowerPlanChangeNotificationAsync( - currentPowerPlan?.Name ?? "Unknown", - newPowerPlan?.Name ?? this.configuration.DefaultPowerPlanName, - string.Empty); - } - else - { - this.logger.LogWarning( - "Failed to restore default power plan {PowerPlanGuid}", - this.configuration.DefaultPowerPlanGuid); - } - } - catch (Exception ex) - { - this.SetStatus(this.isRunning, "Error setting default power plan", $"Error: {ex.Message}", ex); - } - finally - { - this.powerPlanChangeSemaphore.Release(); - } - } - - public async Task GetCurrentActivePowerPlanAsync() - { - return await this.powerPlanService.GetActivePowerPlan(); - } - - public async Task RefreshConfigurationAsync() - { - await this.associationService.LoadConfigurationAsync(); - this.configuration = this.associationService.Configuration; - this.processMonitorService.UpdateSettings(); - - if (this.isRunning) - { - await this.EvaluateCurrentProcessesAsync(); - } - } - - private void OnProcessStarted(object? sender, ProcessEventArgs e) - { - TaskSafety.FireAndForget(this.OnProcessStartedAsync(e), ex => - { - this.SetStatus(this.isRunning, "Error handling process start", $"Error: {ex.Message}", ex); - }); - } - - private async Task OnProcessStartedAsync(ProcessEventArgs e) - { - if (!this.isRunning || this.configuration == null) - { - return; - } - - if (string.IsNullOrWhiteSpace(e.Process.Name) || e.Process.ProcessId <= 0) - { - return; - } - - try - { - await this.enhancedLogger.LogProcessMonitoringEventAsync( - LogEventTypes.ProcessMonitoring.Started, - e.Process.Name, e.Process.ProcessId, "Process started and detected by monitoring"); - - await this.ApplyPersistentRulesForProcessStartAsync(e.Process); - - var association = this.configuration.FindMatchingAssociation(e.Process); - if (association != null) - { - this.runningAssociatedProcesses[e.Process.ProcessId] = e.Process; - - await this.enhancedLogger.LogProcessMonitoringEventAsync( - LogEventTypes.ProcessMonitoring.AssociationTriggered, - e.Process.Name, e.Process.ProcessId, - $"Process matched association for power plan: {association.PowerPlanName}"); - - // Apply CPU affinity mask if configured - await this.ApplyCoreMaskAndPriorityAsync(e.Process, association); - - // Schedule power plan change with delay if configured - if (this.configuration.PowerPlanChangeDelayMs > 0) - { - Interlocked.Exchange(ref this.pendingPowerPlanReevaluation, 1); - this.delayTimer.Change(this.configuration.PowerPlanChangeDelayMs, Timeout.Infinite); - - await this.enhancedLogger.LogSystemEventAsync( - LogEventTypes.System.ConfigurationLoaded, - $"Power plan change scheduled with {this.configuration.PowerPlanChangeDelayMs}ms delay for process {e.Process.Name}"); - } - else - { - await this.ChangePowerPlanForProcess(e.Process, association, "ProcessStarted"); - } - } - } - catch (Exception ex) - { - await this.enhancedLogger.LogErrorAsync(ex, "ProcessMonitorManagerService.OnProcessStarted", - new Dictionary - { - ["ProcessName"] = e.Process.Name, - ["ProcessId"] = e.Process.ProcessId, - }); - this.SetStatus(this.isRunning, "Error handling process start", $"Error: {ex.Message}", ex); - } - } - - private void OnProcessStopped(object? sender, ProcessEventArgs e) - { - TaskSafety.FireAndForget(this.OnProcessStoppedAsync(e), ex => - { - this.SetStatus(this.isRunning, "Error handling process stop", $"Error: {ex.Message}", ex); - }); - } - - private async Task OnProcessStoppedAsync(ProcessEventArgs e) - { - if (!this.isRunning || this.configuration == null) - { - return; - } - - try - { - this.persistentRuleAutoApplyService.MarkProcessExited(e.Process.ProcessId); - - if (this.runningAssociatedProcesses.TryRemove(e.Process.ProcessId, out _)) - { - this.coreMaskService.UnregisterMaskApplication(e.Process.ProcessId); - this.processService.UntrackProcess(e.Process.ProcessId); - - // Check if there are any other associated processes still running - var remainingProcesses = this.runningAssociatedProcesses.Values.ToList(); - await this.DeterminePowerPlanAsync(remainingProcesses); - } - } - catch (Exception ex) - { - this.SetStatus(this.isRunning, "Error handling process stop", $"Error: {ex.Message}", ex); - } - } - - private void OnMonitoringStatusChanged(object? sender, MonitoringStatusEventArgs e) - { - var details = e.StatusMessage ?? (e.IsMonitoring ? "Monitoring active" : "Monitoring inactive"); - this.SetStatus(this.isRunning, $"Monitor: {details}", e.StatusMessage, e.Error); - } - - private void OnConfigurationChanged(object? sender, ConfigurationChangedEventArgs e) - { - this.configuration = this.associationService.Configuration; - - if (this.isRunning) - { - TaskSafety.FireAndForget(this.EvaluateCurrentProcessesAsync(), ex => - { - this.SetStatus(this.isRunning, "Error evaluating processes", $"Error: {ex.Message}", ex); - }); - } - - // Keep process monitor settings synchronized with configuration edits - this.processMonitorService.UpdateSettings(); - } - - private void DelayedPowerPlanChangeCallback(object? state) - { - TaskSafety.FireAndForget(this.DelayedPowerPlanChangeCallbackAsync(), ex => - { - this.SetStatus(this.isRunning, "Error in delayed power plan callback", $"Error: {ex.Message}", ex); - }); - } - - private async Task DelayedPowerPlanChangeCallbackAsync() - { - if (!this.isRunning) - { - return; - } - - if (Interlocked.Exchange(ref this.pendingPowerPlanReevaluation, 0) == 0) - { - return; - } - - var runningProcesses = this.runningAssociatedProcesses.Values.ToList(); - await this.DeterminePowerPlanAsync(runningProcesses); - } - - private async Task DeterminePowerPlanAsync(IList associatedProcesses) - { - if (this.configuration == null) - { - return; - } - - try - { - if (associatedProcesses.Any()) - { - // Find the highest priority association among running processes - var associations = associatedProcesses - .Select(p => this.configuration.FindMatchingAssociation(p)) - .Where(a => a != null) - .OrderByDescending(a => a!.Priority) - .ToList(); - - if (associations.Any()) - { - var topAssociation = associations.First()!; - var matchingProcess = associatedProcesses.First(p => topAssociation.MatchesProcess(p)); - await this.ChangePowerPlanForProcess(matchingProcess, topAssociation, "ProcessStarted"); - } - } - else - { - // No associated processes running, revert to default - if (!string.IsNullOrEmpty(this.configuration.DefaultPowerPlanGuid)) - { - await this.ForceDefaultPowerPlanAsync(); - } - } - } - catch (Exception ex) - { - this.SetStatus(this.isRunning, "Error determining power plan", $"Error: {ex.Message}", ex); - } - } - - private async Task ChangePowerPlanForProcess(ProcessModel process, ProcessPowerPlanAssociation association, string action) - { - try - { - 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); - - if (success) - { - var newPowerPlan = await this.powerPlanService.GetPowerPlanByGuidAsync(association.PowerPlanGuid); - this.ProcessPowerPlanChanged?.Invoke(this, new ProcessPowerPlanChangeEventArgs( - process, association, currentPowerPlan, newPowerPlan, action)); - - // Show notification for power plan change - await this.notificationService.ShowPowerPlanChangeNotificationAsync( - currentPowerPlan?.Name ?? "Unknown", - 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) - { - this.SetStatus(this.isRunning, "Error changing power plan", $"Error: {ex.Message}", ex); - } - finally - { - this.powerPlanChangeSemaphore.Release(); - } - } - - private void SetStatus(bool isRunning, string status, string? details = null, Exception? error = null) - { - lock (this.lockObject) - { - this.status = status; - } - - this.ServiceStatusChanged?.Invoke(this, new ServiceStatusEventArgs(isRunning, status, details, error)); - - // Show error notification if there's an error - if (error != null) - { - TaskSafety.FireAndForget( - this.notificationService.ShowErrorNotificationAsync( - "Process Monitor Error", - details ?? status, - error), - ex => - { - this.logger.LogError(ex, "Failed to show error notification"); - }); - } - } - - private async Task ApplyPersistentRulesForDiscoveredProcessesAsync(IEnumerable processes) - { - try - { - var results = await this.persistentRuleAutoApplyService.ApplyForDiscoveredProcessesAsync(processes); - await this.LogPersistentRuleResultsAsync(results); - } - catch (OperationCanceledException) - { - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Persistent rule auto-apply failed during process snapshot refresh"); - } - } - - private async Task ApplyPersistentRulesForProcessStartAsync(ProcessModel process) - { - try - { - var results = await this.persistentRuleAutoApplyService.ApplyForProcessStartAsync(process); - await this.LogPersistentRuleResultsAsync(results); - } - catch (OperationCanceledException) - { - } - catch (Exception ex) - { - this.logger.LogWarning( - ex, - "Persistent rule auto-apply failed for process {ProcessName} (PID: {ProcessId})", - process.Name, - process.ProcessId); - } - } - - private async Task LogPersistentRuleResultsAsync(IReadOnlyList results) - { - foreach (var result in results) - { - if (result.Success) - { - await this.enhancedLogger.LogProcessMonitoringEventAsync( - LogEventTypes.ProcessMonitoring.AssociationTriggered, - result.ProcessName, - result.ProcessId, - $"Persistent rule '{result.RuleId}' applied automatically"); - } - else - { - this.logger.LogDebug( - "Persistent rule {RuleId} was not applied to process {ProcessName} (PID: {ProcessId}): {Message}", - result.RuleId, - result.ProcessName, - result.ProcessId, - result.UserMessage); - } - } - } - - public void UpdateSettings() - { - // Update the process monitor service with new settings - this.processMonitorService.UpdateSettings(); - - this.logger.LogDebug("ProcessMonitorManagerService settings updated"); - } - - /// - /// Applies CPU affinity mask and process priority from association when a process starts - /// Based on CPUSetSetter's ProgramRule.SetMask pattern. - /// - private async Task ApplyCoreMaskAndPriorityAsync(ProcessModel process, ProcessPowerPlanAssociation association) - { - try - { - // Apply CPU affinity mask if configured - if (!string.IsNullOrEmpty(association.CoreMaskId)) - { - var coreMask = this.coreMaskService.AvailableMasks.FirstOrDefault(m => m.Id == association.CoreMaskId); - if (coreMask != null) - { - try - { - var affinity = coreMask.ToProcessorAffinity(); - if (affinity > 0) - { - 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) - { - var blockedReason = BuildAffinityOrPriorityBlockedMessage(ex, process.Name, "affinity"); - if (!string.IsNullOrEmpty(blockedReason)) - { - await this.notificationService.ShowNotificationAsync( - "Affinity blocked", - blockedReason, - NotificationType.Warning); - } - - this.logger.LogWarning( - ex, - "Failed to apply CPU mask '{MaskName}' to process {ProcessName} (PID: {ProcessId})", - coreMask.Name, process.Name, process.ProcessId); - - await this.enhancedLogger.LogErrorAsync(ex, "ProcessMonitorManagerService.ApplyCoreMaskAndPriorityAsync", - new Dictionary - { - ["ProcessName"] = process.Name, - ["ProcessId"] = process.ProcessId, - ["MaskName"] = coreMask.Name, - }); - } - } - else - { - this.logger.LogWarning( - "Core mask ID '{CoreMaskId}' not found for process {ProcessName}, skipping affinity application", - association.CoreMaskId, process.Name); - } - } - - // Apply process priority if configured - if (!string.IsNullOrEmpty(association.ProcessPriority)) - { - if (Enum.TryParse(association.ProcessPriority, out var priority)) - { - try - { - var currentPriority = process.Priority; - - if (!Enum.IsDefined(typeof(ProcessPriorityClass), currentPriority)) - { - try - { - await this.processService.RefreshProcessInfo(process); - currentPriority = process.Priority; - } - catch (Exception refreshEx) - { - this.logger.LogDebug( - refreshEx, - "Could not refresh process priority before tracking for {ProcessName} (PID: {ProcessId})", - process.Name, process.ProcessId); - } - } - - if (Enum.IsDefined(typeof(ProcessPriorityClass), currentPriority)) - { - this.processService.TrackPriorityChange(process.ProcessId, currentPriority); - } - - await this.processService.SetProcessPriority(process, priority); - - this.logger.LogInformation( - "Applied priority '{Priority}' to process {ProcessName} (PID: {ProcessId})", - priority, process.Name, process.ProcessId); - - await this.enhancedLogger.LogProcessMonitoringEventAsync( - LogEventTypes.ProcessMonitoring.AssociationTriggered, - process.Name, process.ProcessId, - $"Priority '{priority}' applied automatically from association"); - } - catch (Exception ex) - { - var blockedReason = BuildAffinityOrPriorityBlockedMessage(ex, process.Name, "priority"); - if (!string.IsNullOrEmpty(blockedReason)) - { - await this.notificationService.ShowNotificationAsync( - "Priority blocked", - blockedReason, - NotificationType.Warning); - } - - this.logger.LogWarning( - ex, - "Failed to apply priority '{Priority}' to process {ProcessName} (PID: {ProcessId})", - priority, process.Name, process.ProcessId); - - await this.enhancedLogger.LogErrorAsync(ex, "ProcessMonitorManagerService.ApplyCoreMaskAndPriorityAsync", - new Dictionary - { - ["ProcessName"] = process.Name, - ["ProcessId"] = process.ProcessId, - ["Priority"] = priority.ToString(), - }); - } - } - else - { - this.logger.LogWarning( - "Invalid priority value '{Priority}' for process {ProcessName}, skipping priority application", - association.ProcessPriority, process.Name); - } - } - } - catch (Exception ex) - { - this.logger.LogError( - ex, - "Error applying CPU mask and priority to process {ProcessName} (PID: {ProcessId})", - process.Name, process.ProcessId); - - await this.enhancedLogger.LogErrorAsync(ex, "ProcessMonitorManagerService.ApplyCoreMaskAndPriorityAsync", - new Dictionary - { - ["ProcessName"] = process.Name, - ["ProcessId"] = process.ProcessId, - ["AssociationId"] = association.Id, - }); - } - } - - private static string BuildAffinityOrPriorityBlockedMessage(Exception ex, string processName, string operation) - { - var message = ex.Message ?? string.Empty; - var lowered = message.ToLowerInvariant(); - - if (lowered.Contains("access denied") || - lowered.Contains("anti-cheat") || - lowered.Contains("anti cheat") || - lowered.Contains("protected") || - lowered.Contains("insufficient privileges") || - ex is UnauthorizedAccessException) - { - return lowered.Contains("anti-cheat") || lowered.Contains("anti cheat") || lowered.Contains("protected") - ? ProcessOperationUserMessages.AntiCheatProtectedLikely - : ProcessOperationUserMessages.AccessDenied; - } - - if (lowered.Contains("realtime priority is blocked")) - { - return ProcessOperationUserMessages.RealtimePriorityBlocked; - } - - return string.Empty; - } - - public void Dispose() - { - if (this.disposed) - { - return; - } - - try - { - // Dispose can be called from the WPF UI thread; stop on the thread pool to avoid - // deadlocking on a captured SynchronizationContext during async shutdown. - Task.Run(this.StopAsync).GetAwaiter().GetResult(); - } - finally - { - this.processMonitorService.ProcessStarted -= this.OnProcessStarted; - this.processMonitorService.ProcessStopped -= this.OnProcessStopped; - this.processMonitorService.MonitoringStatusChanged -= this.OnMonitoringStatusChanged; - this.associationService.ConfigurationChanged -= this.OnConfigurationChanged; - - this.delayTimer?.Dispose(); - this.powerPlanChangeSemaphore?.Dispose(); - this.stateMutationSemaphore?.Dispose(); - this.processMonitorService?.Dispose(); - - this.disposed = true; - } - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Diagnostics; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public class ProcessMonitorManagerService : IProcessMonitorManagerService + { + private readonly IProcessMonitorService processMonitorService; + private readonly IProcessPowerPlanAssociationService associationService; + private readonly IPowerPlanService powerPlanService; + private readonly INotificationService notificationService; + private readonly IApplicationSettingsService settingsService; + private readonly IProcessService processService; + private readonly ICoreMaskService coreMaskService; + private readonly IAffinityApplyService affinityApplyService; + private readonly IPersistentRuleAutoApplyService persistentRuleAutoApplyService; + private readonly PowerPlanTransitionGate powerPlanTransitionGate; + private readonly ILogger logger; + private readonly IEnhancedLoggingService enhancedLogger; + private readonly object lockObject = new(); + + private readonly ConcurrentDictionary runningAssociatedProcesses = new(); + private readonly System.Threading.Timer delayTimer; + private readonly SemaphoreSlim powerPlanChangeSemaphore = new(1, 1); + private readonly SemaphoreSlim stateMutationSemaphore = new(1, 1); + + private bool isRunning; + private string status = "Stopped"; + private bool disposed; + private ProcessMonitorConfiguration? configuration; + private int pendingPowerPlanReevaluation; + + public event EventHandler? ProcessPowerPlanChanged; + + public event EventHandler? ServiceStatusChanged; + + public bool IsRunning => this.isRunning; + + public string Status => this.status; + + public IEnumerable RunningAssociatedProcesses => this.runningAssociatedProcesses.Values.ToList(); + + public ProcessMonitorManagerService( + IProcessMonitorService processMonitorService, + IProcessPowerPlanAssociationService associationService, + IPowerPlanService powerPlanService, + INotificationService notificationService, + IApplicationSettingsService settingsService, + IProcessService processService, + ICoreMaskService coreMaskService, + IAffinityApplyService affinityApplyService, + IPersistentRuleAutoApplyService persistentRuleAutoApplyService, + PowerPlanTransitionGate powerPlanTransitionGate, + ILogger logger, + IEnhancedLoggingService enhancedLogger) + { + this.processMonitorService = processMonitorService ?? throw new ArgumentNullException(nameof(processMonitorService)); + this.associationService = associationService ?? throw new ArgumentNullException(nameof(associationService)); + this.powerPlanService = powerPlanService ?? throw new ArgumentNullException(nameof(powerPlanService)); + this.notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService)); + 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.persistentRuleAutoApplyService = persistentRuleAutoApplyService ?? throw new ArgumentNullException(nameof(persistentRuleAutoApplyService)); + this.powerPlanTransitionGate = powerPlanTransitionGate ?? throw new ArgumentNullException(nameof(powerPlanTransitionGate)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.enhancedLogger = enhancedLogger ?? throw new ArgumentNullException(nameof(enhancedLogger)); + + // Initialize delay timer (used for delayed power plan changes) + this.delayTimer = new System.Threading.Timer(this.DelayedPowerPlanChangeCallback, null, Timeout.Infinite, Timeout.Infinite); + + // Subscribe to events + this.processMonitorService.ProcessStarted += this.OnProcessStarted; + this.processMonitorService.ProcessStopped += this.OnProcessStopped; + this.processMonitorService.MonitoringStatusChanged += this.OnMonitoringStatusChanged; + this.associationService.ConfigurationChanged += this.OnConfigurationChanged; + } + + public async Task StartAsync() + { + if (this.disposed) + { + throw new ObjectDisposedException(nameof(ProcessMonitorManagerService)); + } + + await this.stateMutationSemaphore.WaitAsync(); + try + { + if (this.isRunning) + { + return; + } + + this.logger.LogInformation("Starting Process Monitor Manager Service"); + await this.enhancedLogger.LogSystemEventAsync( + LogEventTypes.System.ServiceStarted, + "Process Monitor Manager Service starting"); + this.SetStatus(true, "Starting..."); + + // Load configuration + await this.associationService.LoadConfigurationAsync(); + this.configuration = this.associationService.Configuration; + this.logger.LogInformation( + "Configuration loaded with {AssociationCount} associations", + this.configuration.Associations.Count); + + await this.enhancedLogger.LogSystemEventAsync( + LogEventTypes.System.ConfigurationLoaded, + $"Process monitoring configuration loaded with {this.configuration.Associations.Count} associations"); + + // Start process monitoring + await this.processMonitorService.StartMonitoringAsync(); + this.logger.LogInformation("Process monitoring started"); + + Interlocked.Exchange(ref this.pendingPowerPlanReevaluation, 0); + + await this.enhancedLogger.LogProcessMonitoringEventAsync( + LogEventTypes.ProcessMonitoring.MonitoringStarted, + "ProcessMonitorService", 0, "WMI-based process monitoring started"); + + this.isRunning = true; + this.SetStatus(true, "Running"); + + this.logger.LogInformation("Process Monitor Manager Service started successfully"); + + await this.enhancedLogger.LogSystemEventAsync( + LogEventTypes.System.ServiceStarted, + "Process Monitor Manager Service started successfully"); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to start Process Monitor Manager Service"); + await this.enhancedLogger.LogErrorAsync(ex, "ProcessMonitorManagerService.StartAsync", + new Dictionary { ["ServiceName"] = "ProcessMonitorManagerService" }); + this.isRunning = false; + this.SetStatus(false, "Failed to start", $"Error: {ex.Message}", ex); + throw; + } + finally + { + this.stateMutationSemaphore.Release(); + } + + // Evaluate current processes after startup lock is released + await this.EvaluateCurrentProcessesAsync(); + } + + public async Task StopAsync() + { + await this.stateMutationSemaphore.WaitAsync(); + try + { + if (!this.isRunning) + { + return; + } + + this.SetStatus(false, "Stopping..."); + + // Mark as stopped early to prevent new event handling while shutting down + this.isRunning = false; + Interlocked.Exchange(ref this.pendingPowerPlanReevaluation, 0); + + // Stop process monitoring + await this.processMonitorService.StopMonitoringAsync(); + + // Clear running processes + foreach (var processId in this.runningAssociatedProcesses.Keys) + { + this.coreMaskService.UnregisterMaskApplication(processId); + this.processService.UntrackProcess(processId); + this.persistentRuleAutoApplyService.MarkProcessExited(processId); + } + this.runningAssociatedProcesses.Clear(); + + // Restore default power plan if configured + if (this.configuration?.DefaultPowerPlanGuid != null) + { + await this.ForceDefaultPowerPlanAsync(); + } + + this.SetStatus(false, "Stopped"); + } + catch (Exception ex) + { + this.SetStatus(false, "Error stopping", $"Error: {ex.Message}", ex); + throw; + } + finally + { + this.stateMutationSemaphore.Release(); + } + } + + public async Task EvaluateCurrentProcessesAsync() + { + if (!this.isRunning || this.configuration == null) + { + return; + } + + try + { + var currentProcesses = (await this.processMonitorService.GetRunningProcessesAsync()).ToList(); + await this.ApplyPersistentRulesForDiscoveredProcessesAsync(currentProcesses); + var associatedProcesses = new List(); + var currentProcessIds = new HashSet(currentProcesses.Select(p => p.ProcessId)); + + // Remove stale tracked processes that are no longer running + foreach (var trackedPid in this.runningAssociatedProcesses.Keys) + { + if (!currentProcessIds.Contains(trackedPid) && this.runningAssociatedProcesses.TryRemove(trackedPid, out _)) + { + this.coreMaskService.UnregisterMaskApplication(trackedPid); + this.processService.UntrackProcess(trackedPid); + this.persistentRuleAutoApplyService.MarkProcessExited(trackedPid); + } + } + + // Find all currently running processes that have associations + foreach (var process in currentProcesses) + { + var association = this.configuration.FindMatchingAssociation(process); + if (association != null) + { + associatedProcesses.Add(process); + this.runningAssociatedProcesses[process.ProcessId] = process; + } + } + + // Determine which power plan should be active + await this.DeterminePowerPlanAsync(associatedProcesses); + } + catch (Exception ex) + { + this.SetStatus(this.isRunning, "Error evaluating processes", $"Error: {ex.Message}", ex); + } + } + + public async Task ForceDefaultPowerPlanAsync() + { + if (this.configuration?.DefaultPowerPlanGuid == null) + { + return; + } + + try + { + 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); + + if (success) + { + var newPowerPlan = await this.powerPlanService.GetPowerPlanByGuidAsync(this.configuration.DefaultPowerPlanGuid); + // Note: We don't have a specific process for this event, so we'll use a dummy one + var dummyProcess = new ProcessModel { Name = "System", ProcessId = -1 }; + var dummyAssociation = new ProcessPowerPlanAssociation("System", this.configuration.DefaultPowerPlanGuid, this.configuration.DefaultPowerPlanName); + + this.ProcessPowerPlanChanged?.Invoke(this, new ProcessPowerPlanChangeEventArgs( + dummyProcess, dummyAssociation, currentPowerPlan, newPowerPlan, "DefaultRestored")); + + // Show notification for default power plan restoration + await this.notificationService.ShowPowerPlanChangeNotificationAsync( + currentPowerPlan?.Name ?? "Unknown", + newPowerPlan?.Name ?? this.configuration.DefaultPowerPlanName, + string.Empty); + } + else + { + this.logger.LogWarning( + "Failed to restore default power plan {PowerPlanGuid}", + this.configuration.DefaultPowerPlanGuid); + } + } + catch (Exception ex) + { + this.SetStatus(this.isRunning, "Error setting default power plan", $"Error: {ex.Message}", ex); + } + finally + { + this.powerPlanChangeSemaphore.Release(); + } + } + + public async Task GetCurrentActivePowerPlanAsync() + { + return await this.powerPlanService.GetActivePowerPlan(); + } + + public async Task RefreshConfigurationAsync() + { + await this.associationService.LoadConfigurationAsync(); + this.configuration = this.associationService.Configuration; + this.processMonitorService.UpdateSettings(); + + if (this.isRunning) + { + await this.EvaluateCurrentProcessesAsync(); + } + } + + private void OnProcessStarted(object? sender, ProcessEventArgs e) + { + TaskSafety.FireAndForget(this.OnProcessStartedAsync(e), ex => + { + this.SetStatus(this.isRunning, "Error handling process start", $"Error: {ex.Message}", ex); + }); + } + + private async Task OnProcessStartedAsync(ProcessEventArgs e) + { + if (!this.isRunning || this.configuration == null) + { + return; + } + + if (string.IsNullOrWhiteSpace(e.Process.Name) || e.Process.ProcessId <= 0) + { + return; + } + + try + { + await this.enhancedLogger.LogProcessMonitoringEventAsync( + LogEventTypes.ProcessMonitoring.Started, + e.Process.Name, e.Process.ProcessId, "Process started and detected by monitoring"); + + await this.ApplyPersistentRulesForProcessStartAsync(e.Process); + + var association = this.configuration.FindMatchingAssociation(e.Process); + if (association != null) + { + this.runningAssociatedProcesses[e.Process.ProcessId] = e.Process; + + await this.enhancedLogger.LogProcessMonitoringEventAsync( + LogEventTypes.ProcessMonitoring.AssociationTriggered, + e.Process.Name, e.Process.ProcessId, + $"Process matched association for power plan: {association.PowerPlanName}"); + + // Apply CPU affinity mask if configured + await this.ApplyCoreMaskAndPriorityAsync(e.Process, association); + + // Schedule power plan change with delay if configured + if (this.configuration.PowerPlanChangeDelayMs > 0) + { + Interlocked.Exchange(ref this.pendingPowerPlanReevaluation, 1); + this.delayTimer.Change(this.configuration.PowerPlanChangeDelayMs, Timeout.Infinite); + + await this.enhancedLogger.LogSystemEventAsync( + LogEventTypes.System.ConfigurationLoaded, + $"Power plan change scheduled with {this.configuration.PowerPlanChangeDelayMs}ms delay for process {e.Process.Name}"); + } + else + { + await this.ChangePowerPlanForProcess(e.Process, association, "ProcessStarted"); + } + } + } + catch (Exception ex) + { + await this.enhancedLogger.LogErrorAsync(ex, "ProcessMonitorManagerService.OnProcessStarted", + new Dictionary + { + ["ProcessName"] = e.Process.Name, + ["ProcessId"] = e.Process.ProcessId, + }); + this.SetStatus(this.isRunning, "Error handling process start", $"Error: {ex.Message}", ex); + } + } + + private void OnProcessStopped(object? sender, ProcessEventArgs e) + { + TaskSafety.FireAndForget(this.OnProcessStoppedAsync(e), ex => + { + this.SetStatus(this.isRunning, "Error handling process stop", $"Error: {ex.Message}", ex); + }); + } + + private async Task OnProcessStoppedAsync(ProcessEventArgs e) + { + if (!this.isRunning || this.configuration == null) + { + return; + } + + try + { + this.persistentRuleAutoApplyService.MarkProcessExited(e.Process.ProcessId); + + if (this.runningAssociatedProcesses.TryRemove(e.Process.ProcessId, out _)) + { + this.coreMaskService.UnregisterMaskApplication(e.Process.ProcessId); + this.processService.UntrackProcess(e.Process.ProcessId); + + // Check if there are any other associated processes still running + var remainingProcesses = this.runningAssociatedProcesses.Values.ToList(); + await this.DeterminePowerPlanAsync(remainingProcesses); + } + } + catch (Exception ex) + { + this.SetStatus(this.isRunning, "Error handling process stop", $"Error: {ex.Message}", ex); + } + } + + private void OnMonitoringStatusChanged(object? sender, MonitoringStatusEventArgs e) + { + var details = e.StatusMessage ?? (e.IsMonitoring ? "Monitoring active" : "Monitoring inactive"); + this.SetStatus(this.isRunning, $"Monitor: {details}", e.StatusMessage, e.Error); + } + + private void OnConfigurationChanged(object? sender, ConfigurationChangedEventArgs e) + { + this.configuration = this.associationService.Configuration; + + if (this.isRunning) + { + TaskSafety.FireAndForget(this.EvaluateCurrentProcessesAsync(), ex => + { + this.SetStatus(this.isRunning, "Error evaluating processes", $"Error: {ex.Message}", ex); + }); + } + + // Keep process monitor settings synchronized with configuration edits + this.processMonitorService.UpdateSettings(); + } + + private void DelayedPowerPlanChangeCallback(object? state) + { + TaskSafety.FireAndForget(this.DelayedPowerPlanChangeCallbackAsync(), ex => + { + this.SetStatus(this.isRunning, "Error in delayed power plan callback", $"Error: {ex.Message}", ex); + }); + } + + private async Task DelayedPowerPlanChangeCallbackAsync() + { + if (!this.isRunning) + { + return; + } + + if (Interlocked.Exchange(ref this.pendingPowerPlanReevaluation, 0) == 0) + { + return; + } + + var runningProcesses = this.runningAssociatedProcesses.Values.ToList(); + await this.DeterminePowerPlanAsync(runningProcesses); + } + + private async Task DeterminePowerPlanAsync(IList associatedProcesses) + { + if (this.configuration == null) + { + return; + } + + try + { + if (associatedProcesses.Any()) + { + // Find the highest priority association among running processes + var associations = associatedProcesses + .Select(p => this.configuration.FindMatchingAssociation(p)) + .Where(a => a != null) + .OrderByDescending(a => a!.Priority) + .ToList(); + + if (associations.Any()) + { + var topAssociation = associations.First()!; + var matchingProcess = associatedProcesses.First(p => topAssociation.MatchesProcess(p)); + await this.ChangePowerPlanForProcess(matchingProcess, topAssociation, "ProcessStarted"); + } + } + else + { + // No associated processes running, revert to default + if (!string.IsNullOrEmpty(this.configuration.DefaultPowerPlanGuid)) + { + await this.ForceDefaultPowerPlanAsync(); + } + } + } + catch (Exception ex) + { + this.SetStatus(this.isRunning, "Error determining power plan", $"Error: {ex.Message}", ex); + } + } + + private async Task ChangePowerPlanForProcess(ProcessModel process, ProcessPowerPlanAssociation association, string action) + { + try + { + 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); + + if (success) + { + var newPowerPlan = await this.powerPlanService.GetPowerPlanByGuidAsync(association.PowerPlanGuid); + this.ProcessPowerPlanChanged?.Invoke(this, new ProcessPowerPlanChangeEventArgs( + process, association, currentPowerPlan, newPowerPlan, action)); + + // Show notification for power plan change + await this.notificationService.ShowPowerPlanChangeNotificationAsync( + currentPowerPlan?.Name ?? "Unknown", + 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) + { + this.SetStatus(this.isRunning, "Error changing power plan", $"Error: {ex.Message}", ex); + } + finally + { + this.powerPlanChangeSemaphore.Release(); + } + } + + private void SetStatus(bool isRunning, string status, string? details = null, Exception? error = null) + { + lock (this.lockObject) + { + this.status = status; + } + + this.ServiceStatusChanged?.Invoke(this, new ServiceStatusEventArgs(isRunning, status, details, error)); + + // Show error notification if there's an error + if (error != null) + { + TaskSafety.FireAndForget( + this.notificationService.ShowErrorNotificationAsync( + "Process Monitor Error", + details ?? status, + error), + ex => + { + this.logger.LogError(ex, "Failed to show error notification"); + }); + } + } + + private async Task ApplyPersistentRulesForDiscoveredProcessesAsync(IEnumerable processes) + { + try + { + var results = await this.persistentRuleAutoApplyService.ApplyForDiscoveredProcessesAsync(processes); + await this.LogPersistentRuleResultsAsync(results); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Persistent rule auto-apply failed during process snapshot refresh"); + } + } + + private async Task ApplyPersistentRulesForProcessStartAsync(ProcessModel process) + { + try + { + var results = await this.persistentRuleAutoApplyService.ApplyForProcessStartAsync(process); + await this.LogPersistentRuleResultsAsync(results); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + this.logger.LogWarning( + ex, + "Persistent rule auto-apply failed for process {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + } + } + + private async Task LogPersistentRuleResultsAsync(IReadOnlyList results) + { + foreach (var result in results) + { + if (result.Success) + { + await this.enhancedLogger.LogProcessMonitoringEventAsync( + LogEventTypes.ProcessMonitoring.AssociationTriggered, + result.ProcessName, + result.ProcessId, + $"Persistent rule '{result.RuleId}' applied automatically"); + } + else + { + this.logger.LogDebug( + "Persistent rule {RuleId} was not applied to process {ProcessName} (PID: {ProcessId}): {Message}", + result.RuleId, + result.ProcessName, + result.ProcessId, + result.UserMessage); + } + } + } + + public void UpdateSettings() + { + // Update the process monitor service with new settings + this.processMonitorService.UpdateSettings(); + + this.logger.LogDebug("ProcessMonitorManagerService settings updated"); + } + + private async Task ApplyCoreMaskAndPriorityAsync(ProcessModel process, ProcessPowerPlanAssociation association) + { + try + { + // Apply CPU affinity mask if configured + if (!string.IsNullOrEmpty(association.CoreMaskId)) + { + var coreMask = this.coreMaskService.AvailableMasks.FirstOrDefault(m => m.Id == association.CoreMaskId); + if (coreMask != null) + { + try + { + var affinity = coreMask.ToProcessorAffinity(); + if (affinity > 0) + { + 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) + { + var blockedReason = BuildAffinityOrPriorityBlockedMessage(ex, process.Name, "affinity"); + if (!string.IsNullOrEmpty(blockedReason)) + { + await this.notificationService.ShowNotificationAsync( + "Affinity blocked", + blockedReason, + NotificationType.Warning); + } + + this.logger.LogWarning( + ex, + "Failed to apply CPU mask '{MaskName}' to process {ProcessName} (PID: {ProcessId})", + coreMask.Name, process.Name, process.ProcessId); + + await this.enhancedLogger.LogErrorAsync(ex, "ProcessMonitorManagerService.ApplyCoreMaskAndPriorityAsync", + new Dictionary + { + ["ProcessName"] = process.Name, + ["ProcessId"] = process.ProcessId, + ["MaskName"] = coreMask.Name, + }); + } + } + else + { + this.logger.LogWarning( + "Core mask ID '{CoreMaskId}' not found for process {ProcessName}, skipping affinity application", + association.CoreMaskId, process.Name); + } + } + + // Apply process priority if configured + if (!string.IsNullOrEmpty(association.ProcessPriority)) + { + if (Enum.TryParse(association.ProcessPriority, out var priority)) + { + try + { + var currentPriority = process.Priority; + + if (!Enum.IsDefined(typeof(ProcessPriorityClass), currentPriority)) + { + try + { + await this.processService.RefreshProcessInfo(process); + currentPriority = process.Priority; + } + catch (Exception refreshEx) + { + this.logger.LogDebug( + refreshEx, + "Could not refresh process priority before tracking for {ProcessName} (PID: {ProcessId})", + process.Name, process.ProcessId); + } + } + + if (Enum.IsDefined(typeof(ProcessPriorityClass), currentPriority)) + { + this.processService.TrackPriorityChange(process.ProcessId, currentPriority); + } + + await this.processService.SetProcessPriority(process, priority); + + this.logger.LogInformation( + "Applied priority '{Priority}' to process {ProcessName} (PID: {ProcessId})", + priority, process.Name, process.ProcessId); + + await this.enhancedLogger.LogProcessMonitoringEventAsync( + LogEventTypes.ProcessMonitoring.AssociationTriggered, + process.Name, process.ProcessId, + $"Priority '{priority}' applied automatically from association"); + } + catch (Exception ex) + { + var blockedReason = BuildAffinityOrPriorityBlockedMessage(ex, process.Name, "priority"); + if (!string.IsNullOrEmpty(blockedReason)) + { + await this.notificationService.ShowNotificationAsync( + "Priority blocked", + blockedReason, + NotificationType.Warning); + } + + this.logger.LogWarning( + ex, + "Failed to apply priority '{Priority}' to process {ProcessName} (PID: {ProcessId})", + priority, process.Name, process.ProcessId); + + await this.enhancedLogger.LogErrorAsync(ex, "ProcessMonitorManagerService.ApplyCoreMaskAndPriorityAsync", + new Dictionary + { + ["ProcessName"] = process.Name, + ["ProcessId"] = process.ProcessId, + ["Priority"] = priority.ToString(), + }); + } + } + else + { + this.logger.LogWarning( + "Invalid priority value '{Priority}' for process {ProcessName}, skipping priority application", + association.ProcessPriority, process.Name); + } + } + } + catch (Exception ex) + { + this.logger.LogError( + ex, + "Error applying CPU mask and priority to process {ProcessName} (PID: {ProcessId})", + process.Name, process.ProcessId); + + await this.enhancedLogger.LogErrorAsync(ex, "ProcessMonitorManagerService.ApplyCoreMaskAndPriorityAsync", + new Dictionary + { + ["ProcessName"] = process.Name, + ["ProcessId"] = process.ProcessId, + ["AssociationId"] = association.Id, + }); + } + } + + private static string BuildAffinityOrPriorityBlockedMessage(Exception ex, string processName, string operation) + { + var message = ex.Message ?? string.Empty; + var lowered = message.ToLowerInvariant(); + + if (lowered.Contains("access denied") || + lowered.Contains("anti-cheat") || + lowered.Contains("anti cheat") || + lowered.Contains("protected") || + lowered.Contains("insufficient privileges") || + ex is UnauthorizedAccessException) + { + return lowered.Contains("anti-cheat") || lowered.Contains("anti cheat") || lowered.Contains("protected") + ? ProcessOperationUserMessages.AntiCheatProtectedLikely + : ProcessOperationUserMessages.AccessDenied; + } + + if (lowered.Contains("realtime priority is blocked")) + { + return ProcessOperationUserMessages.RealtimePriorityBlocked; + } + + return string.Empty; + } + + public void Dispose() + { + if (this.disposed) + { + return; + } + + try + { + // Dispose can be called from the WPF UI thread; stop on the thread pool to avoid + // deadlocking on a captured SynchronizationContext during async shutdown. + Task.Run(this.StopAsync).GetAwaiter().GetResult(); + } + finally + { + this.processMonitorService.ProcessStarted -= this.OnProcessStarted; + this.processMonitorService.ProcessStopped -= this.OnProcessStopped; + this.processMonitorService.MonitoringStatusChanged -= this.OnMonitoringStatusChanged; + this.associationService.ConfigurationChanged -= this.OnConfigurationChanged; + + this.delayTimer?.Dispose(); + this.powerPlanChangeSemaphore?.Dispose(); + this.stateMutationSemaphore?.Dispose(); + this.processMonitorService?.Dispose(); + + this.disposed = true; + } + } + } +} diff --git a/Services/ProcessMonitorService.cs b/Services/ProcessMonitorService.cs index 3b28310..56b2a1d 100644 --- a/Services/ProcessMonitorService.cs +++ b/Services/ProcessMonitorService.cs @@ -1,724 +1,705 @@ -/* - * 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; - using System.Collections.Generic; - using System.Diagnostics; - using System.IO; - using System.Linq; - using System.Management; - using System.Threading; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - - /// - /// Process monitoring service using WMI events with fallback polling. - /// - public class ProcessMonitorService : IProcessMonitorService - { - private readonly IProcessService processService; - private readonly IApplicationSettingsService settingsService; - private readonly ILogger? logger; - private readonly object lockObject = new(); - private readonly ConcurrentDictionary runningProcesses = new(); - private readonly SemaphoreSlim wmiStartSemaphore = new(1, 1); - private readonly Dictionary pollBuffer = new(); - - private ManagementEventWatcher? processStartWatcher; - private ManagementEventWatcher? processStopWatcher; - private System.Threading.Timer? fallbackTimer; - private CancellationTokenSource? cancellationTokenSource; - - private bool isMonitoring; - private bool isWmiAvailable; - private bool isFallbackPollingActive; - private int disposedFlag; - - // Configuration - will be updated from settings - private int fallbackPollingIntervalMs = 5000; // Default 5 seconds - private int currentFallbackPollingIntervalMs = 5000; - private int idlePollingMultiplier = 1; - private readonly int wmiRetryDelayMs = 10000; // 10 seconds - private const int MaxIdlePollingMultiplier = 6; - private bool enableWmiMonitoring = true; - private bool enableFallbackPolling = true; - private int isFallbackPollingInProgress; - private int isWmiRecoveryInProgress; - private DateTime lastWmiRetryAttemptUtc = DateTime.MinValue; - - public event EventHandler? ProcessStarted; - - public event EventHandler? ProcessStopped; - - public event EventHandler? MonitoringStatusChanged; - - private bool IsDisposed => Interlocked.CompareExchange(ref this.disposedFlag, 0, 0) == 1; - - public bool IsMonitoring => this.isMonitoring; - - public bool IsWmiAvailable => this.isWmiAvailable; - - public bool IsFallbackPollingActive => this.isFallbackPollingActive; - - public ProcessMonitorService( - IProcessService processService, - IApplicationSettingsService settingsService, - ILogger? logger = null) - { - this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); - this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); - this.logger = logger; - - // Initialize polling interval from settings - this.UpdateMonitoringSettings(); - } - - public async Task StartMonitoringAsync() - { - if (this.IsDisposed) - { - throw new ObjectDisposedException(nameof(ProcessMonitorService)); - } - - lock (this.lockObject) - { - if (this.isMonitoring) - { - return; - } - - this.isMonitoring = true; - } - - this.cancellationTokenSource = new CancellationTokenSource(); - this.lastWmiRetryAttemptUtc = DateTime.MinValue; - Interlocked.Exchange(ref this.isFallbackPollingInProgress, 0); - Interlocked.Exchange(ref this.isWmiRecoveryInProgress, 0); - - this.UpdateMonitoringSettings(); - - // Initialize current process list - await this.InitializeProcessListAsync().ConfigureAwait(false); - - bool wmiStarted = false; - if (this.enableWmiMonitoring) - { - // Try to start WMI monitoring first - wmiStarted = await this.TryStartWmiMonitoringAsync().ConfigureAwait(false); - } - - if (!wmiStarted && this.enableFallbackPolling) - { - // Fall back to polling if WMI is not available - this.StartFallbackPolling(); - } - else if (!wmiStarted && !this.enableFallbackPolling) - { - var reason = this.enableWmiMonitoring - ? "WMI monitoring unavailable and fallback polling is disabled" - : "Both WMI monitoring and fallback polling are disabled"; - - this.OnMonitoringStatusChanged(reason); - } - - this.OnMonitoringStatusChanged(); - } - - public async Task StopMonitoringAsync() - { - if (this.IsDisposed) - { - return; - } - - var semaphoreHeld = false; - await this.wmiStartSemaphore.WaitAsync().ConfigureAwait(false); - semaphoreHeld = true; - - try - { - lock (this.lockObject) - { - if (!this.isMonitoring) - { - return; - } - - this.isMonitoring = false; - } - - // Stop WMI watchers - this.StopWmiWatchers(); - - // Stop fallback polling - this.StopFallbackPolling(); - - // Cancel any ongoing operations - this.cancellationTokenSource?.Cancel(); - this.cancellationTokenSource?.Dispose(); - this.cancellationTokenSource = null; - - this.runningProcesses.Clear(); - this.pollBuffer.Clear(); - Interlocked.Exchange(ref this.isFallbackPollingInProgress, 0); - Interlocked.Exchange(ref this.isWmiRecoveryInProgress, 0); - this.OnMonitoringStatusChanged(); - } - finally - { - if (semaphoreHeld) - { - this.wmiStartSemaphore.Release(); - } - } - } - - public async Task> GetRunningProcessesAsync() - { - try - { - var processes = await this.processService.GetProcessesAsync().ConfigureAwait(false); - return processes; - } - catch (Exception) - { - return Enumerable.Empty(); - } - } - - public async Task IsProcessRunningAsync(string executableName) - { - try - { - var processes = await this.GetRunningProcessesAsync().ConfigureAwait(false); - return processes.Any(p => string.Equals(p.Name, executableName, StringComparison.OrdinalIgnoreCase)); - } - catch - { - return false; - } - } - - private async Task InitializeProcessListAsync() - { - try - { - var processes = await this.GetRunningProcessesAsync().ConfigureAwait(false); - this.runningProcesses.Clear(); - - foreach (var process in processes) - { - this.runningProcesses.TryAdd(process.ProcessId, process); - } - } - catch (Exception ex) - { - this.OnMonitoringStatusChanged($"Failed to initialize process list: {ex.Message}", ex); - } - } - - private async Task TryStartWmiMonitoringAsync() - { - if (this.IsDisposed || !this.isMonitoring || !this.enableWmiMonitoring) - { - return false; - } - - await this.wmiStartSemaphore.WaitAsync().ConfigureAwait(false); - try - { - if (this.IsDisposed || !this.isMonitoring || !this.enableWmiMonitoring) - { - return false; - } - - if (this.isWmiAvailable && this.processStartWatcher != null && this.processStopWatcher != null) - { - return true; - } - - await Task.Run(() => - { - // Ensure any previous watchers are fully cleaned up before re-creating them - this.StopWmiWatchers(); - - // Create WMI event watchers for process start and stop - var startQuery = new WqlEventQuery("SELECT * FROM Win32_ProcessStartTrace"); - var stopQuery = new WqlEventQuery("SELECT * FROM Win32_ProcessStopTrace"); - - this.processStartWatcher = new ManagementEventWatcher(startQuery); - this.processStopWatcher = new ManagementEventWatcher(stopQuery); - - this.processStartWatcher.EventArrived += this.OnProcessStarted; - this.processStopWatcher.EventArrived += this.OnProcessStopped; - this.processStartWatcher.Stopped += this.OnWmiWatcherStopped; - this.processStopWatcher.Stopped += this.OnWmiWatcherStopped; - - this.processStartWatcher.Start(); - this.processStopWatcher.Start(); - }).ConfigureAwait(false); - - this.isWmiAvailable = true; - - // Prefer WMI when available to reduce polling overhead - if (this.isFallbackPollingActive) - { - this.StopFallbackPolling(); - } - - this.OnMonitoringStatusChanged("WMI monitoring started successfully"); - return true; - } - catch (Exception ex) - { - this.isWmiAvailable = false; - this.OnMonitoringStatusChanged($"WMI monitoring failed: {ex.Message}", ex); - - // Clean up any partially created watchers - this.StopWmiWatchers(); - - return false; - } - finally - { - this.wmiStartSemaphore.Release(); - } - } - - private void StartFallbackPolling() - { - if (this.IsDisposed || !this.isMonitoring || !this.enableFallbackPolling) - { - return; - } - - // Update polling interval from current settings - this.UpdateMonitoringSettings(); - - if (this.isFallbackPollingActive) - { - this.currentFallbackPollingIntervalMs = this.fallbackPollingIntervalMs; - this.fallbackTimer?.Change(0, this.currentFallbackPollingIntervalMs); - return; - } - - this.isFallbackPollingActive = true; - this.idlePollingMultiplier = 1; - this.currentFallbackPollingIntervalMs = this.fallbackPollingIntervalMs; - this.fallbackTimer = new System.Threading.Timer(this.FallbackPollingCallback, null, 0, this.currentFallbackPollingIntervalMs); - this.OnMonitoringStatusChanged($"Fallback polling started (interval: {this.fallbackPollingIntervalMs}ms)"); - } - - private void OnWmiWatcherStopped(object sender, StoppedEventArgs e) - { - if (!this.isMonitoring || this.IsDisposed) - { - return; - } - - this.isWmiAvailable = false; - this.OnMonitoringStatusChanged($"WMI watcher stopped ({e.Status})"); - - if (this.enableFallbackPolling && !this.isFallbackPollingActive) - { - this.StartFallbackPolling(); - } - } - - private void StopWmiWatchers() - { - try - { - if (this.processStartWatcher != null) - { - this.processStartWatcher.EventArrived -= this.OnProcessStarted; - this.processStartWatcher.Stopped -= this.OnWmiWatcherStopped; - } - - this.processStartWatcher?.Stop(); - this.processStartWatcher?.Dispose(); - this.processStartWatcher = null; - - if (this.processStopWatcher != null) - { - this.processStopWatcher.EventArrived -= this.OnProcessStopped; - this.processStopWatcher.Stopped -= this.OnWmiWatcherStopped; - } - - this.processStopWatcher?.Stop(); - this.processStopWatcher?.Dispose(); - this.processStopWatcher = null; - - this.isWmiAvailable = false; - } - catch (Exception ex) - { - this.OnMonitoringStatusChanged($"Error stopping WMI watchers: {ex.Message}", ex); - } - } - - private void StopFallbackPolling() - { - this.fallbackTimer?.Dispose(); - this.fallbackTimer = null; - this.isFallbackPollingActive = false; - Interlocked.Exchange(ref this.isFallbackPollingInProgress, 0); - } - - private void OnProcessStarted(object sender, EventArrivedEventArgs e) - { - TaskSafety.FireAndForget(this.HandleProcessStartedAsync(e), ex => - { - this.OnMonitoringStatusChanged($"Error handling process start event: {ex.Message}", ex); - }); - } - - private async Task HandleProcessStartedAsync(EventArrivedEventArgs e) - { - if (!this.isMonitoring || this.IsDisposed) - { - return; - } - - try - { - var processId = Convert.ToInt32(e.NewEvent["ProcessID"]); - var processName = e.NewEvent["ProcessName"]?.ToString() ?? string.Empty; - - // Get detailed process information - var process = await this.CreateProcessModelFromId(processId, processName).ConfigureAwait(false) - ?? (!string.IsNullOrWhiteSpace(processName) - ? new ProcessModel { ProcessId = processId, Name = NormalizeProcessName(processName) } - : null); - - if (process != null) - { - this.runningProcesses.TryAdd(processId, process); - this.ProcessStarted?.Invoke(this, new ProcessEventArgs(process)); - } - } - catch (Exception ex) - { - this.OnMonitoringStatusChanged($"Error handling process start event: {ex.Message}", ex); - } - } - - private void OnProcessStopped(object sender, EventArrivedEventArgs e) - { - if (!this.isMonitoring || this.IsDisposed) - { - return; - } - - try - { - var processId = Convert.ToInt32(e.NewEvent["ProcessID"]); - - if (this.runningProcesses.TryRemove(processId, out var process)) - { - this.ProcessStopped?.Invoke(this, new ProcessEventArgs(process)); - } - } - catch (Exception ex) - { - this.OnMonitoringStatusChanged($"Error handling process stop event: {ex.Message}", ex); - } - } - - private void FallbackPollingCallback(object? state) - { - if (!this.isMonitoring || this.IsDisposed || this.cancellationTokenSource?.Token.IsCancellationRequested == true) - { - return; - } - - if (Interlocked.CompareExchange(ref this.disposedFlag, 0, 0) == 1) - { - return; - } - - // Prevent overlapping polling iterations when processing takes longer than interval - if (Interlocked.Exchange(ref this.isFallbackPollingInProgress, 1) == 1) - { - return; - } - - var cancellationToken = this.cancellationTokenSource?.Token ?? CancellationToken.None; - TaskSafety.FireAndForget(this.RunFallbackPollingAsync(cancellationToken), ex => - { - this.OnMonitoringStatusChanged($"Error in fallback polling: {ex.Message}", ex); - }); - } - - private async Task RunFallbackPollingAsync(CancellationToken cancellationToken) - { - try - { - if (cancellationToken.IsCancellationRequested) - { - return; - } - - var currentProcesses = await this.GetRunningProcessesAsync().ConfigureAwait(false); - var detectedChanges = 0; - - this.pollBuffer.Clear(); - foreach (var process in currentProcesses) - { - this.pollBuffer[process.ProcessId] = process; - } - - // Check for new processes - foreach (var process in currentProcesses) - { - if (cancellationToken.IsCancellationRequested) - { - return; - } - - if (!this.runningProcesses.ContainsKey(process.ProcessId)) - { - this.runningProcesses.TryAdd(process.ProcessId, process); - this.ProcessStarted?.Invoke(this, new ProcessEventArgs(process)); - detectedChanges++; - } - } - - // Check for stopped processes - var stoppedProcesses = this.runningProcesses.Keys - .Where(pid => !this.pollBuffer.ContainsKey(pid)) - .ToList(); - - foreach (var pid in stoppedProcesses) - { - if (cancellationToken.IsCancellationRequested) - { - return; - } - - if (this.runningProcesses.TryRemove(pid, out var stoppedProcess)) - { - this.ProcessStopped?.Invoke(this, new ProcessEventArgs(stoppedProcess)); - detectedChanges++; - } - } - - // Periodically retry WMI monitoring recovery while polling is active - await this.TryRecoverWmiMonitoringAsync().ConfigureAwait(false); - - this.UpdateAdaptivePollingInterval(detectedChanges); - } - catch (Exception ex) - { - this.OnMonitoringStatusChanged($"Error in fallback polling: {ex.Message}", ex); - } - finally - { - Interlocked.Exchange(ref this.isFallbackPollingInProgress, 0); - } - } - - private void UpdateAdaptivePollingInterval(int detectedChanges) - { - if (!this.isFallbackPollingActive || this.fallbackTimer == null) - { - return; - } - - var previousMultiplier = this.idlePollingMultiplier; - this.idlePollingMultiplier = detectedChanges > 0 - ? 1 - : Math.Min(MaxIdlePollingMultiplier, this.idlePollingMultiplier + 1); - - var nextInterval = Math.Clamp( - this.fallbackPollingIntervalMs * this.idlePollingMultiplier, - this.fallbackPollingIntervalMs, - 60000); - - if (nextInterval == this.currentFallbackPollingIntervalMs) - { - return; - } - - this.currentFallbackPollingIntervalMs = nextInterval; - this.fallbackTimer.Change(this.currentFallbackPollingIntervalMs, this.currentFallbackPollingIntervalMs); - - if (previousMultiplier != this.idlePollingMultiplier) - { - this.OnMonitoringStatusChanged($"Adaptive polling interval changed to {this.currentFallbackPollingIntervalMs}ms"); - } - } - - private async Task TryRecoverWmiMonitoringAsync() - { - if (!this.isMonitoring || this.IsDisposed || this.isWmiAvailable || !this.enableWmiMonitoring) - { - return; - } - - var now = DateTime.UtcNow; - if ((now - this.lastWmiRetryAttemptUtc).TotalMilliseconds < this.wmiRetryDelayMs) - { - return; - } - - if (Interlocked.Exchange(ref this.isWmiRecoveryInProgress, 1) == 1) - { - return; - } - - this.lastWmiRetryAttemptUtc = now; - - try - { - var recovered = await this.TryStartWmiMonitoringAsync().ConfigureAwait(false); - if (recovered) - { - this.OnMonitoringStatusChanged("WMI monitoring recovered successfully"); - } - } - finally - { - Interlocked.Exchange(ref this.isWmiRecoveryInProgress, 0); - } - } - - private async Task CreateProcessModelFromId(int processId, string processName) - { - try - { - using var process = Process.GetProcessById(processId); - var normalizedName = !string.IsNullOrWhiteSpace(processName) - ? NormalizeProcessName(processName) - : NormalizeProcessName(process.ProcessName); - - return new ProcessModel - { - ProcessId = process.Id, - Name = normalizedName, - CpuUsage = 0, - MemoryUsage = process.PrivateMemorySize64, - Priority = process.PriorityClass, - ProcessorAffinity = (long)process.ProcessorAffinity, - MainWindowHandle = process.MainWindowHandle, - MainWindowTitle = process.MainWindowTitle ?? string.Empty, - HasVisibleWindow = process.MainWindowHandle != IntPtr.Zero && !string.IsNullOrWhiteSpace(process.MainWindowTitle), - ExecutablePath = process.MainModule?.FileName ?? string.Empty, - }; - } - catch (Exception ex) - { - this.logger?.LogDebug(ex, "Process {ProcessId} terminated before access", processId); - return null; - } - } - - private void OnMonitoringStatusChanged(string? message = null, Exception? error = null) - { - this.MonitoringStatusChanged?.Invoke(this, new MonitoringStatusEventArgs( - this.isMonitoring, this.isWmiAvailable, this.isFallbackPollingActive, message, error)); - } - - private void UpdateMonitoringSettings() - { - var settings = this.settingsService.Settings; - this.fallbackPollingIntervalMs = Math.Clamp(settings.FallbackPollingIntervalMs, 1000, 60000); - this.enableWmiMonitoring = settings.EnableWmiMonitoring; - this.enableFallbackPolling = settings.EnableFallbackPolling; - } - - public void UpdateSettings() - { - var previousInterval = this.fallbackPollingIntervalMs; - var previousWmiEnabled = this.enableWmiMonitoring; - var previousFallbackEnabled = this.enableFallbackPolling; - - this.UpdateMonitoringSettings(); - - if (!this.isMonitoring) - { - return; - } - - if (!this.enableWmiMonitoring && this.isWmiAvailable) - { - this.StopWmiWatchers(); - this.OnMonitoringStatusChanged("WMI monitoring disabled by settings"); - } - else if (this.enableWmiMonitoring && !previousWmiEnabled && !this.isWmiAvailable) - { - TaskSafety.FireAndForget(this.TryStartWmiMonitoringAsync(), ex => - { - this.OnMonitoringStatusChanged($"Error recovering WMI monitoring: {ex.Message}", ex); - }); - } - - if (!this.enableFallbackPolling && this.isFallbackPollingActive) - { - this.StopFallbackPolling(); - this.OnMonitoringStatusChanged("Fallback polling disabled by settings"); - } - else if (this.enableFallbackPolling && !previousFallbackEnabled && (!this.isWmiAvailable || !this.enableWmiMonitoring)) - { - this.StartFallbackPolling(); - } - - // If fallback polling is active, restart it with new interval - if (this.isFallbackPollingActive && this.fallbackTimer != null && previousInterval != this.fallbackPollingIntervalMs) - { - this.idlePollingMultiplier = 1; - this.currentFallbackPollingIntervalMs = this.fallbackPollingIntervalMs; - this.fallbackTimer.Change(0, this.currentFallbackPollingIntervalMs); - this.OnMonitoringStatusChanged($"Polling interval updated to {this.fallbackPollingIntervalMs}ms"); - } - } - - private static string NormalizeProcessName(string processName) - { - if (string.IsNullOrWhiteSpace(processName)) - { - return string.Empty; - } - - // Keep process naming consistent with Process.ProcessName (no extension) - return Path.GetFileNameWithoutExtension(processName.Trim()); - } - - public void Dispose() - { - if (Interlocked.Exchange(ref this.disposedFlag, 1) == 1) - { - return; - } - - try - { - this.StopMonitoringAsync().GetAwaiter().GetResult(); - } - catch (Exception ex) - { - this.OnMonitoringStatusChanged($"Error during process monitor disposal: {ex.Message}", ex); - } - - this.wmiStartSemaphore.Dispose(); - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Diagnostics; + using System.IO; + using System.Linq; + using System.Management; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public class ProcessMonitorService : IProcessMonitorService + { + private readonly IProcessService processService; + private readonly IApplicationSettingsService settingsService; + private readonly ILogger? logger; + private readonly object lockObject = new(); + private readonly ConcurrentDictionary runningProcesses = new(); + private readonly SemaphoreSlim wmiStartSemaphore = new(1, 1); + private readonly Dictionary pollBuffer = new(); + + private ManagementEventWatcher? processStartWatcher; + private ManagementEventWatcher? processStopWatcher; + private System.Threading.Timer? fallbackTimer; + private CancellationTokenSource? cancellationTokenSource; + + private bool isMonitoring; + private bool isWmiAvailable; + private bool isFallbackPollingActive; + private int disposedFlag; + + // Configuration - will be updated from settings + private int fallbackPollingIntervalMs = 5000; // Default 5 seconds + private int currentFallbackPollingIntervalMs = 5000; + private int idlePollingMultiplier = 1; + private readonly int wmiRetryDelayMs = 10000; // 10 seconds + private const int MaxIdlePollingMultiplier = 6; + private bool enableWmiMonitoring = true; + private bool enableFallbackPolling = true; + private int isFallbackPollingInProgress; + private int isWmiRecoveryInProgress; + private DateTime lastWmiRetryAttemptUtc = DateTime.MinValue; + + public event EventHandler? ProcessStarted; + + public event EventHandler? ProcessStopped; + + public event EventHandler? MonitoringStatusChanged; + + private bool IsDisposed => Interlocked.CompareExchange(ref this.disposedFlag, 0, 0) == 1; + + public bool IsMonitoring => this.isMonitoring; + + public bool IsWmiAvailable => this.isWmiAvailable; + + public bool IsFallbackPollingActive => this.isFallbackPollingActive; + + public ProcessMonitorService( + IProcessService processService, + IApplicationSettingsService settingsService, + ILogger? logger = null) + { + this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); + this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + this.logger = logger; + + // Initialize polling interval from settings + this.UpdateMonitoringSettings(); + } + + public async Task StartMonitoringAsync() + { + if (this.IsDisposed) + { + throw new ObjectDisposedException(nameof(ProcessMonitorService)); + } + + lock (this.lockObject) + { + if (this.isMonitoring) + { + return; + } + + this.isMonitoring = true; + } + + this.cancellationTokenSource = new CancellationTokenSource(); + this.lastWmiRetryAttemptUtc = DateTime.MinValue; + Interlocked.Exchange(ref this.isFallbackPollingInProgress, 0); + Interlocked.Exchange(ref this.isWmiRecoveryInProgress, 0); + + this.UpdateMonitoringSettings(); + + // Initialize current process list + await this.InitializeProcessListAsync().ConfigureAwait(false); + + bool wmiStarted = false; + if (this.enableWmiMonitoring) + { + // Try to start WMI monitoring first + wmiStarted = await this.TryStartWmiMonitoringAsync().ConfigureAwait(false); + } + + if (!wmiStarted && this.enableFallbackPolling) + { + // Fall back to polling if WMI is not available + this.StartFallbackPolling(); + } + else if (!wmiStarted && !this.enableFallbackPolling) + { + var reason = this.enableWmiMonitoring + ? "WMI monitoring unavailable and fallback polling is disabled" + : "Both WMI monitoring and fallback polling are disabled"; + + this.OnMonitoringStatusChanged(reason); + } + + this.OnMonitoringStatusChanged(); + } + + public async Task StopMonitoringAsync() + { + if (this.IsDisposed) + { + return; + } + + var semaphoreHeld = false; + await this.wmiStartSemaphore.WaitAsync().ConfigureAwait(false); + semaphoreHeld = true; + + try + { + lock (this.lockObject) + { + if (!this.isMonitoring) + { + return; + } + + this.isMonitoring = false; + } + + // Stop WMI watchers + this.StopWmiWatchers(); + + // Stop fallback polling + this.StopFallbackPolling(); + + // Cancel any ongoing operations + this.cancellationTokenSource?.Cancel(); + this.cancellationTokenSource?.Dispose(); + this.cancellationTokenSource = null; + + this.runningProcesses.Clear(); + this.pollBuffer.Clear(); + Interlocked.Exchange(ref this.isFallbackPollingInProgress, 0); + Interlocked.Exchange(ref this.isWmiRecoveryInProgress, 0); + this.OnMonitoringStatusChanged(); + } + finally + { + if (semaphoreHeld) + { + this.wmiStartSemaphore.Release(); + } + } + } + + public async Task> GetRunningProcessesAsync() + { + try + { + var processes = await this.processService.GetProcessesAsync().ConfigureAwait(false); + return processes; + } + catch (Exception) + { + return Enumerable.Empty(); + } + } + + public async Task IsProcessRunningAsync(string executableName) + { + try + { + var processes = await this.GetRunningProcessesAsync().ConfigureAwait(false); + return processes.Any(p => string.Equals(p.Name, executableName, StringComparison.OrdinalIgnoreCase)); + } + catch + { + return false; + } + } + + private async Task InitializeProcessListAsync() + { + try + { + var processes = await this.GetRunningProcessesAsync().ConfigureAwait(false); + this.runningProcesses.Clear(); + + foreach (var process in processes) + { + this.runningProcesses.TryAdd(process.ProcessId, process); + } + } + catch (Exception ex) + { + this.OnMonitoringStatusChanged($"Failed to initialize process list: {ex.Message}", ex); + } + } + + private async Task TryStartWmiMonitoringAsync() + { + if (this.IsDisposed || !this.isMonitoring || !this.enableWmiMonitoring) + { + return false; + } + + await this.wmiStartSemaphore.WaitAsync().ConfigureAwait(false); + try + { + if (this.IsDisposed || !this.isMonitoring || !this.enableWmiMonitoring) + { + return false; + } + + if (this.isWmiAvailable && this.processStartWatcher != null && this.processStopWatcher != null) + { + return true; + } + + await Task.Run(() => + { + // Ensure any previous watchers are fully cleaned up before re-creating them + this.StopWmiWatchers(); + + // Create WMI event watchers for process start and stop + var startQuery = new WqlEventQuery("SELECT * FROM Win32_ProcessStartTrace"); + var stopQuery = new WqlEventQuery("SELECT * FROM Win32_ProcessStopTrace"); + + this.processStartWatcher = new ManagementEventWatcher(startQuery); + this.processStopWatcher = new ManagementEventWatcher(stopQuery); + + this.processStartWatcher.EventArrived += this.OnProcessStarted; + this.processStopWatcher.EventArrived += this.OnProcessStopped; + this.processStartWatcher.Stopped += this.OnWmiWatcherStopped; + this.processStopWatcher.Stopped += this.OnWmiWatcherStopped; + + this.processStartWatcher.Start(); + this.processStopWatcher.Start(); + }).ConfigureAwait(false); + + this.isWmiAvailable = true; + + // Prefer WMI when available to reduce polling overhead + if (this.isFallbackPollingActive) + { + this.StopFallbackPolling(); + } + + this.OnMonitoringStatusChanged("WMI monitoring started successfully"); + return true; + } + catch (Exception ex) + { + this.isWmiAvailable = false; + this.OnMonitoringStatusChanged($"WMI monitoring failed: {ex.Message}", ex); + + // Clean up any partially created watchers + this.StopWmiWatchers(); + + return false; + } + finally + { + this.wmiStartSemaphore.Release(); + } + } + + private void StartFallbackPolling() + { + if (this.IsDisposed || !this.isMonitoring || !this.enableFallbackPolling) + { + return; + } + + // Update polling interval from current settings + this.UpdateMonitoringSettings(); + + if (this.isFallbackPollingActive) + { + this.currentFallbackPollingIntervalMs = this.fallbackPollingIntervalMs; + this.fallbackTimer?.Change(0, this.currentFallbackPollingIntervalMs); + return; + } + + this.isFallbackPollingActive = true; + this.idlePollingMultiplier = 1; + this.currentFallbackPollingIntervalMs = this.fallbackPollingIntervalMs; + this.fallbackTimer = new System.Threading.Timer(this.FallbackPollingCallback, null, 0, this.currentFallbackPollingIntervalMs); + this.OnMonitoringStatusChanged($"Fallback polling started (interval: {this.fallbackPollingIntervalMs}ms)"); + } + + private void OnWmiWatcherStopped(object sender, StoppedEventArgs e) + { + if (!this.isMonitoring || this.IsDisposed) + { + return; + } + + this.isWmiAvailable = false; + this.OnMonitoringStatusChanged($"WMI watcher stopped ({e.Status})"); + + if (this.enableFallbackPolling && !this.isFallbackPollingActive) + { + this.StartFallbackPolling(); + } + } + + private void StopWmiWatchers() + { + try + { + if (this.processStartWatcher != null) + { + this.processStartWatcher.EventArrived -= this.OnProcessStarted; + this.processStartWatcher.Stopped -= this.OnWmiWatcherStopped; + } + + this.processStartWatcher?.Stop(); + this.processStartWatcher?.Dispose(); + this.processStartWatcher = null; + + if (this.processStopWatcher != null) + { + this.processStopWatcher.EventArrived -= this.OnProcessStopped; + this.processStopWatcher.Stopped -= this.OnWmiWatcherStopped; + } + + this.processStopWatcher?.Stop(); + this.processStopWatcher?.Dispose(); + this.processStopWatcher = null; + + this.isWmiAvailable = false; + } + catch (Exception ex) + { + this.OnMonitoringStatusChanged($"Error stopping WMI watchers: {ex.Message}", ex); + } + } + + private void StopFallbackPolling() + { + this.fallbackTimer?.Dispose(); + this.fallbackTimer = null; + this.isFallbackPollingActive = false; + Interlocked.Exchange(ref this.isFallbackPollingInProgress, 0); + } + + private void OnProcessStarted(object sender, EventArrivedEventArgs e) + { + TaskSafety.FireAndForget(this.HandleProcessStartedAsync(e), ex => + { + this.OnMonitoringStatusChanged($"Error handling process start event: {ex.Message}", ex); + }); + } + + private async Task HandleProcessStartedAsync(EventArrivedEventArgs e) + { + if (!this.isMonitoring || this.IsDisposed) + { + return; + } + + try + { + var processId = Convert.ToInt32(e.NewEvent["ProcessID"]); + var processName = e.NewEvent["ProcessName"]?.ToString() ?? string.Empty; + + // Get detailed process information + var process = await this.CreateProcessModelFromId(processId, processName).ConfigureAwait(false) + ?? (!string.IsNullOrWhiteSpace(processName) + ? new ProcessModel { ProcessId = processId, Name = NormalizeProcessName(processName) } + : null); + + if (process != null) + { + this.runningProcesses.TryAdd(processId, process); + this.ProcessStarted?.Invoke(this, new ProcessEventArgs(process)); + } + } + catch (Exception ex) + { + this.OnMonitoringStatusChanged($"Error handling process start event: {ex.Message}", ex); + } + } + + private void OnProcessStopped(object sender, EventArrivedEventArgs e) + { + if (!this.isMonitoring || this.IsDisposed) + { + return; + } + + try + { + var processId = Convert.ToInt32(e.NewEvent["ProcessID"]); + + if (this.runningProcesses.TryRemove(processId, out var process)) + { + this.ProcessStopped?.Invoke(this, new ProcessEventArgs(process)); + } + } + catch (Exception ex) + { + this.OnMonitoringStatusChanged($"Error handling process stop event: {ex.Message}", ex); + } + } + + private void FallbackPollingCallback(object? state) + { + if (!this.isMonitoring || this.IsDisposed || this.cancellationTokenSource?.Token.IsCancellationRequested == true) + { + return; + } + + if (Interlocked.CompareExchange(ref this.disposedFlag, 0, 0) == 1) + { + return; + } + + // Prevent overlapping polling iterations when processing takes longer than interval + if (Interlocked.Exchange(ref this.isFallbackPollingInProgress, 1) == 1) + { + return; + } + + var cancellationToken = this.cancellationTokenSource?.Token ?? CancellationToken.None; + TaskSafety.FireAndForget(this.RunFallbackPollingAsync(cancellationToken), ex => + { + this.OnMonitoringStatusChanged($"Error in fallback polling: {ex.Message}", ex); + }); + } + + private async Task RunFallbackPollingAsync(CancellationToken cancellationToken) + { + try + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + var currentProcesses = await this.GetRunningProcessesAsync().ConfigureAwait(false); + var detectedChanges = 0; + + this.pollBuffer.Clear(); + foreach (var process in currentProcesses) + { + this.pollBuffer[process.ProcessId] = process; + } + + // Check for new processes + foreach (var process in currentProcesses) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + if (!this.runningProcesses.ContainsKey(process.ProcessId)) + { + this.runningProcesses.TryAdd(process.ProcessId, process); + this.ProcessStarted?.Invoke(this, new ProcessEventArgs(process)); + detectedChanges++; + } + } + + // Check for stopped processes + var stoppedProcesses = this.runningProcesses.Keys + .Where(pid => !this.pollBuffer.ContainsKey(pid)) + .ToList(); + + foreach (var pid in stoppedProcesses) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + if (this.runningProcesses.TryRemove(pid, out var stoppedProcess)) + { + this.ProcessStopped?.Invoke(this, new ProcessEventArgs(stoppedProcess)); + detectedChanges++; + } + } + + // Periodically retry WMI monitoring recovery while polling is active + await this.TryRecoverWmiMonitoringAsync().ConfigureAwait(false); + + this.UpdateAdaptivePollingInterval(detectedChanges); + } + catch (Exception ex) + { + this.OnMonitoringStatusChanged($"Error in fallback polling: {ex.Message}", ex); + } + finally + { + Interlocked.Exchange(ref this.isFallbackPollingInProgress, 0); + } + } + + private void UpdateAdaptivePollingInterval(int detectedChanges) + { + if (!this.isFallbackPollingActive || this.fallbackTimer == null) + { + return; + } + + var previousMultiplier = this.idlePollingMultiplier; + this.idlePollingMultiplier = detectedChanges > 0 + ? 1 + : Math.Min(MaxIdlePollingMultiplier, this.idlePollingMultiplier + 1); + + var nextInterval = Math.Clamp( + this.fallbackPollingIntervalMs * this.idlePollingMultiplier, + this.fallbackPollingIntervalMs, + 60000); + + if (nextInterval == this.currentFallbackPollingIntervalMs) + { + return; + } + + this.currentFallbackPollingIntervalMs = nextInterval; + this.fallbackTimer.Change(this.currentFallbackPollingIntervalMs, this.currentFallbackPollingIntervalMs); + + if (previousMultiplier != this.idlePollingMultiplier) + { + this.OnMonitoringStatusChanged($"Adaptive polling interval changed to {this.currentFallbackPollingIntervalMs}ms"); + } + } + + private async Task TryRecoverWmiMonitoringAsync() + { + if (!this.isMonitoring || this.IsDisposed || this.isWmiAvailable || !this.enableWmiMonitoring) + { + return; + } + + var now = DateTime.UtcNow; + if ((now - this.lastWmiRetryAttemptUtc).TotalMilliseconds < this.wmiRetryDelayMs) + { + return; + } + + if (Interlocked.Exchange(ref this.isWmiRecoveryInProgress, 1) == 1) + { + return; + } + + this.lastWmiRetryAttemptUtc = now; + + try + { + var recovered = await this.TryStartWmiMonitoringAsync().ConfigureAwait(false); + if (recovered) + { + this.OnMonitoringStatusChanged("WMI monitoring recovered successfully"); + } + } + finally + { + Interlocked.Exchange(ref this.isWmiRecoveryInProgress, 0); + } + } + + private async Task CreateProcessModelFromId(int processId, string processName) + { + try + { + using var process = Process.GetProcessById(processId); + var normalizedName = !string.IsNullOrWhiteSpace(processName) + ? NormalizeProcessName(processName) + : NormalizeProcessName(process.ProcessName); + + return new ProcessModel + { + ProcessId = process.Id, + Name = normalizedName, + CpuUsage = 0, + MemoryUsage = process.PrivateMemorySize64, + Priority = process.PriorityClass, + ProcessorAffinity = (long)process.ProcessorAffinity, + MainWindowHandle = process.MainWindowHandle, + MainWindowTitle = process.MainWindowTitle ?? string.Empty, + HasVisibleWindow = process.MainWindowHandle != IntPtr.Zero && !string.IsNullOrWhiteSpace(process.MainWindowTitle), + ExecutablePath = process.MainModule?.FileName ?? string.Empty, + }; + } + catch (Exception ex) + { + this.logger?.LogDebug(ex, "Process {ProcessId} terminated before access", processId); + return null; + } + } + + private void OnMonitoringStatusChanged(string? message = null, Exception? error = null) + { + this.MonitoringStatusChanged?.Invoke(this, new MonitoringStatusEventArgs( + this.isMonitoring, this.isWmiAvailable, this.isFallbackPollingActive, message, error)); + } + + private void UpdateMonitoringSettings() + { + var settings = this.settingsService.Settings; + this.fallbackPollingIntervalMs = Math.Clamp(settings.FallbackPollingIntervalMs, 1000, 60000); + this.enableWmiMonitoring = settings.EnableWmiMonitoring; + this.enableFallbackPolling = settings.EnableFallbackPolling; + } + + public void UpdateSettings() + { + var previousInterval = this.fallbackPollingIntervalMs; + var previousWmiEnabled = this.enableWmiMonitoring; + var previousFallbackEnabled = this.enableFallbackPolling; + + this.UpdateMonitoringSettings(); + + if (!this.isMonitoring) + { + return; + } + + if (!this.enableWmiMonitoring && this.isWmiAvailable) + { + this.StopWmiWatchers(); + this.OnMonitoringStatusChanged("WMI monitoring disabled by settings"); + } + else if (this.enableWmiMonitoring && !previousWmiEnabled && !this.isWmiAvailable) + { + TaskSafety.FireAndForget(this.TryStartWmiMonitoringAsync(), ex => + { + this.OnMonitoringStatusChanged($"Error recovering WMI monitoring: {ex.Message}", ex); + }); + } + + if (!this.enableFallbackPolling && this.isFallbackPollingActive) + { + this.StopFallbackPolling(); + this.OnMonitoringStatusChanged("Fallback polling disabled by settings"); + } + else if (this.enableFallbackPolling && !previousFallbackEnabled && (!this.isWmiAvailable || !this.enableWmiMonitoring)) + { + this.StartFallbackPolling(); + } + + // If fallback polling is active, restart it with new interval + if (this.isFallbackPollingActive && this.fallbackTimer != null && previousInterval != this.fallbackPollingIntervalMs) + { + this.idlePollingMultiplier = 1; + this.currentFallbackPollingIntervalMs = this.fallbackPollingIntervalMs; + this.fallbackTimer.Change(0, this.currentFallbackPollingIntervalMs); + this.OnMonitoringStatusChanged($"Polling interval updated to {this.fallbackPollingIntervalMs}ms"); + } + } + + private static string NormalizeProcessName(string processName) + { + if (string.IsNullOrWhiteSpace(processName)) + { + return string.Empty; + } + + // Keep process naming consistent with Process.ProcessName (no extension) + return Path.GetFileNameWithoutExtension(processName.Trim()); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref this.disposedFlag, 1) == 1) + { + return; + } + + try + { + this.StopMonitoringAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + this.OnMonitoringStatusChanged($"Error during process monitor disposal: {ex.Message}", ex); + } + + this.wmiStartSemaphore.Dispose(); + } + } +} + diff --git a/Services/ProcessOperationResult.cs b/Services/ProcessOperationResult.cs index 329dced..3e85712 100644 --- a/Services/ProcessOperationResult.cs +++ b/Services/ProcessOperationResult.cs @@ -1,48 +1,48 @@ -/* - * ThreadPilot - process operation result model. - */ -namespace ThreadPilot.Services -{ - public sealed record ProcessOperationResult - { - public bool Success { get; init; } - - public string ErrorCode { get; init; } = string.Empty; - - public string UserMessage { get; init; } = string.Empty; - - public string TechnicalMessage { get; init; } = string.Empty; - - public bool IsAccessDenied { get; init; } - - public bool IsAntiCheatLikely { get; init; } - - public bool IsProcessExited { get; init; } - - public static ProcessOperationResult Succeeded(string userMessage, string technicalMessage) => - new() - { - Success = true, - UserMessage = userMessage, - TechnicalMessage = technicalMessage, - }; - - public static ProcessOperationResult Failed( - string errorCode, - string userMessage, - string technicalMessage, - bool isAccessDenied = false, - bool isAntiCheatLikely = false, - bool isProcessExited = false) => - new() - { - Success = false, - ErrorCode = errorCode, - UserMessage = userMessage, - TechnicalMessage = technicalMessage, - IsAccessDenied = isAccessDenied, - IsAntiCheatLikely = isAntiCheatLikely, - IsProcessExited = isProcessExited, - }; - } -} +/* + * ThreadPilot - process operation result model. + */ +namespace ThreadPilot.Services +{ + public sealed record ProcessOperationResult + { + public bool Success { get; init; } + + public string ErrorCode { get; init; } = string.Empty; + + public string UserMessage { get; init; } = string.Empty; + + public string TechnicalMessage { get; init; } = string.Empty; + + public bool IsAccessDenied { get; init; } + + public bool IsAntiCheatLikely { get; init; } + + public bool IsProcessExited { get; init; } + + public static ProcessOperationResult Succeeded(string userMessage, string technicalMessage) => + new() + { + Success = true, + UserMessage = userMessage, + TechnicalMessage = technicalMessage, + }; + + public static ProcessOperationResult Failed( + string errorCode, + string userMessage, + string technicalMessage, + bool isAccessDenied = false, + bool isAntiCheatLikely = false, + bool isProcessExited = false) => + new() + { + Success = false, + ErrorCode = errorCode, + UserMessage = userMessage, + TechnicalMessage = technicalMessage, + IsAccessDenied = isAccessDenied, + IsAntiCheatLikely = isAntiCheatLikely, + IsProcessExited = isProcessExited, + }; + } +} diff --git a/Services/ProcessOperationUserMessages.cs b/Services/ProcessOperationUserMessages.cs index 7e48f78..ad7f73a 100644 --- a/Services/ProcessOperationUserMessages.cs +++ b/Services/ProcessOperationUserMessages.cs @@ -1,62 +1,62 @@ -namespace ThreadPilot.Services -{ - using System.Diagnostics; - - internal static class ProcessOperationUserMessages - { - public const string AccessDenied = - "Windows denied this change. The process may require administrator rights or may be protected."; - - public const string AntiCheatProtectedLikely = - "The process appears protected by anti-cheat or process protection. ThreadPilot will not try to bypass it."; - - public const string AdminClarification = - "Administrator mode may help with normal permission issues, but cannot bypass anti-cheat or protected process restrictions."; - - public const string LegacyFallbackBlocked = - "This CPU selection cannot be safely represented by legacy affinity APIs on this topology. CPU Sets are required for this selection."; - - public const string InvalidTopology = - "This CPU selection does not match the current CPU topology. Review or recreate the preset."; - - public const string ProcessExited = - "The process exited before ThreadPilot could apply the change."; - - public const string CpuSetsUnavailable = - "Windows CPU Sets are unavailable or rejected this selection. ThreadPilot will use a safe fallback only when possible."; - - public const string HighPriorityWarning = - "High priority can improve responsiveness for some workloads but may reduce system responsiveness."; - - public const string RealtimePriorityBlocked = - "Realtime priority is blocked by ThreadPilot because it can make Windows unstable or unresponsive."; - - public const string PersistentLaunchTimePriorityNotice = - "Persistent launch-time priority may be supported for normal processes, but it does not bypass protected process or anti-cheat restrictions."; - - public const string PersistentRulesDescription = - "Applies saved rules when a matching process starts. Some protected or anti-cheat processes may reject changes. Administrator mode can help with normal permission issues but cannot bypass protection."; - - public const string PersistentRulesProtectedProcessWarning = - "The process appears protected by anti-cheat or process protection. ThreadPilot will not try to override it."; - } - - internal static class ProcessPriorityGuardrails - { - public static string? GetWarning(ProcessPriorityClass priority) => - priority == ProcessPriorityClass.High - ? ProcessOperationUserMessages.HighPriorityWarning - : null; - - public static bool IsBlocked(ProcessPriorityClass priority) => - priority == ProcessPriorityClass.RealTime; - - public static void ThrowIfBlocked(ProcessPriorityClass priority) - { - if (IsBlocked(priority)) - { - throw new InvalidOperationException(ProcessOperationUserMessages.RealtimePriorityBlocked); - } - } - } -} +namespace ThreadPilot.Services +{ + using System.Diagnostics; + + internal static class ProcessOperationUserMessages + { + public const string AccessDenied = + "Windows denied this change. The process may require administrator rights or may be protected."; + + public const string AntiCheatProtectedLikely = + "The process appears protected by anti-cheat or process protection. ThreadPilot will not try to bypass it."; + + public const string AdminClarification = + "Administrator mode may help with normal permission issues, but cannot bypass anti-cheat or protected process restrictions."; + + public const string LegacyFallbackBlocked = + "This CPU selection cannot be safely represented by legacy affinity APIs on this topology. CPU Sets are required for this selection."; + + public const string InvalidTopology = + "This CPU selection does not match the current CPU topology. Review or recreate the preset."; + + public const string ProcessExited = + "The process exited before ThreadPilot could apply the change."; + + public const string CpuSetsUnavailable = + "Windows CPU Sets are unavailable or rejected this selection. ThreadPilot will use a safe fallback only when possible."; + + public const string HighPriorityWarning = + "High priority can improve responsiveness for some workloads but may reduce system responsiveness."; + + public const string RealtimePriorityBlocked = + "Realtime priority is blocked by ThreadPilot because it can make Windows unstable or unresponsive."; + + public const string PersistentLaunchTimePriorityNotice = + "Persistent launch-time priority may be supported for normal processes, but it does not bypass protected process or anti-cheat restrictions."; + + public const string PersistentRulesDescription = + "Applies saved rules when a matching process starts. Some protected or anti-cheat processes may reject changes. Administrator mode can help with normal permission issues but cannot bypass protection."; + + public const string PersistentRulesProtectedProcessWarning = + "The process appears protected by anti-cheat or process protection. ThreadPilot will not try to override it."; + } + + internal static class ProcessPriorityGuardrails + { + public static string? GetWarning(ProcessPriorityClass priority) => + priority == ProcessPriorityClass.High + ? ProcessOperationUserMessages.HighPriorityWarning + : null; + + public static bool IsBlocked(ProcessPriorityClass priority) => + priority == ProcessPriorityClass.RealTime; + + public static void ThrowIfBlocked(ProcessPriorityClass priority) + { + if (IsBlocked(priority)) + { + throw new InvalidOperationException(ProcessOperationUserMessages.RealtimePriorityBlocked); + } + } + } +} diff --git a/Services/ProcessPowerPlanAssociationService.cs b/Services/ProcessPowerPlanAssociationService.cs index 11b6547..c827309 100644 --- a/Services/ProcessPowerPlanAssociationService.cs +++ b/Services/ProcessPowerPlanAssociationService.cs @@ -1,458 +1,439 @@ -/* - * 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.IO; - using System.Linq; - using System.Text; - using System.Text.Json; - using System.Threading.Tasks; - using ThreadPilot.Models; - - /// - /// Service for managing process-power plan associations with persistence. - /// - public class ProcessPowerPlanAssociationService : IProcessPowerPlanAssociationService - { - private static string LegacyConfigurationDirectory => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Configuration"); - - private static string LegacyConfigurationFilePath => Path.Combine(LegacyConfigurationDirectory, "ProcessPowerPlanAssociations.json"); - - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - ReadCommentHandling = JsonCommentHandling.Skip, - AllowTrailingCommas = true, - }; - - private readonly string configurationDirectory; - private readonly string configurationFilePath; - private readonly object lockObject = new(); - - private ProcessMonitorConfiguration configuration; - - public event EventHandler? ConfigurationChanged; - - public ProcessMonitorConfiguration Configuration => this.configuration; - - public ProcessPowerPlanAssociationService() - { - StoragePaths.EnsureAppDataDirectories(); - - this.configurationDirectory = StoragePaths.ConfigurationDirectory; - this.configurationFilePath = Path.Combine(this.configurationDirectory, "ProcessPowerPlanAssociations.json"); - this.configuration = new ProcessMonitorConfiguration(); - - this.EnsureConfigurationDirectoryExists(); - this.MigrateLegacyConfigurationIfNeeded(); - } - - public async Task LoadConfigurationAsync() - { - try - { - if (!File.Exists(this.configurationFilePath)) - { - // Create default configuration - this.configuration = new ProcessMonitorConfiguration(); - await this.SaveConfigurationAsync().ConfigureAwait(false); - return true; - } - - var json = await File.ReadAllTextAsync(this.configurationFilePath).ConfigureAwait(false); - var loadedConfig = JsonSerializer.Deserialize(json, JsonOptions); - - if (loadedConfig != null) - { - lock (this.lockObject) - { - loadedConfig.Associations ??= new List(); - this.configuration = loadedConfig; - } - - this.OnConfigurationChanged("Loaded", null, "Configuration loaded from file"); - return true; - } - - return false; - } - catch (Exception ex) - { - this.OnConfigurationChanged("LoadError", null, $"Failed to load configuration: {ex.Message}"); - return false; - } - } - - public async Task SaveConfigurationAsync() - { - try - { - ProcessMonitorConfiguration configToSave; - lock (this.lockObject) - { - configToSave = this.configuration; - configToSave.LastSavedDate = DateTime.Now; - } - - var json = JsonSerializer.Serialize(configToSave, JsonOptions); - await AtomicFileWriter.WriteAllTextAsync(this.configurationFilePath, json, Encoding.UTF8).ConfigureAwait(false); - - this.OnConfigurationChanged("Saved", null, "Configuration saved to file"); - return true; - } - catch (Exception ex) - { - this.OnConfigurationChanged("SaveError", null, $"Failed to save configuration: {ex.Message}"); - return false; - } - } - - public async Task> GetAssociationsAsync() - { - await Task.CompletedTask.ConfigureAwait(false); - lock (this.lockObject) - { - return this.configuration.Associations.ToList(); - } - } - - public async Task> GetEnabledAssociationsAsync() - { - await Task.CompletedTask.ConfigureAwait(false); - lock (this.lockObject) - { - return this.configuration.GetEnabledAssociations().ToList(); - } - } - - public async Task AddAssociationAsync(ProcessPowerPlanAssociation association) - { - try - { - if (association == null) - { - return false; - } - - lock (this.lockObject) - { - // Check for duplicates - var existing = this.configuration.Associations - .FirstOrDefault(a => AreAssociationsConflicting(a, association)); - - if (existing != null) - { - return false; // Duplicate found - } - - this.configuration.AddOrUpdateAssociation(association); - } - - await this.SaveConfigurationAsync().ConfigureAwait(false); - this.OnConfigurationChanged("Added", association, $"Association added for {association.ExecutableName}"); - return true; - } - catch (Exception ex) - { - this.OnConfigurationChanged("AddError", association, $"Failed to add association: {ex.Message}"); - return false; - } - } - - public async Task UpdateAssociationAsync(ProcessPowerPlanAssociation association) - { - try - { - if (association == null) - { - return false; - } - - lock (this.lockObject) - { - this.configuration.AddOrUpdateAssociation(association); - } - - await this.SaveConfigurationAsync().ConfigureAwait(false); - this.OnConfigurationChanged("Updated", association, $"Association updated for {association.ExecutableName}"); - return true; - } - catch (Exception ex) - { - this.OnConfigurationChanged("UpdateError", association, $"Failed to update association: {ex.Message}"); - return false; - } - } - - public async Task RemoveAssociationAsync(string associationId) - { - try - { - ProcessPowerPlanAssociation? removedAssociation = null; - bool removed; - - lock (this.lockObject) - { - removedAssociation = this.configuration.Associations.FirstOrDefault(a => a.Id == associationId); - removed = this.configuration.RemoveAssociation(associationId); - } - - if (removed) - { - await this.SaveConfigurationAsync().ConfigureAwait(false); - this.OnConfigurationChanged("Removed", removedAssociation, - $"Association removed for {removedAssociation?.ExecutableName}"); - } - - return removed; - } - catch (Exception ex) - { - this.OnConfigurationChanged("RemoveError", null, $"Failed to remove association: {ex.Message}"); - return false; - } - } - - public async Task FindMatchingAssociationAsync(ProcessModel process) - { - await Task.CompletedTask.ConfigureAwait(false); - lock (this.lockObject) - { - return this.configuration.FindMatchingAssociation(process); - } - } - - public async Task FindAssociationByExecutableAsync(string executableName) - { - await Task.CompletedTask.ConfigureAwait(false); - lock (this.lockObject) - { - return this.configuration.FindAssociationByExecutable(executableName); - } - } - - public async Task SetDefaultPowerPlanAsync(string powerPlanGuid, string powerPlanName) - { - try - { - lock (this.lockObject) - { - this.configuration.DefaultPowerPlanGuid = powerPlanGuid; - this.configuration.DefaultPowerPlanName = powerPlanName; - } - - await this.SaveConfigurationAsync().ConfigureAwait(false); - this.OnConfigurationChanged("DefaultPowerPlanChanged", null, $"Default power plan set to {powerPlanName}"); - return true; - } - catch (Exception ex) - { - this.OnConfigurationChanged("DefaultPowerPlanError", null, $"Failed to set default power plan: {ex.Message}"); - return false; - } - } - - public async Task<(string Guid, string Name)> GetDefaultPowerPlanAsync() - { - await Task.CompletedTask.ConfigureAwait(false); - lock (this.lockObject) - { - return (this.configuration.DefaultPowerPlanGuid, this.configuration.DefaultPowerPlanName); - } - } - - public async Task> ValidateConfigurationAsync() - { - await Task.CompletedTask.ConfigureAwait(false); - lock (this.lockObject) - { - return this.configuration.Validate(); - } - } - - public async Task ResetConfigurationAsync() - { - lock (this.lockObject) - { - this.configuration = new ProcessMonitorConfiguration(); - } - - await this.SaveConfigurationAsync().ConfigureAwait(false); - this.OnConfigurationChanged("Reset", null, "Configuration reset to defaults"); - } - - public async Task ExportConfigurationAsync(string filePath) - { - try - { - ProcessMonitorConfiguration configToExport; - lock (this.lockObject) - { - configToExport = this.configuration; - } - - var json = JsonSerializer.Serialize(configToExport, JsonOptions); - await AtomicFileWriter.WriteAllTextAsync(filePath, json, Encoding.UTF8).ConfigureAwait(false); - - this.OnConfigurationChanged("Exported", null, $"Configuration exported to {filePath}"); - return true; - } - catch (Exception ex) - { - this.OnConfigurationChanged("ExportError", null, $"Failed to export configuration: {ex.Message}"); - return false; - } - } - - public async Task ImportConfigurationAsync(string filePath) - { - try - { - if (!File.Exists(filePath)) - { - return false; - } - - var json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); - var importedConfig = JsonSerializer.Deserialize(json, JsonOptions); - - if (importedConfig != null) - { - var replaced = await this.ReplaceConfigurationAsync(importedConfig).ConfigureAwait(false); - if (replaced) - { - this.OnConfigurationChanged("Imported", null, $"Configuration imported from {filePath}"); - } - - return replaced; - } - - return false; - } - catch (Exception ex) - { - this.OnConfigurationChanged("ImportError", null, $"Failed to import configuration: {ex.Message}"); - return false; - } - } - - public async Task ReplaceConfigurationAsync(ProcessMonitorConfiguration configuration) - { - if (configuration == null) - { - return false; - } - - try - { - var cloned = CloneConfiguration(configuration); - - lock (this.lockObject) - { - this.configuration = cloned; - } - - await this.SaveConfigurationAsync().ConfigureAwait(false); - this.OnConfigurationChanged("Replaced", null, "Configuration replaced from imported bundle"); - return true; - } - catch (Exception ex) - { - this.OnConfigurationChanged("ReplaceError", null, $"Failed to replace configuration: {ex.Message}"); - return false; - } - } - - private void EnsureConfigurationDirectoryExists() - { - if (!Directory.Exists(this.configurationDirectory)) - { - Directory.CreateDirectory(this.configurationDirectory); - } - } - - private void MigrateLegacyConfigurationIfNeeded() - { - try - { - if (!File.Exists(LegacyConfigurationFilePath) || File.Exists(this.configurationFilePath)) - { - return; - } - - Directory.CreateDirectory(this.configurationDirectory); - File.Copy(LegacyConfigurationFilePath, this.configurationFilePath); - } - catch - { - // Ignore migration failures and continue with current storage path. - } - } - - private void OnConfigurationChanged(string changeType, ProcessPowerPlanAssociation? association = null, string? details = null) - { - this.ConfigurationChanged?.Invoke(this, new ConfigurationChangedEventArgs(changeType, association, details)); - } - - private static bool AreAssociationsConflicting(ProcessPowerPlanAssociation existing, ProcessPowerPlanAssociation candidate) - { - var existingName = NormalizeExecutableName(existing.ExecutableName); - var candidateName = NormalizeExecutableName(candidate.ExecutableName); - - if (!string.Equals(existingName, candidateName, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (existing.MatchByPath || candidate.MatchByPath) - { - var existingPath = existing.ExecutablePath?.Trim() ?? string.Empty; - var candidatePath = candidate.ExecutablePath?.Trim() ?? string.Empty; - - return string.Equals(existingPath, candidatePath, StringComparison.OrdinalIgnoreCase) && - existing.MatchByPath == candidate.MatchByPath; - } - - // Name-only associations conflict by executable name - return true; - } - - private static string NormalizeExecutableName(string? executableName) - { - if (string.IsNullOrWhiteSpace(executableName)) - { - return string.Empty; - } - - return Path.GetFileNameWithoutExtension(executableName.Trim()); - } - - private static ProcessMonitorConfiguration CloneConfiguration(ProcessMonitorConfiguration source) - { - var serialized = JsonSerializer.Serialize(source, JsonOptions); - var cloned = JsonSerializer.Deserialize(serialized, JsonOptions) - ?? new ProcessMonitorConfiguration(); - cloned.Associations ??= new List(); - return cloned; - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Text; + using System.Text.Json; + using System.Threading.Tasks; + using ThreadPilot.Models; + + public class ProcessPowerPlanAssociationService : IProcessPowerPlanAssociationService + { + private static string LegacyConfigurationDirectory => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Configuration"); + + private static string LegacyConfigurationFilePath => Path.Combine(LegacyConfigurationDirectory, "ProcessPowerPlanAssociations.json"); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + private readonly string configurationDirectory; + private readonly string configurationFilePath; + private readonly object lockObject = new(); + + private ProcessMonitorConfiguration configuration; + + public event EventHandler? ConfigurationChanged; + + public ProcessMonitorConfiguration Configuration => this.configuration; + + public ProcessPowerPlanAssociationService() + { + StoragePaths.EnsureAppDataDirectories(); + + this.configurationDirectory = StoragePaths.ConfigurationDirectory; + this.configurationFilePath = Path.Combine(this.configurationDirectory, "ProcessPowerPlanAssociations.json"); + this.configuration = new ProcessMonitorConfiguration(); + + this.EnsureConfigurationDirectoryExists(); + this.MigrateLegacyConfigurationIfNeeded(); + } + + public async Task LoadConfigurationAsync() + { + try + { + if (!File.Exists(this.configurationFilePath)) + { + // Create default configuration + this.configuration = new ProcessMonitorConfiguration(); + await this.SaveConfigurationAsync().ConfigureAwait(false); + return true; + } + + var json = await File.ReadAllTextAsync(this.configurationFilePath).ConfigureAwait(false); + var loadedConfig = JsonSerializer.Deserialize(json, JsonOptions); + + if (loadedConfig != null) + { + lock (this.lockObject) + { + loadedConfig.Associations ??= new List(); + this.configuration = loadedConfig; + } + + this.OnConfigurationChanged("Loaded", null, "Configuration loaded from file"); + return true; + } + + return false; + } + catch (Exception ex) + { + this.OnConfigurationChanged("LoadError", null, $"Failed to load configuration: {ex.Message}"); + return false; + } + } + + public async Task SaveConfigurationAsync() + { + try + { + ProcessMonitorConfiguration configToSave; + lock (this.lockObject) + { + configToSave = this.configuration; + configToSave.LastSavedDate = DateTime.Now; + } + + var json = JsonSerializer.Serialize(configToSave, JsonOptions); + await AtomicFileWriter.WriteAllTextAsync(this.configurationFilePath, json, Encoding.UTF8).ConfigureAwait(false); + + this.OnConfigurationChanged("Saved", null, "Configuration saved to file"); + return true; + } + catch (Exception ex) + { + this.OnConfigurationChanged("SaveError", null, $"Failed to save configuration: {ex.Message}"); + return false; + } + } + + public async Task> GetAssociationsAsync() + { + await Task.CompletedTask.ConfigureAwait(false); + lock (this.lockObject) + { + return this.configuration.Associations.ToList(); + } + } + + public async Task> GetEnabledAssociationsAsync() + { + await Task.CompletedTask.ConfigureAwait(false); + lock (this.lockObject) + { + return this.configuration.GetEnabledAssociations().ToList(); + } + } + + public async Task AddAssociationAsync(ProcessPowerPlanAssociation association) + { + try + { + if (association == null) + { + return false; + } + + lock (this.lockObject) + { + // Check for duplicates + var existing = this.configuration.Associations + .FirstOrDefault(a => AreAssociationsConflicting(a, association)); + + if (existing != null) + { + return false; // Duplicate found + } + + this.configuration.AddOrUpdateAssociation(association); + } + + await this.SaveConfigurationAsync().ConfigureAwait(false); + this.OnConfigurationChanged("Added", association, $"Association added for {association.ExecutableName}"); + return true; + } + catch (Exception ex) + { + this.OnConfigurationChanged("AddError", association, $"Failed to add association: {ex.Message}"); + return false; + } + } + + public async Task UpdateAssociationAsync(ProcessPowerPlanAssociation association) + { + try + { + if (association == null) + { + return false; + } + + lock (this.lockObject) + { + this.configuration.AddOrUpdateAssociation(association); + } + + await this.SaveConfigurationAsync().ConfigureAwait(false); + this.OnConfigurationChanged("Updated", association, $"Association updated for {association.ExecutableName}"); + return true; + } + catch (Exception ex) + { + this.OnConfigurationChanged("UpdateError", association, $"Failed to update association: {ex.Message}"); + return false; + } + } + + public async Task RemoveAssociationAsync(string associationId) + { + try + { + ProcessPowerPlanAssociation? removedAssociation = null; + bool removed; + + lock (this.lockObject) + { + removedAssociation = this.configuration.Associations.FirstOrDefault(a => a.Id == associationId); + removed = this.configuration.RemoveAssociation(associationId); + } + + if (removed) + { + await this.SaveConfigurationAsync().ConfigureAwait(false); + this.OnConfigurationChanged("Removed", removedAssociation, + $"Association removed for {removedAssociation?.ExecutableName}"); + } + + return removed; + } + catch (Exception ex) + { + this.OnConfigurationChanged("RemoveError", null, $"Failed to remove association: {ex.Message}"); + return false; + } + } + + public async Task FindMatchingAssociationAsync(ProcessModel process) + { + await Task.CompletedTask.ConfigureAwait(false); + lock (this.lockObject) + { + return this.configuration.FindMatchingAssociation(process); + } + } + + public async Task FindAssociationByExecutableAsync(string executableName) + { + await Task.CompletedTask.ConfigureAwait(false); + lock (this.lockObject) + { + return this.configuration.FindAssociationByExecutable(executableName); + } + } + + public async Task SetDefaultPowerPlanAsync(string powerPlanGuid, string powerPlanName) + { + try + { + lock (this.lockObject) + { + this.configuration.DefaultPowerPlanGuid = powerPlanGuid; + this.configuration.DefaultPowerPlanName = powerPlanName; + } + + await this.SaveConfigurationAsync().ConfigureAwait(false); + this.OnConfigurationChanged("DefaultPowerPlanChanged", null, $"Default power plan set to {powerPlanName}"); + return true; + } + catch (Exception ex) + { + this.OnConfigurationChanged("DefaultPowerPlanError", null, $"Failed to set default power plan: {ex.Message}"); + return false; + } + } + + public async Task<(string Guid, string Name)> GetDefaultPowerPlanAsync() + { + await Task.CompletedTask.ConfigureAwait(false); + lock (this.lockObject) + { + return (this.configuration.DefaultPowerPlanGuid, this.configuration.DefaultPowerPlanName); + } + } + + public async Task> ValidateConfigurationAsync() + { + await Task.CompletedTask.ConfigureAwait(false); + lock (this.lockObject) + { + return this.configuration.Validate(); + } + } + + public async Task ResetConfigurationAsync() + { + lock (this.lockObject) + { + this.configuration = new ProcessMonitorConfiguration(); + } + + await this.SaveConfigurationAsync().ConfigureAwait(false); + this.OnConfigurationChanged("Reset", null, "Configuration reset to defaults"); + } + + public async Task ExportConfigurationAsync(string filePath) + { + try + { + ProcessMonitorConfiguration configToExport; + lock (this.lockObject) + { + configToExport = this.configuration; + } + + var json = JsonSerializer.Serialize(configToExport, JsonOptions); + await AtomicFileWriter.WriteAllTextAsync(filePath, json, Encoding.UTF8).ConfigureAwait(false); + + this.OnConfigurationChanged("Exported", null, $"Configuration exported to {filePath}"); + return true; + } + catch (Exception ex) + { + this.OnConfigurationChanged("ExportError", null, $"Failed to export configuration: {ex.Message}"); + return false; + } + } + + public async Task ImportConfigurationAsync(string filePath) + { + try + { + if (!File.Exists(filePath)) + { + return false; + } + + var json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); + var importedConfig = JsonSerializer.Deserialize(json, JsonOptions); + + if (importedConfig != null) + { + var replaced = await this.ReplaceConfigurationAsync(importedConfig).ConfigureAwait(false); + if (replaced) + { + this.OnConfigurationChanged("Imported", null, $"Configuration imported from {filePath}"); + } + + return replaced; + } + + return false; + } + catch (Exception ex) + { + this.OnConfigurationChanged("ImportError", null, $"Failed to import configuration: {ex.Message}"); + return false; + } + } + + public async Task ReplaceConfigurationAsync(ProcessMonitorConfiguration configuration) + { + if (configuration == null) + { + return false; + } + + try + { + var cloned = CloneConfiguration(configuration); + + lock (this.lockObject) + { + this.configuration = cloned; + } + + await this.SaveConfigurationAsync().ConfigureAwait(false); + this.OnConfigurationChanged("Replaced", null, "Configuration replaced from imported bundle"); + return true; + } + catch (Exception ex) + { + this.OnConfigurationChanged("ReplaceError", null, $"Failed to replace configuration: {ex.Message}"); + return false; + } + } + + private void EnsureConfigurationDirectoryExists() + { + if (!Directory.Exists(this.configurationDirectory)) + { + Directory.CreateDirectory(this.configurationDirectory); + } + } + + private void MigrateLegacyConfigurationIfNeeded() + { + try + { + if (!File.Exists(LegacyConfigurationFilePath) || File.Exists(this.configurationFilePath)) + { + return; + } + + Directory.CreateDirectory(this.configurationDirectory); + File.Copy(LegacyConfigurationFilePath, this.configurationFilePath); + } + catch + { + // Ignore migration failures and continue with current storage path. + } + } + + private void OnConfigurationChanged(string changeType, ProcessPowerPlanAssociation? association = null, string? details = null) + { + this.ConfigurationChanged?.Invoke(this, new ConfigurationChangedEventArgs(changeType, association, details)); + } + + private static bool AreAssociationsConflicting(ProcessPowerPlanAssociation existing, ProcessPowerPlanAssociation candidate) + { + var existingName = NormalizeExecutableName(existing.ExecutableName); + var candidateName = NormalizeExecutableName(candidate.ExecutableName); + + if (!string.Equals(existingName, candidateName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (existing.MatchByPath || candidate.MatchByPath) + { + var existingPath = existing.ExecutablePath?.Trim() ?? string.Empty; + var candidatePath = candidate.ExecutablePath?.Trim() ?? string.Empty; + + return string.Equals(existingPath, candidatePath, StringComparison.OrdinalIgnoreCase) && + existing.MatchByPath == candidate.MatchByPath; + } + + // Name-only associations conflict by executable name + return true; + } + + private static string NormalizeExecutableName(string? executableName) + { + if (string.IsNullOrWhiteSpace(executableName)) + { + return string.Empty; + } + + return Path.GetFileNameWithoutExtension(executableName.Trim()); + } + + private static ProcessMonitorConfiguration CloneConfiguration(ProcessMonitorConfiguration source) + { + var serialized = JsonSerializer.Serialize(source, JsonOptions); + var cloned = JsonSerializer.Deserialize(serialized, JsonOptions) + ?? new ProcessMonitorConfiguration(); + cloned.Associations ??= new List(); + return cloned; + } + } +} + diff --git a/Services/ProcessRuleCreationService.cs b/Services/ProcessRuleCreationService.cs index 2c66802..28a8f4b 100644 --- a/Services/ProcessRuleCreationService.cs +++ b/Services/ProcessRuleCreationService.cs @@ -1,461 +1,461 @@ -/* - * ThreadPilot - persistent process rule creation from explicit Process tab actions. - */ -namespace ThreadPilot.Services -{ - using System.Diagnostics; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - - public interface IProcessRuleCreationService - { - Task SaveRuleAsync( - ProcessModel process, - ProcessRuleCreationPayload payload, - CancellationToken cancellationToken = default); - - Task SaveCurrentSettingsAsRuleAsync( - ProcessModel process, - IReadOnlyList? currentCoreSelection, - ProcessMemoryPriority? currentMemoryPriority, - CancellationToken cancellationToken = default); - } - - public sealed record ProcessRuleCreationPayload - { - public CpuSelection? CpuSelection { get; init; } - - public long? LegacyAffinityMask { get; init; } - - public ProcessPriorityClass? Priority { get; init; } - - public ProcessMemoryPriority? MemoryPriority { get; init; } - } - - public sealed record ProcessRuleCreationResult - { - public bool Success { get; init; } - - public bool Created { get; init; } - - public bool Updated { get; init; } - - public PersistentProcessRule? Rule { get; init; } - - public string UserMessage { get; init; } = string.Empty; - - public string ErrorCode { get; init; } = string.Empty; - - public static ProcessRuleCreationResult Failed(string errorCode, string userMessage) => - new() - { - Success = false, - ErrorCode = errorCode, - UserMessage = userMessage, - }; - } - - public sealed class ProcessRuleCreationService : IProcessRuleCreationService - { - public const string NoCurrentSettingsMessage = - "There are no current settings to save as a rule."; - - public const string UnsafeAffinityMessage = - "The current affinity selection cannot be saved safely on this CPU topology."; - - private const string RuleDescription = "Created from Process tab action."; - - private readonly IPersistentProcessRuleStore ruleStore; - private readonly ICpuTopologyProvider? topologyProvider; - private readonly CpuSelectionMigrationService migrationService; - private readonly ILogger logger; - - public ProcessRuleCreationService( - IPersistentProcessRuleStore ruleStore, - ICpuTopologyProvider? topologyProvider, - CpuSelectionMigrationService migrationService, - ILogger logger) - { - this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore)); - this.topologyProvider = topologyProvider; - this.migrationService = migrationService ?? throw new ArgumentNullException(nameof(migrationService)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task SaveCurrentSettingsAsRuleAsync( - ProcessModel process, - IReadOnlyList? currentCoreSelection, - ProcessMemoryPriority? currentMemoryPriority, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(process); - - var payload = new ProcessRuleCreationPayload - { - Priority = ShouldCaptureCurrentCpuPriority(process.Priority) - ? process.Priority - : null, - MemoryPriority = currentMemoryPriority, - }; - - var affinityPayload = currentCoreSelection == null - ? await this.BuildAffinityPayloadFromLegacyMaskAsync( - process.ProcessorAffinity, - "Saved current Process tab affinity", - cancellationToken).ConfigureAwait(false) - : await this.BuildAffinityPayloadFromCoreSelectionAsync( - currentCoreSelection, - "Saved current Process tab affinity", - cancellationToken).ConfigureAwait(false); - - if (!affinityPayload.Success) - { - return affinityPayload; - } - - if (affinityPayload.Payload != null) - { - payload = payload with - { - CpuSelection = affinityPayload.Payload.CpuSelection, - LegacyAffinityMask = affinityPayload.Payload.LegacyAffinityMask, - }; - } - - return await this.SaveRuleAsync(process, payload, cancellationToken).ConfigureAwait(false); - } - - public async Task SaveRuleAsync( - ProcessModel process, - ProcessRuleCreationPayload payload, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(process); - ArgumentNullException.ThrowIfNull(payload); - - var sanitizedPayload = SanitizePayload(payload); - if (!sanitizedPayload.Success) - { - return sanitizedPayload; - } - - payload = sanitizedPayload.Payload!; - if (!HasActionablePayload(payload)) - { - return ProcessRuleCreationResult.Failed("NoActionableRulePayload", NoCurrentSettingsMessage); - } - - var rules = (await this.ruleStore.LoadAsync().ConfigureAwait(false)).ToList(); - var existingIndex = FindExistingRuleIndex(rules, process); - var created = existingIndex < 0; - var now = DateTime.UtcNow; - var processName = string.IsNullOrWhiteSpace(process.Name) - ? "process" - : process.Name.Trim(); - var executablePath = string.IsNullOrWhiteSpace(process.ExecutablePath) - ? null - : process.ExecutablePath.Trim(); - var existing = created ? null : rules[existingIndex]; - var rule = new PersistentProcessRule - { - Id = existing?.Id ?? Guid.NewGuid().ToString("N"), - Name = $"{processName} rule", - IsEnabled = true, - ProcessName = processName, - ExecutablePath = executablePath, - CpuSelection = payload.CpuSelection, - LegacyAffinityMask = HasSelectionPayload(payload.CpuSelection) ? null : payload.LegacyAffinityMask, - Priority = payload.Priority, - MemoryPriority = payload.MemoryPriority, - ApplyAffinityOnStart = HasSelectionPayload(payload.CpuSelection) || payload.LegacyAffinityMask.HasValue, - ApplyPriorityOnStart = payload.Priority.HasValue, - ApplyMemoryPriorityOnStart = payload.MemoryPriority.HasValue, - CreatedAt = existing?.CreatedAt ?? now, - UpdatedAt = now, - Description = RuleDescription, - }; - - if (created) - { - rules.Add(rule); - } - else - { - rules[existingIndex] = rule; - } - - cancellationToken.ThrowIfCancellationRequested(); - await this.ruleStore.SaveAsync(rules).ConfigureAwait(false); - - return new ProcessRuleCreationResult - { - Success = true, - Created = created, - Updated = !created, - Rule = rule, - UserMessage = created - ? $"Saved rule for {processName}." - : $"Updated saved rule for {processName}.", - }; - } - - private static PayloadBuildResult BuildLegacyAffinityPayload(IReadOnlyList currentCoreSelection) - { - if (currentCoreSelection.Count == 0 || !currentCoreSelection.Any(selected => selected)) - { - return PayloadBuildResult.Empty(); - } - - if (currentCoreSelection.Count > 64) - { - return PayloadBuildResult.Failed("UnsafeLegacyAffinity", UnsafeAffinityMessage); - } - - long legacyMask = 0; - for (var bit = 0; bit < currentCoreSelection.Count; bit++) - { - if (currentCoreSelection[bit]) - { - legacyMask |= 1L << bit; - } - } - - return legacyMask == 0 - ? PayloadBuildResult.Empty() - : PayloadBuildResult.Succeeded(new ProcessRuleCreationPayload { LegacyAffinityMask = legacyMask }); - } - - private static PayloadSanitizationResult SanitizePayload(ProcessRuleCreationPayload payload) - { - if (payload.Priority.HasValue && ProcessPriorityGuardrails.IsBlocked(payload.Priority.Value)) - { - return PayloadSanitizationResult.Failed( - "RealtimePriorityBlocked", - ProcessOperationUserMessages.RealtimePriorityBlocked); - } - - var hasCpuSelection = HasSelectionPayload(payload.CpuSelection); - var legacyMask = hasCpuSelection ? null : payload.LegacyAffinityMask; - if (legacyMask.HasValue && legacyMask.Value == 0) - { - legacyMask = null; - } - - return PayloadSanitizationResult.Succeeded(payload with - { - CpuSelection = hasCpuSelection ? payload.CpuSelection : null, - LegacyAffinityMask = legacyMask, - }); - } - - private static bool ShouldCaptureCurrentCpuPriority(ProcessPriorityClass priority) => - priority is ProcessPriorityClass.Idle - or ProcessPriorityClass.BelowNormal - or ProcessPriorityClass.AboveNormal - or ProcessPriorityClass.High; - - private static bool HasActionablePayload(ProcessRuleCreationPayload payload) => - HasSelectionPayload(payload.CpuSelection) || - payload.LegacyAffinityMask.HasValue || - payload.Priority.HasValue || - payload.MemoryPriority.HasValue; - - private static bool HasSelectionPayload(CpuSelection? selection) => - selection != null && - (selection.CpuSetIds.Count > 0 || selection.LogicalProcessors.Count > 0); - - private static int FindExistingRuleIndex(IReadOnlyList rules, ProcessModel process) - { - var executablePath = string.IsNullOrWhiteSpace(process.ExecutablePath) - ? null - : process.ExecutablePath.Trim(); - if (!string.IsNullOrWhiteSpace(executablePath)) - { - for (var index = 0; index < rules.Count; index++) - { - if (string.Equals( - rules[index].ExecutablePath, - executablePath, - StringComparison.OrdinalIgnoreCase)) - { - return index; - } - } - - var pathlessNameMatch = FindProcessNameMatchIndex(rules, process, requirePathUnavailable: true); - return pathlessNameMatch; - } - - return FindProcessNameMatchIndex(rules, process, requirePathUnavailable: false); - } - - private static int FindProcessNameMatchIndex( - IReadOnlyList rules, - ProcessModel process, - bool requirePathUnavailable) - { - var processName = string.IsNullOrWhiteSpace(process.Name) - ? null - : process.Name.Trim(); - if (string.IsNullOrWhiteSpace(processName)) - { - return -1; - } - - for (var index = 0; index < rules.Count; index++) - { - if (requirePathUnavailable && !string.IsNullOrWhiteSpace(rules[index].ExecutablePath)) - { - continue; - } - - if (string.Equals(rules[index].ProcessName, processName, StringComparison.OrdinalIgnoreCase)) - { - return index; - } - } - - return -1; - } - - private async Task BuildAffinityPayloadFromCoreSelectionAsync( - IReadOnlyList currentCoreSelection, - string selectionReason, - CancellationToken cancellationToken) - { - if (currentCoreSelection.Count == 0 || !currentCoreSelection.Any(selected => selected)) - { - return PayloadBuildResult.Empty(); - } - - var selection = await this.TryMigrateCoreSelectionAsync( - currentCoreSelection, - selectionReason, - cancellationToken).ConfigureAwait(false); - if (selection != null) - { - return PayloadBuildResult.Succeeded(new ProcessRuleCreationPayload { CpuSelection = selection }); - } - - return BuildLegacyAffinityPayload(currentCoreSelection); - } - - private async Task BuildAffinityPayloadFromLegacyMaskAsync( - long legacyMask, - string selectionReason, - CancellationToken cancellationToken) - { - if (legacyMask == 0) - { - return PayloadBuildResult.Empty(); - } - - var selection = await this.TryMigrateLegacyMaskAsync( - legacyMask, - selectionReason, - cancellationToken).ConfigureAwait(false); - if (selection != null) - { - return PayloadBuildResult.Succeeded(new ProcessRuleCreationPayload { CpuSelection = selection }); - } - - return PayloadBuildResult.Succeeded(new ProcessRuleCreationPayload { LegacyAffinityMask = legacyMask }); - } - - private async Task TryMigrateCoreSelectionAsync( - IReadOnlyList currentCoreSelection, - string selectionReason, - CancellationToken cancellationToken) - { - if (this.topologyProvider == null) - { - return null; - } - - try - { - var topology = await this.topologyProvider.GetTopologySnapshotAsync(cancellationToken).ConfigureAwait(false); - var migrated = this.migrationService.MigrateFromLegacyCoreMask(currentCoreSelection, topology); - return WithSelectionReason(migrated.Selection, selectionReason); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - this.logger.LogDebug(ex, "Could not migrate current core selection to CpuSelection for saved rule"); - return null; - } - } - - private async Task TryMigrateLegacyMaskAsync( - long legacyMask, - string selectionReason, - CancellationToken cancellationToken) - { - if (this.topologyProvider == null) - { - return null; - } - - try - { - var topology = await this.topologyProvider.GetTopologySnapshotAsync(cancellationToken).ConfigureAwait(false); - var migrated = this.migrationService.MigrateFromLegacyAffinityMask(legacyMask, topology); - return WithSelectionReason(migrated.Selection, selectionReason); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - this.logger.LogDebug(ex, "Could not migrate current legacy affinity mask to CpuSelection for saved rule"); - return null; - } - } - - private static CpuSelection? WithSelectionReason(CpuSelection? selection, string selectionReason) - { - if (!HasSelectionPayload(selection)) - { - return null; - } - - return selection! with - { - Metadata = selection.Metadata with - { - SelectionReason = selectionReason, - }, - }; - } - - private sealed record PayloadBuildResult( - bool Success, - ProcessRuleCreationPayload? Payload, - string ErrorCode, - string UserMessage) - { - public static PayloadBuildResult Empty() => new(true, null, string.Empty, string.Empty); - - public static PayloadBuildResult Succeeded(ProcessRuleCreationPayload payload) => - new(true, payload, string.Empty, string.Empty); - - public static PayloadBuildResult Failed(string errorCode, string userMessage) => - new(false, null, errorCode, userMessage); - - public static implicit operator ProcessRuleCreationResult(PayloadBuildResult result) => - ProcessRuleCreationResult.Failed(result.ErrorCode, result.UserMessage); - } - - private sealed record PayloadSanitizationResult( - bool Success, - ProcessRuleCreationPayload? Payload, - string ErrorCode, - string UserMessage) - { - public static PayloadSanitizationResult Succeeded(ProcessRuleCreationPayload payload) => - new(true, payload, string.Empty, string.Empty); - - public static PayloadSanitizationResult Failed(string errorCode, string userMessage) => - new(false, null, errorCode, userMessage); - - public static implicit operator ProcessRuleCreationResult(PayloadSanitizationResult result) => - ProcessRuleCreationResult.Failed(result.ErrorCode, result.UserMessage); - } - } -} +/* + * ThreadPilot - persistent process rule creation from explicit Process tab actions. + */ +namespace ThreadPilot.Services +{ + using System.Diagnostics; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public interface IProcessRuleCreationService + { + Task SaveRuleAsync( + ProcessModel process, + ProcessRuleCreationPayload payload, + CancellationToken cancellationToken = default); + + Task SaveCurrentSettingsAsRuleAsync( + ProcessModel process, + IReadOnlyList? currentCoreSelection, + ProcessMemoryPriority? currentMemoryPriority, + CancellationToken cancellationToken = default); + } + + public sealed record ProcessRuleCreationPayload + { + public CpuSelection? CpuSelection { get; init; } + + public long? LegacyAffinityMask { get; init; } + + public ProcessPriorityClass? Priority { get; init; } + + public ProcessMemoryPriority? MemoryPriority { get; init; } + } + + public sealed record ProcessRuleCreationResult + { + public bool Success { get; init; } + + public bool Created { get; init; } + + public bool Updated { get; init; } + + public PersistentProcessRule? Rule { get; init; } + + public string UserMessage { get; init; } = string.Empty; + + public string ErrorCode { get; init; } = string.Empty; + + public static ProcessRuleCreationResult Failed(string errorCode, string userMessage) => + new() + { + Success = false, + ErrorCode = errorCode, + UserMessage = userMessage, + }; + } + + public sealed class ProcessRuleCreationService : IProcessRuleCreationService + { + public const string NoCurrentSettingsMessage = + "There are no current settings to save as a rule."; + + public const string UnsafeAffinityMessage = + "The current affinity selection cannot be saved safely on this CPU topology."; + + private const string RuleDescription = "Created from Process tab action."; + + private readonly IPersistentProcessRuleStore ruleStore; + private readonly ICpuTopologyProvider? topologyProvider; + private readonly CpuSelectionMigrationService migrationService; + private readonly ILogger logger; + + public ProcessRuleCreationService( + IPersistentProcessRuleStore ruleStore, + ICpuTopologyProvider? topologyProvider, + CpuSelectionMigrationService migrationService, + ILogger logger) + { + this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore)); + this.topologyProvider = topologyProvider; + this.migrationService = migrationService ?? throw new ArgumentNullException(nameof(migrationService)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task SaveCurrentSettingsAsRuleAsync( + ProcessModel process, + IReadOnlyList? currentCoreSelection, + ProcessMemoryPriority? currentMemoryPriority, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(process); + + var payload = new ProcessRuleCreationPayload + { + Priority = ShouldCaptureCurrentCpuPriority(process.Priority) + ? process.Priority + : null, + MemoryPriority = currentMemoryPriority, + }; + + var affinityPayload = currentCoreSelection == null + ? await this.BuildAffinityPayloadFromLegacyMaskAsync( + process.ProcessorAffinity, + "Saved current Process tab affinity", + cancellationToken).ConfigureAwait(false) + : await this.BuildAffinityPayloadFromCoreSelectionAsync( + currentCoreSelection, + "Saved current Process tab affinity", + cancellationToken).ConfigureAwait(false); + + if (!affinityPayload.Success) + { + return affinityPayload; + } + + if (affinityPayload.Payload != null) + { + payload = payload with + { + CpuSelection = affinityPayload.Payload.CpuSelection, + LegacyAffinityMask = affinityPayload.Payload.LegacyAffinityMask, + }; + } + + return await this.SaveRuleAsync(process, payload, cancellationToken).ConfigureAwait(false); + } + + public async Task SaveRuleAsync( + ProcessModel process, + ProcessRuleCreationPayload payload, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(process); + ArgumentNullException.ThrowIfNull(payload); + + var sanitizedPayload = SanitizePayload(payload); + if (!sanitizedPayload.Success) + { + return sanitizedPayload; + } + + payload = sanitizedPayload.Payload!; + if (!HasActionablePayload(payload)) + { + return ProcessRuleCreationResult.Failed("NoActionableRulePayload", NoCurrentSettingsMessage); + } + + var rules = (await this.ruleStore.LoadAsync().ConfigureAwait(false)).ToList(); + var existingIndex = FindExistingRuleIndex(rules, process); + var created = existingIndex < 0; + var now = DateTime.UtcNow; + var processName = string.IsNullOrWhiteSpace(process.Name) + ? "process" + : process.Name.Trim(); + var executablePath = string.IsNullOrWhiteSpace(process.ExecutablePath) + ? null + : process.ExecutablePath.Trim(); + var existing = created ? null : rules[existingIndex]; + var rule = new PersistentProcessRule + { + Id = existing?.Id ?? Guid.NewGuid().ToString("N"), + Name = $"{processName} rule", + IsEnabled = true, + ProcessName = processName, + ExecutablePath = executablePath, + CpuSelection = payload.CpuSelection, + LegacyAffinityMask = HasSelectionPayload(payload.CpuSelection) ? null : payload.LegacyAffinityMask, + Priority = payload.Priority, + MemoryPriority = payload.MemoryPriority, + ApplyAffinityOnStart = HasSelectionPayload(payload.CpuSelection) || payload.LegacyAffinityMask.HasValue, + ApplyPriorityOnStart = payload.Priority.HasValue, + ApplyMemoryPriorityOnStart = payload.MemoryPriority.HasValue, + CreatedAt = existing?.CreatedAt ?? now, + UpdatedAt = now, + Description = RuleDescription, + }; + + if (created) + { + rules.Add(rule); + } + else + { + rules[existingIndex] = rule; + } + + cancellationToken.ThrowIfCancellationRequested(); + await this.ruleStore.SaveAsync(rules).ConfigureAwait(false); + + return new ProcessRuleCreationResult + { + Success = true, + Created = created, + Updated = !created, + Rule = rule, + UserMessage = created + ? $"Saved rule for {processName}." + : $"Updated saved rule for {processName}.", + }; + } + + private static PayloadBuildResult BuildLegacyAffinityPayload(IReadOnlyList currentCoreSelection) + { + if (currentCoreSelection.Count == 0 || !currentCoreSelection.Any(selected => selected)) + { + return PayloadBuildResult.Empty(); + } + + if (currentCoreSelection.Count > 64) + { + return PayloadBuildResult.Failed("UnsafeLegacyAffinity", UnsafeAffinityMessage); + } + + long legacyMask = 0; + for (var bit = 0; bit < currentCoreSelection.Count; bit++) + { + if (currentCoreSelection[bit]) + { + legacyMask |= 1L << bit; + } + } + + return legacyMask == 0 + ? PayloadBuildResult.Empty() + : PayloadBuildResult.Succeeded(new ProcessRuleCreationPayload { LegacyAffinityMask = legacyMask }); + } + + private static PayloadSanitizationResult SanitizePayload(ProcessRuleCreationPayload payload) + { + if (payload.Priority.HasValue && ProcessPriorityGuardrails.IsBlocked(payload.Priority.Value)) + { + return PayloadSanitizationResult.Failed( + "RealtimePriorityBlocked", + ProcessOperationUserMessages.RealtimePriorityBlocked); + } + + var hasCpuSelection = HasSelectionPayload(payload.CpuSelection); + var legacyMask = hasCpuSelection ? null : payload.LegacyAffinityMask; + if (legacyMask.HasValue && legacyMask.Value == 0) + { + legacyMask = null; + } + + return PayloadSanitizationResult.Succeeded(payload with + { + CpuSelection = hasCpuSelection ? payload.CpuSelection : null, + LegacyAffinityMask = legacyMask, + }); + } + + private static bool ShouldCaptureCurrentCpuPriority(ProcessPriorityClass priority) => + priority is ProcessPriorityClass.Idle + or ProcessPriorityClass.BelowNormal + or ProcessPriorityClass.AboveNormal + or ProcessPriorityClass.High; + + private static bool HasActionablePayload(ProcessRuleCreationPayload payload) => + HasSelectionPayload(payload.CpuSelection) || + payload.LegacyAffinityMask.HasValue || + payload.Priority.HasValue || + payload.MemoryPriority.HasValue; + + private static bool HasSelectionPayload(CpuSelection? selection) => + selection != null && + (selection.CpuSetIds.Count > 0 || selection.LogicalProcessors.Count > 0); + + private static int FindExistingRuleIndex(IReadOnlyList rules, ProcessModel process) + { + var executablePath = string.IsNullOrWhiteSpace(process.ExecutablePath) + ? null + : process.ExecutablePath.Trim(); + if (!string.IsNullOrWhiteSpace(executablePath)) + { + for (var index = 0; index < rules.Count; index++) + { + if (string.Equals( + rules[index].ExecutablePath, + executablePath, + StringComparison.OrdinalIgnoreCase)) + { + return index; + } + } + + var pathlessNameMatch = FindProcessNameMatchIndex(rules, process, requirePathUnavailable: true); + return pathlessNameMatch; + } + + return FindProcessNameMatchIndex(rules, process, requirePathUnavailable: false); + } + + private static int FindProcessNameMatchIndex( + IReadOnlyList rules, + ProcessModel process, + bool requirePathUnavailable) + { + var processName = string.IsNullOrWhiteSpace(process.Name) + ? null + : process.Name.Trim(); + if (string.IsNullOrWhiteSpace(processName)) + { + return -1; + } + + for (var index = 0; index < rules.Count; index++) + { + if (requirePathUnavailable && !string.IsNullOrWhiteSpace(rules[index].ExecutablePath)) + { + continue; + } + + if (string.Equals(rules[index].ProcessName, processName, StringComparison.OrdinalIgnoreCase)) + { + return index; + } + } + + return -1; + } + + private async Task BuildAffinityPayloadFromCoreSelectionAsync( + IReadOnlyList currentCoreSelection, + string selectionReason, + CancellationToken cancellationToken) + { + if (currentCoreSelection.Count == 0 || !currentCoreSelection.Any(selected => selected)) + { + return PayloadBuildResult.Empty(); + } + + var selection = await this.TryMigrateCoreSelectionAsync( + currentCoreSelection, + selectionReason, + cancellationToken).ConfigureAwait(false); + if (selection != null) + { + return PayloadBuildResult.Succeeded(new ProcessRuleCreationPayload { CpuSelection = selection }); + } + + return BuildLegacyAffinityPayload(currentCoreSelection); + } + + private async Task BuildAffinityPayloadFromLegacyMaskAsync( + long legacyMask, + string selectionReason, + CancellationToken cancellationToken) + { + if (legacyMask == 0) + { + return PayloadBuildResult.Empty(); + } + + var selection = await this.TryMigrateLegacyMaskAsync( + legacyMask, + selectionReason, + cancellationToken).ConfigureAwait(false); + if (selection != null) + { + return PayloadBuildResult.Succeeded(new ProcessRuleCreationPayload { CpuSelection = selection }); + } + + return PayloadBuildResult.Succeeded(new ProcessRuleCreationPayload { LegacyAffinityMask = legacyMask }); + } + + private async Task TryMigrateCoreSelectionAsync( + IReadOnlyList currentCoreSelection, + string selectionReason, + CancellationToken cancellationToken) + { + if (this.topologyProvider == null) + { + return null; + } + + try + { + var topology = await this.topologyProvider.GetTopologySnapshotAsync(cancellationToken).ConfigureAwait(false); + var migrated = this.migrationService.MigrateFromLegacyCoreMask(currentCoreSelection, topology); + return WithSelectionReason(migrated.Selection, selectionReason); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogDebug(ex, "Could not migrate current core selection to CpuSelection for saved rule"); + return null; + } + } + + private async Task TryMigrateLegacyMaskAsync( + long legacyMask, + string selectionReason, + CancellationToken cancellationToken) + { + if (this.topologyProvider == null) + { + return null; + } + + try + { + var topology = await this.topologyProvider.GetTopologySnapshotAsync(cancellationToken).ConfigureAwait(false); + var migrated = this.migrationService.MigrateFromLegacyAffinityMask(legacyMask, topology); + return WithSelectionReason(migrated.Selection, selectionReason); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogDebug(ex, "Could not migrate current legacy affinity mask to CpuSelection for saved rule"); + return null; + } + } + + private static CpuSelection? WithSelectionReason(CpuSelection? selection, string selectionReason) + { + if (!HasSelectionPayload(selection)) + { + return null; + } + + return selection! with + { + Metadata = selection.Metadata with + { + SelectionReason = selectionReason, + }, + }; + } + + private sealed record PayloadBuildResult( + bool Success, + ProcessRuleCreationPayload? Payload, + string ErrorCode, + string UserMessage) + { + public static PayloadBuildResult Empty() => new(true, null, string.Empty, string.Empty); + + public static PayloadBuildResult Succeeded(ProcessRuleCreationPayload payload) => + new(true, payload, string.Empty, string.Empty); + + public static PayloadBuildResult Failed(string errorCode, string userMessage) => + new(false, null, errorCode, userMessage); + + public static implicit operator ProcessRuleCreationResult(PayloadBuildResult result) => + ProcessRuleCreationResult.Failed(result.ErrorCode, result.UserMessage); + } + + private sealed record PayloadSanitizationResult( + bool Success, + ProcessRuleCreationPayload? Payload, + string ErrorCode, + string UserMessage) + { + public static PayloadSanitizationResult Succeeded(ProcessRuleCreationPayload payload) => + new(true, payload, string.Empty, string.Empty); + + public static PayloadSanitizationResult Failed(string errorCode, string userMessage) => + new(false, null, errorCode, userMessage); + + public static implicit operator ProcessRuleCreationResult(PayloadSanitizationResult result) => + ProcessRuleCreationResult.Failed(result.ErrorCode, result.UserMessage); + } + } +} diff --git a/Services/ProcessService.cs b/Services/ProcessService.cs index 47ee701..fd661b7 100644 --- a/Services/ProcessService.cs +++ b/Services/ProcessService.cs @@ -1,1463 +1,1410 @@ -/* - * 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; - using System.Collections.ObjectModel; - using System.ComponentModel; - using System.Diagnostics; - using System.IO; - using System.Linq; - using System.Text; - using System.Text.Json; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - using ThreadPilot.Platforms.Windows; - - public class ProcessService : IProcessService - { - private static string LegacyProfilesDirectory => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Profiles"); - - private readonly ConcurrentDictionary cpuSamples = new(); - 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 readonly CpuSelectionAffinityApplier cpuSelectionAffinityApplier; - private readonly ICpuTopologyProvider? cpuTopologyProvider; - private readonly CpuSelectionMigrationService cpuSelectionMigrationService; - private readonly Func? loadProcessProfilePrioritySetter; - private readonly Func>? loadProcessProfileCpuSelectionSetter; - private readonly Func? loadProcessProfileLegacyAffinitySetter; - - private string ProfilesDirectory => this.profilesDirectoryProvider(); - - private bool useCpuSets = true; // Enable CPU Sets by default - - // Tracking for cleanup on exit - private readonly ConcurrentDictionary appliedMasks = new(); // ProcessId -> MaskId - private readonly ConcurrentDictionary originalPriorities = new(); // ProcessId -> OriginalPriority - - public ProcessService( - ILogger? logger = null, - ISecurityService? securityService = null, - Func? profilesDirectoryProvider = null, - IForegroundProcessService? foregroundProcessService = null, - IProcessClassifier? processClassifier = null, - IPassiveProcessErrorThrottle? passiveProcessErrorThrottle = null, - ICpuTopologyProvider? cpuTopologyProvider = null, - CpuSelectionMigrationService? cpuSelectionMigrationService = null) - : this( - logger, - securityService, - profilesDirectoryProvider, - foregroundProcessService, - processClassifier, - passiveProcessErrorThrottle, - cpuTopologyProvider, - cpuSelectionMigrationService, - loadProcessProfilePrioritySetter: null, - loadProcessProfileCpuSelectionSetter: null, - loadProcessProfileLegacyAffinitySetter: null) - { - } - - internal ProcessService( - ILogger? logger, - ISecurityService? securityService, - Func? profilesDirectoryProvider, - IForegroundProcessService? foregroundProcessService, - IProcessClassifier? processClassifier, - IPassiveProcessErrorThrottle? passiveProcessErrorThrottle, - ICpuTopologyProvider? cpuTopologyProvider, - CpuSelectionMigrationService? cpuSelectionMigrationService, - Func? loadProcessProfilePrioritySetter, - Func>? loadProcessProfileCpuSelectionSetter, - Func? loadProcessProfileLegacyAffinitySetter) - { - 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); - this.cpuTopologyProvider = cpuTopologyProvider; - this.cpuSelectionMigrationService = cpuSelectionMigrationService ?? new CpuSelectionMigrationService(); - this.loadProcessProfilePrioritySetter = loadProcessProfilePrioritySetter; - this.loadProcessProfileCpuSelectionSetter = loadProcessProfileCpuSelectionSetter; - this.loadProcessProfileLegacyAffinitySetter = loadProcessProfileLegacyAffinitySetter; - this.cpuSelectionAffinityApplier = new CpuSelectionAffinityApplier( - this.GetOrCreateCpuSetHandler, - this.ApplyLegacyProcessorAffinityDirectAsync, - logger ?? (ILogger)Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, - process => this.cpuSetHandlers.TryRemove(process.ProcessId, out _), - (process, success) => this.AuditProcessOperation("SetProcessAffinity", process.Name, success)); - - StoragePaths.EnsureAppDataDirectories(); - this.MigrateLegacyProfilesIfNeeded(); - - if (!Directory.Exists(this.ProfilesDirectory)) - { - Directory.CreateDirectory(this.ProfilesDirectory); - } - - this.logger?.LogInformation("ProcessService initialized with CPU Sets support enabled"); - } - - public async Task> GetProcessesAsync() - { - return await Task.Run(() => - { - var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); - var processes = Process.GetProcesses() - .Select(process => this.TryCreateProcessModel(process, foregroundProcessId)) - .OfType() - .OrderBy(p => p.Name); - - return new ObservableCollection(processes); - }).ConfigureAwait(false); - } - - private sealed class CpuSample - { - public CpuSample(TimeSpan totalProcessorTime, DateTime timestamp) - { - this.TotalProcessorTime = totalProcessorTime; - this.Timestamp = timestamp; - } - - public TimeSpan TotalProcessorTime { get; set; } - - public DateTime Timestamp { get; set; } - } - - private double CalculateCpuUsage(Process process) - { - try - { - var now = DateTime.UtcNow; - var totalProcessorTime = process.TotalProcessorTime; - - if (this.cpuSamples.TryGetValue(process.Id, out var previous)) - { - var cpuDeltaMs = (totalProcessorTime - previous.TotalProcessorTime).TotalMilliseconds; - var timeDeltaMs = (now - previous.Timestamp).TotalMilliseconds; - - // Ignore extremely small deltas to avoid noisy values - if (timeDeltaMs <= 0 || cpuDeltaMs < 0) - { - this.cpuSamples[process.Id] = new CpuSample(totalProcessorTime, now); - return 0; - } - - var usage = (cpuDeltaMs / (timeDeltaMs * Environment.ProcessorCount)) * 100.0; - usage = Math.Clamp(usage, 0, 100); - - this.cpuSamples[process.Id] = new CpuSample(totalProcessorTime, now); - return usage; - } - - this.cpuSamples[process.Id] = new CpuSample(totalProcessorTime, now); - return 0; // First sample cannot produce a rate - } - catch - { - return 0; - } - } - - 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; - } - 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}"; - } - - 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); - } - } - - if (!terminated) - { - try - { - model.MemoryUsage = process.PrivateMemorySize64; - } - 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) - { - 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; - } - } - } - - if (terminated) - { - this.CleanupProcessResources(model.ProcessId); - } - - this.ApplyProcessClassification(model, foregroundProcessId, accessDenied, terminated); - return model; - } - - public async Task SetProcessorAffinity(ProcessModel process, long affinityMask) - { - this.EnsureProcessOperationAllowed(process, "SetProcessAffinity"); - - await Task.Run(() => - { - try - { - if (affinityMask == 0) - { - throw new InvalidOperationException("Affinity mask cannot be zero."); - } - - // Try using CPU Sets first (Windows 10+) - if (this.useCpuSets) - { - bool cpuSetSuccess = this.TrySetAffinityViaCpuSets(process, affinityMask); - if (cpuSetSuccess) - { - this.logger?.LogInformation( - "Successfully applied CPU Sets affinity 0x{AffinityMask:X} to process {ProcessName} (PID: {ProcessId})", - affinityMask, process.Name, process.ProcessId); - - // Update the model with the new affinity - process.ProcessorAffinity = affinityMask; - this.AuditProcessOperation("SetProcessAffinity", process.Name, success: true); - return; - } - else - { - this.logger?.LogDebug( - "CPU Sets failed for process {ProcessName} (PID: {ProcessId}), falling back to classic ProcessorAffinity", - process.Name, process.ProcessId); - } - } - - // Fallback to classic ProcessorAffinity method - using var targetProcess = Process.GetProcessById(process.ProcessId); - - targetProcess.ProcessorAffinity = new IntPtr(affinityMask); - process.ProcessorAffinity = (long)targetProcess.ProcessorAffinity; - this.AuditProcessOperation("SetProcessAffinity", process.Name, success: true); - - this.logger?.LogInformation( - "Successfully applied classic ProcessorAffinity 0x{AffinityMask:X} to process {ProcessName} (PID: {ProcessId})", - affinityMask, process.Name, process.ProcessId); - } - catch (Win32Exception ex) when (ex.NativeErrorCode == 87) - { - this.AuditProcessOperation("SetProcessAffinity", process.Name, success: false); - throw new InvalidOperationException("Invalid affinity mask for this system.", ex); - } - catch (Win32Exception ex) when (ex.NativeErrorCode == 5) - { - this.AuditProcessOperation("SetProcessAffinity", process.Name, success: false); - throw new InvalidOperationException("Access denied while setting processor affinity. The process may be protected (e.g., anti-cheat).", ex); - } - catch (Exception ex) - { - this.AuditProcessOperation("SetProcessAffinity", process.Name, success: false); - throw new InvalidOperationException($"Failed to set processor affinity: {ex.Message}"); - } - }).ConfigureAwait(false); - } - - public async Task SetProcessorAffinity(ProcessModel process, CpuSelection selection) - { - if (process == null) - { - return AffinityApplyResult.Failed( - AffinityApplyErrorCodes.ProcessExited, - "Process is no longer running.", - "ProcessModel is null.", - failureReason: AffinityApplyFailureReason.ProcessTerminated); - } - - try - { - this.EnsureProcessOperationAllowed(process, "SetProcessAffinity"); - } - catch (Exception ex) when (AffinityApplyExceptionClassifier.IsAccessDenied(ex)) - { - this.AuditProcessOperation("SetProcessAffinity", process?.Name ?? string.Empty, success: false); - var antiCheatLikely = AffinityApplyExceptionClassifier.IsAntiCheatLikely(ex); - return AffinityApplyResult.Failed( - antiCheatLikely - ? AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely - : AffinityApplyErrorCodes.AccessDenied, - antiCheatLikely - ? CpuSelectionAffinityApplier.AntiCheatUserMessage - : CpuSelectionAffinityApplier.AccessDeniedUserMessage, - ex.Message, - isAccessDenied: true, - isAntiCheatLikely: antiCheatLikely, - verifiedMask: process?.ProcessorAffinity ?? 0, - failureReason: AffinityApplyFailureReason.AccessDenied); - } - catch (Exception ex) when (AffinityApplyExceptionClassifier.IsProcessExited(ex)) - { - this.AuditProcessOperation("SetProcessAffinity", process?.Name ?? string.Empty, success: false); - return AffinityApplyResult.Failed( - AffinityApplyErrorCodes.ProcessExited, - "Process is no longer running.", - ex.Message, - verifiedMask: process?.ProcessorAffinity ?? 0, - failureReason: AffinityApplyFailureReason.ProcessTerminated); - } - - return await this.cpuSelectionAffinityApplier.ApplyAsync(process, selection).ConfigureAwait(false); - } - - /// - /// Attempts to set process affinity using CPU Sets (Windows 10+ feature). - /// - private bool TrySetAffinityViaCpuSets(ProcessModel process, long affinityMask) - { - try - { - // Get or create CPU Set handler for this process - var handler = this.GetOrCreateCpuSetHandler(process); - - // Check if handler is valid - if (!handler.IsValid) - { - this.logger?.LogDebug( - "CPU Set handler for process {ProcessName} (PID: {ProcessId}) is invalid", - process.Name, process.ProcessId); - - // Remove invalid handler - this.cpuSetHandlers.TryRemove(process.ProcessId, out _); - return false; - } - - // Apply the CPU Set mask - bool success = handler.ApplyCpuSetMask(affinityMask, clearMask: false); - - if (!success) - { - // Remove failed handler so we can try again later if needed - this.cpuSetHandlers.TryRemove(process.ProcessId, out _); - } - - return success; - } - catch (Exception ex) - { - this.logger?.LogWarning(ex, "Exception while applying CPU Sets to process {ProcessName} (PID: {ProcessId})", - process.Name, process.ProcessId); - - // Remove handler on exception - this.cpuSetHandlers.TryRemove(process.ProcessId, out _); - return false; - } - } - - private IProcessCpuSetHandler GetOrCreateCpuSetHandler(ProcessModel process) => - this.cpuSetHandlers.GetOrAdd(process.ProcessId, pid => - { - return new ProcessCpuSetHandler((uint)pid, process.Name, this.logger); - }); - - private async Task ApplyLegacyProcessorAffinityDirectAsync(ProcessModel process, long affinityMask) - { - return await Task.Run(() => - { - try - { - using var targetProcess = Process.GetProcessById(process.ProcessId); - targetProcess.ProcessorAffinity = new IntPtr(affinityMask); - var verifiedMask = (long)targetProcess.ProcessorAffinity; - process.ProcessorAffinity = verifiedMask; - - this.AuditProcessOperation("SetProcessAffinity", process.Name, success: true); - this.logger?.LogInformation( - "Successfully applied classic ProcessorAffinity 0x{AffinityMask:X} to process {ProcessName} (PID: {ProcessId})", - affinityMask, - process.Name, - process.ProcessId); - - return verifiedMask; - } - catch - { - this.AuditProcessOperation("SetProcessAffinity", process.Name, success: false); - throw; - } - }).ConfigureAwait(false); - } - - public async Task SetProcessPriority(ProcessModel process, ProcessPriorityClass priority) - { - ArgumentNullException.ThrowIfNull(process); - if (ProcessPriorityGuardrails.IsBlocked(priority)) - { - this.logger?.LogWarning( - "Blocked priority change for process {ProcessName} (PID: {ProcessId}): {Message}", - process.Name, - process.ProcessId, - ProcessOperationUserMessages.RealtimePriorityBlocked); - this.AuditProcessOperation("SetProcessPriority", process.Name, success: false); - throw new InvalidOperationException(ProcessOperationUserMessages.RealtimePriorityBlocked); - } - - this.EnsureProcessOperationAllowed(process, "SetProcessPriority"); - - var warning = ProcessPriorityGuardrails.GetWarning(priority); - if (!string.IsNullOrWhiteSpace(warning)) - { - this.logger?.LogWarning( - "Applying High priority to process {ProcessName} (PID: {ProcessId}): {Message}", - process.Name, - process.ProcessId, - warning); - } - - await Task.Run(() => - { - try - { - using var targetProcess = Process.GetProcessById(process.ProcessId); - - targetProcess.PriorityClass = priority; - process.Priority = targetProcess.PriorityClass; - this.AuditProcessOperation("SetProcessPriority", process.Name, success: true); - } - catch (Win32Exception ex) when (ex.NativeErrorCode == 5) - { - this.AuditProcessOperation("SetProcessPriority", process.Name, success: false); - throw new InvalidOperationException(ProcessOperationUserMessages.AccessDenied, ex); - } - catch (UnauthorizedAccessException ex) - { - this.AuditProcessOperation("SetProcessPriority", process.Name, success: false); - throw new InvalidOperationException(ProcessOperationUserMessages.AccessDenied, ex); - } - catch (Exception ex) - { - this.AuditProcessOperation("SetProcessPriority", process.Name, success: false); - throw new InvalidOperationException($"Failed to set process priority: {ex.Message}"); - } - }).ConfigureAwait(false); - } - - public async Task SaveProcessProfile(string profileName, ProcessModel process) - { - var profile = new ProcessProfileSnapshot - { - ProcessName = process.Name, - Priority = process.Priority, - ProcessorAffinity = process.ProcessorAffinity, - }; - - var topology = await this.TryGetTopologySnapshotAsync().ConfigureAwait(false); - if (topology != null) - { - this.cpuSelectionMigrationService.PrepareProcessProfileForSave(profile, topology); - } - - var filePath = Path.Combine(this.ProfilesDirectory, $"{profileName}.json"); - var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); - await AtomicFileWriter.WriteAllTextAsync(filePath, json, Encoding.UTF8).ConfigureAwait(false); - return true; - } - - public async Task LoadProcessProfile(string profileName, ProcessModel process) - { - var filePath = Path.Combine(this.ProfilesDirectory, $"{profileName}.json"); - if (!File.Exists(filePath)) - { - return false; - } - - var content = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); - var profile = JsonSerializer.Deserialize(content, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - ReadCommentHandling = JsonCommentHandling.Skip, - AllowTrailingCommas = true, - }); - - if (profile == null) - { - return false; - } - - if (ProcessPriorityGuardrails.IsBlocked(profile.Priority)) - { - this.logger?.LogWarning( - "Profile {ProfileName} requested blocked priority {Priority} for process {ProcessName} (PID: {ProcessId}). {Message}", - profileName, - profile.Priority, - process.Name, - process.ProcessId, - ProcessOperationUserMessages.RealtimePriorityBlocked); - return false; - } - - await this.SetLoadProcessProfilePriorityAsync(process, profile.Priority).ConfigureAwait(false); - var topology = await this.TryGetTopologySnapshotAsync().ConfigureAwait(false); - if (topology != null) - { - this.cpuSelectionMigrationService.MigrateProcessProfile(profile, topology); - } - - if (profile.CpuSelection != null) - { - var result = await this.SetLoadProcessProfileCpuSelectionAsync(process, profile.CpuSelection).ConfigureAwait(false); - if (!result.Success) - { - this.logger?.LogWarning( - "Failed to apply CpuSelection profile {ProfileName} to process {ProcessName} (PID: {ProcessId}). ErrorCode: {ErrorCode}. Message: {Message}", - profileName, - process.Name, - process.ProcessId, - result.ErrorCode, - result.Message); - - return false; - } - } - else - { - await this.SetLoadProcessProfileLegacyAffinityAsync(process, profile.ProcessorAffinity).ConfigureAwait(false); - } - - return true; - } - - private Task SetLoadProcessProfilePriorityAsync(ProcessModel process, ProcessPriorityClass priority) => - this.loadProcessProfilePrioritySetter != null - ? this.loadProcessProfilePrioritySetter(process, priority) - : this.SetProcessPriority(process, priority); - - private Task SetLoadProcessProfileCpuSelectionAsync(ProcessModel process, CpuSelection selection) => - this.loadProcessProfileCpuSelectionSetter != null - ? this.loadProcessProfileCpuSelectionSetter(process, selection) - : this.SetProcessorAffinity(process, selection); - - private Task SetLoadProcessProfileLegacyAffinityAsync(ProcessModel process, long affinityMask) => - this.loadProcessProfileLegacyAffinitySetter != null - ? this.loadProcessProfileLegacyAffinitySetter(process, affinityMask) - : this.SetProcessorAffinity(process, affinityMask); - - private async Task TryGetTopologySnapshotAsync() - { - if (this.cpuTopologyProvider == null) - { - return null; - } - - try - { - return await this.cpuTopologyProvider.GetTopologySnapshotAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - this.logger?.LogWarning(ex, "Failed to get CPU topology snapshot for profile CpuSelection migration"); - return null; - } - } - - public async Task RefreshProcessInfo(ProcessModel process) - { - await Task.Run(() => - { - try - { - using var p = Process.GetProcessById(process.ProcessId); - - // Check if process has exited - if (p.HasExited) - { - throw new InvalidOperationException("Process has exited"); - } - - process.MemoryUsage = p.PrivateMemorySize64; - process.Priority = p.PriorityClass; - process.ProcessorAffinity = (long)p.ProcessorAffinity; - process.CpuUsage = this.CalculateCpuUsage(p); - - // Update window information - 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. - /// - private void CleanupProcessResources(int processId) - { - // Remove CPU samples - this.cpuSamples.TryRemove(processId, out _); - - // Dispose and remove CPU Set handler - if (this.cpuSetHandlers.TryRemove(processId, out var handler)) - { - try - { - handler.Dispose(); - this.logger?.LogDebug("Cleaned up CPU Set handler for process ID {ProcessId}", processId); - } - catch (Exception ex) - { - this.logger?.LogWarning(ex, "Error disposing CPU Set handler for process ID {ProcessId}", processId); - } - } - } - - /// - /// Enables or disables the use of Windows CPU Sets for affinity management. - /// - public void SetUseCpuSets(bool useCpuSets) - { - this.useCpuSets = useCpuSets; - this.logger?.LogInformation("CPU Sets usage {Status}", useCpuSets ? "enabled" : "disabled"); - } - - /// - /// Gets whether CPU Sets are currently enabled for affinity management. - /// - public bool GetUseCpuSets() - { - return this.useCpuSets; - } - - /// - /// Clears the CPU Set for a process (allows it to run on all cores). - /// - public async Task ClearProcessCpuSetAsync(ProcessModel process) - { - return await Task.Run(() => - { - try - { - if (!this.useCpuSets) - { - this.logger?.LogDebug("CPU Sets are disabled, cannot clear CPU Set for process {ProcessName}", process.Name); - return false; - } - - // Get or create CPU Set handler for this process - var handler = this.cpuSetHandlers.GetOrAdd(process.ProcessId, pid => - { - return new ProcessCpuSetHandler((uint)pid, process.Name, this.logger); - }); - - if (!handler.IsValid) - { - this.logger?.LogDebug( - "CPU Set handler for process {ProcessName} (PID: {ProcessId}) is invalid", - process.Name, process.ProcessId); - this.cpuSetHandlers.TryRemove(process.ProcessId, out _); - return false; - } - - // Clear the mask (set clearMask = true) - bool success = handler.ApplyCpuSetMask(0, clearMask: true); - - if (success) - { - this.logger?.LogInformation( - "Successfully cleared CPU Set for process {ProcessName} (PID: {ProcessId})", - process.Name, process.ProcessId); - } - else - { - this.cpuSetHandlers.TryRemove(process.ProcessId, out _); - } - - return success; - } - catch (Exception ex) - { - this.logger?.LogWarning(ex, "Exception while clearing CPU Set for process {ProcessName} (PID: {ProcessId})", - process.Name, process.ProcessId); - this.cpuSetHandlers.TryRemove(process.ProcessId, out _); - return false; - } - }).ConfigureAwait(false); - } - - public async Task GetProcessByIdAsync(int processId) - { - return await Task.Run(() => - { - try - { - var process = Process.GetProcessById(processId); - return this.CreateProcessModel(process); - } - catch - { - return null; - } - }).ConfigureAwait(false); - } - - public async Task> GetProcessesByNameAsync(string executableName) - { - return await Task.Run(() => - { - try - { - var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); - var processes = Process.GetProcessesByName(executableName) - .Select(process => this.TryCreateProcessModel(process, foregroundProcessId)) - .OfType(); - - return processes; - } - catch - { - return Enumerable.Empty(); - } - }).ConfigureAwait(false); - } - - public async Task IsProcessRunningAsync(string executableName) - { - var processes = await this.GetProcessesByNameAsync(executableName).ConfigureAwait(false); - return processes.Any(); - } - - public async Task> GetProcessesWithPathsAsync() - { - return await Task.Run(() => - { - var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); - var processes = Process.GetProcesses() - .Select(process => this.TryCreateProcessModel(process, foregroundProcessId)) - .OfType() - .Where(p => !string.IsNullOrEmpty(p.ExecutablePath)) - .OrderBy(p => p.Name); - - return processes; - }).ConfigureAwait(false); - } - - public async Task> GetActiveApplicationsAsync() - { - return await Task.Run(() => - { - var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); - var processes = Process.GetProcesses() - .Select(process => this.TryCreateProcessModel(process, foregroundProcessId)) - .OfType() - .Where(p => p.HasVisibleWindow) - .OrderBy(p => p.Name); - - return new ObservableCollection(processes); - }).ConfigureAwait(false); - } - - public async Task IsProcessStillRunning(ProcessModel process) - { - return await Task.Run(() => - { - try - { - var p = Process.GetProcessById(process.ProcessId); - return !p.HasExited; - } - catch (ArgumentException) - { - // Process with the specified ID does not exist - return false; - } - catch - { - // Any other exception means process is not accessible/running - return false; - } - }).ConfigureAwait(false); - } - - public async Task SetIdleServerStateAsync(ProcessModel process, bool enableIdleServer) - { - return await Task.Run(() => - { - try - { - // Get the actual process - var actualProcess = Process.GetProcessById(process.ProcessId); - - // Use Windows API to set execution state for the process - // This prevents the system from entering idle state while the process is running - if (!enableIdleServer) - { - // Disable idle server by setting ES_CONTINUOUS | ES_SYSTEM_REQUIRED - // This keeps the system awake while the process is running - var result = NativeMethods.SetThreadExecutionState( - NativeMethods.EXECUTION_STATE.ES_CONTINUOUS | - NativeMethods.EXECUTION_STATE.ES_SYSTEM_REQUIRED); - - return result != 0; - } - else - { - // Re-enable idle server by clearing the execution state - var result = NativeMethods.SetThreadExecutionState( - NativeMethods.EXECUTION_STATE.ES_CONTINUOUS); - - return result != 0; - } - } - catch (Exception) - { - return false; - } - }).ConfigureAwait(false); - } - - public async Task SetRegistryPriorityAsync(ProcessModel process, bool enable, ProcessPriorityClass priority) - { - ArgumentNullException.ThrowIfNull(process); - - if (enable && ProcessPriorityGuardrails.IsBlocked(priority)) - { - this.logger?.LogWarning( - "Registry priority request blocked for process {ProcessName} (PID: {ProcessId}). {Message}", - process.Name, - process.ProcessId, - ProcessOperationUserMessages.RealtimePriorityBlocked); - this.AuditProcessOperation("SetProcessPriority", process.Name, success: false); - return false; - } - - this.EnsureProcessOperationAllowed(process, "SetProcessPriority"); - - return await Task.Run(() => - { - try - { - using var key = Microsoft.Win32.Registry.LocalMachine.CreateSubKey( - @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\" + - Path.GetFileName(process.ExecutablePath)); - - if (enable) - { - // Convert ProcessPriorityClass to registry priority value - int priorityValue = priority switch - { - ProcessPriorityClass.Idle => 4, - ProcessPriorityClass.BelowNormal => 6, - ProcessPriorityClass.Normal => 8, - ProcessPriorityClass.AboveNormal => 10, - ProcessPriorityClass.High => 13, - ProcessPriorityClass.RealTime => 24, - _ => 8, // Default to Normal - }; - - key.SetValue("PriorityClass", priorityValue, Microsoft.Win32.RegistryValueKind.DWord); - } - else - { - // Remove the registry entry to disable enforcement - key.DeleteValue("PriorityClass", false); - } - - this.AuditProcessOperation("SetProcessPriority", process.Name, success: true); - - return true; - } - catch (Exception) - { - this.AuditProcessOperation("SetProcessPriority", process.Name, success: false); - return false; - } - }).ConfigureAwait(false); - } - - private void EnsureProcessOperationAllowed(ProcessModel process, string operation) - { - ArgumentNullException.ThrowIfNull(process); - - if (this.securityService == null) - { - return; - } - - var processName = this.GetProcessDisplayName(process); - if (!this.securityService.ValidateProcessOperation(processName, operation)) - { - this.AuditProcessOperation(operation, processName, success: false); - throw new UnauthorizedAccessException( - $"Operation '{operation}' is not permitted for process '{processName}'."); - } - - try - { - using var liveProcess = Process.GetProcessById(process.ProcessId); - if (this.securityService.IsProtected(liveProcess)) - { - this.AuditProcessOperation(operation, processName, success: false); - throw new UnauthorizedAccessException( - $"Operation '{operation}' is blocked for protected process '{processName}'."); - } - } - catch (ArgumentException) - { - // Process already exited; defer to operation code-paths for termination handling. - } - } - - private string GetProcessDisplayName(ProcessModel process) - { - if (!string.IsNullOrWhiteSpace(process.Name)) - { - return process.Name; - } - - return $"PID_{process.ProcessId}"; - } - - private void AuditProcessOperation(string operation, string processName, bool success) - { - if (this.securityService == null) - { - return; - } - - TaskSafety.FireAndForget( - this.securityService.AuditElevatedAction(operation, processName, success), - ex => this.logger?.LogDebug(ex, "Security audit logging failed for {Operation} on {ProcessName}", operation, processName)); - } - - /// - /// Registers that a mask has been applied to a process (for tracking cleanup on exit). - /// - public void TrackAppliedMask(int processId, string maskId) - { - this.appliedMasks[processId] = maskId; - this.logger?.LogDebug("Tracking mask {MaskId} applied to process {ProcessId}", maskId, processId); - } - - /// - /// Registers that a priority has been changed for a process (for tracking cleanup on exit). - /// - public void TrackPriorityChange(int processId, ProcessPriorityClass originalPriority) - { - // Only track if not already tracked (keep the original priority) - if (!this.originalPriorities.ContainsKey(processId)) - { - this.originalPriorities[processId] = originalPriority; - this.logger?.LogDebug("Tracking original priority {Priority} for process {ProcessId}", originalPriority, processId); - } - } - - /// - /// Unregisters tracking when a process exits. - /// - public void UntrackProcess(int processId) - { - this.appliedMasks.TryRemove(processId, out _); - this.originalPriorities.TryRemove(processId, out _); - this.CleanupProcessResources(processId); - this.logger?.LogDebug("Untracked process {ProcessId}", processId); - } - - /// - /// Clears all applied CPU masks/affinities from all tracked processes - /// Processes return to using all cores (used on application exit). - /// - public Task ClearAllAppliedMasksAsync() - { - this.logger?.LogInformation("Clearing all applied CPU masks from {Count} tracked processes", this.appliedMasks.Count); - - var processIds = this.appliedMasks.Keys.ToList(); - var clearedCount = 0; - var failedCount = 0; - - foreach (var processId in processIds) - { - try - { - // Check if process is still running - Process process; - try - { - process = Process.GetProcessById(processId); - if (process.HasExited) - { - this.appliedMasks.TryRemove(processId, out _); - continue; - } - } - catch (ArgumentException) - { - // Process no longer exists - this.appliedMasks.TryRemove(processId, out _); - continue; - } - - // Clear CPU Set if we have a handler - if (this.cpuSetHandlers.TryGetValue(processId, out var handler) && handler.IsValid) - { - handler.ApplyCpuSetMask(0, clearMask: true); - this.logger?.LogDebug( - "Cleared CPU Set for process {ProcessName} (PID: {ProcessId})", - process.ProcessName, processId); - } - - // Reset processor affinity to all cores - try - { - long allCoresMask = this.GetAllCoresAffinityMask(); - process.ProcessorAffinity = new IntPtr(allCoresMask); - this.logger?.LogDebug( - "Reset ProcessorAffinity for process {ProcessName} (PID: {ProcessId})", - process.ProcessName, processId); - } - catch (Exception ex) - { - this.logger?.LogWarning(ex, "Failed to reset ProcessorAffinity for process PID {ProcessId}", processId); - } - - this.appliedMasks.TryRemove(processId, out _); - clearedCount++; - } - catch (Exception ex) - { - this.logger?.LogWarning(ex, "Failed to clear mask for process PID {ProcessId}", processId); - failedCount++; - } - } - - this.logger?.LogInformation("Cleared CPU masks: {Cleared} succeeded, {Failed} failed", clearedCount, failedCount); - return Task.CompletedTask; - } - - /// - /// Resets all modified process priorities to Normal (or their original priority). - /// - public Task ResetAllProcessPrioritiesAsync() - { - this.logger?.LogInformation("Resetting priorities for {Count} tracked processes", this.originalPriorities.Count); - - var processIds = this.originalPriorities.Keys.ToList(); - var resetCount = 0; - var failedCount = 0; - - foreach (var processId in processIds) - { - try - { - // Check if process is still running - Process process; - try - { - process = Process.GetProcessById(processId); - if (process.HasExited) - { - this.originalPriorities.TryRemove(processId, out _); - continue; - } - } - catch (ArgumentException) - { - // Process no longer exists - this.originalPriorities.TryRemove(processId, out _); - continue; - } - - // Get original priority - if (this.originalPriorities.TryGetValue(processId, out var originalPriority)) - { - process.PriorityClass = originalPriority; - this.logger?.LogDebug( - "Reset priority for process {ProcessName} (PID: {ProcessId}) to {Priority}", - process.ProcessName, processId, originalPriority); - } - - this.originalPriorities.TryRemove(processId, out _); - resetCount++; - } - catch (Exception ex) - { - this.logger?.LogWarning(ex, "Failed to reset priority for process PID {ProcessId}", processId); - failedCount++; - } - } - - this.logger?.LogInformation("Reset priorities: {Reset} succeeded, {Failed} failed", resetCount, failedCount); - return Task.CompletedTask; - } - - /// - /// Gets an affinity mask with all cores enabled. - /// - private long GetAllCoresAffinityMask() - { - int coreCount = Environment.ProcessorCount; - return coreCount >= 64 ? -1L : (1L << coreCount) - 1; - } - - private void MigrateLegacyProfilesIfNeeded() - { - try - { - if (!Directory.Exists(LegacyProfilesDirectory)) - { - return; - } - - Directory.CreateDirectory(this.ProfilesDirectory); - var legacyFiles = Directory.GetFiles(LegacyProfilesDirectory, "*.json"); - foreach (var legacyFile in legacyFiles) - { - var destinationFile = Path.Combine(this.ProfilesDirectory, Path.GetFileName(legacyFile)); - if (!File.Exists(destinationFile)) - { - File.Copy(legacyFile, destinationFile); - } - } - - if (legacyFiles.Length > 0) - { - this.logger?.LogInformation("Migrated {Count} legacy profile files to AppData storage", legacyFiles.Length); - } - } - catch (Exception ex) - { - this.logger?.LogWarning(ex, "Failed to migrate legacy profile files"); - } - } - } - - /// - /// Native methods for Windows API calls. - /// - internal static class NativeMethods - { - [System.Runtime.InteropServices.DllImport("kernel32.dll", CharSet = System.Runtime.InteropServices.CharSet.Auto, SetLastError = true)] - public static extern uint SetThreadExecutionState(EXECUTION_STATE esFlags); - - [System.Flags] - public enum EXECUTION_STATE : uint - { - ES_AWAYMODE_REQUIRED = 0x00000040, - ES_CONTINUOUS = 0x80000000, - ES_DISPLAY_REQUIRED = 0x00000002, - ES_SYSTEM_REQUIRED = 0x00000001, - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Concurrent; + using System.Collections.ObjectModel; + using System.ComponentModel; + using System.Diagnostics; + using System.IO; + using System.Linq; + using System.Text; + using System.Text.Json; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + using ThreadPilot.Platforms.Windows; + + public class ProcessService : IProcessService + { + private static string LegacyProfilesDirectory => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Profiles"); + + private readonly ConcurrentDictionary cpuSamples = new(); + 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 readonly CpuSelectionAffinityApplier cpuSelectionAffinityApplier; + private readonly ICpuTopologyProvider? cpuTopologyProvider; + private readonly CpuSelectionMigrationService cpuSelectionMigrationService; + private readonly Func? loadProcessProfilePrioritySetter; + private readonly Func>? loadProcessProfileCpuSelectionSetter; + private readonly Func? loadProcessProfileLegacyAffinitySetter; + + private string ProfilesDirectory => this.profilesDirectoryProvider(); + + private bool useCpuSets = true; // Enable CPU Sets by default + + // Tracking for cleanup on exit + private readonly ConcurrentDictionary appliedMasks = new(); // ProcessId -> MaskId + private readonly ConcurrentDictionary originalPriorities = new(); // ProcessId -> OriginalPriority + + public ProcessService( + ILogger? logger = null, + ISecurityService? securityService = null, + Func? profilesDirectoryProvider = null, + IForegroundProcessService? foregroundProcessService = null, + IProcessClassifier? processClassifier = null, + IPassiveProcessErrorThrottle? passiveProcessErrorThrottle = null, + ICpuTopologyProvider? cpuTopologyProvider = null, + CpuSelectionMigrationService? cpuSelectionMigrationService = null) + : this( + logger, + securityService, + profilesDirectoryProvider, + foregroundProcessService, + processClassifier, + passiveProcessErrorThrottle, + cpuTopologyProvider, + cpuSelectionMigrationService, + loadProcessProfilePrioritySetter: null, + loadProcessProfileCpuSelectionSetter: null, + loadProcessProfileLegacyAffinitySetter: null) + { + } + + internal ProcessService( + ILogger? logger, + ISecurityService? securityService, + Func? profilesDirectoryProvider, + IForegroundProcessService? foregroundProcessService, + IProcessClassifier? processClassifier, + IPassiveProcessErrorThrottle? passiveProcessErrorThrottle, + ICpuTopologyProvider? cpuTopologyProvider, + CpuSelectionMigrationService? cpuSelectionMigrationService, + Func? loadProcessProfilePrioritySetter, + Func>? loadProcessProfileCpuSelectionSetter, + Func? loadProcessProfileLegacyAffinitySetter) + { + 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); + this.cpuTopologyProvider = cpuTopologyProvider; + this.cpuSelectionMigrationService = cpuSelectionMigrationService ?? new CpuSelectionMigrationService(); + this.loadProcessProfilePrioritySetter = loadProcessProfilePrioritySetter; + this.loadProcessProfileCpuSelectionSetter = loadProcessProfileCpuSelectionSetter; + this.loadProcessProfileLegacyAffinitySetter = loadProcessProfileLegacyAffinitySetter; + this.cpuSelectionAffinityApplier = new CpuSelectionAffinityApplier( + this.GetOrCreateCpuSetHandler, + this.ApplyLegacyProcessorAffinityDirectAsync, + logger ?? (ILogger)Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, + process => this.cpuSetHandlers.TryRemove(process.ProcessId, out _), + (process, success) => this.AuditProcessOperation("SetProcessAffinity", process.Name, success)); + + StoragePaths.EnsureAppDataDirectories(); + this.MigrateLegacyProfilesIfNeeded(); + + if (!Directory.Exists(this.ProfilesDirectory)) + { + Directory.CreateDirectory(this.ProfilesDirectory); + } + + this.logger?.LogInformation("ProcessService initialized with CPU Sets support enabled"); + } + + public async Task> GetProcessesAsync() + { + return await Task.Run(() => + { + var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); + var processes = Process.GetProcesses() + .Select(process => this.TryCreateProcessModel(process, foregroundProcessId)) + .OfType() + .OrderBy(p => p.Name); + + return new ObservableCollection(processes); + }).ConfigureAwait(false); + } + + private sealed class CpuSample + { + public CpuSample(TimeSpan totalProcessorTime, DateTime timestamp) + { + this.TotalProcessorTime = totalProcessorTime; + this.Timestamp = timestamp; + } + + public TimeSpan TotalProcessorTime { get; set; } + + public DateTime Timestamp { get; set; } + } + + private double CalculateCpuUsage(Process process) + { + try + { + var now = DateTime.UtcNow; + var totalProcessorTime = process.TotalProcessorTime; + + if (this.cpuSamples.TryGetValue(process.Id, out var previous)) + { + var cpuDeltaMs = (totalProcessorTime - previous.TotalProcessorTime).TotalMilliseconds; + var timeDeltaMs = (now - previous.Timestamp).TotalMilliseconds; + + // Ignore extremely small deltas to avoid noisy values + if (timeDeltaMs <= 0 || cpuDeltaMs < 0) + { + this.cpuSamples[process.Id] = new CpuSample(totalProcessorTime, now); + return 0; + } + + var usage = (cpuDeltaMs / (timeDeltaMs * Environment.ProcessorCount)) * 100.0; + usage = Math.Clamp(usage, 0, 100); + + this.cpuSamples[process.Id] = new CpuSample(totalProcessorTime, now); + return usage; + } + + this.cpuSamples[process.Id] = new CpuSample(totalProcessorTime, now); + return 0; // First sample cannot produce a rate + } + catch + { + return 0; + } + } + + 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; + } + 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}"; + } + + 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); + } + } + + if (!terminated) + { + try + { + model.MemoryUsage = process.PrivateMemorySize64; + } + 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) + { + 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; + } + } + } + + if (terminated) + { + this.CleanupProcessResources(model.ProcessId); + } + + this.ApplyProcessClassification(model, foregroundProcessId, accessDenied, terminated); + return model; + } + + public async Task SetProcessorAffinity(ProcessModel process, long affinityMask) + { + this.EnsureProcessOperationAllowed(process, "SetProcessAffinity"); + + await Task.Run(() => + { + try + { + if (affinityMask == 0) + { + throw new InvalidOperationException("Affinity mask cannot be zero."); + } + + // Try using CPU Sets first (Windows 10+) + if (this.useCpuSets) + { + bool cpuSetSuccess = this.TrySetAffinityViaCpuSets(process, affinityMask); + if (cpuSetSuccess) + { + this.logger?.LogInformation( + "Successfully applied CPU Sets affinity 0x{AffinityMask:X} to process {ProcessName} (PID: {ProcessId})", + affinityMask, process.Name, process.ProcessId); + + // Update the model with the new affinity + process.ProcessorAffinity = affinityMask; + this.AuditProcessOperation("SetProcessAffinity", process.Name, success: true); + return; + } + else + { + this.logger?.LogDebug( + "CPU Sets failed for process {ProcessName} (PID: {ProcessId}), falling back to classic ProcessorAffinity", + process.Name, process.ProcessId); + } + } + + // Fallback to classic ProcessorAffinity method + using var targetProcess = Process.GetProcessById(process.ProcessId); + + targetProcess.ProcessorAffinity = new IntPtr(affinityMask); + process.ProcessorAffinity = (long)targetProcess.ProcessorAffinity; + this.AuditProcessOperation("SetProcessAffinity", process.Name, success: true); + + this.logger?.LogInformation( + "Successfully applied classic ProcessorAffinity 0x{AffinityMask:X} to process {ProcessName} (PID: {ProcessId})", + affinityMask, process.Name, process.ProcessId); + } + catch (Win32Exception ex) when (ex.NativeErrorCode == 87) + { + this.AuditProcessOperation("SetProcessAffinity", process.Name, success: false); + throw new InvalidOperationException("Invalid affinity mask for this system.", ex); + } + catch (Win32Exception ex) when (ex.NativeErrorCode == 5) + { + this.AuditProcessOperation("SetProcessAffinity", process.Name, success: false); + throw new InvalidOperationException("Access denied while setting processor affinity. The process may be protected (e.g., anti-cheat).", ex); + } + catch (Exception ex) + { + this.AuditProcessOperation("SetProcessAffinity", process.Name, success: false); + throw new InvalidOperationException($"Failed to set processor affinity: {ex.Message}"); + } + }).ConfigureAwait(false); + } + + public async Task SetProcessorAffinity(ProcessModel process, CpuSelection selection) + { + if (process == null) + { + return AffinityApplyResult.Failed( + AffinityApplyErrorCodes.ProcessExited, + "Process is no longer running.", + "ProcessModel is null.", + failureReason: AffinityApplyFailureReason.ProcessTerminated); + } + + try + { + this.EnsureProcessOperationAllowed(process, "SetProcessAffinity"); + } + catch (Exception ex) when (AffinityApplyExceptionClassifier.IsAccessDenied(ex)) + { + this.AuditProcessOperation("SetProcessAffinity", process?.Name ?? string.Empty, success: false); + var antiCheatLikely = AffinityApplyExceptionClassifier.IsAntiCheatLikely(ex); + return AffinityApplyResult.Failed( + antiCheatLikely + ? AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely + : AffinityApplyErrorCodes.AccessDenied, + antiCheatLikely + ? CpuSelectionAffinityApplier.AntiCheatUserMessage + : CpuSelectionAffinityApplier.AccessDeniedUserMessage, + ex.Message, + isAccessDenied: true, + isAntiCheatLikely: antiCheatLikely, + verifiedMask: process?.ProcessorAffinity ?? 0, + failureReason: AffinityApplyFailureReason.AccessDenied); + } + catch (Exception ex) when (AffinityApplyExceptionClassifier.IsProcessExited(ex)) + { + this.AuditProcessOperation("SetProcessAffinity", process?.Name ?? string.Empty, success: false); + return AffinityApplyResult.Failed( + AffinityApplyErrorCodes.ProcessExited, + "Process is no longer running.", + ex.Message, + verifiedMask: process?.ProcessorAffinity ?? 0, + failureReason: AffinityApplyFailureReason.ProcessTerminated); + } + + return await this.cpuSelectionAffinityApplier.ApplyAsync(process, selection).ConfigureAwait(false); + } + + private bool TrySetAffinityViaCpuSets(ProcessModel process, long affinityMask) + { + try + { + // Get or create CPU Set handler for this process + var handler = this.GetOrCreateCpuSetHandler(process); + + // Check if handler is valid + if (!handler.IsValid) + { + this.logger?.LogDebug( + "CPU Set handler for process {ProcessName} (PID: {ProcessId}) is invalid", + process.Name, process.ProcessId); + + // Remove invalid handler + this.cpuSetHandlers.TryRemove(process.ProcessId, out _); + return false; + } + + // Apply the CPU Set mask + bool success = handler.ApplyCpuSetMask(affinityMask, clearMask: false); + + if (!success) + { + // Remove failed handler so we can try again later if needed + this.cpuSetHandlers.TryRemove(process.ProcessId, out _); + } + + return success; + } + catch (Exception ex) + { + this.logger?.LogWarning(ex, "Exception while applying CPU Sets to process {ProcessName} (PID: {ProcessId})", + process.Name, process.ProcessId); + + // Remove handler on exception + this.cpuSetHandlers.TryRemove(process.ProcessId, out _); + return false; + } + } + + private IProcessCpuSetHandler GetOrCreateCpuSetHandler(ProcessModel process) => + this.cpuSetHandlers.GetOrAdd(process.ProcessId, pid => + { + return new ProcessCpuSetHandler((uint)pid, process.Name, this.logger); + }); + + private async Task ApplyLegacyProcessorAffinityDirectAsync(ProcessModel process, long affinityMask) + { + return await Task.Run(() => + { + try + { + using var targetProcess = Process.GetProcessById(process.ProcessId); + targetProcess.ProcessorAffinity = new IntPtr(affinityMask); + var verifiedMask = (long)targetProcess.ProcessorAffinity; + process.ProcessorAffinity = verifiedMask; + + this.AuditProcessOperation("SetProcessAffinity", process.Name, success: true); + this.logger?.LogInformation( + "Successfully applied classic ProcessorAffinity 0x{AffinityMask:X} to process {ProcessName} (PID: {ProcessId})", + affinityMask, + process.Name, + process.ProcessId); + + return verifiedMask; + } + catch + { + this.AuditProcessOperation("SetProcessAffinity", process.Name, success: false); + throw; + } + }).ConfigureAwait(false); + } + + public async Task SetProcessPriority(ProcessModel process, ProcessPriorityClass priority) + { + ArgumentNullException.ThrowIfNull(process); + if (ProcessPriorityGuardrails.IsBlocked(priority)) + { + this.logger?.LogWarning( + "Blocked priority change for process {ProcessName} (PID: {ProcessId}): {Message}", + process.Name, + process.ProcessId, + ProcessOperationUserMessages.RealtimePriorityBlocked); + this.AuditProcessOperation("SetProcessPriority", process.Name, success: false); + throw new InvalidOperationException(ProcessOperationUserMessages.RealtimePriorityBlocked); + } + + this.EnsureProcessOperationAllowed(process, "SetProcessPriority"); + + var warning = ProcessPriorityGuardrails.GetWarning(priority); + if (!string.IsNullOrWhiteSpace(warning)) + { + this.logger?.LogWarning( + "Applying High priority to process {ProcessName} (PID: {ProcessId}): {Message}", + process.Name, + process.ProcessId, + warning); + } + + await Task.Run(() => + { + try + { + using var targetProcess = Process.GetProcessById(process.ProcessId); + + targetProcess.PriorityClass = priority; + process.Priority = targetProcess.PriorityClass; + this.AuditProcessOperation("SetProcessPriority", process.Name, success: true); + } + catch (Win32Exception ex) when (ex.NativeErrorCode == 5) + { + this.AuditProcessOperation("SetProcessPriority", process.Name, success: false); + throw new InvalidOperationException(ProcessOperationUserMessages.AccessDenied, ex); + } + catch (UnauthorizedAccessException ex) + { + this.AuditProcessOperation("SetProcessPriority", process.Name, success: false); + throw new InvalidOperationException(ProcessOperationUserMessages.AccessDenied, ex); + } + catch (Exception ex) + { + this.AuditProcessOperation("SetProcessPriority", process.Name, success: false); + throw new InvalidOperationException($"Failed to set process priority: {ex.Message}"); + } + }).ConfigureAwait(false); + } + + public async Task SaveProcessProfile(string profileName, ProcessModel process) + { + var profile = new ProcessProfileSnapshot + { + ProcessName = process.Name, + Priority = process.Priority, + ProcessorAffinity = process.ProcessorAffinity, + }; + + var topology = await this.TryGetTopologySnapshotAsync().ConfigureAwait(false); + if (topology != null) + { + this.cpuSelectionMigrationService.PrepareProcessProfileForSave(profile, topology); + } + + var filePath = Path.Combine(this.ProfilesDirectory, $"{profileName}.json"); + var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); + await AtomicFileWriter.WriteAllTextAsync(filePath, json, Encoding.UTF8).ConfigureAwait(false); + return true; + } + + public async Task LoadProcessProfile(string profileName, ProcessModel process) + { + var filePath = Path.Combine(this.ProfilesDirectory, $"{profileName}.json"); + if (!File.Exists(filePath)) + { + return false; + } + + var content = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); + var profile = JsonSerializer.Deserialize(content, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }); + + if (profile == null) + { + return false; + } + + if (ProcessPriorityGuardrails.IsBlocked(profile.Priority)) + { + this.logger?.LogWarning( + "Profile {ProfileName} requested blocked priority {Priority} for process {ProcessName} (PID: {ProcessId}). {Message}", + profileName, + profile.Priority, + process.Name, + process.ProcessId, + ProcessOperationUserMessages.RealtimePriorityBlocked); + return false; + } + + await this.SetLoadProcessProfilePriorityAsync(process, profile.Priority).ConfigureAwait(false); + var topology = await this.TryGetTopologySnapshotAsync().ConfigureAwait(false); + if (topology != null) + { + this.cpuSelectionMigrationService.MigrateProcessProfile(profile, topology); + } + + if (profile.CpuSelection != null) + { + var result = await this.SetLoadProcessProfileCpuSelectionAsync(process, profile.CpuSelection).ConfigureAwait(false); + if (!result.Success) + { + this.logger?.LogWarning( + "Failed to apply CpuSelection profile {ProfileName} to process {ProcessName} (PID: {ProcessId}). ErrorCode: {ErrorCode}. Message: {Message}", + profileName, + process.Name, + process.ProcessId, + result.ErrorCode, + result.Message); + + return false; + } + } + else + { + await this.SetLoadProcessProfileLegacyAffinityAsync(process, profile.ProcessorAffinity).ConfigureAwait(false); + } + + return true; + } + + private Task SetLoadProcessProfilePriorityAsync(ProcessModel process, ProcessPriorityClass priority) => + this.loadProcessProfilePrioritySetter != null + ? this.loadProcessProfilePrioritySetter(process, priority) + : this.SetProcessPriority(process, priority); + + private Task SetLoadProcessProfileCpuSelectionAsync(ProcessModel process, CpuSelection selection) => + this.loadProcessProfileCpuSelectionSetter != null + ? this.loadProcessProfileCpuSelectionSetter(process, selection) + : this.SetProcessorAffinity(process, selection); + + private Task SetLoadProcessProfileLegacyAffinityAsync(ProcessModel process, long affinityMask) => + this.loadProcessProfileLegacyAffinitySetter != null + ? this.loadProcessProfileLegacyAffinitySetter(process, affinityMask) + : this.SetProcessorAffinity(process, affinityMask); + + private async Task TryGetTopologySnapshotAsync() + { + if (this.cpuTopologyProvider == null) + { + return null; + } + + try + { + return await this.cpuTopologyProvider.GetTopologySnapshotAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this.logger?.LogWarning(ex, "Failed to get CPU topology snapshot for profile CpuSelection migration"); + return null; + } + } + + public async Task RefreshProcessInfo(ProcessModel process) + { + await Task.Run(() => + { + try + { + using var p = Process.GetProcessById(process.ProcessId); + + // Check if process has exited + if (p.HasExited) + { + throw new InvalidOperationException("Process has exited"); + } + + process.MemoryUsage = p.PrivateMemorySize64; + process.Priority = p.PriorityClass; + process.ProcessorAffinity = (long)p.ProcessorAffinity; + process.CpuUsage = this.CalculateCpuUsage(p); + + // Update window information + 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}"; + } + } + + private void CleanupProcessResources(int processId) + { + // Remove CPU samples + this.cpuSamples.TryRemove(processId, out _); + + // Dispose and remove CPU Set handler + if (this.cpuSetHandlers.TryRemove(processId, out var handler)) + { + try + { + handler.Dispose(); + this.logger?.LogDebug("Cleaned up CPU Set handler for process ID {ProcessId}", processId); + } + catch (Exception ex) + { + this.logger?.LogWarning(ex, "Error disposing CPU Set handler for process ID {ProcessId}", processId); + } + } + } + + public void SetUseCpuSets(bool useCpuSets) + { + this.useCpuSets = useCpuSets; + this.logger?.LogInformation("CPU Sets usage {Status}", useCpuSets ? "enabled" : "disabled"); + } + + public bool GetUseCpuSets() + { + return this.useCpuSets; + } + + public async Task ClearProcessCpuSetAsync(ProcessModel process) + { + return await Task.Run(() => + { + try + { + if (!this.useCpuSets) + { + this.logger?.LogDebug("CPU Sets are disabled, cannot clear CPU Set for process {ProcessName}", process.Name); + return false; + } + + // Get or create CPU Set handler for this process + var handler = this.cpuSetHandlers.GetOrAdd(process.ProcessId, pid => + { + return new ProcessCpuSetHandler((uint)pid, process.Name, this.logger); + }); + + if (!handler.IsValid) + { + this.logger?.LogDebug( + "CPU Set handler for process {ProcessName} (PID: {ProcessId}) is invalid", + process.Name, process.ProcessId); + this.cpuSetHandlers.TryRemove(process.ProcessId, out _); + return false; + } + + // Clear the mask (set clearMask = true) + bool success = handler.ApplyCpuSetMask(0, clearMask: true); + + if (success) + { + this.logger?.LogInformation( + "Successfully cleared CPU Set for process {ProcessName} (PID: {ProcessId})", + process.Name, process.ProcessId); + } + else + { + this.cpuSetHandlers.TryRemove(process.ProcessId, out _); + } + + return success; + } + catch (Exception ex) + { + this.logger?.LogWarning(ex, "Exception while clearing CPU Set for process {ProcessName} (PID: {ProcessId})", + process.Name, process.ProcessId); + this.cpuSetHandlers.TryRemove(process.ProcessId, out _); + return false; + } + }).ConfigureAwait(false); + } + + public async Task GetProcessByIdAsync(int processId) + { + return await Task.Run(() => + { + try + { + var process = Process.GetProcessById(processId); + return this.CreateProcessModel(process); + } + catch + { + return null; + } + }).ConfigureAwait(false); + } + + public async Task> GetProcessesByNameAsync(string executableName) + { + return await Task.Run(() => + { + try + { + var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); + var processes = Process.GetProcessesByName(executableName) + .Select(process => this.TryCreateProcessModel(process, foregroundProcessId)) + .OfType(); + + return processes; + } + catch + { + return Enumerable.Empty(); + } + }).ConfigureAwait(false); + } + + public async Task IsProcessRunningAsync(string executableName) + { + var processes = await this.GetProcessesByNameAsync(executableName).ConfigureAwait(false); + return processes.Any(); + } + + public async Task> GetProcessesWithPathsAsync() + { + return await Task.Run(() => + { + var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); + var processes = Process.GetProcesses() + .Select(process => this.TryCreateProcessModel(process, foregroundProcessId)) + .OfType() + .Where(p => !string.IsNullOrEmpty(p.ExecutablePath)) + .OrderBy(p => p.Name); + + return processes; + }).ConfigureAwait(false); + } + + public async Task> GetActiveApplicationsAsync() + { + return await Task.Run(() => + { + var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); + var processes = Process.GetProcesses() + .Select(process => this.TryCreateProcessModel(process, foregroundProcessId)) + .OfType() + .Where(p => p.HasVisibleWindow) + .OrderBy(p => p.Name); + + return new ObservableCollection(processes); + }).ConfigureAwait(false); + } + + public async Task IsProcessStillRunning(ProcessModel process) + { + return await Task.Run(() => + { + try + { + var p = Process.GetProcessById(process.ProcessId); + return !p.HasExited; + } + catch (ArgumentException) + { + // Process with the specified ID does not exist + return false; + } + catch + { + // Any other exception means process is not accessible/running + return false; + } + }).ConfigureAwait(false); + } + + public async Task SetIdleServerStateAsync(ProcessModel process, bool enableIdleServer) + { + return await Task.Run(() => + { + try + { + // Get the actual process + var actualProcess = Process.GetProcessById(process.ProcessId); + + // Use Windows API to set execution state for the process + // This prevents the system from entering idle state while the process is running + if (!enableIdleServer) + { + // Disable idle server by setting ES_CONTINUOUS | ES_SYSTEM_REQUIRED + // This keeps the system awake while the process is running + var result = NativeMethods.SetThreadExecutionState( + NativeMethods.EXECUTION_STATE.ES_CONTINUOUS | + NativeMethods.EXECUTION_STATE.ES_SYSTEM_REQUIRED); + + return result != 0; + } + else + { + // Re-enable idle server by clearing the execution state + var result = NativeMethods.SetThreadExecutionState( + NativeMethods.EXECUTION_STATE.ES_CONTINUOUS); + + return result != 0; + } + } + catch (Exception) + { + return false; + } + }).ConfigureAwait(false); + } + + public async Task SetRegistryPriorityAsync(ProcessModel process, bool enable, ProcessPriorityClass priority) + { + ArgumentNullException.ThrowIfNull(process); + + if (enable && ProcessPriorityGuardrails.IsBlocked(priority)) + { + this.logger?.LogWarning( + "Registry priority request blocked for process {ProcessName} (PID: {ProcessId}). {Message}", + process.Name, + process.ProcessId, + ProcessOperationUserMessages.RealtimePriorityBlocked); + this.AuditProcessOperation("SetProcessPriority", process.Name, success: false); + return false; + } + + this.EnsureProcessOperationAllowed(process, "SetProcessPriority"); + + return await Task.Run(() => + { + try + { + using var key = Microsoft.Win32.Registry.LocalMachine.CreateSubKey( + @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\" + + Path.GetFileName(process.ExecutablePath)); + + if (enable) + { + // Convert ProcessPriorityClass to registry priority value + int priorityValue = priority switch + { + ProcessPriorityClass.Idle => 4, + ProcessPriorityClass.BelowNormal => 6, + ProcessPriorityClass.Normal => 8, + ProcessPriorityClass.AboveNormal => 10, + ProcessPriorityClass.High => 13, + ProcessPriorityClass.RealTime => 24, + _ => 8, // Default to Normal + }; + + key.SetValue("PriorityClass", priorityValue, Microsoft.Win32.RegistryValueKind.DWord); + } + else + { + // Remove the registry entry to disable enforcement + key.DeleteValue("PriorityClass", false); + } + + this.AuditProcessOperation("SetProcessPriority", process.Name, success: true); + + return true; + } + catch (Exception) + { + this.AuditProcessOperation("SetProcessPriority", process.Name, success: false); + return false; + } + }).ConfigureAwait(false); + } + + private void EnsureProcessOperationAllowed(ProcessModel process, string operation) + { + ArgumentNullException.ThrowIfNull(process); + + if (this.securityService == null) + { + return; + } + + var processName = this.GetProcessDisplayName(process); + if (!this.securityService.ValidateProcessOperation(processName, operation)) + { + this.AuditProcessOperation(operation, processName, success: false); + throw new UnauthorizedAccessException( + $"Operation '{operation}' is not permitted for process '{processName}'."); + } + + try + { + using var liveProcess = Process.GetProcessById(process.ProcessId); + if (this.securityService.IsProtected(liveProcess)) + { + this.AuditProcessOperation(operation, processName, success: false); + throw new UnauthorizedAccessException( + $"Operation '{operation}' is blocked for protected process '{processName}'."); + } + } + catch (ArgumentException) + { + // Process already exited; defer to operation code-paths for termination handling. + } + } + + private string GetProcessDisplayName(ProcessModel process) + { + if (!string.IsNullOrWhiteSpace(process.Name)) + { + return process.Name; + } + + return $"PID_{process.ProcessId}"; + } + + private void AuditProcessOperation(string operation, string processName, bool success) + { + if (this.securityService == null) + { + return; + } + + TaskSafety.FireAndForget( + this.securityService.AuditElevatedAction(operation, processName, success), + ex => this.logger?.LogDebug(ex, "Security audit logging failed for {Operation} on {ProcessName}", operation, processName)); + } + + public void TrackAppliedMask(int processId, string maskId) + { + this.appliedMasks[processId] = maskId; + this.logger?.LogDebug("Tracking mask {MaskId} applied to process {ProcessId}", maskId, processId); + } + + public void TrackPriorityChange(int processId, ProcessPriorityClass originalPriority) + { + // Only track if not already tracked (keep the original priority) + if (!this.originalPriorities.ContainsKey(processId)) + { + this.originalPriorities[processId] = originalPriority; + this.logger?.LogDebug("Tracking original priority {Priority} for process {ProcessId}", originalPriority, processId); + } + } + + public void UntrackProcess(int processId) + { + this.appliedMasks.TryRemove(processId, out _); + this.originalPriorities.TryRemove(processId, out _); + this.CleanupProcessResources(processId); + this.logger?.LogDebug("Untracked process {ProcessId}", processId); + } + + public Task ClearAllAppliedMasksAsync() + { + this.logger?.LogInformation("Clearing all applied CPU masks from {Count} tracked processes", this.appliedMasks.Count); + + var processIds = this.appliedMasks.Keys.ToList(); + var clearedCount = 0; + var failedCount = 0; + + foreach (var processId in processIds) + { + try + { + // Check if process is still running + Process process; + try + { + process = Process.GetProcessById(processId); + if (process.HasExited) + { + this.appliedMasks.TryRemove(processId, out _); + continue; + } + } + catch (ArgumentException) + { + // Process no longer exists + this.appliedMasks.TryRemove(processId, out _); + continue; + } + + // Clear CPU Set if we have a handler + if (this.cpuSetHandlers.TryGetValue(processId, out var handler) && handler.IsValid) + { + handler.ApplyCpuSetMask(0, clearMask: true); + this.logger?.LogDebug( + "Cleared CPU Set for process {ProcessName} (PID: {ProcessId})", + process.ProcessName, processId); + } + + // Reset processor affinity to all cores + try + { + long allCoresMask = this.GetAllCoresAffinityMask(); + process.ProcessorAffinity = new IntPtr(allCoresMask); + this.logger?.LogDebug( + "Reset ProcessorAffinity for process {ProcessName} (PID: {ProcessId})", + process.ProcessName, processId); + } + catch (Exception ex) + { + this.logger?.LogWarning(ex, "Failed to reset ProcessorAffinity for process PID {ProcessId}", processId); + } + + this.appliedMasks.TryRemove(processId, out _); + clearedCount++; + } + catch (Exception ex) + { + this.logger?.LogWarning(ex, "Failed to clear mask for process PID {ProcessId}", processId); + failedCount++; + } + } + + this.logger?.LogInformation("Cleared CPU masks: {Cleared} succeeded, {Failed} failed", clearedCount, failedCount); + return Task.CompletedTask; + } + + public Task ResetAllProcessPrioritiesAsync() + { + this.logger?.LogInformation("Resetting priorities for {Count} tracked processes", this.originalPriorities.Count); + + var processIds = this.originalPriorities.Keys.ToList(); + var resetCount = 0; + var failedCount = 0; + + foreach (var processId in processIds) + { + try + { + // Check if process is still running + Process process; + try + { + process = Process.GetProcessById(processId); + if (process.HasExited) + { + this.originalPriorities.TryRemove(processId, out _); + continue; + } + } + catch (ArgumentException) + { + // Process no longer exists + this.originalPriorities.TryRemove(processId, out _); + continue; + } + + // Get original priority + if (this.originalPriorities.TryGetValue(processId, out var originalPriority)) + { + process.PriorityClass = originalPriority; + this.logger?.LogDebug( + "Reset priority for process {ProcessName} (PID: {ProcessId}) to {Priority}", + process.ProcessName, processId, originalPriority); + } + + this.originalPriorities.TryRemove(processId, out _); + resetCount++; + } + catch (Exception ex) + { + this.logger?.LogWarning(ex, "Failed to reset priority for process PID {ProcessId}", processId); + failedCount++; + } + } + + this.logger?.LogInformation("Reset priorities: {Reset} succeeded, {Failed} failed", resetCount, failedCount); + return Task.CompletedTask; + } + + private long GetAllCoresAffinityMask() + { + int coreCount = Environment.ProcessorCount; + return coreCount >= 64 ? -1L : (1L << coreCount) - 1; + } + + private void MigrateLegacyProfilesIfNeeded() + { + try + { + if (!Directory.Exists(LegacyProfilesDirectory)) + { + return; + } + + Directory.CreateDirectory(this.ProfilesDirectory); + var legacyFiles = Directory.GetFiles(LegacyProfilesDirectory, "*.json"); + foreach (var legacyFile in legacyFiles) + { + var destinationFile = Path.Combine(this.ProfilesDirectory, Path.GetFileName(legacyFile)); + if (!File.Exists(destinationFile)) + { + File.Copy(legacyFile, destinationFile); + } + } + + if (legacyFiles.Length > 0) + { + this.logger?.LogInformation("Migrated {Count} legacy profile files to AppData storage", legacyFiles.Length); + } + } + catch (Exception ex) + { + this.logger?.LogWarning(ex, "Failed to migrate legacy profile files"); + } + } + } + + internal static class NativeMethods + { + [System.Runtime.InteropServices.DllImport("kernel32.dll", CharSet = System.Runtime.InteropServices.CharSet.Auto, SetLastError = true)] + public static extern uint SetThreadExecutionState(EXECUTION_STATE esFlags); + + [System.Flags] + public enum EXECUTION_STATE : uint + { + ES_AWAYMODE_REQUIRED = 0x00000040, + ES_CONTINUOUS = 0x80000000, + ES_DISPLAY_REQUIRED = 0x00000002, + ES_SYSTEM_REQUIRED = 0x00000001, + } + } +} diff --git a/Services/RetryPolicyService.cs b/Services/RetryPolicyService.cs deleted file mode 100644 index 4e4204b..0000000 --- a/Services/RetryPolicyService.cs +++ /dev/null @@ -1,155 +0,0 @@ -/* - * 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.ComponentModel; - using System.IO; - using System.Management; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - - /// - /// Implementation of retry policy service with exponential backoff. - /// - public class RetryPolicyService : IRetryPolicyService - { - private readonly ILogger logger; - - public RetryPolicyService(ILogger logger) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task ExecuteAsync(Func> operation, RetryPolicy? policy = null) - { - policy ??= CreateDefaultPolicy(); - - Exception? lastException = null; - var delay = policy.InitialDelay; - - for (int attempt = 1; attempt <= policy.MaxAttempts; attempt++) - { - try - { - var result = await operation(); - if (attempt > 1) - { - this.logger.LogInformation("Operation succeeded on attempt {Attempt}", attempt); - } - return result; - } - catch (Exception ex) - { - lastException = ex; - - if (attempt == policy.MaxAttempts || (policy.ShouldRetry != null && !policy.ShouldRetry(ex))) - { - this.logger.LogError(ex, "Operation failed after {Attempts} attempts", attempt); - throw; - } - - this.logger.LogWarning(ex, "Operation failed on attempt {Attempt}, retrying in {Delay}ms", - attempt, delay.TotalMilliseconds); - - await Task.Delay(delay); - delay = TimeSpan.FromMilliseconds(Math.Min( - delay.TotalMilliseconds * policy.BackoffMultiplier, - policy.MaxDelay.TotalMilliseconds)); - } - } - - throw lastException ?? new InvalidOperationException("Retry loop completed without result"); - } - - public async Task ExecuteAsync(Func operation, RetryPolicy? policy = null) - { - await this.ExecuteAsync( - async () => - { - await operation(); - return true; // Dummy return value - }, policy); - } - - public RetryPolicy CreateProcessOperationPolicy() - { - return new RetryPolicy - { - MaxAttempts = 3, - InitialDelay = TimeSpan.FromMilliseconds(200), - MaxDelay = TimeSpan.FromSeconds(2), - BackoffMultiplier = 1.5, - ShouldRetry = ex => ex switch - { - Win32Exception win32Ex when win32Ex.NativeErrorCode == 5 => false, // Access denied - don't retry - InvalidOperationException invalidOp when invalidOp.Message.Contains("terminated") => false, // Process terminated - UnauthorizedAccessException => false, // Permission issue - don't retry - _ => true // Retry other exceptions - }, - }; - } - - public RetryPolicy CreateWmiOperationPolicy() - { - return new RetryPolicy - { - MaxAttempts = 4, - InitialDelay = TimeSpan.FromMilliseconds(500), - MaxDelay = TimeSpan.FromSeconds(5), - BackoffMultiplier = 2.0, - ShouldRetry = ex => ex switch - { - ManagementException mgmtEx when mgmtEx.ErrorCode == ManagementStatus.AccessDenied => false, - ManagementException mgmtEx when mgmtEx.ErrorCode == ManagementStatus.NotFound => false, - _ => true - }, - }; - } - - public RetryPolicy CreateFileOperationPolicy() - { - return new RetryPolicy - { - MaxAttempts = 3, - InitialDelay = TimeSpan.FromMilliseconds(100), - MaxDelay = TimeSpan.FromSeconds(1), - BackoffMultiplier = 2.0, - ShouldRetry = ex => ex switch - { - FileNotFoundException => false, // File doesn't exist - don't retry - DirectoryNotFoundException => false, // Directory doesn't exist - don't retry - UnauthorizedAccessException => false, // Permission issue - don't retry - IOException ioEx when ioEx.Message.Contains("being used by another process") => true, // File in use - retry - _ => true - }, - }; - } - - private static RetryPolicy CreateDefaultPolicy() - { - return new RetryPolicy - { - MaxAttempts = 3, - InitialDelay = TimeSpan.FromMilliseconds(100), - MaxDelay = TimeSpan.FromSeconds(2), - BackoffMultiplier = 2.0, - }; - } - } -} - diff --git a/Services/SecurityService.cs b/Services/SecurityService.cs index be8dcba..cd4f777 100644 --- a/Services/SecurityService.cs +++ b/Services/SecurityService.cs @@ -1,19 +1,3 @@ -/* - * 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; @@ -25,9 +9,6 @@ namespace ThreadPilot.Services using Microsoft.Extensions.Logging; using Microsoft.Win32.SafeHandles; - /// - /// Service for security validation and auditing of elevated operations. - /// public class SecurityService : ISecurityService { private readonly ILogger logger; diff --git a/Services/SelfResourceManagementService.cs b/Services/SelfResourceManagementService.cs index 6350e5b..f345753 100644 --- a/Services/SelfResourceManagementService.cs +++ b/Services/SelfResourceManagementService.cs @@ -1,221 +1,205 @@ -/* - * 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.ComponentModel; - using System.Diagnostics; - using Microsoft.Extensions.Logging; - - public static class SelfResourcePolicy - { - public static bool ShouldApplyLowImpactMode(bool isHidden, bool enableSelfLowImpactMode) - { - return isHidden && enableSelfLowImpactMode; - } - - public static bool ShouldLimitAffinity( - bool isHidden, - bool enableSelfLowImpactMode, - bool enableSelfAffinityLimit) - { - return ShouldApplyLowImpactMode(isHidden, enableSelfLowImpactMode) && enableSelfAffinityLimit; - } - - public static bool TryCreateLowImpactAffinityMask(int logicalProcessorCount, out long affinityMask) - { - affinityMask = 0; - - if (logicalProcessorCount <= 2 || logicalProcessorCount >= 64) - { - return false; - } - - var selectedProcessorCount = logicalProcessorCount >= 4 ? 2 : 1; - for (var index = logicalProcessorCount - selectedProcessorCount; index < logicalProcessorCount; index++) - { - affinityMask |= 1L << index; - } - - return affinityMask != 0; - } - } - - public sealed class SelfResourceManagementService : ISelfResourceManagementService - { - private readonly ILogger logger; - private readonly object syncRoot = new(); - private ProcessPriorityClass? originalPriority; - private IntPtr? originalAffinity; - private bool priorityLowered; - private bool affinityConstrained; - - public SelfResourceManagementService(ILogger logger) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public void ApplyLowImpactMode(bool limitAffinity) - { - lock (this.syncRoot) - { - this.TryLowerPriority(); - - if (limitAffinity) - { - this.TryConstrainAffinity(); - return; - } - - this.TryRestoreAffinity(); - } - } - - public void RestoreForegroundMode() - { - lock (this.syncRoot) - { - this.TryRestoreAffinity(); - this.TryRestorePriority(); - } - } - - private void TryLowerPriority() - { - if (this.priorityLowered) - { - return; - } - - try - { - using var process = Process.GetCurrentProcess(); - var currentPriority = process.PriorityClass; - if (!ShouldLowerPriority(currentPriority)) - { - return; - } - - this.originalPriority = currentPriority; - process.PriorityClass = ProcessPriorityClass.BelowNormal; - this.priorityLowered = true; - this.logger.LogDebug("Lowered ThreadPilot priority from {OriginalPriority} to BelowNormal", currentPriority); - } - catch (Win32Exception ex) - { - this.logger.LogDebug(ex, "Windows blocked ThreadPilot self-priority lowering"); - } - catch (Exception ex) - { - this.logger.LogDebug(ex, "Failed to lower ThreadPilot priority"); - } - } - - private void TryConstrainAffinity() - { - if (this.affinityConstrained) - { - return; - } - - try - { - if (!SelfResourcePolicy.TryCreateLowImpactAffinityMask(Environment.ProcessorCount, out var candidateMask)) - { - return; - } - - using var process = Process.GetCurrentProcess(); - var currentAffinity = process.ProcessorAffinity; - var effectiveMask = candidateMask & currentAffinity.ToInt64(); - if (effectiveMask == 0 || effectiveMask == currentAffinity.ToInt64()) - { - return; - } - - this.originalAffinity = currentAffinity; - process.ProcessorAffinity = new IntPtr(effectiveMask); - this.affinityConstrained = true; - this.logger.LogDebug("Constrained ThreadPilot affinity from 0x{OriginalAffinity:X} to 0x{LowImpactAffinity:X}", currentAffinity.ToInt64(), effectiveMask); - } - catch (Win32Exception ex) - { - this.logger.LogDebug(ex, "Windows blocked ThreadPilot self-affinity limiting"); - } - catch (Exception ex) - { - this.logger.LogDebug(ex, "Failed to constrain ThreadPilot affinity"); - } - } - - private void TryRestoreAffinity() - { - if (!this.affinityConstrained || this.originalAffinity == null) - { - return; - } - - try - { - using var process = Process.GetCurrentProcess(); - process.ProcessorAffinity = this.originalAffinity.Value; - this.logger.LogDebug("Restored ThreadPilot affinity to 0x{OriginalAffinity:X}", this.originalAffinity.Value.ToInt64()); - } - catch (Exception ex) - { - this.logger.LogDebug(ex, "Failed to restore ThreadPilot affinity"); - } - finally - { - this.affinityConstrained = false; - this.originalAffinity = null; - } - } - - private void TryRestorePriority() - { - if (!this.priorityLowered || this.originalPriority == null) - { - return; - } - - try - { - using var process = Process.GetCurrentProcess(); - process.PriorityClass = this.originalPriority.Value; - this.logger.LogDebug("Restored ThreadPilot priority to {OriginalPriority}", this.originalPriority.Value); - } - catch (Exception ex) - { - this.logger.LogDebug(ex, "Failed to restore ThreadPilot priority"); - } - finally - { - this.priorityLowered = false; - this.originalPriority = null; - } - } - - private static bool ShouldLowerPriority(ProcessPriorityClass priority) - { - return priority is ProcessPriorityClass.Normal - or ProcessPriorityClass.AboveNormal - or ProcessPriorityClass.High - or ProcessPriorityClass.RealTime; - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.ComponentModel; + using System.Diagnostics; + using Microsoft.Extensions.Logging; + + public static class SelfResourcePolicy + { + public static bool ShouldApplyLowImpactMode(bool isHidden, bool enableSelfLowImpactMode) + { + return isHidden && enableSelfLowImpactMode; + } + + public static bool ShouldLimitAffinity( + bool isHidden, + bool enableSelfLowImpactMode, + bool enableSelfAffinityLimit) + { + return ShouldApplyLowImpactMode(isHidden, enableSelfLowImpactMode) && enableSelfAffinityLimit; + } + + public static bool TryCreateLowImpactAffinityMask(int logicalProcessorCount, out long affinityMask) + { + affinityMask = 0; + + if (logicalProcessorCount <= 2 || logicalProcessorCount >= 64) + { + return false; + } + + var selectedProcessorCount = logicalProcessorCount >= 4 ? 2 : 1; + for (var index = logicalProcessorCount - selectedProcessorCount; index < logicalProcessorCount; index++) + { + affinityMask |= 1L << index; + } + + return affinityMask != 0; + } + } + + public sealed class SelfResourceManagementService : ISelfResourceManagementService + { + private readonly ILogger logger; + private readonly object syncRoot = new(); + private ProcessPriorityClass? originalPriority; + private IntPtr? originalAffinity; + private bool priorityLowered; + private bool affinityConstrained; + + public SelfResourceManagementService(ILogger logger) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void ApplyLowImpactMode(bool limitAffinity) + { + lock (this.syncRoot) + { + this.TryLowerPriority(); + + if (limitAffinity) + { + this.TryConstrainAffinity(); + return; + } + + this.TryRestoreAffinity(); + } + } + + public void RestoreForegroundMode() + { + lock (this.syncRoot) + { + this.TryRestoreAffinity(); + this.TryRestorePriority(); + } + } + + private void TryLowerPriority() + { + if (this.priorityLowered) + { + return; + } + + try + { + using var process = Process.GetCurrentProcess(); + var currentPriority = process.PriorityClass; + if (!ShouldLowerPriority(currentPriority)) + { + return; + } + + this.originalPriority = currentPriority; + process.PriorityClass = ProcessPriorityClass.BelowNormal; + this.priorityLowered = true; + this.logger.LogDebug("Lowered ThreadPilot priority from {OriginalPriority} to BelowNormal", currentPriority); + } + catch (Win32Exception ex) + { + this.logger.LogDebug(ex, "Windows blocked ThreadPilot self-priority lowering"); + } + catch (Exception ex) + { + this.logger.LogDebug(ex, "Failed to lower ThreadPilot priority"); + } + } + + private void TryConstrainAffinity() + { + if (this.affinityConstrained) + { + return; + } + + try + { + if (!SelfResourcePolicy.TryCreateLowImpactAffinityMask(Environment.ProcessorCount, out var candidateMask)) + { + return; + } + + using var process = Process.GetCurrentProcess(); + var currentAffinity = process.ProcessorAffinity; + var effectiveMask = candidateMask & currentAffinity.ToInt64(); + if (effectiveMask == 0 || effectiveMask == currentAffinity.ToInt64()) + { + return; + } + + this.originalAffinity = currentAffinity; + process.ProcessorAffinity = new IntPtr(effectiveMask); + this.affinityConstrained = true; + this.logger.LogDebug("Constrained ThreadPilot affinity from 0x{OriginalAffinity:X} to 0x{LowImpactAffinity:X}", currentAffinity.ToInt64(), effectiveMask); + } + catch (Win32Exception ex) + { + this.logger.LogDebug(ex, "Windows blocked ThreadPilot self-affinity limiting"); + } + catch (Exception ex) + { + this.logger.LogDebug(ex, "Failed to constrain ThreadPilot affinity"); + } + } + + private void TryRestoreAffinity() + { + if (!this.affinityConstrained || this.originalAffinity == null) + { + return; + } + + try + { + using var process = Process.GetCurrentProcess(); + process.ProcessorAffinity = this.originalAffinity.Value; + this.logger.LogDebug("Restored ThreadPilot affinity to 0x{OriginalAffinity:X}", this.originalAffinity.Value.ToInt64()); + } + catch (Exception ex) + { + this.logger.LogDebug(ex, "Failed to restore ThreadPilot affinity"); + } + finally + { + this.affinityConstrained = false; + this.originalAffinity = null; + } + } + + private void TryRestorePriority() + { + if (!this.priorityLowered || this.originalPriority == null) + { + return; + } + + try + { + using var process = Process.GetCurrentProcess(); + process.PriorityClass = this.originalPriority.Value; + this.logger.LogDebug("Restored ThreadPilot priority to {OriginalPriority}", this.originalPriority.Value); + } + catch (Exception ex) + { + this.logger.LogDebug(ex, "Failed to restore ThreadPilot priority"); + } + finally + { + this.priorityLowered = false; + this.originalPriority = null; + } + } + + private static bool ShouldLowerPriority(ProcessPriorityClass priority) + { + return priority is ProcessPriorityClass.Normal + or ProcessPriorityClass.AboveNormal + or ProcessPriorityClass.High + or ProcessPriorityClass.RealTime; + } + } +} diff --git a/Services/SemanticVersion.cs b/Services/SemanticVersion.cs index cf8dd64..d006f2a 100644 --- a/Services/SemanticVersion.cs +++ b/Services/SemanticVersion.cs @@ -1,104 +1,104 @@ -/* - * ThreadPilot - semantic version parsing for updater decisions. - */ -namespace ThreadPilot.Services -{ - using System; - using System.Globalization; - - public readonly record struct SemanticVersion(int Major, int Minor, int Patch, string? Prerelease = null) - : IComparable - { - public bool IsPrerelease => !string.IsNullOrWhiteSpace(this.Prerelease); - - public static bool TryParse(string? value, out SemanticVersion version) - { - version = default; - if (string.IsNullOrWhiteSpace(value)) - { - return false; - } - - var sanitized = value.Trim(); - if (sanitized.StartsWith("v", StringComparison.OrdinalIgnoreCase)) - { - sanitized = sanitized[1..]; - } - - sanitized = sanitized.Split('+')[0]; - var versionAndPrerelease = sanitized.Split('-', 2); - var parts = versionAndPrerelease[0].Split('.'); - if (parts.Length < 2 || parts.Length > 3) - { - return false; - } - - if (!int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major) || - !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor)) - { - return false; - } - - var patch = 0; - if (parts.Length == 3 && - !int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out patch)) - { - return false; - } - - version = new SemanticVersion( - major, - minor, - patch, - versionAndPrerelease.Length == 2 ? versionAndPrerelease[1] : null); - return true; - } - - public int CompareTo(SemanticVersion other) - { - var major = this.Major.CompareTo(other.Major); - if (major != 0) - { - return major; - } - - var minor = this.Minor.CompareTo(other.Minor); - if (minor != 0) - { - return minor; - } - - var patch = this.Patch.CompareTo(other.Patch); - if (patch != 0) - { - return patch; - } - - if (!this.IsPrerelease && other.IsPrerelease) - { - return 1; - } - - if (this.IsPrerelease && !other.IsPrerelease) - { - return -1; - } - - return string.Compare(this.Prerelease, other.Prerelease, StringComparison.OrdinalIgnoreCase); - } - - public override string ToString() - { - var version = $"{this.Major}.{this.Minor}.{this.Patch}"; - return this.IsPrerelease ? $"{version}-{this.Prerelease}" : version; - } - - public static bool operator >(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) > 0; - - public static bool operator <(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) < 0; - - public static bool operator >=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) >= 0; - - public static bool operator <=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) <= 0; - } -} +/* + * ThreadPilot - semantic version parsing for updater decisions. + */ +namespace ThreadPilot.Services +{ + using System; + using System.Globalization; + + public readonly record struct SemanticVersion(int Major, int Minor, int Patch, string? Prerelease = null) + : IComparable + { + public bool IsPrerelease => !string.IsNullOrWhiteSpace(this.Prerelease); + + public static bool TryParse(string? value, out SemanticVersion version) + { + version = default; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var sanitized = value.Trim(); + if (sanitized.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + { + sanitized = sanitized[1..]; + } + + sanitized = sanitized.Split('+')[0]; + var versionAndPrerelease = sanitized.Split('-', 2); + var parts = versionAndPrerelease[0].Split('.'); + if (parts.Length < 2 || parts.Length > 3) + { + return false; + } + + if (!int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major) || + !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor)) + { + return false; + } + + var patch = 0; + if (parts.Length == 3 && + !int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out patch)) + { + return false; + } + + version = new SemanticVersion( + major, + minor, + patch, + versionAndPrerelease.Length == 2 ? versionAndPrerelease[1] : null); + return true; + } + + public int CompareTo(SemanticVersion other) + { + var major = this.Major.CompareTo(other.Major); + if (major != 0) + { + return major; + } + + var minor = this.Minor.CompareTo(other.Minor); + if (minor != 0) + { + return minor; + } + + var patch = this.Patch.CompareTo(other.Patch); + if (patch != 0) + { + return patch; + } + + if (!this.IsPrerelease && other.IsPrerelease) + { + return 1; + } + + if (this.IsPrerelease && !other.IsPrerelease) + { + return -1; + } + + return string.Compare(this.Prerelease, other.Prerelease, StringComparison.OrdinalIgnoreCase); + } + + public override string ToString() + { + var version = $"{this.Major}.{this.Minor}.{this.Patch}"; + return this.IsPrerelease ? $"{version}-{this.Prerelease}" : version; + } + + public static bool operator >(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) > 0; + + public static bool operator <(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) < 0; + + public static bool operator >=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) >= 0; + + public static bool operator <=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) <= 0; + } +} diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs index 45a0a46..5fb5c07 100644 --- a/Services/ServiceConfiguration.cs +++ b/Services/ServiceConfiguration.cs @@ -1,273 +1,212 @@ -/* - * 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.Net.Http; - using System.Net.Http.Headers; - using Microsoft.Extensions.DependencyInjection; - using Microsoft.Extensions.Logging; - using ThreadPilot.Platforms.Windows; - using ThreadPilot.Services.Abstractions; - using ThreadPilot.ViewModels; - - /// - /// Centralized service configuration for dependency injection. - /// - public static class ServiceConfiguration - { - /// - /// Configure all application services. - /// - public static IServiceCollection ConfigureApplicationServices(this IServiceCollection services) - { - // Configure service infrastructure - services.ConfigureServiceInfrastructure(); - - // Configure core system services - services.ConfigureCoreSystemServices(); - - // Configure process management services - services.ConfigureProcessManagementServices(); - - // Configure application services - services.ConfigureApplicationLevelServices(); - - // Configure presentation layer - services.ConfigurePresentationLayer(); - - return services; - } - - /// - /// Configure service infrastructure (logging, factories, etc.) - /// - private static IServiceCollection ConfigureServiceInfrastructure(this IServiceCollection services) - { - // Logging infrastructure - services.AddLogging(builder => - { - builder.AddConsole(); - builder.SetMinimumLevel(LogLevel.Information); - }); - - // Enhanced logging service - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(sp => - { - var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("ThreadPilot", "1.0")); - httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); - return httpClient; - }); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // Memory caching for performance - PERFORMANCE IMPROVEMENT - services.AddMemoryCache(); - - // Service lifecycle management - PERFORMANCE IMPROVEMENT - services.AddSingleton(); - services.AddSingleton(); - - // Error recovery and retry policies - RELIABILITY IMPROVEMENT - services.AddSingleton(); - - // Service factory for advanced service management - services.AddSingleton(); - - return services; - } - - /// - /// Configure core system services that interact directly with the OS. - /// - 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(ProcessMemoryPriorityNativeApi.Instance); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - 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 - services.AddSingleton(sp => - { - var logger = sp.GetRequiredService>(); - var cpuTopologyService = sp.GetRequiredService(); - var cpuTopologyProvider = sp.GetRequiredService(); - var migrationService = sp.GetRequiredService(); - return new CoreMaskService(logger, cpuTopologyService, sp, cpuTopologyProvider, migrationService); - }); - - return services; - } - - /// - /// Configure process monitoring and management services. - /// - private static IServiceCollection ConfigureProcessManagementServices(this IServiceCollection services) - { - // Process monitoring services - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // Performance monitoring services - services.AddSingleton(); - services.AddSingleton(sp => new Lazy( - () => sp.GetRequiredService())); - services.AddSingleton(); - - return services; - } - - /// - /// Configure application-level services (settings, notifications, etc.) - /// - private static IServiceCollection ConfigureApplicationLevelServices(this IServiceCollection services) - { - // Application configuration and settings - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // User interface services - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // System integration services - services.AddSingleton(); - - // System optimization services - services.AddSingleton(); - - // Security and elevation services - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // System tweaks service - services.AddSingleton(); - - // Keyboard shortcut service - services.AddSingleton(); - - return services; - } - - /// - /// Configure presentation layer (ViewModels and Views). - /// - private static IServiceCollection ConfigurePresentationLayer(this IServiceCollection services) - { - // ViewModel factory for centralized ViewModel management - services.AddViewModelFactory(); - - // ViewModels - ProcessViewModel as Singleton to share state across views, others as Transient - services.AddSingleton(); - services.AddSingleton(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(sp => new Lazy( - () => sp.GetRequiredService())); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - - // Views - Transient for proper lifecycle management - services.AddTransient(); - - return services; - } - - /// - /// Validate service configuration. - /// - public static void ValidateServiceConfiguration(IServiceProvider serviceProvider) - { - var loggerFactory = serviceProvider.GetRequiredService(); - var logger = loggerFactory.CreateLogger("ServiceConfiguration"); - - try - { - // Validate core services can be resolved - var coreServices = new[] - { - typeof(IProcessService), - typeof(IPowerPlanService), - typeof(ICpuTopologyService), - typeof(IEnhancedLoggingService), - typeof(IActivityAuditService), - typeof(IApplicationSettingsService), - }; - - foreach (var serviceType in coreServices) - { - var service = serviceProvider.GetRequiredService(serviceType); - if (service == null) - { - throw new InvalidOperationException($"Failed to resolve required service: {serviceType.Name}"); - } - } - - logger.LogInformation("Service configuration validation completed successfully"); - } - catch (Exception ex) - { - logger.LogError(ex, "Service configuration validation failed"); - throw; - } - } - } -} +namespace ThreadPilot.Services +{ + using System.Net.Http; + using System.Net.Http.Headers; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + using ThreadPilot.Platforms.Windows; + using ThreadPilot.Services.Abstractions; + using ThreadPilot.ViewModels; + + public static class ServiceConfiguration + { + public static IServiceCollection ConfigureApplicationServices(this IServiceCollection services) + { + // Configure service infrastructure + services.ConfigureServiceInfrastructure(); + + // Configure core system services + services.ConfigureCoreSystemServices(); + + // Configure process management services + services.ConfigureProcessManagementServices(); + + // Configure application services + services.ConfigureApplicationLevelServices(); + + // Configure presentation layer + services.ConfigurePresentationLayer(); + + return services; + } + + private static IServiceCollection ConfigureServiceInfrastructure(this IServiceCollection services) + { + // Logging infrastructure + services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Information)); + + // Enhanced logging service + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => + { + var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("ThreadPilot", "1.0")); + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + return httpClient; + }); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } + + 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(ProcessMemoryPriorityNativeApi.Instance); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + 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 + services.AddSingleton(sp => + { + var logger = sp.GetRequiredService>(); + var cpuTopologyService = sp.GetRequiredService(); + var cpuTopologyProvider = sp.GetRequiredService(); + var migrationService = sp.GetRequiredService(); + return new CoreMaskService(logger, cpuTopologyService, sp, cpuTopologyProvider, migrationService); + }); + + return services; + } + + private static IServiceCollection ConfigureProcessManagementServices(this IServiceCollection services) + { + // Process monitoring services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Performance monitoring services + services.AddSingleton(); + services.AddSingleton(sp => new Lazy( + () => sp.GetRequiredService())); + services.AddSingleton(); + + return services; + } + + private static IServiceCollection ConfigureApplicationLevelServices(this IServiceCollection services) + { + // Application configuration and settings + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // User interface services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // System integration services + services.AddSingleton(); + + // System optimization services + services.AddSingleton(); + + // Security and elevation services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // System tweaks service + services.AddSingleton(); + + // Keyboard shortcut service + services.AddSingleton(); + + return services; + } + + private static IServiceCollection ConfigurePresentationLayer(this IServiceCollection services) + { + // ViewModels - ProcessViewModel as Singleton to share state across views, others as Transient + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(sp => new Lazy( + () => sp.GetRequiredService())); + services.AddTransient(); + services.AddTransient(); + + // Views - Transient for proper lifecycle management + services.AddTransient(); + + return services; + } + + public static void ValidateServiceConfiguration(IServiceProvider serviceProvider) + { + var loggerFactory = serviceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger("ServiceConfiguration"); + + try + { + // Validate core services can be resolved + var coreServices = new[] + { + typeof(IProcessService), + typeof(IPowerPlanService), + typeof(ICpuTopologyService), + typeof(IEnhancedLoggingService), + typeof(IActivityAuditService), + typeof(IApplicationSettingsService), + }; + + foreach (var serviceType in coreServices) + { + var service = serviceProvider.GetRequiredService(serviceType); + if (service == null) + { + throw new InvalidOperationException($"Failed to resolve required service: {serviceType.Name}"); + } + } + + logger.LogInformation("Service configuration validation completed successfully"); + } + catch (Exception ex) + { + logger.LogError(ex, "Service configuration validation failed"); + throw; + } + } + } +} diff --git a/Services/ServiceDisposalCoordinator.cs b/Services/ServiceDisposalCoordinator.cs deleted file mode 100644 index e4de580..0000000 --- a/Services/ServiceDisposalCoordinator.cs +++ /dev/null @@ -1,197 +0,0 @@ -/* - * 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.Linq; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - - /// - /// Coordinates proper disposal of services in priority order. - /// - public class ServiceDisposalCoordinator : IServiceDisposalCoordinator - { - private readonly ILogger logger; - private readonly List disposalItems = new(); - private readonly object lockObject = new(); - private bool disposed; - - public bool IsDisposed => this.disposed; - - public ServiceDisposalCoordinator(ILogger logger) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public void RegisterService(string serviceName, IDisposable service, int priority = 0) - { - if (string.IsNullOrWhiteSpace(serviceName)) - { - throw new ArgumentException("Service name cannot be null or empty", nameof(serviceName)); - } - - if (service == null) - { - throw new ArgumentNullException(nameof(service)); - } - - lock (this.lockObject) - { - if (this.disposed) - { - throw new ObjectDisposedException(nameof(ServiceDisposalCoordinator)); - } - - this.disposalItems.Add(new DisposalItem - { - Name = serviceName, - Priority = priority, - DisposalAction = () => - { - service.Dispose(); - return Task.CompletedTask; - }, - }); - - this.logger.LogDebug( - "Registered service for disposal: {ServiceName} (Priority: {Priority})", - serviceName, priority); - } - } - - public void RegisterAsyncService(string serviceName, IAsyncDisposable service, int priority = 0) - { - if (string.IsNullOrWhiteSpace(serviceName)) - { - throw new ArgumentException("Service name cannot be null or empty", nameof(serviceName)); - } - - if (service == null) - { - throw new ArgumentNullException(nameof(service)); - } - - lock (this.lockObject) - { - if (this.disposed) - { - throw new ObjectDisposedException(nameof(ServiceDisposalCoordinator)); - } - - this.disposalItems.Add(new DisposalItem - { - Name = serviceName, - Priority = priority, - DisposalAction = async () => await service.DisposeAsync(), - }); - - this.logger.LogDebug( - "Registered async service for disposal: {ServiceName} (Priority: {Priority})", - serviceName, priority); - } - } - - public void RegisterDisposalAction(string actionName, Func disposalAction, int priority = 0) - { - if (string.IsNullOrWhiteSpace(actionName)) - { - throw new ArgumentException("Action name cannot be null or empty", nameof(actionName)); - } - - if (disposalAction == null) - { - throw new ArgumentNullException(nameof(disposalAction)); - } - - lock (this.lockObject) - { - if (this.disposed) - { - throw new ObjectDisposedException(nameof(ServiceDisposalCoordinator)); - } - - this.disposalItems.Add(new DisposalItem - { - Name = actionName, - Priority = priority, - DisposalAction = disposalAction, - }); - - this.logger.LogDebug( - "Registered disposal action: {ActionName} (Priority: {Priority})", - actionName, priority); - } - } - - public async Task DisposeAllAsync() - { - if (this.disposed) - { - return; - } - - List itemsToDispose; - lock (this.lockObject) - { - if (this.disposed) - { - return; - } - - // Sort by priority (higher priority disposed first) - itemsToDispose = this.disposalItems.OrderByDescending(x => x.Priority).ToList(); - this.disposed = true; - } - - this.logger.LogInformation("Starting coordinated disposal of {Count} services/actions", itemsToDispose.Count); - - foreach (var item in itemsToDispose) - { - try - { - this.logger.LogDebug("Disposing: {Name} (Priority: {Priority})", item.Name, item.Priority); - await item.DisposalAction(); - this.logger.LogDebug("Successfully disposed: {Name}", item.Name); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error disposing {Name}: {Error}", item.Name, ex.Message); - // Continue with other disposals even if one fails - } - } - - this.logger.LogInformation("Coordinated disposal completed"); - } - - public void Dispose() - { - this.DisposeAllAsync().GetAwaiter().GetResult(); - } - - private class DisposalItem - { - public string Name { get; set; } = string.Empty; - - public int Priority { get; set; } - - public Func DisposalAction { get; set; } = () => Task.CompletedTask; - } - } -} - diff --git a/Services/ServiceFactory.cs b/Services/ServiceFactory.cs deleted file mode 100644 index de20b20..0000000 --- a/Services/ServiceFactory.cs +++ /dev/null @@ -1,189 +0,0 @@ -/* - * 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.DependencyInjection; - using Microsoft.Extensions.Logging; - using ThreadPilot.Services.Core; - - /// - /// Factory for creating and managing service instances with proper dependency resolution. - /// - public interface IServiceFactory - { - /// - /// Create a service instance of the specified type. - /// - T CreateService() - where T : class; - - /// - /// Create a service instance with additional parameters. - /// - T CreateService(params object[] parameters) - where T : class; - - /// - /// Get or create a singleton service instance. - /// - T GetSingletonService() - where T : class; - - /// - /// Initialize all core services. - /// - Task InitializeAllServicesAsync(); - - /// - /// Dispose all managed services. - /// - Task DisposeAllServicesAsync(); - } - - /// - /// Implementation of service factory with dependency injection support. - /// - public class ServiceFactory : IServiceFactory, IDisposable - { - private readonly IServiceProvider serviceProvider; - private readonly ILogger logger; - private readonly Dictionary singletonInstances = new(); - private readonly List managedServices = new(); - private bool disposed; - - public ServiceFactory(IServiceProvider serviceProvider, ILogger logger) - { - this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public T CreateService() - where T : class - { - try - { - var service = this.serviceProvider.GetRequiredService(); - - // Track system services for lifecycle management - if (service is ISystemService systemService) - { - this.managedServices.Add(systemService); - } - - return service; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to create service of type {ServiceType}", typeof(T).Name); - throw; - } - } - - public T CreateService(params object[] parameters) - where T : class - { - try - { - // For services with additional parameters, use ActivatorUtilities - var service = ActivatorUtilities.CreateInstance(this.serviceProvider, parameters); - - if (service is ISystemService systemService) - { - this.managedServices.Add(systemService); - } - - return service; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to create service of type {ServiceType} with parameters", typeof(T).Name); - throw; - } - } - - public T GetSingletonService() - where T : class - { - var serviceType = typeof(T); - - if (this.singletonInstances.TryGetValue(serviceType, out var existingInstance)) - { - return (T)existingInstance; - } - - var newInstance = this.CreateService(); - this.singletonInstances[serviceType] = newInstance; - - return newInstance; - } - - public async Task InitializeAllServicesAsync() - { - this.logger.LogInformation("Initializing all managed services"); - - var initializationTasks = this.managedServices.Select(async service => - { - try - { - await service.InitializeAsync(); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to initialize service {ServiceType}", service.GetType().Name); - throw; - } - }); - - await Task.WhenAll(initializationTasks); - this.logger.LogInformation("All managed services initialized successfully"); - } - - public async Task DisposeAllServicesAsync() - { - this.logger.LogInformation("Disposing all managed services"); - - var disposalTasks = this.managedServices.Select(async service => - { - try - { - await service.DisposeAsync(); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error disposing service {ServiceType}", service.GetType().Name); - } - }); - - await Task.WhenAll(disposalTasks); - this.managedServices.Clear(); - this.singletonInstances.Clear(); - - this.logger.LogInformation("All managed services disposed"); - } - - public void Dispose() - { - if (!this.disposed) - { - _ = Task.Run(async () => await this.DisposeAllServicesAsync()); - this.disposed = true; - } - } - } -} - diff --git a/Services/ServiceHealthMonitor.cs b/Services/ServiceHealthMonitor.cs deleted file mode 100644 index 731be6b..0000000 --- a/Services/ServiceHealthMonitor.cs +++ /dev/null @@ -1,170 +0,0 @@ -/* - * 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; - using System.Collections.Generic; - using System.Diagnostics; - using System.Linq; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - - /// - /// Implementation of service health monitoring. - /// - public class ServiceHealthMonitor : IServiceHealthMonitor, IDisposable - { - private readonly ILogger logger; - private readonly ConcurrentDictionary>> healthChecks = new(); - private readonly ConcurrentDictionary lastResults = new(); - private readonly object lockObject = new(); - private bool disposed; - - public event EventHandler? ServiceHealthChanged; - - public ServiceHealthMonitor(ILogger logger) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public void RegisterService(string serviceName, Func> healthCheck) - { - if (string.IsNullOrWhiteSpace(serviceName)) - { - throw new ArgumentException("Service name cannot be null or empty", nameof(serviceName)); - } - - if (healthCheck == null) - { - throw new ArgumentNullException(nameof(healthCheck)); - } - - this.healthChecks.AddOrUpdate(serviceName, healthCheck, (key, oldValue) => healthCheck); - this.logger.LogInformation("Registered health check for service: {ServiceName}", serviceName); - } - - public void UnregisterService(string serviceName) - { - if (string.IsNullOrWhiteSpace(serviceName)) - { - return; - } - - this.healthChecks.TryRemove(serviceName, out _); - this.lastResults.TryRemove(serviceName, out _); - this.logger.LogInformation("Unregistered health check for service: {ServiceName}", serviceName); - } - - public async Task CheckServiceHealthAsync(string serviceName) - { - if (!this.healthChecks.TryGetValue(serviceName, out var healthCheck)) - { - return new ServiceHealthResult - { - ServiceName = serviceName, - Status = ServiceHealthStatus.Critical, - Description = "Service not registered for health monitoring", - CheckTime = DateTime.UtcNow, - }; - } - - var stopwatch = Stopwatch.StartNew(); - ServiceHealthResult result; - - try - { - result = await healthCheck(); - result.ResponseTime = stopwatch.Elapsed; - result.CheckTime = DateTime.UtcNow; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Health check failed for service: {ServiceName}", serviceName); - result = new ServiceHealthResult - { - ServiceName = serviceName, - Status = ServiceHealthStatus.Critical, - Description = $"Health check threw exception: {ex.Message}", - ResponseTime = stopwatch.Elapsed, - CheckTime = DateTime.UtcNow, - Exception = ex, - }; - } - - // Check if status changed and raise event - if (this.lastResults.TryGetValue(serviceName, out var lastResult)) - { - if (lastResult.Status != result.Status) - { - this.ServiceHealthChanged?.Invoke(this, new ServiceHealthChangedEventArgs - { - ServiceName = serviceName, - PreviousStatus = lastResult.Status, - CurrentStatus = result.Status, - HealthResult = result, - }); - } - } - - this.lastResults.AddOrUpdate(serviceName, result, (key, oldValue) => result); - return result; - } - - public async Task> CheckAllServicesHealthAsync() - { - var results = new Dictionary(); - var tasks = this.healthChecks.Keys.Select(async serviceName => - { - var result = await this.CheckServiceHealthAsync(serviceName); - lock (this.lockObject) - { - results[serviceName] = result; - } - }); - - await Task.WhenAll(tasks); - return results; - } - - public Dictionary GetCurrentHealthStatus() - { - return this.lastResults.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - } - - protected virtual void Dispose(bool disposing) - { - if (!this.disposed) - { - if (disposing) - { - this.healthChecks.Clear(); - this.lastResults.Clear(); - this.logger.LogInformation("ServiceHealthMonitor disposed"); - } - this.disposed = true; - } - } - - public void Dispose() - { - this.Dispose(disposing: true); - GC.SuppressFinalize(this); - } - } -} - diff --git a/Services/SmartNotificationService.cs b/Services/SmartNotificationService.cs index 7bc1745..4484357 100644 --- a/Services/SmartNotificationService.cs +++ b/Services/SmartNotificationService.cs @@ -1,639 +1,620 @@ -/* - * 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; - using System.Collections.Generic; - using System.Linq; - using System.Threading; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - - /// - /// Implementation of smart notification service with throttling and priority queuing. - /// - public class SmartNotificationService : ISmartNotificationService, IDisposable - { - private readonly ILogger logger; - private readonly INotificationService baseNotificationService; - private readonly ConcurrentQueue notificationQueue = new(); - private readonly ConcurrentDictionary scheduledNotifications = new(); - private readonly ConcurrentDictionary lastNotificationTimes = new(); - private readonly ConcurrentDictionary> notificationHistory = new(); - private readonly List sentNotifications = new(); - private readonly System.Threading.Timer processingTimer; - private readonly System.Threading.Timer cleanupTimer; - private readonly SemaphoreSlim processingLock = new(1, 1); - - private NotificationPreferences preferences = new(); - private DateTime? doNotDisturbUntil; - private bool disposed; - - public event EventHandler? NotificationSent; - - public event EventHandler? NotificationThrottled; - - public event EventHandler? NotificationDeduplicated; - - public event EventHandler? DoNotDisturbChanged; - - public SmartNotificationService( - ILogger logger, - INotificationService baseNotificationService) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.baseNotificationService = baseNotificationService ?? throw new ArgumentNullException(nameof(baseNotificationService)); - - // Set up processing timer (process queue every 2 seconds) - this.processingTimer = new System.Threading.Timer(this.ProcessQueueCallback, null, - TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2)); - - // Set up cleanup timer (clean history every hour) - this.cleanupTimer = new System.Threading.Timer(this.CleanupCallback, null, - TimeSpan.FromHours(1), TimeSpan.FromHours(1)); - } - - public async Task InitializeAsync() - { - this.logger.LogInformation("Initializing SmartNotificationService"); - - // Initialize default preferences - this.preferences = this.CreateDefaultPreferences(); - - // Load preferences from storage (simplified) - await this.LoadPreferencesAsync(); - } - - public async Task SendNotificationAsync(SmartNotification notification) - { - try - { - // Validate notification - if (string.IsNullOrWhiteSpace(notification.Title) && string.IsNullOrWhiteSpace(notification.Message)) - { - this.logger.LogWarning("Attempted to send notification with empty title and message"); - return false; - } - - // Check if notifications are enabled - if (!this.preferences.IsEnabled) - { - this.logger.LogDebug("Notifications are disabled, skipping notification: {Title}", notification.Title); - return false; - } - - // Check category preferences - if (this.preferences.CategoryEnabled.TryGetValue(notification.Category, out var categoryEnabled) && !categoryEnabled) - { - this.logger.LogDebug( - "Category {Category} is disabled, skipping notification: {Title}", - notification.Category, notification.Title); - return false; - } - - // Check minimum priority - if (notification.Priority < this.preferences.MinimumPriority) - { - this.logger.LogDebug( - "Notification priority {Priority} below minimum {MinPriority}, skipping: {Title}", - notification.Priority, this.preferences.MinimumPriority, notification.Title); - return false; - } - - // Check Do Not Disturb mode - if (this.IsDoNotDisturbActive() && notification.Priority < NotificationPriority.Critical) - { - this.logger.LogDebug("Do Not Disturb is active, skipping non-critical notification: {Title}", notification.Title); - return false; - } - - // Check throttling - if (this.IsThrottled(notification)) - { - this.NotificationThrottled?.Invoke(this, new SmartNotificationEventArgs - { - Notification = notification, - Reason = "Throttled due to rate limiting", - }); - return false; - } - - // Check deduplication - if (this.IsDuplicate(notification)) - { - this.NotificationDeduplicated?.Invoke(this, new SmartNotificationEventArgs - { - Notification = notification, - Reason = "Deduplicated - similar notification recently sent", - }); - return false; - } - - // Add to queue for processing - this.notificationQueue.Enqueue(notification); - this.logger.LogDebug( - "Queued notification: {Title} (Priority: {Priority}, Category: {Category})", - notification.Title, notification.Priority, notification.Category); - - return true; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error sending notification: {Title}", notification.Title); - return false; - } - } - - public async Task SendNotificationAsync(string title, string message, - NotificationPriority priority = NotificationPriority.Normal, - NotificationCategory category = NotificationCategory.Information) - { - var notification = new SmartNotification - { - Title = title, - Message = message, - Priority = priority, - Category = category, - DeduplicationKey = $"{category}:{title}:{message}".GetHashCode().ToString(), - }; - - return await this.SendNotificationAsync(notification); - } - - public async Task ScheduleNotificationAsync(SmartNotification notification, DateTime deliveryTime) - { - notification.ScheduledFor = deliveryTime; - this.scheduledNotifications.TryAdd(notification.Id, notification); - - this.logger.LogDebug( - "Scheduled notification {Id} for delivery at {DeliveryTime}", - notification.Id, deliveryTime); - - return true; - } - - public async Task CancelNotificationAsync(string notificationId) - { - var removed = this.scheduledNotifications.TryRemove(notificationId, out var notification); - if (removed) - { - this.logger.LogDebug("Cancelled scheduled notification: {Id}", notificationId); - } - return removed; - } - - public async Task> GetPendingNotificationsAsync() - { - var pending = new List(); - - // Add queued notifications - pending.AddRange(this.notificationQueue.ToArray()); - - // Add scheduled notifications - pending.AddRange(this.scheduledNotifications.Values); - - return pending.OrderByDescending(n => n.Priority).ThenBy(n => n.CreatedAt).ToList(); - } - - public async Task> GetNotificationHistoryAsync(TimeSpan? period = null) - { - var cutoff = period.HasValue ? DateTime.UtcNow - period.Value : DateTime.MinValue; - - lock (this.sentNotifications) - { - return this.sentNotifications - .Where(n => n.CreatedAt >= cutoff) - .OrderByDescending(n => n.CreatedAt) - .ToList(); - } - } - - public async Task ClearHistoryAsync() - { - lock (this.sentNotifications) - { - this.sentNotifications.Clear(); - } - - this.notificationHistory.Clear(); - this.logger.LogInformation("Cleared notification history"); - } - - public async Task UpdatePreferencesAsync(NotificationPreferences preferences) - { - this.preferences = preferences ?? throw new ArgumentNullException(nameof(preferences)); - await this.SavePreferencesAsync(); - this.logger.LogInformation("Updated notification preferences"); - } - - public async Task GetPreferencesAsync() - { - return this.preferences; - } - - public async Task SetDoNotDisturbAsync(bool enabled, TimeSpan? duration = null) - { - var wasActive = this.IsDoNotDisturbActive(); - - if (enabled) - { - this.doNotDisturbUntil = duration.HasValue - ? DateTime.UtcNow + duration.Value - : DateTime.MaxValue; - this.preferences.DoNotDisturbMode = true; - } - else - { - this.doNotDisturbUntil = null; - this.preferences.DoNotDisturbMode = false; - } - - var isActive = this.IsDoNotDisturbActive(); - if (wasActive != isActive) - { - this.DoNotDisturbChanged?.Invoke(this, isActive); - this.logger.LogInformation("Do Not Disturb mode {Status}", isActive ? "enabled" : "disabled"); - } - } - - public bool IsDoNotDisturbActive() - { - if (!this.preferences.DoNotDisturbMode) - { - return false; - } - - if (this.doNotDisturbUntil.HasValue && DateTime.UtcNow > this.doNotDisturbUntil.Value) - { - this.preferences.DoNotDisturbMode = false; - this.doNotDisturbUntil = null; - return false; - } - - // Check time-based DND - var now = DateTime.Now.TimeOfDay; - if (this.preferences.DoNotDisturbStart < this.preferences.DoNotDisturbEnd) - { - // Same day range (e.g., 10 PM to 8 AM next day) - return now >= this.preferences.DoNotDisturbStart || now <= this.preferences.DoNotDisturbEnd; - } - else - { - // Cross-midnight range (e.g., 10 PM to 8 AM) - return now >= this.preferences.DoNotDisturbStart && now <= this.preferences.DoNotDisturbEnd; - } - } - - public async Task> GetStatisticsAsync() - { - var stats = new Dictionary(); - - lock (this.sentNotifications) - { - var last24Hours = this.sentNotifications.Where(n => n.CreatedAt >= DateTime.UtcNow.AddDays(-1)).ToList(); - var lastWeek = this.sentNotifications.Where(n => n.CreatedAt >= DateTime.UtcNow.AddDays(-7)).ToList(); - - stats["TotalSent"] = this.sentNotifications.Count; - stats["SentLast24Hours"] = last24Hours.Count; - stats["SentLastWeek"] = lastWeek.Count; - stats["PendingCount"] = this.notificationQueue.Count; - stats["ScheduledCount"] = this.scheduledNotifications.Count; - - // Category breakdown - var categoryStats = this.sentNotifications - .GroupBy(n => n.Category) - .ToDictionary(g => g.Key.ToString(), g => g.Count()); - stats["ByCategory"] = categoryStats; - - // Priority breakdown - var priorityStats = this.sentNotifications - .GroupBy(n => n.Priority) - .ToDictionary(g => g.Key.ToString(), g => g.Count()); - stats["ByPriority"] = priorityStats; - } - - return stats; - } - - public async Task TestNotificationAsync() - { - var testNotification = new SmartNotification - { - Title = "Test Notification", - Message = "This is a test notification from ThreadPilot Smart Notification System", - Priority = NotificationPriority.Normal, - Category = NotificationCategory.System, - }; - - return await this.SendNotificationAsync(testNotification); - } - - private void ProcessQueueCallback(object? state) - { - TaskSafety.FireAndForget(this.ProcessQueueCallbackAsync(), ex => - { - this.logger.LogWarning(ex, "Error during notification queue processing"); - }); - } - - private async Task ProcessQueueCallbackAsync() - { - if (this.disposed) - { - return; - } - - await this.processingLock.WaitAsync(); - try - { - var processedCount = 0; - var maxProcessPerCycle = 10; - - while (this.notificationQueue.TryDequeue(out var notification) && processedCount < maxProcessPerCycle) - { - await this.ProcessNotificationAsync(notification); - processedCount++; - } - - // Process scheduled notifications - var now = DateTime.UtcNow; - var dueNotifications = this.scheduledNotifications.Values - .Where(n => n.ScheduledFor <= now) - .ToList(); - - foreach (var notification in dueNotifications) - { - this.scheduledNotifications.TryRemove(notification.Id, out _); - await this.ProcessNotificationAsync(notification); - } - } - finally - { - this.processingLock.Release(); - } - } - - private async Task ProcessNotificationAsync(SmartNotification notification) - { - try - { - // Check if notification has expired - if (notification.ExpiresAfter.HasValue && - DateTime.UtcNow - notification.CreatedAt > notification.ExpiresAfter.Value) - { - this.logger.LogDebug("Notification expired: {Title}", notification.Title); - return; - } - - // Send through base notification service - await this.baseNotificationService.ShowNotificationAsync( - notification.Title, - notification.Message, - this.ConvertToNotificationType(notification.Priority)); - - // Assume success since no exception was thrown - var success = true; - - if (success) - { - // Record successful delivery - this.RecordNotificationSent(notification); - - this.NotificationSent?.Invoke(this, new SmartNotificationEventArgs - { - Notification = notification, - Reason = "Successfully delivered", - }); - - this.logger.LogDebug("Successfully sent notification: {Title}", notification.Title); - } - else if (notification.RetryCount < notification.MaxRetries) - { - // Retry failed notification - notification.RetryCount++; - this.notificationQueue.Enqueue(notification); - this.logger.LogDebug( - "Retrying notification: {Title} (Attempt {Retry}/{Max})", - notification.Title, notification.RetryCount, notification.MaxRetries); - } - else - { - this.logger.LogWarning( - "Failed to send notification after {MaxRetries} attempts: {Title}", - notification.MaxRetries, notification.Title); - } - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error processing notification: {Title}", notification.Title); - } - } - - private bool IsThrottled(SmartNotification notification) - { - if (!this.preferences.ThrottleConfigs.TryGetValue(notification.Category, out var config)) - { - return false; // No throttling configured for this category - } - - var key = $"{notification.Category}:{notification.DeduplicationKey}"; - var now = DateTime.UtcNow; - - // Check minimum interval - if (this.lastNotificationTimes.TryGetValue(key, out var lastTime)) - { - if (now - lastTime < config.MinInterval) - { - return true; - } - } - - // Check hourly and daily limits - if (!this.notificationHistory.TryGetValue(key, out var history)) - { - history = new List(); - this.notificationHistory[key] = history; - } - - // Clean old entries - var oneHourAgo = now.AddHours(-1); - var oneDayAgo = now.AddDays(-1); - history.RemoveAll(t => t < oneDayAgo); - - var hourlyCount = history.Count(t => t >= oneHourAgo); - var dailyCount = history.Count; - - return hourlyCount >= config.MaxPerHour || dailyCount >= config.MaxPerDay; - } - - private bool IsDuplicate(SmartNotification notification) - { - if (string.IsNullOrEmpty(notification.DeduplicationKey)) - { - return false; - } - - if (!this.preferences.ThrottleConfigs.TryGetValue(notification.Category, out var config) || - !config.EnableDeduplication) - { - return false; - } - - var key = $"{notification.Category}:{notification.DeduplicationKey}"; - if (this.lastNotificationTimes.TryGetValue(key, out var lastTime)) - { - return DateTime.UtcNow - lastTime < config.DeduplicationWindow; - } - - return false; - } - - private void RecordNotificationSent(SmartNotification notification) - { - var key = $"{notification.Category}:{notification.DeduplicationKey}"; - var now = DateTime.UtcNow; - - this.lastNotificationTimes[key] = now; - - if (!this.notificationHistory.TryGetValue(key, out var history)) - { - history = new List(); - this.notificationHistory[key] = history; - } - history.Add(now); - - lock (this.sentNotifications) - { - this.sentNotifications.Add(notification); - - // Keep only last 1000 notifications in memory - if (this.sentNotifications.Count > 1000) - { - this.sentNotifications.RemoveRange(0, this.sentNotifications.Count - 1000); - } - } - } - - private NotificationType ConvertToNotificationType(NotificationPriority priority) - { - return priority switch - { - NotificationPriority.Critical => NotificationType.Error, - NotificationPriority.High => NotificationType.Warning, - NotificationPriority.Normal => NotificationType.Information, - NotificationPriority.Low => NotificationType.Information, - _ => NotificationType.Information, - }; - } - - private NotificationPreferences CreateDefaultPreferences() - { - var preferences = new NotificationPreferences(); - - // Enable all categories by default - foreach (NotificationCategory category in Enum.GetValues()) - { - preferences.CategoryEnabled[category] = true; - preferences.ThrottleConfigs[category] = new NotificationThrottleConfig - { - Category = category, - MinInterval = TimeSpan.FromSeconds(30), - MaxPerHour = category == NotificationCategory.Error ? 20 : 10, - MaxPerDay = category == NotificationCategory.Error ? 100 : 50, - }; - } - - return preferences; - } - - private Task LoadPreferencesAsync() - { - // Simplified - would load from actual storage - this.logger.LogDebug("Loaded notification preferences"); - return Task.CompletedTask; - } - - private Task SavePreferencesAsync() - { - // Simplified - would save to actual storage - this.logger.LogDebug("Saved notification preferences"); - return Task.CompletedTask; - } - - private void CleanupCallback(object? state) - { - TaskSafety.FireAndForget(this.CleanupCallbackAsync(), ex => - { - this.logger.LogWarning(ex, "Error during notification cleanup"); - }); - } - - private async Task CleanupCallbackAsync() - { - try - { - var cutoff = DateTime.UtcNow.AddDays(-7); - - // Clean notification history - var keysToRemove = new List(); - foreach (var kvp in this.notificationHistory) - { - kvp.Value.RemoveAll(t => t < cutoff); - if (!kvp.Value.Any()) - { - keysToRemove.Add(kvp.Key); - } - } - - foreach (var key in keysToRemove) - { - this.notificationHistory.TryRemove(key, out _); - } - - this.logger.LogDebug("Cleaned up notification history, removed {Count} empty entries", keysToRemove.Count); - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Error during notification cleanup"); - } - } - - protected virtual void Dispose(bool disposing) - { - if (!this.disposed) - { - if (disposing) - { - this.processingTimer?.Dispose(); - this.cleanupTimer?.Dispose(); - this.processingLock?.Dispose(); - this.logger.LogInformation("SmartNotificationService disposed"); - } - this.disposed = true; - } - } - - public void Dispose() - { - this.Dispose(disposing: true); - GC.SuppressFinalize(this); - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public class SmartNotificationService : ISmartNotificationService, IDisposable + { + private readonly ILogger logger; + private readonly INotificationService baseNotificationService; + private readonly ConcurrentQueue notificationQueue = new(); + private readonly ConcurrentDictionary scheduledNotifications = new(); + private readonly ConcurrentDictionary lastNotificationTimes = new(); + private readonly ConcurrentDictionary> notificationHistory = new(); + private readonly List sentNotifications = new(); + private readonly System.Threading.Timer processingTimer; + private readonly System.Threading.Timer cleanupTimer; + private readonly SemaphoreSlim processingLock = new(1, 1); + + private NotificationPreferences preferences = new(); + private DateTime? doNotDisturbUntil; + private bool disposed; + + public event EventHandler? NotificationSent; + + public event EventHandler? NotificationThrottled; + + public event EventHandler? NotificationDeduplicated; + + public event EventHandler? DoNotDisturbChanged; + + public SmartNotificationService( + ILogger logger, + INotificationService baseNotificationService) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.baseNotificationService = baseNotificationService ?? throw new ArgumentNullException(nameof(baseNotificationService)); + + // Set up processing timer (process queue every 2 seconds) + this.processingTimer = new System.Threading.Timer(this.ProcessQueueCallback, null, + TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2)); + + // Set up cleanup timer (clean history every hour) + this.cleanupTimer = new System.Threading.Timer(this.CleanupCallback, null, + TimeSpan.FromHours(1), TimeSpan.FromHours(1)); + } + + public async Task InitializeAsync() + { + this.logger.LogInformation("Initializing SmartNotificationService"); + + // Initialize default preferences + this.preferences = this.CreateDefaultPreferences(); + + // Load preferences from storage (simplified) + await this.LoadPreferencesAsync(); + } + + public async Task SendNotificationAsync(SmartNotification notification) + { + try + { + // Validate notification + if (string.IsNullOrWhiteSpace(notification.Title) && string.IsNullOrWhiteSpace(notification.Message)) + { + this.logger.LogWarning("Attempted to send notification with empty title and message"); + return false; + } + + // Check if notifications are enabled + if (!this.preferences.IsEnabled) + { + this.logger.LogDebug("Notifications are disabled, skipping notification: {Title}", notification.Title); + return false; + } + + // Check category preferences + if (this.preferences.CategoryEnabled.TryGetValue(notification.Category, out var categoryEnabled) && !categoryEnabled) + { + this.logger.LogDebug( + "Category {Category} is disabled, skipping notification: {Title}", + notification.Category, notification.Title); + return false; + } + + // Check minimum priority + if (notification.Priority < this.preferences.MinimumPriority) + { + this.logger.LogDebug( + "Notification priority {Priority} below minimum {MinPriority}, skipping: {Title}", + notification.Priority, this.preferences.MinimumPriority, notification.Title); + return false; + } + + // Check Do Not Disturb mode + if (this.IsDoNotDisturbActive() && notification.Priority < NotificationPriority.Critical) + { + this.logger.LogDebug("Do Not Disturb is active, skipping non-critical notification: {Title}", notification.Title); + return false; + } + + // Check throttling + if (this.IsThrottled(notification)) + { + this.NotificationThrottled?.Invoke(this, new SmartNotificationEventArgs + { + Notification = notification, + Reason = "Throttled due to rate limiting", + }); + return false; + } + + // Check deduplication + if (this.IsDuplicate(notification)) + { + this.NotificationDeduplicated?.Invoke(this, new SmartNotificationEventArgs + { + Notification = notification, + Reason = "Deduplicated - similar notification recently sent", + }); + return false; + } + + // Add to queue for processing + this.notificationQueue.Enqueue(notification); + this.logger.LogDebug( + "Queued notification: {Title} (Priority: {Priority}, Category: {Category})", + notification.Title, notification.Priority, notification.Category); + + return true; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error sending notification: {Title}", notification.Title); + return false; + } + } + + public async Task SendNotificationAsync(string title, string message, + NotificationPriority priority = NotificationPriority.Normal, + NotificationCategory category = NotificationCategory.Information) + { + var notification = new SmartNotification + { + Title = title, + Message = message, + Priority = priority, + Category = category, + DeduplicationKey = $"{category}:{title}:{message}".GetHashCode().ToString(), + }; + + return await this.SendNotificationAsync(notification); + } + + public async Task ScheduleNotificationAsync(SmartNotification notification, DateTime deliveryTime) + { + notification.ScheduledFor = deliveryTime; + this.scheduledNotifications.TryAdd(notification.Id, notification); + + this.logger.LogDebug( + "Scheduled notification {Id} for delivery at {DeliveryTime}", + notification.Id, deliveryTime); + + return true; + } + + public async Task CancelNotificationAsync(string notificationId) + { + var removed = this.scheduledNotifications.TryRemove(notificationId, out var notification); + if (removed) + { + this.logger.LogDebug("Cancelled scheduled notification: {Id}", notificationId); + } + return removed; + } + + public async Task> GetPendingNotificationsAsync() + { + var pending = new List(); + + // Add queued notifications + pending.AddRange(this.notificationQueue.ToArray()); + + // Add scheduled notifications + pending.AddRange(this.scheduledNotifications.Values); + + return pending.OrderByDescending(n => n.Priority).ThenBy(n => n.CreatedAt).ToList(); + } + + public async Task> GetNotificationHistoryAsync(TimeSpan? period = null) + { + var cutoff = period.HasValue ? DateTime.UtcNow - period.Value : DateTime.MinValue; + + lock (this.sentNotifications) + { + return this.sentNotifications + .Where(n => n.CreatedAt >= cutoff) + .OrderByDescending(n => n.CreatedAt) + .ToList(); + } + } + + public async Task ClearHistoryAsync() + { + lock (this.sentNotifications) + { + this.sentNotifications.Clear(); + } + + this.notificationHistory.Clear(); + this.logger.LogInformation("Cleared notification history"); + } + + public async Task UpdatePreferencesAsync(NotificationPreferences preferences) + { + this.preferences = preferences ?? throw new ArgumentNullException(nameof(preferences)); + await this.SavePreferencesAsync(); + this.logger.LogInformation("Updated notification preferences"); + } + + public async Task GetPreferencesAsync() + { + return this.preferences; + } + + public async Task SetDoNotDisturbAsync(bool enabled, TimeSpan? duration = null) + { + var wasActive = this.IsDoNotDisturbActive(); + + if (enabled) + { + this.doNotDisturbUntil = duration.HasValue + ? DateTime.UtcNow + duration.Value + : DateTime.MaxValue; + this.preferences.DoNotDisturbMode = true; + } + else + { + this.doNotDisturbUntil = null; + this.preferences.DoNotDisturbMode = false; + } + + var isActive = this.IsDoNotDisturbActive(); + if (wasActive != isActive) + { + this.DoNotDisturbChanged?.Invoke(this, isActive); + this.logger.LogInformation("Do Not Disturb mode {Status}", isActive ? "enabled" : "disabled"); + } + } + + public bool IsDoNotDisturbActive() + { + if (!this.preferences.DoNotDisturbMode) + { + return false; + } + + if (this.doNotDisturbUntil.HasValue && DateTime.UtcNow > this.doNotDisturbUntil.Value) + { + this.preferences.DoNotDisturbMode = false; + this.doNotDisturbUntil = null; + return false; + } + + // Check time-based DND + var now = DateTime.Now.TimeOfDay; + if (this.preferences.DoNotDisturbStart < this.preferences.DoNotDisturbEnd) + { + // Same day range (e.g., 10 PM to 8 AM next day) + return now >= this.preferences.DoNotDisturbStart || now <= this.preferences.DoNotDisturbEnd; + } + else + { + // Cross-midnight range (e.g., 10 PM to 8 AM) + return now >= this.preferences.DoNotDisturbStart && now <= this.preferences.DoNotDisturbEnd; + } + } + + public async Task> GetStatisticsAsync() + { + var stats = new Dictionary(); + + lock (this.sentNotifications) + { + var last24Hours = this.sentNotifications.Where(n => n.CreatedAt >= DateTime.UtcNow.AddDays(-1)).ToList(); + var lastWeek = this.sentNotifications.Where(n => n.CreatedAt >= DateTime.UtcNow.AddDays(-7)).ToList(); + + stats["TotalSent"] = this.sentNotifications.Count; + stats["SentLast24Hours"] = last24Hours.Count; + stats["SentLastWeek"] = lastWeek.Count; + stats["PendingCount"] = this.notificationQueue.Count; + stats["ScheduledCount"] = this.scheduledNotifications.Count; + + // Category breakdown + var categoryStats = this.sentNotifications + .GroupBy(n => n.Category) + .ToDictionary(g => g.Key.ToString(), g => g.Count()); + stats["ByCategory"] = categoryStats; + + // Priority breakdown + var priorityStats = this.sentNotifications + .GroupBy(n => n.Priority) + .ToDictionary(g => g.Key.ToString(), g => g.Count()); + stats["ByPriority"] = priorityStats; + } + + return stats; + } + + public async Task TestNotificationAsync() + { + var testNotification = new SmartNotification + { + Title = "Test Notification", + Message = "This is a test notification from ThreadPilot Smart Notification System", + Priority = NotificationPriority.Normal, + Category = NotificationCategory.System, + }; + + return await this.SendNotificationAsync(testNotification); + } + + private void ProcessQueueCallback(object? state) + { + TaskSafety.FireAndForget(this.ProcessQueueCallbackAsync(), ex => + { + this.logger.LogWarning(ex, "Error during notification queue processing"); + }); + } + + private async Task ProcessQueueCallbackAsync() + { + if (this.disposed) + { + return; + } + + await this.processingLock.WaitAsync(); + try + { + var processedCount = 0; + var maxProcessPerCycle = 10; + + while (this.notificationQueue.TryDequeue(out var notification) && processedCount < maxProcessPerCycle) + { + await this.ProcessNotificationAsync(notification); + processedCount++; + } + + // Process scheduled notifications + var now = DateTime.UtcNow; + var dueNotifications = this.scheduledNotifications.Values + .Where(n => n.ScheduledFor <= now) + .ToList(); + + foreach (var notification in dueNotifications) + { + this.scheduledNotifications.TryRemove(notification.Id, out _); + await this.ProcessNotificationAsync(notification); + } + } + finally + { + this.processingLock.Release(); + } + } + + private async Task ProcessNotificationAsync(SmartNotification notification) + { + try + { + // Check if notification has expired + if (notification.ExpiresAfter.HasValue && + DateTime.UtcNow - notification.CreatedAt > notification.ExpiresAfter.Value) + { + this.logger.LogDebug("Notification expired: {Title}", notification.Title); + return; + } + + // Send through base notification service + await this.baseNotificationService.ShowNotificationAsync( + notification.Title, + notification.Message, + this.ConvertToNotificationType(notification.Priority)); + + // Assume success since no exception was thrown + var success = true; + + if (success) + { + // Record successful delivery + this.RecordNotificationSent(notification); + + this.NotificationSent?.Invoke(this, new SmartNotificationEventArgs + { + Notification = notification, + Reason = "Successfully delivered", + }); + + this.logger.LogDebug("Successfully sent notification: {Title}", notification.Title); + } + else if (notification.RetryCount < notification.MaxRetries) + { + // Retry failed notification + notification.RetryCount++; + this.notificationQueue.Enqueue(notification); + this.logger.LogDebug( + "Retrying notification: {Title} (Attempt {Retry}/{Max})", + notification.Title, notification.RetryCount, notification.MaxRetries); + } + else + { + this.logger.LogWarning( + "Failed to send notification after {MaxRetries} attempts: {Title}", + notification.MaxRetries, notification.Title); + } + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error processing notification: {Title}", notification.Title); + } + } + + private bool IsThrottled(SmartNotification notification) + { + if (!this.preferences.ThrottleConfigs.TryGetValue(notification.Category, out var config)) + { + return false; // No throttling configured for this category + } + + var key = $"{notification.Category}:{notification.DeduplicationKey}"; + var now = DateTime.UtcNow; + + // Check minimum interval + if (this.lastNotificationTimes.TryGetValue(key, out var lastTime)) + { + if (now - lastTime < config.MinInterval) + { + return true; + } + } + + // Check hourly and daily limits + if (!this.notificationHistory.TryGetValue(key, out var history)) + { + history = new List(); + this.notificationHistory[key] = history; + } + + // Clean old entries + var oneHourAgo = now.AddHours(-1); + var oneDayAgo = now.AddDays(-1); + history.RemoveAll(t => t < oneDayAgo); + + var hourlyCount = history.Count(t => t >= oneHourAgo); + var dailyCount = history.Count; + + return hourlyCount >= config.MaxPerHour || dailyCount >= config.MaxPerDay; + } + + private bool IsDuplicate(SmartNotification notification) + { + if (string.IsNullOrEmpty(notification.DeduplicationKey)) + { + return false; + } + + if (!this.preferences.ThrottleConfigs.TryGetValue(notification.Category, out var config) || + !config.EnableDeduplication) + { + return false; + } + + var key = $"{notification.Category}:{notification.DeduplicationKey}"; + if (this.lastNotificationTimes.TryGetValue(key, out var lastTime)) + { + return DateTime.UtcNow - lastTime < config.DeduplicationWindow; + } + + return false; + } + + private void RecordNotificationSent(SmartNotification notification) + { + var key = $"{notification.Category}:{notification.DeduplicationKey}"; + var now = DateTime.UtcNow; + + this.lastNotificationTimes[key] = now; + + if (!this.notificationHistory.TryGetValue(key, out var history)) + { + history = new List(); + this.notificationHistory[key] = history; + } + history.Add(now); + + lock (this.sentNotifications) + { + this.sentNotifications.Add(notification); + + // Keep only last 1000 notifications in memory + if (this.sentNotifications.Count > 1000) + { + this.sentNotifications.RemoveRange(0, this.sentNotifications.Count - 1000); + } + } + } + + private NotificationType ConvertToNotificationType(NotificationPriority priority) + { + return priority switch + { + NotificationPriority.Critical => NotificationType.Error, + NotificationPriority.High => NotificationType.Warning, + NotificationPriority.Normal => NotificationType.Information, + NotificationPriority.Low => NotificationType.Information, + _ => NotificationType.Information, + }; + } + + private NotificationPreferences CreateDefaultPreferences() + { + var preferences = new NotificationPreferences(); + + // Enable all categories by default + foreach (NotificationCategory category in Enum.GetValues()) + { + preferences.CategoryEnabled[category] = true; + preferences.ThrottleConfigs[category] = new NotificationThrottleConfig + { + Category = category, + MinInterval = TimeSpan.FromSeconds(30), + MaxPerHour = category == NotificationCategory.Error ? 20 : 10, + MaxPerDay = category == NotificationCategory.Error ? 100 : 50, + }; + } + + return preferences; + } + + private Task LoadPreferencesAsync() + { + // Simplified - would load from actual storage + this.logger.LogDebug("Loaded notification preferences"); + return Task.CompletedTask; + } + + private Task SavePreferencesAsync() + { + // Simplified - would save to actual storage + this.logger.LogDebug("Saved notification preferences"); + return Task.CompletedTask; + } + + private void CleanupCallback(object? state) + { + TaskSafety.FireAndForget(this.CleanupCallbackAsync(), ex => + { + this.logger.LogWarning(ex, "Error during notification cleanup"); + }); + } + + private async Task CleanupCallbackAsync() + { + try + { + var cutoff = DateTime.UtcNow.AddDays(-7); + + // Clean notification history + var keysToRemove = new List(); + foreach (var kvp in this.notificationHistory) + { + kvp.Value.RemoveAll(t => t < cutoff); + if (!kvp.Value.Any()) + { + keysToRemove.Add(kvp.Key); + } + } + + foreach (var key in keysToRemove) + { + this.notificationHistory.TryRemove(key, out _); + } + + this.logger.LogDebug("Cleaned up notification history, removed {Count} empty entries", keysToRemove.Count); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Error during notification cleanup"); + } + } + + protected virtual void Dispose(bool disposing) + { + if (!this.disposed) + { + if (disposing) + { + this.processingTimer?.Dispose(); + this.cleanupTimer?.Dispose(); + this.processingLock?.Dispose(); + this.logger.LogInformation("SmartNotificationService disposed"); + } + this.disposed = true; + } + } + + public void Dispose() + { + this.Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} + diff --git a/Services/StoragePaths.cs b/Services/StoragePaths.cs index f8b05b4..286eb34 100644 --- a/Services/StoragePaths.cs +++ b/Services/StoragePaths.cs @@ -1,48 +1,32 @@ -/* - * 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.IO; - - internal static class StoragePaths - { - public static string AppDataRoot { get; } = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "ThreadPilot"); - - public static string SettingsFilePath => Path.Combine(AppDataRoot, "settings.json"); - - public static string ProfilesDirectory => Path.Combine(AppDataRoot, "Profiles"); - - public static string ConfigurationDirectory => Path.Combine(AppDataRoot, "Configuration"); - - public static string CoreMasksFilePath => Path.Combine(AppDataRoot, "core_masks.json"); - - public static string PersistentRulesFilePath => Path.Combine(AppDataRoot, "persistent_rules.json"); - - public static string PowerPlansDirectory => Path.Combine(AppDataRoot, "Powerplans"); - - public static void EnsureAppDataDirectories() - { - Directory.CreateDirectory(AppDataRoot); - Directory.CreateDirectory(ProfilesDirectory); - Directory.CreateDirectory(ConfigurationDirectory); - Directory.CreateDirectory(PowerPlansDirectory); - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.IO; + + internal static class StoragePaths + { + public static string AppDataRoot { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "ThreadPilot"); + + public static string SettingsFilePath => Path.Combine(AppDataRoot, "settings.json"); + + public static string ProfilesDirectory => Path.Combine(AppDataRoot, "Profiles"); + + public static string ConfigurationDirectory => Path.Combine(AppDataRoot, "Configuration"); + + public static string CoreMasksFilePath => Path.Combine(AppDataRoot, "core_masks.json"); + + public static string PersistentRulesFilePath => Path.Combine(AppDataRoot, "persistent_rules.json"); + + public static string PowerPlansDirectory => Path.Combine(AppDataRoot, "Powerplans"); + + public static void EnsureAppDataDirectories() + { + Directory.CreateDirectory(AppDataRoot); + Directory.CreateDirectory(ProfilesDirectory); + Directory.CreateDirectory(ConfigurationDirectory); + Directory.CreateDirectory(PowerPlansDirectory); + } + } +} diff --git a/Services/SystemProcessRunner.cs b/Services/SystemProcessRunner.cs index d666d35..2c89224 100644 --- a/Services/SystemProcessRunner.cs +++ b/Services/SystemProcessRunner.cs @@ -1,69 +1,65 @@ -/* - * ThreadPilot - default process runner. - */ -namespace ThreadPilot.Services -{ - using System; - using System.Collections.Generic; - using System.Diagnostics; - using System.IO; - using System.Threading.Tasks; - using ThreadPilot.Services.Abstractions; - - /// - /// Production implementation of backed by . - /// - public sealed class SystemProcessRunner : IProcessRunner - { - /// - public async Task RunAsync(string fileName, IReadOnlyList arguments, TimeSpan timeout) - { - if (!File.Exists(fileName)) - { - return new ProcessRunResult(-1, string.Empty, $"Executable not found: {fileName}"); - } - - var processInfo = new ProcessStartInfo - { - FileName = fileName, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - }; - - foreach (var argument in arguments) - { - processInfo.ArgumentList.Add(argument); - } - - using var process = Process.Start(processInfo); - if (process == null) - { - return new ProcessRunResult(-1, string.Empty, $"Could not start process: {fileName}"); - } - - var outputTask = process.StandardOutput.ReadToEndAsync(); - var errorTask = process.StandardError.ReadToEndAsync(); - var exitTask = process.WaitForExitAsync(); - var completedTask = await Task.WhenAny(exitTask, Task.Delay(timeout)).ConfigureAwait(false); - - if (completedTask != exitTask) - { - try - { - process.Kill(entireProcessTree: true); - } - catch - { - // Best-effort kill for timed-out processes. - } - - return new ProcessRunResult(-1, await outputTask.ConfigureAwait(false), $"Process timeout after {timeout.TotalSeconds} seconds"); - } - - await exitTask.ConfigureAwait(false); - return new ProcessRunResult(process.ExitCode, await outputTask.ConfigureAwait(false), await errorTask.ConfigureAwait(false)); - } - } -} +/* + * ThreadPilot - default process runner. + */ +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.IO; + using System.Threading.Tasks; + using ThreadPilot.Services.Abstractions; + + public sealed class SystemProcessRunner : IProcessRunner + { + public async Task RunAsync(string fileName, IReadOnlyList arguments, TimeSpan timeout) + { + if (!File.Exists(fileName)) + { + return new ProcessRunResult(-1, string.Empty, $"Executable not found: {fileName}"); + } + + var processInfo = new ProcessStartInfo + { + FileName = fileName, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + foreach (var argument in arguments) + { + processInfo.ArgumentList.Add(argument); + } + + using var process = Process.Start(processInfo); + if (process == null) + { + return new ProcessRunResult(-1, string.Empty, $"Could not start process: {fileName}"); + } + + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + var exitTask = process.WaitForExitAsync(); + var completedTask = await Task.WhenAny(exitTask, Task.Delay(timeout)).ConfigureAwait(false); + + if (completedTask != exitTask) + { + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // Best-effort kill for timed-out processes. + } + + return new ProcessRunResult(-1, await outputTask.ConfigureAwait(false), $"Process timeout after {timeout.TotalSeconds} seconds"); + } + + await exitTask.ConfigureAwait(false); + return new ProcessRunResult(process.ExitCode, await outputTask.ConfigureAwait(false), await errorTask.ConfigureAwait(false)); + } + } +} diff --git a/Services/SystemTrayMenuPlacement.cs b/Services/SystemTrayMenuPlacement.cs index 246b72e..9ae50b9 100644 --- a/Services/SystemTrayMenuPlacement.cs +++ b/Services/SystemTrayMenuPlacement.cs @@ -1,47 +1,47 @@ -namespace ThreadPilot.Services -{ - using System.Drawing; - - public static class SystemTrayMenuPlacement - { - public static Point ResolveMenuOpenPoint(Point cursorPosition, Point lastKnownPosition) - { - return ResolveMenuOpenPoint( - cursorPosition, - lastKnownPosition, - Rectangle.Empty, - Rectangle.Empty); - } - - public static Point ResolveMenuOpenPoint( - Point cursorPosition, - Point lastKnownPosition, - Rectangle trayBounds, - Rectangle fallbackWorkingArea) - { - if (!cursorPosition.IsEmpty) - { - return cursorPosition; - } - - if (!lastKnownPosition.IsEmpty) - { - return lastKnownPosition; - } - - if (!trayBounds.IsEmpty) - { - return new Point(trayBounds.Left + (trayBounds.Width / 2), trayBounds.Top + (trayBounds.Height / 2)); - } - - if (!fallbackWorkingArea.IsEmpty) - { - return new Point( - Math.Max(fallbackWorkingArea.Left + 1, fallbackWorkingArea.Right - 8), - Math.Max(fallbackWorkingArea.Top + 1, fallbackWorkingArea.Bottom - 8)); - } - - return new Point(16, 16); - } - } -} +namespace ThreadPilot.Services +{ + using System.Drawing; + + public static class SystemTrayMenuPlacement + { + public static Point ResolveMenuOpenPoint(Point cursorPosition, Point lastKnownPosition) + { + return ResolveMenuOpenPoint( + cursorPosition, + lastKnownPosition, + Rectangle.Empty, + Rectangle.Empty); + } + + public static Point ResolveMenuOpenPoint( + Point cursorPosition, + Point lastKnownPosition, + Rectangle trayBounds, + Rectangle fallbackWorkingArea) + { + if (!cursorPosition.IsEmpty) + { + return cursorPosition; + } + + if (!lastKnownPosition.IsEmpty) + { + return lastKnownPosition; + } + + if (!trayBounds.IsEmpty) + { + return new Point(trayBounds.Left + (trayBounds.Width / 2), trayBounds.Top + (trayBounds.Height / 2)); + } + + if (!fallbackWorkingArea.IsEmpty) + { + return new Point( + Math.Max(fallbackWorkingArea.Left + 1, fallbackWorkingArea.Right - 8), + Math.Max(fallbackWorkingArea.Top + 1, fallbackWorkingArea.Bottom - 8)); + } + + return new Point(16, 16); + } + } +} diff --git a/Services/SystemTrayService.cs b/Services/SystemTrayService.cs index 52e8b85..1bce92f 100644 --- a/Services/SystemTrayService.cs +++ b/Services/SystemTrayService.cs @@ -1,883 +1,864 @@ -/* - * 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.Drawing; - using System.IO; - using System.Windows.Forms; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - using MediaSolidColorBrush = System.Windows.Media.SolidColorBrush; - - /// - /// Service for managing system tray icon and context menu. - /// - public class SystemTrayService : ISystemTrayService - { - private readonly ILogger logger; - private NotifyIcon? notifyIcon; - private ContextMenuStrip? contextMenu; - private ToolStripMenuItem? quickApplyMenuItem; - private ToolStripMenuItem? selectedProcessMenuItem; - private ToolStripMenuItem? monitoringToggleMenuItem; - private ToolStripMenuItem? settingsMenuItem; - private ToolStripMenuItem? powerPlansMenuItem; - private ToolStripMenuItem? profilesMenuItem; - private ToolStripMenuItem? performanceMenuItem; - private ToolStripMenuItem? systemStatusMenuItem; - private ToolStripMenuItem? openDashboardMenuItem; - private ToolStripMenuItem? exitMenuItem; - private ApplicationSettingsModel settings; - private readonly ILocalizationService? localizationService; - private bool isMonitoring = true; - private bool isWmiAvailable = true; - private TrayIconState currentIconState = TrayIconState.Normal; - private bool isDarkTheme = true; - private Font? menuFont; - private Point lastContextMenuOpenPoint = Point.Empty; - private bool disposed = false; - - public event EventHandler? QuickApplyRequested; - - public event EventHandler? ShowMainWindowRequested; - - public event EventHandler? ExitRequested; - - public event EventHandler? MonitoringToggleRequested; - - public event EventHandler? SettingsRequested; - - public event EventHandler? PowerPlanChangeRequested; - - public event EventHandler? ProfileApplicationRequested; - - public event EventHandler? PerformanceDashboardRequested; - - public event EventHandler? DashboardRequested; - - public SystemTrayService(ILogger logger, ILocalizationService? localizationService = null) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.localizationService = localizationService; - this.settings = new ApplicationSettingsModel(); // Default settings - if (this.localizationService != null) - { - this.localizationService.LanguageChanged += this.OnLanguageChanged; - } - } - - public void Initialize() - { - try - { - this.logger.LogInformation("Initializing system tray service"); - - // Check if already initialized to prevent duplicate icons - if (this.notifyIcon != null) - { - this.logger.LogInformation("System tray service already initialized, skipping duplicate initialization to prevent duplicate icons"); - return; - } - - // Create the notify icon - this.notifyIcon = new NotifyIcon - { - Text = this.Localize("MainWindow_Title", "ThreadPilot - Process & Power Plan Manager"), - Visible = false, - }; - - // Load the tray icon (custom path if enabled, otherwise bundled ico.ico) - this.TryLoadTrayIcon(); - - // Create context menu - this.CreateContextMenu(); - - // Set up event handlers - this.notifyIcon.DoubleClick += this.OnTrayIconDoubleClick; - this.notifyIcon.MouseUp += this.OnTrayIconMouseUp; - - // Set initial icon state - this.UpdateTrayIcon(TrayIconState.Normal); - - this.logger.LogInformation("System tray service initialized successfully"); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to initialize system tray service"); - throw; - } - } - - private void CreateContextMenu() - { - this.contextMenu = new ContextMenuStrip(); - this.menuFont = CreatePreferredMenuFont(this.contextMenu.Font.Size); - this.contextMenu.Font = this.menuFont; - - this.openDashboardMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_OpenDashboard", "Open Dashboard")) - { - Font = new Font(this.menuFont, FontStyle.Regular), - }; - this.openDashboardMenuItem.Click += this.OnDashboardClick; - this.contextMenu.Items.Add(this.openDashboardMenuItem); - - if (AppNavigationOptions.ShowAdvancedDiagnostics) - { - this.performanceMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_OpenDiagnostics", "Open Diagnostics")) - { - Font = new Font(this.menuFont, FontStyle.Regular), - }; - this.performanceMenuItem.Click += this.OnPerformanceDashboardClick; - this.contextMenu.Items.Add(this.performanceMenuItem); - } - - this.monitoringToggleMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_PauseMonitoring", "Pause Automation Monitoring")); - this.monitoringToggleMenuItem.Click += this.OnMonitoringToggleClick; - this.contextMenu.Items.Add(this.monitoringToggleMenuItem); - - this.contextMenu.Items.Add(new ToolStripSeparator()); - - // System status (disabled, for display only) - this.systemStatusMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_SystemStatus", "System Status")) - { - Enabled = false, - Font = new Font(this.menuFont, FontStyle.Regular), - }; - this.contextMenu.Items.Add(this.systemStatusMenuItem); - - // Selected process info (disabled, for display only) - this.selectedProcessMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_NoProcessSelected", "No process selected")) - { - Enabled = false, - Font = new Font(this.menuFont, FontStyle.Regular), - }; - this.contextMenu.Items.Add(this.selectedProcessMenuItem); - - // Quick apply command - this.quickApplyMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_ApplyPendingToSelected", "Apply Pending Settings to Selected Process")) - { - Enabled = false, - }; - this.quickApplyMenuItem.Click += this.OnQuickApplyClick; - this.contextMenu.Items.Add(this.quickApplyMenuItem); - - this.contextMenu.Items.Add(new ToolStripSeparator()); - - // Power Plans submenu - this.powerPlansMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_PowerPlans", "๐Ÿ”‹ Power Plans")); - this.contextMenu.Items.Add(this.powerPlansMenuItem); - - // Profiles submenu - this.profilesMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_Profiles", "๐Ÿ“‹ Profiles")); - this.contextMenu.Items.Add(this.profilesMenuItem); - - this.contextMenu.Items.Add(new ToolStripSeparator()); - - // Settings - this.settingsMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_Settings", "Settings")); - this.settingsMenuItem.Click += this.OnSettingsClick; - this.contextMenu.Items.Add(this.settingsMenuItem); - - // Separator - this.contextMenu.Items.Add(new ToolStripSeparator()); - - // Exit - this.exitMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_Exit", "Exit")); - this.exitMenuItem.Click += this.OnExitClick; - this.contextMenu.Items.Add(this.exitMenuItem); - - this.ApplyContextMenuTheme(); - } - - public void Show() - { - if (this.notifyIcon != null) - { - this.notifyIcon.Visible = true; - this.logger.LogDebug("System tray icon shown"); - } - } - - public void Hide() - { - if (this.notifyIcon != null) - { - this.notifyIcon.Visible = false; - this.logger.LogDebug("System tray icon hidden"); - } - } - - public void UpdateTooltip(string tooltip) - { - if (this.notifyIcon != null) - { - this.notifyIcon.Text = tooltip.Length > 63 ? tooltip.Substring(0, 60) + "..." : tooltip; - } - } - - public void ShowBalloonTip(string title, string text, int timeoutMs = 3000) - { - if (this.notifyIcon != null && this.notifyIcon.Visible) - { - this.notifyIcon.ShowBalloonTip(timeoutMs, title, text, ToolTipIcon.Info); - } - } - - public void UpdateContextMenu(string? selectedProcessName = null, bool hasSelection = false) - { - if (this.selectedProcessMenuItem == null || this.quickApplyMenuItem == null) - { - return; - } - - if (hasSelection && !string.IsNullOrEmpty(selectedProcessName)) - { - this.selectedProcessMenuItem.Text = string.Format( - this.Localize("SystemTray_SelectedProcessFormat", "Selected: {0}"), - selectedProcessName); - this.quickApplyMenuItem.Enabled = true; - this.quickApplyMenuItem.Text = string.Format( - this.Localize("SystemTray_ApplyPendingToProcessFormat", "Apply Pending Settings to {0}"), - selectedProcessName); - } - else - { - this.selectedProcessMenuItem.Text = this.Localize("SystemTray_NoProcessSelected", "No process selected"); - this.quickApplyMenuItem.Enabled = false; - this.quickApplyMenuItem.Text = this.Localize("SystemTray_ApplyPendingToSelected", "Apply Pending Settings to Selected Process"); - } - } - - private void OnTrayIconDoubleClick(object? sender, EventArgs e) - { - this.ShowMainWindowRequested?.Invoke(this, EventArgs.Empty); - } - - private void OnTrayIconMouseUp(object? sender, MouseEventArgs e) - { - if (e.Button != MouseButtons.Right || this.contextMenu == null) - { - return; - } - - var cursorPosition = Cursor.Position; - var workingArea = Screen.FromPoint(cursorPosition.IsEmpty ? this.lastContextMenuOpenPoint : cursorPosition).WorkingArea; - var openPoint = SystemTrayMenuPlacement.ResolveMenuOpenPoint( - cursorPosition, - this.lastContextMenuOpenPoint, - Rectangle.Empty, - workingArea); - this.lastContextMenuOpenPoint = openPoint; - - if (this.contextMenu.Visible) - { - this.contextMenu.Close(ToolStripDropDownCloseReason.CloseCalled); - } - - this.contextMenu.Show(openPoint, ToolStripDropDownDirection.Default); - } - - private void OnQuickApplyClick(object? sender, EventArgs e) - { - this.QuickApplyRequested?.Invoke(this, EventArgs.Empty); - } - - private void OnDashboardClick(object? sender, EventArgs e) - { - this.DashboardRequested?.Invoke(this, EventArgs.Empty); - } - - private void OnExitClick(object? sender, EventArgs e) - { - this.ExitRequested?.Invoke(this, EventArgs.Empty); - } - - private void OnMonitoringToggleClick(object? sender, EventArgs e) - { - this.isMonitoring = !this.isMonitoring; - this.MonitoringToggleRequested?.Invoke(this, new MonitoringToggleEventArgs(this.isMonitoring)); - this.UpdateMonitoringStatus(this.isMonitoring, this.isWmiAvailable); - } - - private void OnSettingsClick(object? sender, EventArgs e) - { - this.SettingsRequested?.Invoke(this, EventArgs.Empty); - } - - private void OnPerformanceDashboardClick(object? sender, EventArgs e) - { - this.PerformanceDashboardRequested?.Invoke(this, EventArgs.Empty); - } - - private void OnPowerPlanClick(object? sender, EventArgs e) - { - if (sender is ToolStripMenuItem menuItem && menuItem.Tag is PowerPlanModel powerPlan) - { - this.PowerPlanChangeRequested?.Invoke(this, new PowerPlanChangeRequestedEventArgs(powerPlan.Guid, powerPlan.Name)); - } - } - - private void OnProfileClick(object? sender, EventArgs e) - { - if (sender is ToolStripMenuItem menuItem && menuItem.Tag is string profileName) - { - this.ProfileApplicationRequested?.Invoke(this, new ProfileApplicationRequestedEventArgs(profileName)); - } - } - - public void UpdateMonitoringStatus(bool isMonitoring, bool isWmiAvailable = true) - { - this.isMonitoring = isMonitoring; - this.isWmiAvailable = isWmiAvailable; - - if (this.monitoringToggleMenuItem != null) - { - this.monitoringToggleMenuItem.Text = isMonitoring - ? this.Localize("SystemTray_PauseMonitoring", "Pause Automation Monitoring") - : this.Localize("SystemTray_ResumeMonitoring", "Resume Automation Monitoring"); - this.monitoringToggleMenuItem.Enabled = isWmiAvailable; - } - - // Update tray icon state - var iconState = !isWmiAvailable ? TrayIconState.Error : - isMonitoring ? TrayIconState.Monitoring : TrayIconState.Disabled; - this.UpdateTrayIcon(iconState); - - // Update tooltip - var status = !isWmiAvailable - ? this.Localize("SystemTray_StatusWmiError", "Automation WMI Error") - : isMonitoring - ? this.Localize("SystemTray_StatusActive", "Automation Active") - : this.Localize("SystemTray_StatusDisabled", "Automation Disabled"); - this.UpdateTooltip($"ThreadPilot - {status}"); - } - - public void UpdateTrayIcon(TrayIconState state) - { - if (this.notifyIcon == null) - { - return; - } - - this.currentIconState = state; - - this.TryLoadTrayIcon(state); - } - - public void ShowTrayNotification(string title, string message, NotificationType type = NotificationType.Information, int timeoutMs = 3000) - { - if (this.notifyIcon == null || !this.settings.EnableBalloonNotifications) - { - return; - } - - try - { - var balloonIcon = type switch - { - NotificationType.Error => ToolTipIcon.Error, - NotificationType.Warning => ToolTipIcon.Warning, - NotificationType.Success => ToolTipIcon.Info, - _ => ToolTipIcon.Info, - }; - - this.notifyIcon.ShowBalloonTip(timeoutMs, title, message, balloonIcon); - this.logger.LogDebug("Balloon tip shown: {Title}", title); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error showing balloon tip"); - } - } - - public void UpdateSettings(ApplicationSettingsModel settings) - { - this.settings = settings ?? throw new ArgumentNullException(nameof(settings)); - - // Update tray icon visibility - if (this.notifyIcon != null) - { - this.notifyIcon.Visible = settings.ShowTrayIcon; - this.TryLoadTrayIcon(this.currentIconState); - } - - this.ApplyContextMenuTheme(); - - this.logger.LogDebug("Tray service settings updated"); - } - - public void ApplyTheme(bool useDarkTheme) - { - if (this.isDarkTheme == useDarkTheme) - { - return; - } - - this.isDarkTheme = useDarkTheme; - this.ApplyContextMenuTheme(); - } - - public void UpdatePowerPlans(IEnumerable powerPlans, PowerPlanModel? activePlan) - { - if (this.powerPlansMenuItem == null) - { - return; - } - - try - { - this.powerPlansMenuItem.DropDownItems.Clear(); - - foreach (var powerPlan in powerPlans) - { - var menuItem = new ToolStripMenuItem(powerPlan.Name) - { - Tag = powerPlan, - Checked = activePlan?.Guid == powerPlan.Guid, - }; - menuItem.Click += this.OnPowerPlanClick; - this.powerPlansMenuItem.DropDownItems.Add(menuItem); - } - - this.ApplyContextMenuTheme(); - - this.logger.LogDebug("Updated power plans in context menu"); - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to update power plans in context menu"); - } - } - - public void UpdateProfiles(IEnumerable profileNames) - { - if (this.profilesMenuItem == null) - { - return; - } - - try - { - this.profilesMenuItem.DropDownItems.Clear(); - - if (!profileNames.Any()) - { - var noProfilesItem = new ToolStripMenuItem(this.Localize("SystemTray_NoProfilesAvailable", "No profiles available")) - { - Enabled = false, - }; - this.profilesMenuItem.DropDownItems.Add(noProfilesItem); - } - else - { - foreach (var profileName in profileNames) - { - var menuItem = new ToolStripMenuItem(profileName) - { - Tag = profileName, - }; - menuItem.Click += this.OnProfileClick; - this.profilesMenuItem.DropDownItems.Add(menuItem); - } - } - - this.ApplyContextMenuTheme(); - - this.logger.LogDebug("Updated profiles in context menu"); - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to update profiles in context menu"); - } - } - - public void UpdateSystemStatus(string currentPowerPlan, double cpuUsage, double memoryUsage) - { - if (this.systemStatusMenuItem == null) - { - return; - } - - try - { - this.systemStatusMenuItem.Text = string.Format( - this.Localize("SystemTray_CpuRamStatusFormat", "๐Ÿ’ป CPU: {0:F1}% | RAM: {1:F1}% | {2}"), - cpuUsage, - memoryUsage, - currentPowerPlan); - this.logger.LogDebug("Updated system status in context menu"); - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to update system status in context menu"); - } - } - - public void UpdateSystemStatus(string currentPowerPlan) - { - if (this.systemStatusMenuItem == null) - { - return; - } - - try - { - this.systemStatusMenuItem.Text = string.Format( - this.Localize("SystemTray_PowerPlanFormat", "Power Plan: {0}"), - currentPowerPlan); - this.logger.LogDebug("Updated non-performance system status in context menu"); - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to update system status in context menu"); - } - } - - public void Dispose() - { - if (this.disposed) - { - return; - } - - try - { - this.logger.LogInformation("Disposing system tray service"); - - if (this.notifyIcon != null) - { - this.notifyIcon.MouseUp -= this.OnTrayIconMouseUp; - this.notifyIcon.Visible = false; - this.notifyIcon.Dispose(); - this.notifyIcon = null; - } - - if (this.contextMenu != null) - { - this.contextMenu.Dispose(); - this.contextMenu = null; - } - - this.menuFont?.Dispose(); - this.menuFont = null; - if (this.localizationService != null) - { - this.localizationService.LanguageChanged -= this.OnLanguageChanged; - } - - this.disposed = true; - this.logger.LogInformation("System tray service disposed"); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error disposing system tray service"); - } - } - - private string? ResolveTrayIconPath() - { - if (this.settings.UseCustomTrayIcon && !string.IsNullOrWhiteSpace(this.settings.CustomTrayIconPath) && File.Exists(this.settings.CustomTrayIconPath)) - { - return this.settings.CustomTrayIconPath; - } - - var iconCandidates = new[] - { - Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ico.ico"), - Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "assets", "icons", "ico.ico"), - }; - - foreach (var candidate in iconCandidates) - { - if (File.Exists(candidate)) - { - return candidate; - } - } - - return null; - } - - private void OnLanguageChanged(object? sender, string language) - { - this.UpdateLocalizedMenuText(); - } - - private void UpdateLocalizedMenuText() - { - if (this.notifyIcon != null) - { - this.notifyIcon.Text = this.Localize("MainWindow_Title", "ThreadPilot - Process & Power Plan Manager"); - } - - if (this.openDashboardMenuItem != null) - { - this.openDashboardMenuItem.Text = this.Localize("SystemTray_OpenDashboard", "Open Dashboard"); - } - - if (this.performanceMenuItem != null) - { - this.performanceMenuItem.Text = this.Localize("SystemTray_OpenDiagnostics", "Open Diagnostics"); - } - - if (this.systemStatusMenuItem != null) - { - this.systemStatusMenuItem.Text = this.Localize("SystemTray_SystemStatus", "System Status"); - } - - if (this.powerPlansMenuItem != null) - { - this.powerPlansMenuItem.Text = this.Localize("SystemTray_PowerPlans", "๐Ÿ”‹ Power Plans"); - } - - if (this.profilesMenuItem != null) - { - this.profilesMenuItem.Text = this.Localize("SystemTray_Profiles", "๐Ÿ“‹ Profiles"); - } - - if (this.settingsMenuItem != null) - { - this.settingsMenuItem.Text = this.Localize("SystemTray_Settings", "Settings"); - } - - if (this.exitMenuItem != null) - { - this.exitMenuItem.Text = this.Localize("SystemTray_Exit", "Exit"); - } - - this.UpdateContextMenu(); - this.UpdateMonitoringStatus(this.isMonitoring, this.isWmiAvailable); - } - - private string Localize(string key, string fallback) - { - if (this.localizationService == null) - { - return fallback; - } - - var localized = this.localizationService.GetString(key); - return string.IsNullOrWhiteSpace(localized) || string.Equals(localized, key, StringComparison.Ordinal) - ? fallback - : localized; - } - - private Icon? TryLoadEmbeddedIcon() - { - try - { - var uri = new Uri("pack://application:,,,/assets/icons/ico.ico", UriKind.Absolute); - var streamInfo = System.Windows.Application.GetResourceStream(uri); - if (streamInfo != null) - { - return new Icon(streamInfo.Stream); - } - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to load embedded icon"); - } - return null; - } - - private void TryLoadTrayIcon(TrayIconState? stateOverride = null) - { - if (this.notifyIcon == null) - { - return; - } - - try - { - // Try custom or external bundled icon first - var iconPath = this.ResolveTrayIconPath(); - if (iconPath != null) - { - this.notifyIcon.Icon = new Icon(iconPath); - this.logger.LogDebug("Tray icon set from {IconPath}", iconPath); - return; - } - - // Try embedded resource icon (for single-file publish) - var embeddedIcon = this.TryLoadEmbeddedIcon(); - if (embeddedIcon != null) - { - this.notifyIcon.Icon = embeddedIcon; - this.logger.LogDebug("Tray icon set from embedded resource"); - return; - } - - // Fallback to system icons if no custom/bundled/embedded icon is available - var state = stateOverride ?? this.currentIconState; - this.notifyIcon.Icon = state switch - { - TrayIconState.Normal => SystemIcons.Application, - TrayIconState.Monitoring => SystemIcons.Information, - TrayIconState.Error => SystemIcons.Error, - TrayIconState.Disabled => SystemIcons.Warning, - _ => SystemIcons.Application, - }; - this.logger.LogDebug("Tray icon set to system icon for state {State}", state); - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to load tray icon"); - } - } - - private void ApplyContextMenuTheme() - { - if (this.contextMenu == null) - { - return; - } - - Color backgroundColor; - Color foregroundColor; - Color selectionColor; - Color borderColor; - Color disabledColor; - - if (this.isDarkTheme) - { - // Force stable dark palette for WinForms tray menu even if XAML resources are unavailable. - backgroundColor = Color.FromArgb(28, 28, 30); - foregroundColor = Color.FromArgb(232, 232, 232); - selectionColor = Color.FromArgb(60, 60, 64); - borderColor = Color.FromArgb(74, 74, 79); - disabledColor = Color.FromArgb(132, 132, 136); - } - else - { - backgroundColor = ResolveColorFromResource("SurfaceAltBrush", SystemColors.Menu); - foregroundColor = ResolveColorFromResource("TextPrimaryBrush", SystemColors.MenuText); - selectionColor = ResolveColorFromResource("SoftSelectionBackgroundBrush", SystemColors.Highlight); - borderColor = ResolveColorFromResource("BorderBrush", SystemColors.ControlDark); - disabledColor = ResolveColorFromResource("TextDisabledBrush", SystemColors.GrayText); - } - - this.contextMenu.RenderMode = ToolStripRenderMode.Professional; - this.contextMenu.Renderer = new ToolStripProfessionalRenderer(new TrayMenuColorTable(backgroundColor, selectionColor, borderColor)); - this.contextMenu.BackColor = backgroundColor; - this.contextMenu.ForeColor = foregroundColor; - - ApplyMenuItemTheme(this.contextMenu.Items, backgroundColor, foregroundColor, disabledColor); - } - - private static void ApplyMenuItemTheme(ToolStripItemCollection items, Color backColor, Color foreColor, Color disabledColor) - { - foreach (ToolStripItem item in items) - { - if (item is ToolStripSeparator) - { - continue; - } - - item.BackColor = backColor; - item.ForeColor = item.Enabled ? foreColor : disabledColor; - - if (item is ToolStripMenuItem menuItem) - { - menuItem.DropDown.BackColor = backColor; - menuItem.DropDown.ForeColor = foreColor; - - if (menuItem.DropDownItems.Count > 0) - { - ApplyMenuItemTheme(menuItem.DropDownItems, backColor, foreColor, disabledColor); - } - } - } - } - - private sealed class TrayMenuColorTable : ProfessionalColorTable - { - private readonly Color backgroundColor; - private readonly Color selectionColor; - private readonly Color borderColor; - - public TrayMenuColorTable(Color backgroundColor, Color selectionColor, Color borderColor) - { - this.backgroundColor = backgroundColor; - this.selectionColor = selectionColor; - this.borderColor = borderColor; - } - - public override Color MenuBorder => this.borderColor; - - public override Color ToolStripDropDownBackground => this.backgroundColor; - - public override Color ImageMarginGradientBegin => this.ToolStripDropDownBackground; - - public override Color ImageMarginGradientMiddle => this.ToolStripDropDownBackground; - - public override Color ImageMarginGradientEnd => this.ToolStripDropDownBackground; - - public override Color MenuItemSelected => this.selectionColor; - - public override Color MenuItemSelectedGradientBegin => this.MenuItemSelected; - - public override Color MenuItemSelectedGradientEnd => this.MenuItemSelected; - - public override Color MenuItemBorder => this.borderColor; - } - - private static Font CreatePreferredMenuFont(float baseSize) - { - var size = Math.Max(8.5f, baseSize); - - try - { - return new Font("Segoe UI Variable", size, FontStyle.Regular, GraphicsUnit.Point); - } - catch - { - return new Font("Segoe UI", size, FontStyle.Regular, GraphicsUnit.Point); - } - } - - private static Color ResolveColorFromResource(string resourceKey, Color fallback) - { - var app = System.Windows.Application.Current; - if (app == null) - { - return fallback; - } - - MediaSolidColorBrush? brush = null; - - if (app.Dispatcher.CheckAccess()) - { - brush = app.TryFindResource(resourceKey) as MediaSolidColorBrush; - } - else - { - app.Dispatcher.Invoke(() => - { - brush = app.TryFindResource(resourceKey) as MediaSolidColorBrush; - }); - } - - if (brush != null) - { - return Color.FromArgb(brush.Color.A, brush.Color.R, brush.Color.G, brush.Color.B); - } - - return fallback; - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.Drawing; + using System.IO; + using System.Windows.Forms; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + using MediaSolidColorBrush = System.Windows.Media.SolidColorBrush; + + public class SystemTrayService : ISystemTrayService + { + private readonly ILogger logger; + private NotifyIcon? notifyIcon; + private ContextMenuStrip? contextMenu; + private ToolStripMenuItem? quickApplyMenuItem; + private ToolStripMenuItem? selectedProcessMenuItem; + private ToolStripMenuItem? monitoringToggleMenuItem; + private ToolStripMenuItem? settingsMenuItem; + private ToolStripMenuItem? powerPlansMenuItem; + private ToolStripMenuItem? profilesMenuItem; + private ToolStripMenuItem? performanceMenuItem; + private ToolStripMenuItem? systemStatusMenuItem; + private ToolStripMenuItem? openDashboardMenuItem; + private ToolStripMenuItem? exitMenuItem; + private ApplicationSettingsModel settings; + private readonly ILocalizationService? localizationService; + private bool isMonitoring = true; + private bool isWmiAvailable = true; + private TrayIconState currentIconState = TrayIconState.Normal; + private bool isDarkTheme = true; + private Font? menuFont; + private Point lastContextMenuOpenPoint = Point.Empty; + private bool disposed = false; + + public event EventHandler? QuickApplyRequested; + + public event EventHandler? ShowMainWindowRequested; + + public event EventHandler? ExitRequested; + + public event EventHandler? MonitoringToggleRequested; + + public event EventHandler? SettingsRequested; + + public event EventHandler? PowerPlanChangeRequested; + + public event EventHandler? ProfileApplicationRequested; + + public event EventHandler? PerformanceDashboardRequested; + + public event EventHandler? DashboardRequested; + + public SystemTrayService(ILogger logger, ILocalizationService? localizationService = null) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.localizationService = localizationService; + this.settings = new ApplicationSettingsModel(); // Default settings + if (this.localizationService != null) + { + this.localizationService.LanguageChanged += this.OnLanguageChanged; + } + } + + public void Initialize() + { + try + { + this.logger.LogInformation("Initializing system tray service"); + + // Check if already initialized to prevent duplicate icons + if (this.notifyIcon != null) + { + this.logger.LogInformation("System tray service already initialized, skipping duplicate initialization to prevent duplicate icons"); + return; + } + + // Create the notify icon + this.notifyIcon = new NotifyIcon + { + Text = this.Localize("MainWindow_Title", "ThreadPilot - Process & Power Plan Manager"), + Visible = false, + }; + + // Load the tray icon (custom path if enabled, otherwise bundled ico.ico) + this.TryLoadTrayIcon(); + + // Create context menu + this.CreateContextMenu(); + + // Set up event handlers + this.notifyIcon.DoubleClick += this.OnTrayIconDoubleClick; + this.notifyIcon.MouseUp += this.OnTrayIconMouseUp; + + // Set initial icon state + this.UpdateTrayIcon(TrayIconState.Normal); + + this.logger.LogInformation("System tray service initialized successfully"); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to initialize system tray service"); + throw; + } + } + + private void CreateContextMenu() + { + this.contextMenu = new ContextMenuStrip(); + this.menuFont = CreatePreferredMenuFont(this.contextMenu.Font.Size); + this.contextMenu.Font = this.menuFont; + + this.openDashboardMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_OpenDashboard", "Open Dashboard")) + { + Font = new Font(this.menuFont, FontStyle.Regular), + }; + this.openDashboardMenuItem.Click += this.OnDashboardClick; + this.contextMenu.Items.Add(this.openDashboardMenuItem); + + if (AppNavigationOptions.ShowAdvancedDiagnostics) + { + this.performanceMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_OpenDiagnostics", "Open Diagnostics")) + { + Font = new Font(this.menuFont, FontStyle.Regular), + }; + this.performanceMenuItem.Click += this.OnPerformanceDashboardClick; + this.contextMenu.Items.Add(this.performanceMenuItem); + } + + this.monitoringToggleMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_PauseMonitoring", "Pause Automation Monitoring")); + this.monitoringToggleMenuItem.Click += this.OnMonitoringToggleClick; + this.contextMenu.Items.Add(this.monitoringToggleMenuItem); + + this.contextMenu.Items.Add(new ToolStripSeparator()); + + // System status (disabled, for display only) + this.systemStatusMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_SystemStatus", "System Status")) + { + Enabled = false, + Font = new Font(this.menuFont, FontStyle.Regular), + }; + this.contextMenu.Items.Add(this.systemStatusMenuItem); + + // Selected process info (disabled, for display only) + this.selectedProcessMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_NoProcessSelected", "No process selected")) + { + Enabled = false, + Font = new Font(this.menuFont, FontStyle.Regular), + }; + this.contextMenu.Items.Add(this.selectedProcessMenuItem); + + // Quick apply command + this.quickApplyMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_ApplyPendingToSelected", "Apply Pending Settings to Selected Process")) + { + Enabled = false, + }; + this.quickApplyMenuItem.Click += this.OnQuickApplyClick; + this.contextMenu.Items.Add(this.quickApplyMenuItem); + + this.contextMenu.Items.Add(new ToolStripSeparator()); + + // Power Plans submenu + this.powerPlansMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_PowerPlans", "รฐลธโ€โ€น Power Plans")); + this.contextMenu.Items.Add(this.powerPlansMenuItem); + + // Profiles submenu + this.profilesMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_Profiles", "รฐลธโ€œโ€น Profiles")); + this.contextMenu.Items.Add(this.profilesMenuItem); + + this.contextMenu.Items.Add(new ToolStripSeparator()); + + // Settings + this.settingsMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_Settings", "Settings")); + this.settingsMenuItem.Click += this.OnSettingsClick; + this.contextMenu.Items.Add(this.settingsMenuItem); + + // Separator + this.contextMenu.Items.Add(new ToolStripSeparator()); + + // Exit + this.exitMenuItem = new ToolStripMenuItem(this.Localize("SystemTray_Exit", "Exit")); + this.exitMenuItem.Click += this.OnExitClick; + this.contextMenu.Items.Add(this.exitMenuItem); + + this.ApplyContextMenuTheme(); + } + + public void Show() + { + if (this.notifyIcon != null) + { + this.notifyIcon.Visible = true; + this.logger.LogDebug("System tray icon shown"); + } + } + + public void Hide() + { + if (this.notifyIcon != null) + { + this.notifyIcon.Visible = false; + this.logger.LogDebug("System tray icon hidden"); + } + } + + public void UpdateTooltip(string tooltip) + { + if (this.notifyIcon != null) + { + this.notifyIcon.Text = tooltip.Length > 63 ? tooltip.Substring(0, 60) + "..." : tooltip; + } + } + + public void ShowBalloonTip(string title, string text, int timeoutMs = 3000) + { + if (this.notifyIcon != null && this.notifyIcon.Visible) + { + this.notifyIcon.ShowBalloonTip(timeoutMs, title, text, ToolTipIcon.Info); + } + } + + public void UpdateContextMenu(string? selectedProcessName = null, bool hasSelection = false) + { + if (this.selectedProcessMenuItem == null || this.quickApplyMenuItem == null) + { + return; + } + + if (hasSelection && !string.IsNullOrEmpty(selectedProcessName)) + { + this.selectedProcessMenuItem.Text = string.Format( + this.Localize("SystemTray_SelectedProcessFormat", "Selected: {0}"), + selectedProcessName); + this.quickApplyMenuItem.Enabled = true; + this.quickApplyMenuItem.Text = string.Format( + this.Localize("SystemTray_ApplyPendingToProcessFormat", "Apply Pending Settings to {0}"), + selectedProcessName); + } + else + { + this.selectedProcessMenuItem.Text = this.Localize("SystemTray_NoProcessSelected", "No process selected"); + this.quickApplyMenuItem.Enabled = false; + this.quickApplyMenuItem.Text = this.Localize("SystemTray_ApplyPendingToSelected", "Apply Pending Settings to Selected Process"); + } + } + + private void OnTrayIconDoubleClick(object? sender, EventArgs e) + { + this.ShowMainWindowRequested?.Invoke(this, EventArgs.Empty); + } + + private void OnTrayIconMouseUp(object? sender, MouseEventArgs e) + { + if (e.Button != MouseButtons.Right || this.contextMenu == null) + { + return; + } + + var cursorPosition = Cursor.Position; + var workingArea = Screen.FromPoint(cursorPosition.IsEmpty ? this.lastContextMenuOpenPoint : cursorPosition).WorkingArea; + var openPoint = SystemTrayMenuPlacement.ResolveMenuOpenPoint( + cursorPosition, + this.lastContextMenuOpenPoint, + Rectangle.Empty, + workingArea); + this.lastContextMenuOpenPoint = openPoint; + + if (this.contextMenu.Visible) + { + this.contextMenu.Close(ToolStripDropDownCloseReason.CloseCalled); + } + + this.contextMenu.Show(openPoint, ToolStripDropDownDirection.Default); + } + + private void OnQuickApplyClick(object? sender, EventArgs e) + { + this.QuickApplyRequested?.Invoke(this, EventArgs.Empty); + } + + private void OnDashboardClick(object? sender, EventArgs e) + { + this.DashboardRequested?.Invoke(this, EventArgs.Empty); + } + + private void OnExitClick(object? sender, EventArgs e) + { + this.ExitRequested?.Invoke(this, EventArgs.Empty); + } + + private void OnMonitoringToggleClick(object? sender, EventArgs e) + { + this.isMonitoring = !this.isMonitoring; + this.MonitoringToggleRequested?.Invoke(this, new MonitoringToggleEventArgs(this.isMonitoring)); + this.UpdateMonitoringStatus(this.isMonitoring, this.isWmiAvailable); + } + + private void OnSettingsClick(object? sender, EventArgs e) + { + this.SettingsRequested?.Invoke(this, EventArgs.Empty); + } + + private void OnPerformanceDashboardClick(object? sender, EventArgs e) + { + this.PerformanceDashboardRequested?.Invoke(this, EventArgs.Empty); + } + + private void OnPowerPlanClick(object? sender, EventArgs e) + { + if (sender is ToolStripMenuItem menuItem && menuItem.Tag is PowerPlanModel powerPlan) + { + this.PowerPlanChangeRequested?.Invoke(this, new PowerPlanChangeRequestedEventArgs(powerPlan.Guid, powerPlan.Name)); + } + } + + private void OnProfileClick(object? sender, EventArgs e) + { + if (sender is ToolStripMenuItem menuItem && menuItem.Tag is string profileName) + { + this.ProfileApplicationRequested?.Invoke(this, new ProfileApplicationRequestedEventArgs(profileName)); + } + } + + public void UpdateMonitoringStatus(bool isMonitoring, bool isWmiAvailable = true) + { + this.isMonitoring = isMonitoring; + this.isWmiAvailable = isWmiAvailable; + + if (this.monitoringToggleMenuItem != null) + { + this.monitoringToggleMenuItem.Text = isMonitoring + ? this.Localize("SystemTray_PauseMonitoring", "Pause Automation Monitoring") + : this.Localize("SystemTray_ResumeMonitoring", "Resume Automation Monitoring"); + this.monitoringToggleMenuItem.Enabled = isWmiAvailable; + } + + // Update tray icon state + var iconState = !isWmiAvailable ? TrayIconState.Error : + isMonitoring ? TrayIconState.Monitoring : TrayIconState.Disabled; + this.UpdateTrayIcon(iconState); + + // Update tooltip + var status = !isWmiAvailable + ? this.Localize("SystemTray_StatusWmiError", "Automation WMI Error") + : isMonitoring + ? this.Localize("SystemTray_StatusActive", "Automation Active") + : this.Localize("SystemTray_StatusDisabled", "Automation Disabled"); + this.UpdateTooltip($"ThreadPilot - {status}"); + } + + public void UpdateTrayIcon(TrayIconState state) + { + if (this.notifyIcon == null) + { + return; + } + + this.currentIconState = state; + + this.TryLoadTrayIcon(state); + } + + public void ShowTrayNotification(string title, string message, NotificationType type = NotificationType.Information, int timeoutMs = 3000) + { + if (this.notifyIcon == null || !this.settings.EnableBalloonNotifications) + { + return; + } + + try + { + var balloonIcon = type switch + { + NotificationType.Error => ToolTipIcon.Error, + NotificationType.Warning => ToolTipIcon.Warning, + NotificationType.Success => ToolTipIcon.Info, + _ => ToolTipIcon.Info, + }; + + this.notifyIcon.ShowBalloonTip(timeoutMs, title, message, balloonIcon); + this.logger.LogDebug("Balloon tip shown: {Title}", title); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error showing balloon tip"); + } + } + + public void UpdateSettings(ApplicationSettingsModel settings) + { + this.settings = settings ?? throw new ArgumentNullException(nameof(settings)); + + // Update tray icon visibility + if (this.notifyIcon != null) + { + this.notifyIcon.Visible = settings.ShowTrayIcon; + this.TryLoadTrayIcon(this.currentIconState); + } + + this.ApplyContextMenuTheme(); + + this.logger.LogDebug("Tray service settings updated"); + } + + public void ApplyTheme(bool useDarkTheme) + { + if (this.isDarkTheme == useDarkTheme) + { + return; + } + + this.isDarkTheme = useDarkTheme; + this.ApplyContextMenuTheme(); + } + + public void UpdatePowerPlans(IEnumerable powerPlans, PowerPlanModel? activePlan) + { + if (this.powerPlansMenuItem == null) + { + return; + } + + try + { + this.powerPlansMenuItem.DropDownItems.Clear(); + + foreach (var powerPlan in powerPlans) + { + var menuItem = new ToolStripMenuItem(powerPlan.Name) + { + Tag = powerPlan, + Checked = activePlan?.Guid == powerPlan.Guid, + }; + menuItem.Click += this.OnPowerPlanClick; + this.powerPlansMenuItem.DropDownItems.Add(menuItem); + } + + this.ApplyContextMenuTheme(); + + this.logger.LogDebug("Updated power plans in context menu"); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to update power plans in context menu"); + } + } + + public void UpdateProfiles(IEnumerable profileNames) + { + if (this.profilesMenuItem == null) + { + return; + } + + try + { + this.profilesMenuItem.DropDownItems.Clear(); + + if (!profileNames.Any()) + { + var noProfilesItem = new ToolStripMenuItem(this.Localize("SystemTray_NoProfilesAvailable", "No profiles available")) + { + Enabled = false, + }; + this.profilesMenuItem.DropDownItems.Add(noProfilesItem); + } + else + { + foreach (var profileName in profileNames) + { + var menuItem = new ToolStripMenuItem(profileName) + { + Tag = profileName, + }; + menuItem.Click += this.OnProfileClick; + this.profilesMenuItem.DropDownItems.Add(menuItem); + } + } + + this.ApplyContextMenuTheme(); + + this.logger.LogDebug("Updated profiles in context menu"); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to update profiles in context menu"); + } + } + + public void UpdateSystemStatus(string currentPowerPlan, double cpuUsage, double memoryUsage) + { + if (this.systemStatusMenuItem == null) + { + return; + } + + try + { + this.systemStatusMenuItem.Text = string.Format( + this.Localize("SystemTray_CpuRamStatusFormat", "รฐลธโ€™ยป CPU: {0:F1}% | RAM: {1:F1}% | {2}"), + cpuUsage, + memoryUsage, + currentPowerPlan); + this.logger.LogDebug("Updated system status in context menu"); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to update system status in context menu"); + } + } + + public void UpdateSystemStatus(string currentPowerPlan) + { + if (this.systemStatusMenuItem == null) + { + return; + } + + try + { + this.systemStatusMenuItem.Text = string.Format( + this.Localize("SystemTray_PowerPlanFormat", "Power Plan: {0}"), + currentPowerPlan); + this.logger.LogDebug("Updated non-performance system status in context menu"); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to update system status in context menu"); + } + } + + public void Dispose() + { + if (this.disposed) + { + return; + } + + try + { + this.logger.LogInformation("Disposing system tray service"); + + if (this.notifyIcon != null) + { + this.notifyIcon.MouseUp -= this.OnTrayIconMouseUp; + this.notifyIcon.Visible = false; + this.notifyIcon.Dispose(); + this.notifyIcon = null; + } + + if (this.contextMenu != null) + { + this.contextMenu.Dispose(); + this.contextMenu = null; + } + + this.menuFont?.Dispose(); + this.menuFont = null; + if (this.localizationService != null) + { + this.localizationService.LanguageChanged -= this.OnLanguageChanged; + } + + this.disposed = true; + this.logger.LogInformation("System tray service disposed"); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error disposing system tray service"); + } + } + + private string? ResolveTrayIconPath() + { + if (this.settings.UseCustomTrayIcon && !string.IsNullOrWhiteSpace(this.settings.CustomTrayIconPath) && File.Exists(this.settings.CustomTrayIconPath)) + { + return this.settings.CustomTrayIconPath; + } + + var iconCandidates = new[] + { + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ico.ico"), + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "assets", "icons", "ico.ico"), + }; + + foreach (var candidate in iconCandidates) + { + if (File.Exists(candidate)) + { + return candidate; + } + } + + return null; + } + + private void OnLanguageChanged(object? sender, string language) + { + this.UpdateLocalizedMenuText(); + } + + private void UpdateLocalizedMenuText() + { + if (this.notifyIcon != null) + { + this.notifyIcon.Text = this.Localize("MainWindow_Title", "ThreadPilot - Process & Power Plan Manager"); + } + + if (this.openDashboardMenuItem != null) + { + this.openDashboardMenuItem.Text = this.Localize("SystemTray_OpenDashboard", "Open Dashboard"); + } + + if (this.performanceMenuItem != null) + { + this.performanceMenuItem.Text = this.Localize("SystemTray_OpenDiagnostics", "Open Diagnostics"); + } + + if (this.systemStatusMenuItem != null) + { + this.systemStatusMenuItem.Text = this.Localize("SystemTray_SystemStatus", "System Status"); + } + + if (this.powerPlansMenuItem != null) + { + this.powerPlansMenuItem.Text = this.Localize("SystemTray_PowerPlans", "รฐลธโ€โ€น Power Plans"); + } + + if (this.profilesMenuItem != null) + { + this.profilesMenuItem.Text = this.Localize("SystemTray_Profiles", "รฐลธโ€œโ€น Profiles"); + } + + if (this.settingsMenuItem != null) + { + this.settingsMenuItem.Text = this.Localize("SystemTray_Settings", "Settings"); + } + + if (this.exitMenuItem != null) + { + this.exitMenuItem.Text = this.Localize("SystemTray_Exit", "Exit"); + } + + this.UpdateContextMenu(); + this.UpdateMonitoringStatus(this.isMonitoring, this.isWmiAvailable); + } + + private string Localize(string key, string fallback) + { + if (this.localizationService == null) + { + return fallback; + } + + var localized = this.localizationService.GetString(key); + return string.IsNullOrWhiteSpace(localized) || string.Equals(localized, key, StringComparison.Ordinal) + ? fallback + : localized; + } + + private Icon? TryLoadEmbeddedIcon() + { + try + { + var uri = new Uri("pack://application:,,,/assets/icons/ico.ico", UriKind.Absolute); + var streamInfo = System.Windows.Application.GetResourceStream(uri); + if (streamInfo != null) + { + return new Icon(streamInfo.Stream); + } + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to load embedded icon"); + } + return null; + } + + private void TryLoadTrayIcon(TrayIconState? stateOverride = null) + { + if (this.notifyIcon == null) + { + return; + } + + try + { + // Try custom or external bundled icon first + var iconPath = this.ResolveTrayIconPath(); + if (iconPath != null) + { + this.notifyIcon.Icon = new Icon(iconPath); + this.logger.LogDebug("Tray icon set from {IconPath}", iconPath); + return; + } + + // Try embedded resource icon (for single-file publish) + var embeddedIcon = this.TryLoadEmbeddedIcon(); + if (embeddedIcon != null) + { + this.notifyIcon.Icon = embeddedIcon; + this.logger.LogDebug("Tray icon set from embedded resource"); + return; + } + + // Fallback to system icons if no custom/bundled/embedded icon is available + var state = stateOverride ?? this.currentIconState; + this.notifyIcon.Icon = state switch + { + TrayIconState.Normal => SystemIcons.Application, + TrayIconState.Monitoring => SystemIcons.Information, + TrayIconState.Error => SystemIcons.Error, + TrayIconState.Disabled => SystemIcons.Warning, + _ => SystemIcons.Application, + }; + this.logger.LogDebug("Tray icon set to system icon for state {State}", state); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to load tray icon"); + } + } + + private void ApplyContextMenuTheme() + { + if (this.contextMenu == null) + { + return; + } + + Color backgroundColor; + Color foregroundColor; + Color selectionColor; + Color borderColor; + Color disabledColor; + + if (this.isDarkTheme) + { + // Force stable dark palette for WinForms tray menu even if XAML resources are unavailable. + backgroundColor = Color.FromArgb(28, 28, 30); + foregroundColor = Color.FromArgb(232, 232, 232); + selectionColor = Color.FromArgb(60, 60, 64); + borderColor = Color.FromArgb(74, 74, 79); + disabledColor = Color.FromArgb(132, 132, 136); + } + else + { + backgroundColor = ResolveColorFromResource("SurfaceAltBrush", SystemColors.Menu); + foregroundColor = ResolveColorFromResource("TextPrimaryBrush", SystemColors.MenuText); + selectionColor = ResolveColorFromResource("SoftSelectionBackgroundBrush", SystemColors.Highlight); + borderColor = ResolveColorFromResource("BorderBrush", SystemColors.ControlDark); + disabledColor = ResolveColorFromResource("TextDisabledBrush", SystemColors.GrayText); + } + + this.contextMenu.RenderMode = ToolStripRenderMode.Professional; + this.contextMenu.Renderer = new ToolStripProfessionalRenderer(new TrayMenuColorTable(backgroundColor, selectionColor, borderColor)); + this.contextMenu.BackColor = backgroundColor; + this.contextMenu.ForeColor = foregroundColor; + + ApplyMenuItemTheme(this.contextMenu.Items, backgroundColor, foregroundColor, disabledColor); + } + + private static void ApplyMenuItemTheme(ToolStripItemCollection items, Color backColor, Color foreColor, Color disabledColor) + { + foreach (ToolStripItem item in items) + { + if (item is ToolStripSeparator) + { + continue; + } + + item.BackColor = backColor; + item.ForeColor = item.Enabled ? foreColor : disabledColor; + + if (item is ToolStripMenuItem menuItem) + { + menuItem.DropDown.BackColor = backColor; + menuItem.DropDown.ForeColor = foreColor; + + if (menuItem.DropDownItems.Count > 0) + { + ApplyMenuItemTheme(menuItem.DropDownItems, backColor, foreColor, disabledColor); + } + } + } + } + + private sealed class TrayMenuColorTable : ProfessionalColorTable + { + private readonly Color backgroundColor; + private readonly Color selectionColor; + private readonly Color borderColor; + + public TrayMenuColorTable(Color backgroundColor, Color selectionColor, Color borderColor) + { + this.backgroundColor = backgroundColor; + this.selectionColor = selectionColor; + this.borderColor = borderColor; + } + + public override Color MenuBorder => this.borderColor; + + public override Color ToolStripDropDownBackground => this.backgroundColor; + + public override Color ImageMarginGradientBegin => this.ToolStripDropDownBackground; + + public override Color ImageMarginGradientMiddle => this.ToolStripDropDownBackground; + + public override Color ImageMarginGradientEnd => this.ToolStripDropDownBackground; + + public override Color MenuItemSelected => this.selectionColor; + + public override Color MenuItemSelectedGradientBegin => this.MenuItemSelected; + + public override Color MenuItemSelectedGradientEnd => this.MenuItemSelected; + + public override Color MenuItemBorder => this.borderColor; + } + + private static Font CreatePreferredMenuFont(float baseSize) + { + var size = Math.Max(8.5f, baseSize); + + try + { + return new Font("Segoe UI Variable", size, FontStyle.Regular, GraphicsUnit.Point); + } + catch + { + return new Font("Segoe UI", size, FontStyle.Regular, GraphicsUnit.Point); + } + } + + private static Color ResolveColorFromResource(string resourceKey, Color fallback) + { + var app = System.Windows.Application.Current; + if (app == null) + { + return fallback; + } + + MediaSolidColorBrush? brush = null; + + if (app.Dispatcher.CheckAccess()) + { + brush = app.TryFindResource(resourceKey) as MediaSolidColorBrush; + } + else + { + app.Dispatcher.Invoke(() => + { + brush = app.TryFindResource(resourceKey) as MediaSolidColorBrush; + }); + } + + if (brush != null) + { + return Color.FromArgb(brush.Color.A, brush.Color.R, brush.Color.G, brush.Color.B); + } + + return fallback; + } + } +} diff --git a/Services/SystemTrayStatusUpdater.cs b/Services/SystemTrayStatusUpdater.cs index 6fa2592..f214d42 100644 --- a/Services/SystemTrayStatusUpdater.cs +++ b/Services/SystemTrayStatusUpdater.cs @@ -1,160 +1,144 @@ -/* - * 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.IO; - using System.Linq; - using System.Threading.Tasks; - using ThreadPilot.Models; - - public interface ISystemTrayStatusUpdater - { - bool ShouldRunPerformanceStatusUpdates { get; } - - Task UpdateContextMenuAsync(ISystemTrayService systemTrayService); - - Task UpdateStatusAsync(ISystemTrayService systemTrayService, Func dispatchAsync); - } - - public sealed class SystemTrayStatusUpdater : ISystemTrayStatusUpdater - { - private readonly IPowerPlanService powerPlanService; - private readonly Lazy performanceService; - private readonly ILocalizationService? localizationService; - - public SystemTrayStatusUpdater( - IPowerPlanService powerPlanService, - Lazy performanceService, - ILocalizationService? localizationService = null) - { - this.powerPlanService = powerPlanService ?? throw new ArgumentNullException(nameof(powerPlanService)); - this.performanceService = performanceService ?? throw new ArgumentNullException(nameof(performanceService)); - this.localizationService = localizationService; - } - - public bool ShouldRunPerformanceStatusUpdates => AppNavigationOptions.ShowAdvancedDiagnostics; - - public async Task UpdateContextMenuAsync(ISystemTrayService systemTrayService) - { - ArgumentNullException.ThrowIfNull(systemTrayService); - - var activePowerPlan = await this.UpdatePowerPlanMenuAsync(systemTrayService).ConfigureAwait(false); - this.UpdateProfileMenu(systemTrayService); - - await this.UpdateStatusCoreAsync( - systemTrayService, - activePowerPlan, - action => - { - action(); - return Task.CompletedTask; - }).ConfigureAwait(false); - } - - public async Task UpdateStatusAsync(ISystemTrayService systemTrayService, Func dispatchAsync) - { - ArgumentNullException.ThrowIfNull(systemTrayService); - ArgumentNullException.ThrowIfNull(dispatchAsync); - - try - { - var activePowerPlan = await this.powerPlanService.GetActivePowerPlan().ConfigureAwait(false); - await this.UpdateStatusCoreAsync(systemTrayService, activePowerPlan, dispatchAsync).ConfigureAwait(false); - return true; - } - catch - { - return false; - } - } - - private async Task UpdatePowerPlanMenuAsync(ISystemTrayService systemTrayService) - { - var powerPlans = await this.powerPlanService.GetPowerPlansAsync().ConfigureAwait(false); - var activePowerPlan = await this.powerPlanService.GetActivePowerPlan().ConfigureAwait(false); - systemTrayService.UpdatePowerPlans(powerPlans, activePowerPlan); - return activePowerPlan; - } - - private void UpdateProfileMenu(ISystemTrayService systemTrayService) - { - var profilesDirectory = StoragePaths.ProfilesDirectory; - var profileNames = new List(); - - if (Directory.Exists(profilesDirectory)) - { - profileNames = Directory.GetFiles(profilesDirectory, "*.json") - .Select(Path.GetFileNameWithoutExtension) - .Where(name => !string.IsNullOrWhiteSpace(name)) - .ToList()!; - } - - systemTrayService.UpdateProfiles(profileNames); - } - - private async Task UpdateStatusCoreAsync( - ISystemTrayService systemTrayService, - PowerPlanModel? activePowerPlan, - Func dispatchAsync) - { - var planName = activePowerPlan?.Name ?? this.Localize("SystemTray_Unknown", "Unknown"); - - if (!this.ShouldRunPerformanceStatusUpdates) - { - await dispatchAsync(() => systemTrayService.UpdateSystemStatus(planName)).ConfigureAwait(false); - return; - } - - try - { - var metricsTask = this.performanceService.Value.GetSystemMetricsAsync(lightweight: true); - var metricsResult = await Task.WhenAny(metricsTask, Task.Delay(2000)).ConfigureAwait(false); - - if (metricsResult == metricsTask) - { - var currentMetrics = await metricsTask.ConfigureAwait(false); - await dispatchAsync(() => systemTrayService.UpdateSystemStatus( - planName, - currentMetrics?.TotalCpuUsage ?? 0.0, - currentMetrics?.MemoryUsagePercentage ?? 0.0)).ConfigureAwait(false); - return; - } - } - catch - { - // Fall back to non-performance status below. - } - - await dispatchAsync(() => systemTrayService.UpdateSystemStatus(planName)).ConfigureAwait(false); - } - - private string Localize(string key, string fallback) - { - if (this.localizationService == null) - { - return fallback; - } - - var localized = this.localizationService.GetString(key); - return string.IsNullOrWhiteSpace(localized) || string.Equals(localized, key, StringComparison.Ordinal) - ? fallback - : localized; - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Threading.Tasks; + using ThreadPilot.Models; + + public interface ISystemTrayStatusUpdater + { + bool ShouldRunPerformanceStatusUpdates { get; } + + Task UpdateContextMenuAsync(ISystemTrayService systemTrayService); + + Task UpdateStatusAsync(ISystemTrayService systemTrayService, Func dispatchAsync); + } + + public sealed class SystemTrayStatusUpdater : ISystemTrayStatusUpdater + { + private readonly IPowerPlanService powerPlanService; + private readonly Lazy performanceService; + private readonly ILocalizationService? localizationService; + + public SystemTrayStatusUpdater( + IPowerPlanService powerPlanService, + Lazy performanceService, + ILocalizationService? localizationService = null) + { + this.powerPlanService = powerPlanService ?? throw new ArgumentNullException(nameof(powerPlanService)); + this.performanceService = performanceService ?? throw new ArgumentNullException(nameof(performanceService)); + this.localizationService = localizationService; + } + + public bool ShouldRunPerformanceStatusUpdates => AppNavigationOptions.ShowAdvancedDiagnostics; + + public async Task UpdateContextMenuAsync(ISystemTrayService systemTrayService) + { + ArgumentNullException.ThrowIfNull(systemTrayService); + + var activePowerPlan = await this.UpdatePowerPlanMenuAsync(systemTrayService).ConfigureAwait(false); + this.UpdateProfileMenu(systemTrayService); + + await this.UpdateStatusCoreAsync( + systemTrayService, + activePowerPlan, + action => + { + action(); + return Task.CompletedTask; + }).ConfigureAwait(false); + } + + public async Task UpdateStatusAsync(ISystemTrayService systemTrayService, Func dispatchAsync) + { + ArgumentNullException.ThrowIfNull(systemTrayService); + ArgumentNullException.ThrowIfNull(dispatchAsync); + + try + { + var activePowerPlan = await this.powerPlanService.GetActivePowerPlan().ConfigureAwait(false); + await this.UpdateStatusCoreAsync(systemTrayService, activePowerPlan, dispatchAsync).ConfigureAwait(false); + return true; + } + catch + { + return false; + } + } + + private async Task UpdatePowerPlanMenuAsync(ISystemTrayService systemTrayService) + { + var powerPlans = await this.powerPlanService.GetPowerPlansAsync().ConfigureAwait(false); + var activePowerPlan = await this.powerPlanService.GetActivePowerPlan().ConfigureAwait(false); + systemTrayService.UpdatePowerPlans(powerPlans, activePowerPlan); + return activePowerPlan; + } + + private void UpdateProfileMenu(ISystemTrayService systemTrayService) + { + var profilesDirectory = StoragePaths.ProfilesDirectory; + var profileNames = new List(); + + if (Directory.Exists(profilesDirectory)) + { + profileNames = Directory.GetFiles(profilesDirectory, "*.json") + .Select(Path.GetFileNameWithoutExtension) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToList()!; + } + + systemTrayService.UpdateProfiles(profileNames); + } + + private async Task UpdateStatusCoreAsync( + ISystemTrayService systemTrayService, + PowerPlanModel? activePowerPlan, + Func dispatchAsync) + { + var planName = activePowerPlan?.Name ?? this.Localize("SystemTray_Unknown", "Unknown"); + + if (!this.ShouldRunPerformanceStatusUpdates) + { + await dispatchAsync(() => systemTrayService.UpdateSystemStatus(planName)).ConfigureAwait(false); + return; + } + + try + { + var metricsTask = this.performanceService.Value.GetSystemMetricsAsync(lightweight: true); + var metricsResult = await Task.WhenAny(metricsTask, Task.Delay(2000)).ConfigureAwait(false); + + if (metricsResult == metricsTask) + { + var currentMetrics = await metricsTask.ConfigureAwait(false); + await dispatchAsync(() => systemTrayService.UpdateSystemStatus( + planName, + currentMetrics?.TotalCpuUsage ?? 0.0, + currentMetrics?.MemoryUsagePercentage ?? 0.0)).ConfigureAwait(false); + return; + } + } + catch + { + // Fall back to non-performance status below. + } + + await dispatchAsync(() => systemTrayService.UpdateSystemStatus(planName)).ConfigureAwait(false); + } + + private string Localize(string key, string fallback) + { + if (this.localizationService == null) + { + return fallback; + } + + var localized = this.localizationService.GetString(key); + return string.IsNullOrWhiteSpace(localized) || string.Equals(localized, key, StringComparison.Ordinal) + ? fallback + : localized; + } + } +} diff --git a/Services/SystemTweaksService.cs b/Services/SystemTweaksService.cs index 5609bb2..7da99fd 100644 --- a/Services/SystemTweaksService.cs +++ b/Services/SystemTweaksService.cs @@ -1,799 +1,780 @@ -/* - * 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.Diagnostics; - using System.IO; - using System.Management; - using System.ServiceProcess; - using System.Text.RegularExpressions; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - using Microsoft.Win32; - - /// - /// Service for managing Windows system tweaks and optimizations. - /// - public class SystemTweaksService : ISystemTweaksService - { - private static readonly string BcdEditExecutablePath = Path.Combine(Environment.SystemDirectory, "bcdedit.exe"); - private static readonly string PowerCfgExecutablePath = Path.Combine(Environment.SystemDirectory, "powercfg.exe"); - private static readonly string ScExecutablePath = Path.Combine(Environment.SystemDirectory, "sc.exe"); - private static readonly HashSet AllowedExecutablePaths = new(StringComparer.OrdinalIgnoreCase) - { - Path.GetFullPath(BcdEditExecutablePath), - Path.GetFullPath(PowerCfgExecutablePath), - Path.GetFullPath(ScExecutablePath), - }; - - private static readonly Regex HexValueRegex = new("0x([0-9a-fA-F]+)", RegexOptions.Compiled); - private static readonly Regex ServiceNameRegex = new("^[A-Za-z0-9_.-]+$", RegexOptions.Compiled); - private static readonly TimeSpan ExternalCommandTimeout = TimeSpan.FromSeconds(20); - private const string ProcessorSubgroupAlias = "SUB_PROCESSOR"; - private const string CoreParkingSettingAlias = "CPMINCORES"; - private const string CStatesSettingAlias = "IDLEDISABLE"; - private const string CoreParkingVisibilityKeyPath = @"SYSTEM\CurrentControlSet\Control\Power\PowerSettings\54533251-82be-4824-96c1-47b60b740d00\0cc5b647-c1df-4637-891a-dec35c318583"; - private const string PriorityControlKeyPath = @"SYSTEM\CurrentControlSet\Control\PriorityControl"; - private const string PrioritySeparationValueName = "Win32PrioritySeparation"; - private const int HighSchedulingCategoryDisabledValue = 2; - internal const int HighSchedulingCategoryEnabledValue = 0x1A; - private readonly ILogger logger; - private readonly IElevationService elevationService; - - public event EventHandler? TweakStatusChanged; - - public SystemTweaksService( - ILogger logger, - IElevationService elevationService) - { - this.logger = logger; - this.elevationService = elevationService; - } - - public async Task GetCoreParkingStatusAsync() - { - try - { - await this.EnsurePowerSettingVisibleAsync(ProcessorSubgroupAlias, CoreParkingSettingAlias); - - var acValue = await this.GetPowerCfgAcSettingValueAsync(ProcessorSubgroupAlias, CoreParkingSettingAlias); - if (!acValue.HasValue) - { - return new TweakStatus { IsAvailable = false, ErrorMessage = "Could not query Core Parking value via powercfg" }; - } - - // ON = disable parking (keep all cores unparked, typically 100) - var isEnabled = acValue.Value >= 100; - - return new TweakStatus - { - IsEnabled = isEnabled, - IsAvailable = true, - Description = "ON disables core parking (all cores unparked); OFF allows parking", - }; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error getting Core Parking status"); - return new TweakStatus { IsAvailable = false, ErrorMessage = ex.Message }; - } - } - - public async Task SetCoreParkingAsync(bool enabled) - { - try - { - if (!this.elevationService.IsRunningAsAdministrator()) - { - this.logger.LogWarning("Administrator privileges required to modify Core Parking"); - return false; - } - - await this.EnsurePowerSettingVisibleAsync(ProcessorSubgroupAlias, CoreParkingSettingAlias); - - var acValue = enabled ? 100 : 10; - var setValueResult = await RunProcessAsync( - PowerCfgExecutablePath, - $"-setacvalueindex SCHEME_CURRENT {ProcessorSubgroupAlias} {CoreParkingSettingAlias} {acValue}"); - if (setValueResult.ExitCode != 0) - { - this.logger.LogError( - "Failed setting Core Parking AC value. ExitCode={ExitCode}, Error={Error}", - setValueResult.ExitCode, setValueResult.StandardError); - return false; - } - - var activateResult = await RunProcessAsync(PowerCfgExecutablePath, "/setactive SCHEME_CURRENT"); - if (activateResult.ExitCode != 0) - { - this.logger.LogError( - "Failed activating current power scheme after Core Parking change. ExitCode={ExitCode}, Error={Error}", - activateResult.ExitCode, activateResult.StandardError); - return false; - } - - // Keep setting visible in Windows advanced power UI if the key exists. - using var visibilityKey = Registry.LocalMachine.OpenSubKey(CoreParkingVisibilityKeyPath, true); - if (visibilityKey != null) - { - visibilityKey.SetValue("Attributes", 2, RegistryValueKind.DWord); - } - - var status = await this.GetCoreParkingStatusAsync(); - this.TweakStatusChanged?.Invoke(this, new TweakStatusChangedEventArgs("CoreParking", status)); - - this.logger.LogInformation("Core Parking {Status}", enabled ? "enabled" : "disabled"); - return true; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error setting Core Parking to {Enabled}", enabled); - return false; - } - } - - public async Task GetCStatesStatusAsync() - { - try - { - await this.EnsurePowerSettingVisibleAsync(ProcessorSubgroupAlias, CStatesSettingAlias); - - var acValue = await this.GetPowerCfgAcSettingValueAsync(ProcessorSubgroupAlias, CStatesSettingAlias); - if (!acValue.HasValue) - { - return new TweakStatus { IsAvailable = false, ErrorMessage = "Could not query C-States value via powercfg" }; - } - - // ON = enable C-States (IDLEDISABLE=0), OFF = disable C-States (IDLEDISABLE=1) - var isEnabled = acValue.Value == 0; - - return new TweakStatus - { - IsEnabled = isEnabled, - IsAvailable = true, - Description = "ON enables C-States; OFF disables C-States for lower latency", - }; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error getting C-States status"); - return new TweakStatus { IsAvailable = false, ErrorMessage = ex.Message }; - } - } - - public async Task SetCStatesAsync(bool enabled) - { - try - { - if (!this.elevationService.IsRunningAsAdministrator()) - { - this.logger.LogWarning("Administrator privileges required to modify C-States"); - return false; - } - - await this.EnsurePowerSettingVisibleAsync(ProcessorSubgroupAlias, CStatesSettingAlias); - - var value = enabled ? 0 : 1; - var setValueResult = await RunProcessAsync( - PowerCfgExecutablePath, - $"-setacvalueindex SCHEME_CURRENT {ProcessorSubgroupAlias} {CStatesSettingAlias} {value}"); - if (setValueResult.ExitCode != 0) - { - this.logger.LogError( - "Failed setting C-States AC value. ExitCode={ExitCode}, Error={Error}", - setValueResult.ExitCode, setValueResult.StandardError); - return false; - } - - var activateResult = await RunProcessAsync(PowerCfgExecutablePath, "/setactive SCHEME_CURRENT"); - if (activateResult.ExitCode != 0) - { - this.logger.LogError( - "Failed activating current power scheme after C-States change. ExitCode={ExitCode}, Error={Error}", - activateResult.ExitCode, activateResult.StandardError); - return false; - } - - var status = await this.GetCStatesStatusAsync(); - this.TweakStatusChanged?.Invoke(this, new TweakStatusChangedEventArgs("CStates", status)); - - this.logger.LogInformation("C-States {Status}", enabled ? "enabled" : "disabled"); - return true; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error setting C-States to {Enabled}", enabled); - return false; - } - } - - public Task GetSysMainStatusAsync() - { - try - { - using var serviceController = new ServiceController("SysMain"); - serviceController.Refresh(); - var isEnabled = serviceController.StartType != ServiceStartMode.Disabled; - var isAvailable = true; - - return Task.FromResult(new TweakStatus - { - IsEnabled = isEnabled, - IsAvailable = isAvailable, - Description = "Windows Superfetch/SysMain service for memory management", - }); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error getting SysMain status"); - return Task.FromResult(new TweakStatus { IsAvailable = false, ErrorMessage = ex.Message }); - } - } - - public async Task SetSysMainAsync(bool enabled) - { - try - { - if (!this.elevationService.IsRunningAsAdministrator()) - { - this.logger.LogWarning("Administrator privileges required to modify SysMain service"); - return false; - } - - using var serviceController = new ServiceController("SysMain"); - if (!await this.SetServiceStartModeAsync("SysMain", enabled ? ServiceStartMode.Automatic : ServiceStartMode.Disabled)) - { - this.logger.LogError("Failed to set SysMain startup mode"); - return false; - } - - serviceController.Refresh(); - - if (enabled && serviceController.Status == ServiceControllerStatus.Stopped) - { - serviceController.Start(); - serviceController.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(30)); - } - else if (!enabled && (serviceController.Status == ServiceControllerStatus.Running || serviceController.Status == ServiceControllerStatus.Paused)) - { - serviceController.Stop(); - serviceController.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(30)); - } - - var status = await this.GetSysMainStatusAsync(); - this.TweakStatusChanged?.Invoke(this, new TweakStatusChangedEventArgs("SysMain", status)); - - this.logger.LogInformation("SysMain service {Status}", enabled ? "started" : "stopped"); - return true; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error setting SysMain service to {Enabled}", enabled); - return false; - } - } - - public Task GetPrefetchStatusAsync() - { - try - { - using var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management\PrefetchParameters"); - if (key == null) - { - return Task.FromResult(new TweakStatus { IsAvailable = false, ErrorMessage = "Prefetch registry key not found" }); - } - - var enablePrefetcher = key.GetValue("EnablePrefetcher"); - var isEnabled = enablePrefetcher?.ToString() != "0"; // 0 = disabled, 1-3 = enabled - - return Task.FromResult(new TweakStatus - { - IsEnabled = isEnabled, - IsAvailable = true, - Description = "Windows Prefetch feature for faster application loading", - }); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error getting Prefetch status"); - return Task.FromResult(new TweakStatus { IsAvailable = false, ErrorMessage = ex.Message }); - } - } - - public async Task SetPrefetchAsync(bool enabled) - { - try - { - if (!this.elevationService.IsRunningAsAdministrator()) - { - this.logger.LogWarning("Administrator privileges required to modify Prefetch"); - return false; - } - - using var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management\PrefetchParameters", true); - if (key == null) - { - this.logger.LogError("Prefetch registry key not found"); - return false; - } - - // Set EnablePrefetcher: 0 = disabled, 3 = enabled for both applications and boot - key.SetValue("EnablePrefetcher", enabled ? 3 : 0, RegistryValueKind.DWord); - - var status = await this.GetPrefetchStatusAsync(); - this.TweakStatusChanged?.Invoke(this, new TweakStatusChangedEventArgs("Prefetch", status)); - - this.logger.LogInformation("Prefetch {Status}", enabled ? "enabled" : "disabled"); - return true; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error setting Prefetch to {Enabled}", enabled); - return false; - } - } - - public Task GetPowerThrottlingStatusAsync() - { - try - { - using var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\Power\PowerThrottling"); - if (key == null) - { - return Task.FromResult(new TweakStatus { IsAvailable = false, ErrorMessage = "Power Throttling not available on this system" }); - } - - var powerThrottlingOff = ReadRegistryIntValue(key, "PowerThrottlingOff"); - // ON = disable throttling (PowerThrottlingOff=1) - var isEnabled = powerThrottlingOff.GetValueOrDefault(0) == 1; - - return Task.FromResult(new TweakStatus - { - IsEnabled = isEnabled, - IsAvailable = true, - Description = "ON disables Windows Power Throttling for sustained performance", - }); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error getting Power Throttling status"); - return Task.FromResult(new TweakStatus { IsAvailable = false, ErrorMessage = ex.Message }); - } - } - - public async Task SetPowerThrottlingAsync(bool enabled) - { - try - { - if (!this.elevationService.IsRunningAsAdministrator()) - { - this.logger.LogWarning("Administrator privileges required to modify Power Throttling"); - return false; - } - - using var key = Registry.LocalMachine.CreateSubKey(@"SYSTEM\CurrentControlSet\Control\Power\PowerThrottling"); - if (key == null) - { - this.logger.LogError("Could not create Power Throttling registry key"); - return false; - } - - // Set PowerThrottlingOff: 1 = throttling disabled, 0 = throttling enabled - key.SetValue("PowerThrottlingOff", enabled ? 1 : 0, RegistryValueKind.DWord); - - var status = await this.GetPowerThrottlingStatusAsync(); - this.TweakStatusChanged?.Invoke(this, new TweakStatusChangedEventArgs("PowerThrottling", status)); - - this.logger.LogInformation("Power Throttling {Status}", enabled ? "enabled" : "disabled"); - return true; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error setting Power Throttling to {Enabled}", enabled); - return false; - } - } - - public async Task GetHpetStatusAsync() - { - try - { - var result = await RunProcessAsync(BcdEditExecutablePath, "/enum"); - if (result.ExitCode != 0) - { - return new TweakStatus - { - IsAvailable = false, - ErrorMessage = string.IsNullOrWhiteSpace(result.StandardError) - ? "Could not query bcdedit status" - : result.StandardError, - }; - } - - var output = result.StandardOutput; - - var platformClockLine = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) - .FirstOrDefault(l => l.TrimStart().StartsWith("useplatformclock", StringComparison.OrdinalIgnoreCase)); - - // ON = disable HPET (useplatformclock removed/absent) - var isEnabled = true; - if (!string.IsNullOrWhiteSpace(platformClockLine)) - { - isEnabled = !platformClockLine.TrimEnd().EndsWith("Yes", StringComparison.OrdinalIgnoreCase); - } - - return new TweakStatus - { - IsEnabled = isEnabled, - IsAvailable = true, - Description = "High Precision Event Timer for system timing", - }; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error getting HPET status"); - return new TweakStatus { IsAvailable = false, ErrorMessage = ex.Message }; - } - } - - public async Task SetHpetAsync(bool enabled) - { - try - { - if (!this.elevationService.IsRunningAsAdministrator()) - { - this.logger.LogWarning("Administrator privileges required to modify HPET"); - return false; - } - - // ON = disable HPET (/deletevalue), OFF = force HPET (/set true) - var arguments = enabled ? "/deletevalue useplatformclock" : "/set useplatformclock true"; - var commandResult = await RunProcessAsync(BcdEditExecutablePath, arguments); - var success = commandResult.ExitCode == 0; - - if (success) - { - var status = await this.GetHpetStatusAsync(); - this.TweakStatusChanged?.Invoke(this, new TweakStatusChangedEventArgs("Hpet", status)); - this.logger.LogInformation("HPET {Status}", enabled ? "enabled" : "disabled"); - } - else - { - this.logger.LogWarning( - "Failed to set HPET. ExitCode={ExitCode}, Error={Error}", - commandResult.ExitCode, commandResult.StandardError); - } - - return success; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error setting HPET to {Enabled}", enabled); - return false; - } - } - - public Task GetHighSchedulingCategoryStatusAsync() - { - try - { - using var key = Registry.LocalMachine.OpenSubKey(PriorityControlKeyPath); - if (key == null) - { - return Task.FromResult(new TweakStatus { IsAvailable = false, ErrorMessage = "PriorityControl registry key not found" }); - } - - var rawValue = ReadRegistryIntValue(key, PrioritySeparationValueName); - var isEnabled = rawValue.GetValueOrDefault(HighSchedulingCategoryDisabledValue) == HighSchedulingCategoryEnabledValue; - - return Task.FromResult(new TweakStatus - { - IsEnabled = isEnabled, - IsAvailable = true, - Description = "ON applies high foreground boost (Win32PrioritySeparation=26 / 0x1A)", - }); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error getting High Scheduling Category status"); - return Task.FromResult(new TweakStatus { IsAvailable = false, ErrorMessage = ex.Message }); - } - } - - public async Task SetHighSchedulingCategoryAsync(bool enabled) - { - try - { - if (!this.elevationService.IsRunningAsAdministrator()) - { - this.logger.LogWarning("Administrator privileges required to modify High Scheduling Category"); - return false; - } - - using var key = Registry.LocalMachine.OpenSubKey(PriorityControlKeyPath, true); - if (key == null) - { - this.logger.LogError("PriorityControl registry key not found"); - return false; - } - - // ON = 26 / 0x1A, OFF = 2 (default/minimal boost) - key.SetValue(PrioritySeparationValueName, GetHighSchedulingCategoryRegistryValue(enabled), RegistryValueKind.DWord); - - var status = await this.GetHighSchedulingCategoryStatusAsync(); - this.TweakStatusChanged?.Invoke(this, new TweakStatusChangedEventArgs("HighSchedulingCategory", status)); - - this.logger.LogInformation("High Scheduling Category {Status}", enabled ? "enabled" : "disabled"); - return true; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error setting High Scheduling Category to {Enabled}", enabled); - return false; - } - } - - private async Task SetServiceStartModeAsync(string serviceName, ServiceStartMode mode) - { - if (!ServiceNameRegex.IsMatch(serviceName)) - { - this.logger.LogWarning("Rejected invalid service name format: {ServiceName}", serviceName); - return false; - } - - var startModeValue = mode switch - { - ServiceStartMode.Automatic => "auto", - ServiceStartMode.Manual => "demand", - ServiceStartMode.Disabled => "disabled", - _ => "demand", - }; - - var result = await RunProcessAsync(ScExecutablePath, $"config \"{serviceName}\" start= {startModeValue}"); - if (result.ExitCode != 0) - { - this.logger.LogWarning( - "Failed to update service start mode for {ServiceName}. ExitCode={ExitCode}, Error={Error}", - serviceName, result.ExitCode, result.StandardError); - return false; - } - - return true; - } - - internal static int GetHighSchedulingCategoryRegistryValue(bool enabled) => - enabled ? HighSchedulingCategoryEnabledValue : HighSchedulingCategoryDisabledValue; - - private async Task EnsurePowerSettingVisibleAsync(string subgroupAlias, string settingAlias) - { - var attributesResult = await RunProcessAsync( - PowerCfgExecutablePath, - $"-attributes {subgroupAlias} {settingAlias} -ATTRIB_HIDE"); - - if (attributesResult.ExitCode != 0) - { - this.logger.LogDebug( - "Could not unhide power setting {Subgroup}/{Setting}. ExitCode={ExitCode}, Error={Error}", - subgroupAlias, settingAlias, attributesResult.ExitCode, attributesResult.StandardError); - } - } - - private async Task GetPowerCfgAcSettingValueAsync(string subgroupAlias, string settingAlias) - { - var queryResult = await RunProcessAsync( - PowerCfgExecutablePath, - $"-query SCHEME_CURRENT {subgroupAlias} {settingAlias}"); - - if (queryResult.ExitCode != 0) - { - this.logger.LogWarning( - "powercfg query failed for {Subgroup}/{Setting}. ExitCode={ExitCode}, Error={Error}", - subgroupAlias, settingAlias, queryResult.ExitCode, queryResult.StandardError); - return null; - } - - var line = queryResult.StandardOutput - .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) - .FirstOrDefault(l => l.Contains("Current AC Power Setting Index", StringComparison.OrdinalIgnoreCase)); - if (string.IsNullOrWhiteSpace(line)) - { - return null; - } - - var match = HexValueRegex.Match(line); - if (!match.Success) - { - return null; - } - - return int.TryParse(match.Groups[1].Value, System.Globalization.NumberStyles.HexNumber, - System.Globalization.CultureInfo.InvariantCulture, out var parsed) - ? parsed - : null; - } - - private static async Task RunProcessAsync(string fileName, string arguments) - { - if (!IsAllowedExecutable(fileName)) - { - return new ProcessResult(-1, string.Empty, $"Executable not allowed: {fileName}"); - } - - var processInfo = new ProcessStartInfo - { - FileName = Path.GetFullPath(fileName), - Arguments = arguments, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - - using var process = Process.Start(processInfo); - if (process == null) - { - return new ProcessResult(-1, string.Empty, "Could not start process"); - } - - var outputTask = process.StandardOutput.ReadToEndAsync(); - var errorTask = process.StandardError.ReadToEndAsync(); - var exitTask = process.WaitForExitAsync(); - var completedTask = await Task.WhenAny(exitTask, Task.Delay(ExternalCommandTimeout)); - if (completedTask != exitTask) - { - try - { - process.Kill(entireProcessTree: true); - } - catch - { - // Best-effort kill for stuck child processes. - } - - return new ProcessResult(-1, await outputTask, $"Process timed out after {ExternalCommandTimeout.TotalSeconds} seconds"); - } - - await exitTask; - - return new ProcessResult(process.ExitCode, await outputTask, await errorTask); - } - - private static bool IsAllowedExecutable(string fileName) - { - if (string.IsNullOrWhiteSpace(fileName) || !Path.IsPathRooted(fileName)) - { - return false; - } - - var fullPath = Path.GetFullPath(fileName); - return AllowedExecutablePaths.Contains(fullPath) && File.Exists(fullPath); - } - - private static int? ReadRegistryIntValue(RegistryKey key, string valueName) - { - var raw = key.GetValue(valueName); - return raw switch - { - int intValue => intValue, - uint uintValue => unchecked((int)uintValue), - long longValue when longValue >= int.MinValue && longValue <= int.MaxValue => (int)longValue, - string stringValue when int.TryParse(stringValue, out var parsed) => parsed, - _ => null, - }; - } - - private readonly struct ProcessResult - { - public ProcessResult(int exitCode, string standardOutput, string standardError) - { - this.ExitCode = exitCode; - this.StandardOutput = standardOutput; - this.StandardError = standardError; - } - - public int ExitCode { get; } - - public string StandardOutput { get; } - - public string StandardError { get; } - } - - public Task GetMenuShowDelayStatusAsync() - { - try - { - using var key = Registry.CurrentUser.OpenSubKey(@"Control Panel\Desktop"); - if (key == null) - { - return Task.FromResult(new TweakStatus { IsAvailable = false, ErrorMessage = "Desktop registry key not found" }); - } - - var menuShowDelay = key.GetValue("MenuShowDelay"); - var isEnabled = menuShowDelay?.ToString() != "0"; // 0 = no delay, >0 = delay enabled - - return Task.FromResult(new TweakStatus - { - IsEnabled = isEnabled, - IsAvailable = true, - Description = "Delay before showing context menus", - }); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error getting Menu Show Delay status"); - return Task.FromResult(new TweakStatus { IsAvailable = false, ErrorMessage = ex.Message }); - } - } - - public async Task SetMenuShowDelayAsync(bool enabled) - { - try - { - using var key = Registry.CurrentUser.OpenSubKey(@"Control Panel\Desktop", true); - if (key == null) - { - this.logger.LogError("Desktop registry key not found"); - return false; - } - - // Set MenuShowDelay: 0 = no delay, 400 = default delay - key.SetValue("MenuShowDelay", enabled ? "400" : "0", RegistryValueKind.String); - - var status = await this.GetMenuShowDelayStatusAsync(); - this.TweakStatusChanged?.Invoke(this, new TweakStatusChangedEventArgs("MenuShowDelay", status)); - - this.logger.LogInformation("Menu Show Delay {Status}", enabled ? "enabled" : "disabled"); - return true; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error setting Menu Show Delay to {Enabled}", enabled); - return false; - } - } - - public async Task RefreshAllStatusesAsync() - { - try - { - this.logger.LogInformation("Refreshing all system tweak statuses"); - - var tasks = new[] - { - this.GetCoreParkingStatusAsync(), - this.GetCStatesStatusAsync(), - this.GetSysMainStatusAsync(), - this.GetPrefetchStatusAsync(), - this.GetPowerThrottlingStatusAsync(), - this.GetHpetStatusAsync(), - this.GetHighSchedulingCategoryStatusAsync(), - this.GetMenuShowDelayStatusAsync(), - }; - - await Task.WhenAll(tasks); - this.logger.LogInformation("All system tweak statuses refreshed"); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error refreshing system tweak statuses"); - } - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.IO; + using System.Management; + using System.ServiceProcess; + using System.Text.RegularExpressions; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using Microsoft.Win32; + + public class SystemTweaksService : ISystemTweaksService + { + private static readonly string BcdEditExecutablePath = Path.Combine(Environment.SystemDirectory, "bcdedit.exe"); + private static readonly string PowerCfgExecutablePath = Path.Combine(Environment.SystemDirectory, "powercfg.exe"); + private static readonly string ScExecutablePath = Path.Combine(Environment.SystemDirectory, "sc.exe"); + private static readonly HashSet AllowedExecutablePaths = new(StringComparer.OrdinalIgnoreCase) + { + Path.GetFullPath(BcdEditExecutablePath), + Path.GetFullPath(PowerCfgExecutablePath), + Path.GetFullPath(ScExecutablePath), + }; + + private static readonly Regex HexValueRegex = new("0x([0-9a-fA-F]+)", RegexOptions.Compiled); + private static readonly Regex ServiceNameRegex = new("^[A-Za-z0-9_.-]+$", RegexOptions.Compiled); + private static readonly TimeSpan ExternalCommandTimeout = TimeSpan.FromSeconds(20); + private const string ProcessorSubgroupAlias = "SUB_PROCESSOR"; + private const string CoreParkingSettingAlias = "CPMINCORES"; + private const string CStatesSettingAlias = "IDLEDISABLE"; + private const string CoreParkingVisibilityKeyPath = @"SYSTEM\CurrentControlSet\Control\Power\PowerSettings\54533251-82be-4824-96c1-47b60b740d00\0cc5b647-c1df-4637-891a-dec35c318583"; + private const string PriorityControlKeyPath = @"SYSTEM\CurrentControlSet\Control\PriorityControl"; + private const string PrioritySeparationValueName = "Win32PrioritySeparation"; + private const int HighSchedulingCategoryDisabledValue = 2; + internal const int HighSchedulingCategoryEnabledValue = 0x1A; + private readonly ILogger logger; + private readonly IElevationService elevationService; + + public event EventHandler? TweakStatusChanged; + + public SystemTweaksService( + ILogger logger, + IElevationService elevationService) + { + this.logger = logger; + this.elevationService = elevationService; + } + + public async Task GetCoreParkingStatusAsync() + { + try + { + await this.EnsurePowerSettingVisibleAsync(ProcessorSubgroupAlias, CoreParkingSettingAlias); + + var acValue = await this.GetPowerCfgAcSettingValueAsync(ProcessorSubgroupAlias, CoreParkingSettingAlias); + if (!acValue.HasValue) + { + return new TweakStatus { IsAvailable = false, ErrorMessage = "Could not query Core Parking value via powercfg" }; + } + + // ON = disable parking (keep all cores unparked, typically 100) + var isEnabled = acValue.Value >= 100; + + return new TweakStatus + { + IsEnabled = isEnabled, + IsAvailable = true, + Description = "ON disables core parking (all cores unparked); OFF allows parking", + }; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error getting Core Parking status"); + return new TweakStatus { IsAvailable = false, ErrorMessage = ex.Message }; + } + } + + public async Task SetCoreParkingAsync(bool enabled) + { + try + { + if (!this.elevationService.IsRunningAsAdministrator()) + { + this.logger.LogWarning("Administrator privileges required to modify Core Parking"); + return false; + } + + await this.EnsurePowerSettingVisibleAsync(ProcessorSubgroupAlias, CoreParkingSettingAlias); + + var acValue = enabled ? 100 : 10; + var setValueResult = await RunProcessAsync( + PowerCfgExecutablePath, + $"-setacvalueindex SCHEME_CURRENT {ProcessorSubgroupAlias} {CoreParkingSettingAlias} {acValue}"); + if (setValueResult.ExitCode != 0) + { + this.logger.LogError( + "Failed setting Core Parking AC value. ExitCode={ExitCode}, Error={Error}", + setValueResult.ExitCode, setValueResult.StandardError); + return false; + } + + var activateResult = await RunProcessAsync(PowerCfgExecutablePath, "/setactive SCHEME_CURRENT"); + if (activateResult.ExitCode != 0) + { + this.logger.LogError( + "Failed activating current power scheme after Core Parking change. ExitCode={ExitCode}, Error={Error}", + activateResult.ExitCode, activateResult.StandardError); + return false; + } + + // Keep setting visible in Windows advanced power UI if the key exists. + using var visibilityKey = Registry.LocalMachine.OpenSubKey(CoreParkingVisibilityKeyPath, true); + if (visibilityKey != null) + { + visibilityKey.SetValue("Attributes", 2, RegistryValueKind.DWord); + } + + var status = await this.GetCoreParkingStatusAsync(); + this.TweakStatusChanged?.Invoke(this, new TweakStatusChangedEventArgs("CoreParking", status)); + + this.logger.LogInformation("Core Parking {Status}", enabled ? "enabled" : "disabled"); + return true; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error setting Core Parking to {Enabled}", enabled); + return false; + } + } + + public async Task GetCStatesStatusAsync() + { + try + { + await this.EnsurePowerSettingVisibleAsync(ProcessorSubgroupAlias, CStatesSettingAlias); + + var acValue = await this.GetPowerCfgAcSettingValueAsync(ProcessorSubgroupAlias, CStatesSettingAlias); + if (!acValue.HasValue) + { + return new TweakStatus { IsAvailable = false, ErrorMessage = "Could not query C-States value via powercfg" }; + } + + // ON = enable C-States (IDLEDISABLE=0), OFF = disable C-States (IDLEDISABLE=1) + var isEnabled = acValue.Value == 0; + + return new TweakStatus + { + IsEnabled = isEnabled, + IsAvailable = true, + Description = "ON enables C-States; OFF disables C-States for lower latency", + }; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error getting C-States status"); + return new TweakStatus { IsAvailable = false, ErrorMessage = ex.Message }; + } + } + + public async Task SetCStatesAsync(bool enabled) + { + try + { + if (!this.elevationService.IsRunningAsAdministrator()) + { + this.logger.LogWarning("Administrator privileges required to modify C-States"); + return false; + } + + await this.EnsurePowerSettingVisibleAsync(ProcessorSubgroupAlias, CStatesSettingAlias); + + var value = enabled ? 0 : 1; + var setValueResult = await RunProcessAsync( + PowerCfgExecutablePath, + $"-setacvalueindex SCHEME_CURRENT {ProcessorSubgroupAlias} {CStatesSettingAlias} {value}"); + if (setValueResult.ExitCode != 0) + { + this.logger.LogError( + "Failed setting C-States AC value. ExitCode={ExitCode}, Error={Error}", + setValueResult.ExitCode, setValueResult.StandardError); + return false; + } + + var activateResult = await RunProcessAsync(PowerCfgExecutablePath, "/setactive SCHEME_CURRENT"); + if (activateResult.ExitCode != 0) + { + this.logger.LogError( + "Failed activating current power scheme after C-States change. ExitCode={ExitCode}, Error={Error}", + activateResult.ExitCode, activateResult.StandardError); + return false; + } + + var status = await this.GetCStatesStatusAsync(); + this.TweakStatusChanged?.Invoke(this, new TweakStatusChangedEventArgs("CStates", status)); + + this.logger.LogInformation("C-States {Status}", enabled ? "enabled" : "disabled"); + return true; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error setting C-States to {Enabled}", enabled); + return false; + } + } + + public Task GetSysMainStatusAsync() + { + try + { + using var serviceController = new ServiceController("SysMain"); + serviceController.Refresh(); + var isEnabled = serviceController.StartType != ServiceStartMode.Disabled; + var isAvailable = true; + + return Task.FromResult(new TweakStatus + { + IsEnabled = isEnabled, + IsAvailable = isAvailable, + Description = "Windows Superfetch/SysMain service for memory management", + }); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error getting SysMain status"); + return Task.FromResult(new TweakStatus { IsAvailable = false, ErrorMessage = ex.Message }); + } + } + + public async Task SetSysMainAsync(bool enabled) + { + try + { + if (!this.elevationService.IsRunningAsAdministrator()) + { + this.logger.LogWarning("Administrator privileges required to modify SysMain service"); + return false; + } + + using var serviceController = new ServiceController("SysMain"); + if (!await this.SetServiceStartModeAsync("SysMain", enabled ? ServiceStartMode.Automatic : ServiceStartMode.Disabled)) + { + this.logger.LogError("Failed to set SysMain startup mode"); + return false; + } + + serviceController.Refresh(); + + if (enabled && serviceController.Status == ServiceControllerStatus.Stopped) + { + serviceController.Start(); + serviceController.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(30)); + } + else if (!enabled && (serviceController.Status == ServiceControllerStatus.Running || serviceController.Status == ServiceControllerStatus.Paused)) + { + serviceController.Stop(); + serviceController.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(30)); + } + + var status = await this.GetSysMainStatusAsync(); + this.TweakStatusChanged?.Invoke(this, new TweakStatusChangedEventArgs("SysMain", status)); + + this.logger.LogInformation("SysMain service {Status}", enabled ? "started" : "stopped"); + return true; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error setting SysMain service to {Enabled}", enabled); + return false; + } + } + + public Task GetPrefetchStatusAsync() + { + try + { + using var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management\PrefetchParameters"); + if (key == null) + { + return Task.FromResult(new TweakStatus { IsAvailable = false, ErrorMessage = "Prefetch registry key not found" }); + } + + var enablePrefetcher = key.GetValue("EnablePrefetcher"); + var isEnabled = enablePrefetcher?.ToString() != "0"; // 0 = disabled, 1-3 = enabled + + return Task.FromResult(new TweakStatus + { + IsEnabled = isEnabled, + IsAvailable = true, + Description = "Windows Prefetch feature for faster application loading", + }); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error getting Prefetch status"); + return Task.FromResult(new TweakStatus { IsAvailable = false, ErrorMessage = ex.Message }); + } + } + + public async Task SetPrefetchAsync(bool enabled) + { + try + { + if (!this.elevationService.IsRunningAsAdministrator()) + { + this.logger.LogWarning("Administrator privileges required to modify Prefetch"); + return false; + } + + using var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management\PrefetchParameters", true); + if (key == null) + { + this.logger.LogError("Prefetch registry key not found"); + return false; + } + + // Set EnablePrefetcher: 0 = disabled, 3 = enabled for both applications and boot + key.SetValue("EnablePrefetcher", enabled ? 3 : 0, RegistryValueKind.DWord); + + var status = await this.GetPrefetchStatusAsync(); + this.TweakStatusChanged?.Invoke(this, new TweakStatusChangedEventArgs("Prefetch", status)); + + this.logger.LogInformation("Prefetch {Status}", enabled ? "enabled" : "disabled"); + return true; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error setting Prefetch to {Enabled}", enabled); + return false; + } + } + + public Task GetPowerThrottlingStatusAsync() + { + try + { + using var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\Power\PowerThrottling"); + if (key == null) + { + return Task.FromResult(new TweakStatus { IsAvailable = false, ErrorMessage = "Power Throttling not available on this system" }); + } + + var powerThrottlingOff = ReadRegistryIntValue(key, "PowerThrottlingOff"); + // ON = disable throttling (PowerThrottlingOff=1) + var isEnabled = powerThrottlingOff.GetValueOrDefault(0) == 1; + + return Task.FromResult(new TweakStatus + { + IsEnabled = isEnabled, + IsAvailable = true, + Description = "ON disables Windows Power Throttling for sustained performance", + }); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error getting Power Throttling status"); + return Task.FromResult(new TweakStatus { IsAvailable = false, ErrorMessage = ex.Message }); + } + } + + public async Task SetPowerThrottlingAsync(bool enabled) + { + try + { + if (!this.elevationService.IsRunningAsAdministrator()) + { + this.logger.LogWarning("Administrator privileges required to modify Power Throttling"); + return false; + } + + using var key = Registry.LocalMachine.CreateSubKey(@"SYSTEM\CurrentControlSet\Control\Power\PowerThrottling"); + if (key == null) + { + this.logger.LogError("Could not create Power Throttling registry key"); + return false; + } + + // Set PowerThrottlingOff: 1 = throttling disabled, 0 = throttling enabled + key.SetValue("PowerThrottlingOff", enabled ? 1 : 0, RegistryValueKind.DWord); + + var status = await this.GetPowerThrottlingStatusAsync(); + this.TweakStatusChanged?.Invoke(this, new TweakStatusChangedEventArgs("PowerThrottling", status)); + + this.logger.LogInformation("Power Throttling {Status}", enabled ? "enabled" : "disabled"); + return true; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error setting Power Throttling to {Enabled}", enabled); + return false; + } + } + + public async Task GetHpetStatusAsync() + { + try + { + var result = await RunProcessAsync(BcdEditExecutablePath, "/enum"); + if (result.ExitCode != 0) + { + return new TweakStatus + { + IsAvailable = false, + ErrorMessage = string.IsNullOrWhiteSpace(result.StandardError) + ? "Could not query bcdedit status" + : result.StandardError, + }; + } + + var output = result.StandardOutput; + + var platformClockLine = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(l => l.TrimStart().StartsWith("useplatformclock", StringComparison.OrdinalIgnoreCase)); + + // ON = disable HPET (useplatformclock removed/absent) + var isEnabled = true; + if (!string.IsNullOrWhiteSpace(platformClockLine)) + { + isEnabled = !platformClockLine.TrimEnd().EndsWith("Yes", StringComparison.OrdinalIgnoreCase); + } + + return new TweakStatus + { + IsEnabled = isEnabled, + IsAvailable = true, + Description = "High Precision Event Timer for system timing", + }; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error getting HPET status"); + return new TweakStatus { IsAvailable = false, ErrorMessage = ex.Message }; + } + } + + public async Task SetHpetAsync(bool enabled) + { + try + { + if (!this.elevationService.IsRunningAsAdministrator()) + { + this.logger.LogWarning("Administrator privileges required to modify HPET"); + return false; + } + + // ON = disable HPET (/deletevalue), OFF = force HPET (/set true) + var arguments = enabled ? "/deletevalue useplatformclock" : "/set useplatformclock true"; + var commandResult = await RunProcessAsync(BcdEditExecutablePath, arguments); + var success = commandResult.ExitCode == 0; + + if (success) + { + var status = await this.GetHpetStatusAsync(); + this.TweakStatusChanged?.Invoke(this, new TweakStatusChangedEventArgs("Hpet", status)); + this.logger.LogInformation("HPET {Status}", enabled ? "enabled" : "disabled"); + } + else + { + this.logger.LogWarning( + "Failed to set HPET. ExitCode={ExitCode}, Error={Error}", + commandResult.ExitCode, commandResult.StandardError); + } + + return success; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error setting HPET to {Enabled}", enabled); + return false; + } + } + + public Task GetHighSchedulingCategoryStatusAsync() + { + try + { + using var key = Registry.LocalMachine.OpenSubKey(PriorityControlKeyPath); + if (key == null) + { + return Task.FromResult(new TweakStatus { IsAvailable = false, ErrorMessage = "PriorityControl registry key not found" }); + } + + var rawValue = ReadRegistryIntValue(key, PrioritySeparationValueName); + var isEnabled = rawValue.GetValueOrDefault(HighSchedulingCategoryDisabledValue) == HighSchedulingCategoryEnabledValue; + + return Task.FromResult(new TweakStatus + { + IsEnabled = isEnabled, + IsAvailable = true, + Description = "ON applies high foreground boost (Win32PrioritySeparation=26 / 0x1A)", + }); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error getting High Scheduling Category status"); + return Task.FromResult(new TweakStatus { IsAvailable = false, ErrorMessage = ex.Message }); + } + } + + public async Task SetHighSchedulingCategoryAsync(bool enabled) + { + try + { + if (!this.elevationService.IsRunningAsAdministrator()) + { + this.logger.LogWarning("Administrator privileges required to modify High Scheduling Category"); + return false; + } + + using var key = Registry.LocalMachine.OpenSubKey(PriorityControlKeyPath, true); + if (key == null) + { + this.logger.LogError("PriorityControl registry key not found"); + return false; + } + + // ON = 26 / 0x1A, OFF = 2 (default/minimal boost) + key.SetValue(PrioritySeparationValueName, GetHighSchedulingCategoryRegistryValue(enabled), RegistryValueKind.DWord); + + var status = await this.GetHighSchedulingCategoryStatusAsync(); + this.TweakStatusChanged?.Invoke(this, new TweakStatusChangedEventArgs("HighSchedulingCategory", status)); + + this.logger.LogInformation("High Scheduling Category {Status}", enabled ? "enabled" : "disabled"); + return true; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error setting High Scheduling Category to {Enabled}", enabled); + return false; + } + } + + private async Task SetServiceStartModeAsync(string serviceName, ServiceStartMode mode) + { + if (!ServiceNameRegex.IsMatch(serviceName)) + { + this.logger.LogWarning("Rejected invalid service name format: {ServiceName}", serviceName); + return false; + } + + var startModeValue = mode switch + { + ServiceStartMode.Automatic => "auto", + ServiceStartMode.Manual => "demand", + ServiceStartMode.Disabled => "disabled", + _ => "demand", + }; + + var result = await RunProcessAsync(ScExecutablePath, $"config \"{serviceName}\" start= {startModeValue}"); + if (result.ExitCode != 0) + { + this.logger.LogWarning( + "Failed to update service start mode for {ServiceName}. ExitCode={ExitCode}, Error={Error}", + serviceName, result.ExitCode, result.StandardError); + return false; + } + + return true; + } + + internal static int GetHighSchedulingCategoryRegistryValue(bool enabled) => + enabled ? HighSchedulingCategoryEnabledValue : HighSchedulingCategoryDisabledValue; + + private async Task EnsurePowerSettingVisibleAsync(string subgroupAlias, string settingAlias) + { + var attributesResult = await RunProcessAsync( + PowerCfgExecutablePath, + $"-attributes {subgroupAlias} {settingAlias} -ATTRIB_HIDE"); + + if (attributesResult.ExitCode != 0) + { + this.logger.LogDebug( + "Could not unhide power setting {Subgroup}/{Setting}. ExitCode={ExitCode}, Error={Error}", + subgroupAlias, settingAlias, attributesResult.ExitCode, attributesResult.StandardError); + } + } + + private async Task GetPowerCfgAcSettingValueAsync(string subgroupAlias, string settingAlias) + { + var queryResult = await RunProcessAsync( + PowerCfgExecutablePath, + $"-query SCHEME_CURRENT {subgroupAlias} {settingAlias}"); + + if (queryResult.ExitCode != 0) + { + this.logger.LogWarning( + "powercfg query failed for {Subgroup}/{Setting}. ExitCode={ExitCode}, Error={Error}", + subgroupAlias, settingAlias, queryResult.ExitCode, queryResult.StandardError); + return null; + } + + var line = queryResult.StandardOutput + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(l => l.Contains("Current AC Power Setting Index", StringComparison.OrdinalIgnoreCase)); + if (string.IsNullOrWhiteSpace(line)) + { + return null; + } + + var match = HexValueRegex.Match(line); + if (!match.Success) + { + return null; + } + + return int.TryParse(match.Groups[1].Value, System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, out var parsed) + ? parsed + : null; + } + + private static async Task RunProcessAsync(string fileName, string arguments) + { + if (!IsAllowedExecutable(fileName)) + { + return new ProcessResult(-1, string.Empty, $"Executable not allowed: {fileName}"); + } + + var processInfo = new ProcessStartInfo + { + FileName = Path.GetFullPath(fileName), + Arguments = arguments, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + using var process = Process.Start(processInfo); + if (process == null) + { + return new ProcessResult(-1, string.Empty, "Could not start process"); + } + + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + var exitTask = process.WaitForExitAsync(); + var completedTask = await Task.WhenAny(exitTask, Task.Delay(ExternalCommandTimeout)); + if (completedTask != exitTask) + { + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // Best-effort kill for stuck child processes. + } + + return new ProcessResult(-1, await outputTask, $"Process timed out after {ExternalCommandTimeout.TotalSeconds} seconds"); + } + + await exitTask; + + return new ProcessResult(process.ExitCode, await outputTask, await errorTask); + } + + private static bool IsAllowedExecutable(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName) || !Path.IsPathRooted(fileName)) + { + return false; + } + + var fullPath = Path.GetFullPath(fileName); + return AllowedExecutablePaths.Contains(fullPath) && File.Exists(fullPath); + } + + private static int? ReadRegistryIntValue(RegistryKey key, string valueName) + { + var raw = key.GetValue(valueName); + return raw switch + { + int intValue => intValue, + uint uintValue => unchecked((int)uintValue), + long longValue when longValue >= int.MinValue && longValue <= int.MaxValue => (int)longValue, + string stringValue when int.TryParse(stringValue, out var parsed) => parsed, + _ => null, + }; + } + + private readonly struct ProcessResult + { + public ProcessResult(int exitCode, string standardOutput, string standardError) + { + this.ExitCode = exitCode; + this.StandardOutput = standardOutput; + this.StandardError = standardError; + } + + public int ExitCode { get; } + + public string StandardOutput { get; } + + public string StandardError { get; } + } + + public Task GetMenuShowDelayStatusAsync() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(@"Control Panel\Desktop"); + if (key == null) + { + return Task.FromResult(new TweakStatus { IsAvailable = false, ErrorMessage = "Desktop registry key not found" }); + } + + var menuShowDelay = key.GetValue("MenuShowDelay"); + var isEnabled = menuShowDelay?.ToString() != "0"; // 0 = no delay, >0 = delay enabled + + return Task.FromResult(new TweakStatus + { + IsEnabled = isEnabled, + IsAvailable = true, + Description = "Delay before showing context menus", + }); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error getting Menu Show Delay status"); + return Task.FromResult(new TweakStatus { IsAvailable = false, ErrorMessage = ex.Message }); + } + } + + public async Task SetMenuShowDelayAsync(bool enabled) + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(@"Control Panel\Desktop", true); + if (key == null) + { + this.logger.LogError("Desktop registry key not found"); + return false; + } + + // Set MenuShowDelay: 0 = no delay, 400 = default delay + key.SetValue("MenuShowDelay", enabled ? "400" : "0", RegistryValueKind.String); + + var status = await this.GetMenuShowDelayStatusAsync(); + this.TweakStatusChanged?.Invoke(this, new TweakStatusChangedEventArgs("MenuShowDelay", status)); + + this.logger.LogInformation("Menu Show Delay {Status}", enabled ? "enabled" : "disabled"); + return true; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error setting Menu Show Delay to {Enabled}", enabled); + return false; + } + } + + public async Task RefreshAllStatusesAsync() + { + try + { + this.logger.LogInformation("Refreshing all system tweak statuses"); + + var tasks = new[] + { + this.GetCoreParkingStatusAsync(), + this.GetCStatesStatusAsync(), + this.GetSysMainStatusAsync(), + this.GetPrefetchStatusAsync(), + this.GetPowerThrottlingStatusAsync(), + this.GetHpetStatusAsync(), + this.GetHighSchedulingCategoryStatusAsync(), + this.GetMenuShowDelayStatusAsync(), + }; + + await Task.WhenAll(tasks); + this.logger.LogInformation("All system tweak statuses refreshed"); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error refreshing system tweak statuses"); + } + } + } +} + diff --git a/Services/SystemUpdateClock.cs b/Services/SystemUpdateClock.cs index 35f8351..c5cd63c 100644 --- a/Services/SystemUpdateClock.cs +++ b/Services/SystemUpdateClock.cs @@ -1,12 +1,12 @@ -/* - * ThreadPilot - updater clock abstraction. - */ -namespace ThreadPilot.Services -{ - using System; - - public sealed class SystemUpdateClock : IUpdateClock - { - public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; - } -} +/* + * ThreadPilot - updater clock abstraction. + */ +namespace ThreadPilot.Services +{ + using System; + + public sealed class SystemUpdateClock : IUpdateClock + { + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; + } +} diff --git a/Services/TaskSafety.cs b/Services/TaskSafety.cs index 55bdef0..26e9b1d 100644 --- a/Services/TaskSafety.cs +++ b/Services/TaskSafety.cs @@ -1,45 +1,29 @@ -/* - * 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.Threading.Tasks; - - internal static class TaskSafety - { - public static void FireAndForget(Task task, Action onError) - { - _ = ObserveAsync(task, onError); - } - - private static async Task ObserveAsync(Task task, Action onError) - { - try - { - await task.ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Cancellation is expected in shutdown paths. - } - catch (Exception ex) - { - onError(ex); - } - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.Threading.Tasks; + + internal static class TaskSafety + { + public static void FireAndForget(Task task, Action onError) + { + _ = ObserveAsync(task, onError); + } + + private static async Task ObserveAsync(Task task, Action onError) + { + try + { + await task.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Cancellation is expected in shutdown paths. + } + catch (Exception ex) + { + onError(ex); + } + } + } +} diff --git a/Services/ThemeService.cs b/Services/ThemeService.cs index 7ceafba..d5062fb 100644 --- a/Services/ThemeService.cs +++ b/Services/ThemeService.cs @@ -1,237 +1,221 @@ -/* - * 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.Windows; - using Microsoft.Extensions.Logging; - using Microsoft.Win32; - using Wpf.Ui.Appearance; - using Wpf.Ui.Controls; - - public class ThemeService : IThemeService, IDisposable - { - private const string LightThemeDictionaryPath = "Themes/FluentLight.xaml"; - private const string DarkThemeDictionaryPath = "Themes/FluentDark.xaml"; - - private readonly ILogger logger; - private ResourceDictionary? activeThemeDictionary; - private Uri? activeThemeUri; - private bool hasAppliedTheme; - - public bool IsDarkTheme { get; private set; } - - public ThemeService(ILogger logger) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - SystemEvents.UserPreferenceChanged += this.OnUserPreferenceChanged; - } - - public void ApplyTheme(bool useDarkTheme) - { - var targetUri = new Uri(useDarkTheme ? DarkThemeDictionaryPath : LightThemeDictionaryPath, UriKind.Relative); - if (this.hasAppliedTheme && - this.IsDarkTheme == useDarkTheme && - string.Equals(this.activeThemeUri?.OriginalString, targetUri.OriginalString, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - var appResources = System.Windows.Application.Current?.Resources; - if (appResources == null) - { - return; - } - - try - { - // Keep Wpf.Ui controls aligned with app theme (NavigationView, TitleBar, etc.). - var applicationTheme = useDarkTheme ? ApplicationTheme.Dark : ApplicationTheme.Light; - ApplicationThemeManager.Apply(applicationTheme, WindowBackdropType.Mica, updateAccent: true); - - // ThreadPilot overrides depend on the active Wpf.Ui theme and must remain last - // because later merged dictionaries have precedence in WPF resource lookup. - this.activeThemeDictionary = ThemeDictionaryPolicy.ReplaceThreadPilotThemeDictionary(appResources, targetUri); - - this.IsDarkTheme = useDarkTheme; - this.activeThemeUri = targetUri; - this.hasAppliedTheme = true; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to apply theme {ThemeUri}", targetUri); - } - } - - public bool GetSystemUsesDarkTheme() - { - try - { - const string personalizeKey = @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; - - using var key = Registry.CurrentUser.OpenSubKey(personalizeKey, writable: false); - if (key != null) - { - var appsThemeValue = key.GetValue("AppsUseLightTheme"); - if (TryResolveDarkPreference(appsThemeValue, out var useDarkTheme)) - { - return useDarkTheme; - } - - // Fallback key used on some Windows configurations. - var systemThemeValue = key.GetValue("SystemUsesLightTheme"); - if (TryResolveDarkPreference(systemThemeValue, out useDarkTheme)) - { - return useDarkTheme; - } - } - - var detectedTheme = ApplicationThemeManager.GetSystemTheme(); - if (detectedTheme == SystemTheme.Dark) - { - return true; - } - - if (detectedTheme == SystemTheme.Light) - { - return false; - } - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to read system theme preference, falling back to light theme"); - } - - return false; - } - - private static bool TryResolveDarkPreference(object? value, out bool useDarkTheme) - { - useDarkTheme = false; - - switch (value) - { - case int intValue: - useDarkTheme = intValue == 0; - return true; - case long longValue: - useDarkTheme = longValue == 0; - return true; - case string stringValue when int.TryParse(stringValue, out var parsed): - useDarkTheme = parsed == 0; - return true; - default: - return false; - } - } - - private void OnUserPreferenceChanged(object sender, UserPreferenceChangedEventArgs e) - { - if (e.Category != UserPreferenceCategory.Color && - e.Category != UserPreferenceCategory.General && - e.Category != UserPreferenceCategory.VisualStyle) - { - return; - } - - try - { - this.ApplyTheme(this.GetSystemUsesDarkTheme()); - } - catch (Exception ex) - { - this.logger.LogDebug(ex, "Failed to apply theme after system preference change"); - } - } - - public void Dispose() - { - SystemEvents.UserPreferenceChanged -= this.OnUserPreferenceChanged; - } - } - - internal static class ThemeDictionaryPolicy - { - public static bool IsThreadPilotThemeDictionary(string? source) - { - if (string.IsNullOrWhiteSpace(source)) - { - return false; - } - - var normalized = source.Replace('\\', '/'); - return normalized.EndsWith("Themes/FluentLight.xaml", StringComparison.OrdinalIgnoreCase) || - normalized.EndsWith("Themes/FluentDark.xaml", StringComparison.OrdinalIgnoreCase); - } - - public static int GetInsertionIndex(int mergedDictionaryCount) - { - return Math.Max(0, mergedDictionaryCount); - } - - public static ResourceDictionary ReplaceThreadPilotThemeDictionary(ResourceDictionary appResources, Uri targetUri) - { - return ReplaceThreadPilotThemeDictionary( - appResources, - targetUri, - uri => new ResourceDictionary { Source = uri }); - } - - internal static ResourceDictionary ReplaceThreadPilotThemeDictionary( - ResourceDictionary appResources, - Uri targetUri, - Func dictionaryFactory) - { - ArgumentNullException.ThrowIfNull(appResources); - ArgumentNullException.ThrowIfNull(targetUri); - ArgumentNullException.ThrowIfNull(dictionaryFactory); - - ResourceDictionary? matchingDictionary = null; - for (int i = appResources.MergedDictionaries.Count - 1; i >= 0; i--) - { - var dictionary = appResources.MergedDictionaries[i]; - if (IsThreadPilotThemeDictionary(dictionary.Source?.OriginalString)) - { - if (matchingDictionary == null && - string.Equals(dictionary.Source?.OriginalString, targetUri.OriginalString, StringComparison.OrdinalIgnoreCase)) - { - matchingDictionary = dictionary; - continue; - } - - appResources.MergedDictionaries.RemoveAt(i); - } - } - - if (matchingDictionary != null) - { - appResources.MergedDictionaries.Remove(matchingDictionary); - appResources.MergedDictionaries.Insert( - GetInsertionIndex(appResources.MergedDictionaries.Count), - matchingDictionary); - return matchingDictionary; - } - - var nextDictionary = dictionaryFactory(targetUri); - appResources.MergedDictionaries.Insert( - GetInsertionIndex(appResources.MergedDictionaries.Count), - nextDictionary); - - return nextDictionary; - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.Windows; + using Microsoft.Extensions.Logging; + using Microsoft.Win32; + using Wpf.Ui.Appearance; + using Wpf.Ui.Controls; + + public class ThemeService : IThemeService, IDisposable + { + private const string LightThemeDictionaryPath = "Themes/FluentLight.xaml"; + private const string DarkThemeDictionaryPath = "Themes/FluentDark.xaml"; + + private readonly ILogger logger; + private ResourceDictionary? activeThemeDictionary; + private Uri? activeThemeUri; + private bool hasAppliedTheme; + + public bool IsDarkTheme { get; private set; } + + public ThemeService(ILogger logger) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + SystemEvents.UserPreferenceChanged += this.OnUserPreferenceChanged; + } + + public void ApplyTheme(bool useDarkTheme) + { + var targetUri = new Uri(useDarkTheme ? DarkThemeDictionaryPath : LightThemeDictionaryPath, UriKind.Relative); + if (this.hasAppliedTheme && + this.IsDarkTheme == useDarkTheme && + string.Equals(this.activeThemeUri?.OriginalString, targetUri.OriginalString, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var appResources = System.Windows.Application.Current?.Resources; + if (appResources == null) + { + return; + } + + try + { + // Keep Wpf.Ui controls aligned with app theme (NavigationView, TitleBar, etc.). + var applicationTheme = useDarkTheme ? ApplicationTheme.Dark : ApplicationTheme.Light; + ApplicationThemeManager.Apply(applicationTheme, WindowBackdropType.Mica, updateAccent: true); + + // ThreadPilot overrides depend on the active Wpf.Ui theme and must remain last + // because later merged dictionaries have precedence in WPF resource lookup. + this.activeThemeDictionary = ThemeDictionaryPolicy.ReplaceThreadPilotThemeDictionary(appResources, targetUri); + + this.IsDarkTheme = useDarkTheme; + this.activeThemeUri = targetUri; + this.hasAppliedTheme = true; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to apply theme {ThemeUri}", targetUri); + } + } + + public bool GetSystemUsesDarkTheme() + { + try + { + const string personalizeKey = @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; + + using var key = Registry.CurrentUser.OpenSubKey(personalizeKey, writable: false); + if (key != null) + { + var appsThemeValue = key.GetValue("AppsUseLightTheme"); + if (TryResolveDarkPreference(appsThemeValue, out var useDarkTheme)) + { + return useDarkTheme; + } + + // Fallback key used on some Windows configurations. + var systemThemeValue = key.GetValue("SystemUsesLightTheme"); + if (TryResolveDarkPreference(systemThemeValue, out useDarkTheme)) + { + return useDarkTheme; + } + } + + var detectedTheme = ApplicationThemeManager.GetSystemTheme(); + if (detectedTheme == SystemTheme.Dark) + { + return true; + } + + if (detectedTheme == SystemTheme.Light) + { + return false; + } + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to read system theme preference, falling back to light theme"); + } + + return false; + } + + private static bool TryResolveDarkPreference(object? value, out bool useDarkTheme) + { + useDarkTheme = false; + + switch (value) + { + case int intValue: + useDarkTheme = intValue == 0; + return true; + case long longValue: + useDarkTheme = longValue == 0; + return true; + case string stringValue when int.TryParse(stringValue, out var parsed): + useDarkTheme = parsed == 0; + return true; + default: + return false; + } + } + + private void OnUserPreferenceChanged(object sender, UserPreferenceChangedEventArgs e) + { + if (e.Category != UserPreferenceCategory.Color && + e.Category != UserPreferenceCategory.General && + e.Category != UserPreferenceCategory.VisualStyle) + { + return; + } + + try + { + this.ApplyTheme(this.GetSystemUsesDarkTheme()); + } + catch (Exception ex) + { + this.logger.LogDebug(ex, "Failed to apply theme after system preference change"); + } + } + + public void Dispose() + { + SystemEvents.UserPreferenceChanged -= this.OnUserPreferenceChanged; + } + } + + internal static class ThemeDictionaryPolicy + { + public static bool IsThreadPilotThemeDictionary(string? source) + { + if (string.IsNullOrWhiteSpace(source)) + { + return false; + } + + var normalized = source.Replace('\\', '/'); + return normalized.EndsWith("Themes/FluentLight.xaml", StringComparison.OrdinalIgnoreCase) || + normalized.EndsWith("Themes/FluentDark.xaml", StringComparison.OrdinalIgnoreCase); + } + + public static int GetInsertionIndex(int mergedDictionaryCount) + { + return Math.Max(0, mergedDictionaryCount); + } + + public static ResourceDictionary ReplaceThreadPilotThemeDictionary(ResourceDictionary appResources, Uri targetUri) + { + return ReplaceThreadPilotThemeDictionary( + appResources, + targetUri, + uri => new ResourceDictionary { Source = uri }); + } + + internal static ResourceDictionary ReplaceThreadPilotThemeDictionary( + ResourceDictionary appResources, + Uri targetUri, + Func dictionaryFactory) + { + ArgumentNullException.ThrowIfNull(appResources); + ArgumentNullException.ThrowIfNull(targetUri); + ArgumentNullException.ThrowIfNull(dictionaryFactory); + + ResourceDictionary? matchingDictionary = null; + for (int i = appResources.MergedDictionaries.Count - 1; i >= 0; i--) + { + var dictionary = appResources.MergedDictionaries[i]; + if (IsThreadPilotThemeDictionary(dictionary.Source?.OriginalString)) + { + if (matchingDictionary == null && + string.Equals(dictionary.Source?.OriginalString, targetUri.OriginalString, StringComparison.OrdinalIgnoreCase)) + { + matchingDictionary = dictionary; + continue; + } + + appResources.MergedDictionaries.RemoveAt(i); + } + } + + if (matchingDictionary != null) + { + appResources.MergedDictionaries.Remove(matchingDictionary); + appResources.MergedDictionaries.Insert( + GetInsertionIndex(appResources.MergedDictionaries.Count), + matchingDictionary); + return matchingDictionary; + } + + var nextDictionary = dictionaryFactory(targetUri); + appResources.MergedDictionaries.Insert( + GetInsertionIndex(appResources.MergedDictionaries.Count), + nextDictionary); + + return nextDictionary; + } + } +} diff --git a/Services/ThrottledRefreshCoordinator.cs b/Services/ThrottledRefreshCoordinator.cs index 8d4952b..aecba30 100644 --- a/Services/ThrottledRefreshCoordinator.cs +++ b/Services/ThrottledRefreshCoordinator.cs @@ -1,103 +1,84 @@ -/* - * 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.Threading; - using System.Threading.Tasks; - - /// - /// Reusable async throttling coordinator for delayed refresh operations. - /// - public sealed class ThrottledRefreshCoordinator : IDisposable - { - private readonly Func callback; - private readonly Action? onError; - private readonly object lockObject = new(); - private readonly TimeSpan defaultDelay; - private System.Threading.Timer? timer; - private int isExecuting; - private int disposedFlag; - - public ThrottledRefreshCoordinator(TimeSpan defaultDelay, Func callback, Action? onError = null) - { - this.defaultDelay = defaultDelay; - this.callback = callback ?? throw new ArgumentNullException(nameof(callback)); - this.onError = onError; - } - - public void Schedule() - { - this.Schedule(this.defaultDelay); - } - - public void Schedule(TimeSpan delay) - { - if (Interlocked.CompareExchange(ref this.disposedFlag, 0, 0) == 1) - { - return; - } - - lock (this.lockObject) - { - this.timer?.Dispose(); - this.timer = new System.Threading.Timer(this.OnTimerTick, null, delay, Timeout.InfiniteTimeSpan); - } - } - - private void OnTimerTick(object? state) - { - if (Interlocked.CompareExchange(ref this.disposedFlag, 0, 0) == 1) - { - return; - } - - if (Interlocked.Exchange(ref this.isExecuting, 1) == 1) - { - return; - } - - TaskSafety.FireAndForget(this.ExecuteCallbackAsync(), ex => this.onError?.Invoke(ex)); - } - - private async Task ExecuteCallbackAsync() - { - try - { - await this.callback().ConfigureAwait(false); - } - finally - { - Interlocked.Exchange(ref this.isExecuting, 0); - } - } - - public void Dispose() - { - if (Interlocked.Exchange(ref this.disposedFlag, 1) == 1) - { - return; - } - - lock (this.lockObject) - { - this.timer?.Dispose(); - this.timer = null; - } - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.Threading; + using System.Threading.Tasks; + + public sealed class ThrottledRefreshCoordinator : IDisposable + { + private readonly Func callback; + private readonly Action? onError; + private readonly object lockObject = new(); + private readonly TimeSpan defaultDelay; + private System.Threading.Timer? timer; + private int isExecuting; + private int disposedFlag; + + public ThrottledRefreshCoordinator(TimeSpan defaultDelay, Func callback, Action? onError = null) + { + this.defaultDelay = defaultDelay; + this.callback = callback ?? throw new ArgumentNullException(nameof(callback)); + this.onError = onError; + } + + public void Schedule() + { + this.Schedule(this.defaultDelay); + } + + public void Schedule(TimeSpan delay) + { + if (Interlocked.CompareExchange(ref this.disposedFlag, 0, 0) == 1) + { + return; + } + + lock (this.lockObject) + { + this.timer?.Dispose(); + this.timer = new System.Threading.Timer(this.OnTimerTick, null, delay, Timeout.InfiniteTimeSpan); + } + } + + private void OnTimerTick(object? state) + { + if (Interlocked.CompareExchange(ref this.disposedFlag, 0, 0) == 1) + { + return; + } + + if (Interlocked.Exchange(ref this.isExecuting, 1) == 1) + { + return; + } + + TaskSafety.FireAndForget(this.ExecuteCallbackAsync(), ex => this.onError?.Invoke(ex)); + } + + private async Task ExecuteCallbackAsync() + { + try + { + await this.callback().ConfigureAwait(false); + } + finally + { + Interlocked.Exchange(ref this.isExecuting, 0); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref this.disposedFlag, 1) == 1) + { + return; + } + + lock (this.lockObject) + { + this.timer?.Dispose(); + this.timer = null; + } + } + } +} diff --git a/Services/UpdateAssetSelector.cs b/Services/UpdateAssetSelector.cs index 10819f0..f3d619c 100644 --- a/Services/UpdateAssetSelector.cs +++ b/Services/UpdateAssetSelector.cs @@ -1,81 +1,81 @@ -/* - * ThreadPilot - release asset selection for safe installer updates. - */ -namespace ThreadPilot.Services -{ - using System; - using System.IO; - using System.Linq; - - public static class UpdateAssetSelector - { - public static bool TrySelectInstaller(UpdateReleaseInfo release, out UpdateAsset asset) - { - var selected = release.Assets - .Where(IsInstallerAsset) - .OrderByDescending(candidate => candidate.Name.Contains("setup", StringComparison.OrdinalIgnoreCase)) - .FirstOrDefault(); - - asset = selected!; - return selected != null; - } - - public static UpdateAsset? SelectChecksumAsset(UpdateReleaseInfo release) - { - return release.Assets.FirstOrDefault(asset => - string.Equals(asset.Name, "SHA256SUMS.txt", StringComparison.OrdinalIgnoreCase)); - } - - public static bool IsSafeGitHubAssetUrl(Uri uri) - { - if (uri.Scheme != Uri.UriSchemeHttps) - { - return false; - } - - return string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase) || - string.Equals(uri.Host, "objects.githubusercontent.com", StringComparison.OrdinalIgnoreCase); - } - - public static bool IsSafeAssetFileName(string assetName) - { - if (string.IsNullOrWhiteSpace(assetName)) - { - return false; - } - - if (!string.Equals(Path.GetFileName(assetName), assetName, StringComparison.Ordinal)) - { - return false; - } - - return assetName.IndexOfAny(Path.GetInvalidFileNameChars()) < 0; - } - - private static bool IsInstallerAsset(UpdateAsset asset) - { - if (!IsSafeGitHubAssetUrl(asset.DownloadUrl) || !IsSafeAssetFileName(asset.Name)) - { - return false; - } - - if (!asset.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (!asset.Name.StartsWith("ThreadPilot", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (asset.Name.Contains("portable", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - return asset.Name.Contains("setup", StringComparison.OrdinalIgnoreCase) || - asset.Name.Contains("installer", StringComparison.OrdinalIgnoreCase); - } - } -} +/* + * ThreadPilot - release asset selection for safe installer updates. + */ +namespace ThreadPilot.Services +{ + using System; + using System.IO; + using System.Linq; + + public static class UpdateAssetSelector + { + public static bool TrySelectInstaller(UpdateReleaseInfo release, out UpdateAsset asset) + { + var selected = release.Assets + .Where(IsInstallerAsset) + .OrderByDescending(candidate => candidate.Name.Contains("setup", StringComparison.OrdinalIgnoreCase)) + .FirstOrDefault(); + + asset = selected!; + return selected != null; + } + + public static UpdateAsset? SelectChecksumAsset(UpdateReleaseInfo release) + { + return release.Assets.FirstOrDefault(asset => + string.Equals(asset.Name, "SHA256SUMS.txt", StringComparison.OrdinalIgnoreCase)); + } + + public static bool IsSafeGitHubAssetUrl(Uri uri) + { + if (uri.Scheme != Uri.UriSchemeHttps) + { + return false; + } + + return string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase) || + string.Equals(uri.Host, "objects.githubusercontent.com", StringComparison.OrdinalIgnoreCase); + } + + public static bool IsSafeAssetFileName(string assetName) + { + if (string.IsNullOrWhiteSpace(assetName)) + { + return false; + } + + if (!string.Equals(Path.GetFileName(assetName), assetName, StringComparison.Ordinal)) + { + return false; + } + + return assetName.IndexOfAny(Path.GetInvalidFileNameChars()) < 0; + } + + private static bool IsInstallerAsset(UpdateAsset asset) + { + if (!IsSafeGitHubAssetUrl(asset.DownloadUrl) || !IsSafeAssetFileName(asset.Name)) + { + return false; + } + + if (!asset.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!asset.Name.StartsWith("ThreadPilot", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (asset.Name.Contains("portable", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return asset.Name.Contains("setup", StringComparison.OrdinalIgnoreCase) || + asset.Name.Contains("installer", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/Services/UpdateChecksumVerifier.cs b/Services/UpdateChecksumVerifier.cs index 82ff53e..f39130f 100644 --- a/Services/UpdateChecksumVerifier.cs +++ b/Services/UpdateChecksumVerifier.cs @@ -1,73 +1,73 @@ -/* - * ThreadPilot - SHA256SUMS parsing and verification. - */ -namespace ThreadPilot.Services -{ - using System; - using System.Globalization; - using System.IO; - using System.Linq; - using System.Security.Cryptography; - - public static class UpdateChecksumVerifier - { - public static bool TryFindExpectedHash(string checksumsText, string fileName, out string expectedHash) - { - expectedHash = string.Empty; - foreach (var rawLine in checksumsText.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries)) - { - var line = rawLine.Trim(); - if (line.Length == 0 || line.StartsWith('#')) - { - continue; - } - - if (line.StartsWith("SHA256(", StringComparison.OrdinalIgnoreCase)) - { - var close = line.IndexOf(')'); - var equals = line.IndexOf('=', StringComparison.Ordinal); - if (close > 7 && equals > close) - { - var listedName = line[7..close]; - var hash = line[(equals + 1)..].Trim(); - if (IsHash(hash) && string.Equals(listedName, fileName, StringComparison.OrdinalIgnoreCase)) - { - expectedHash = hash.ToUpperInvariant(); - return true; - } - } - } - - var parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 2 && IsHash(parts[0])) - { - var listedName = parts[^1].TrimStart('*'); - if (string.Equals(listedName, fileName, StringComparison.OrdinalIgnoreCase)) - { - expectedHash = parts[0].ToUpperInvariant(); - return true; - } - } - } - - return false; - } - - public static string ComputeSha256(string filePath) - { - using var stream = File.OpenRead(filePath); - var hash = SHA256.HashData(stream); - return string.Concat(hash.Select(b => b.ToString("X2", CultureInfo.InvariantCulture))); - } - - public static bool Verify(string filePath, string expectedHash) - { - return string.Equals(ComputeSha256(filePath), expectedHash, StringComparison.OrdinalIgnoreCase); - } - - private static bool IsHash(string value) - { - return value.Length == 64 && value.All(Uri.IsHexDigit); - } - } -} +/* + * ThreadPilot - SHA256SUMS parsing and verification. + */ +namespace ThreadPilot.Services +{ + using System; + using System.Globalization; + using System.IO; + using System.Linq; + using System.Security.Cryptography; + + public static class UpdateChecksumVerifier + { + public static bool TryFindExpectedHash(string checksumsText, string fileName, out string expectedHash) + { + expectedHash = string.Empty; + foreach (var rawLine in checksumsText.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries)) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith('#')) + { + continue; + } + + if (line.StartsWith("SHA256(", StringComparison.OrdinalIgnoreCase)) + { + var close = line.IndexOf(')'); + var equals = line.IndexOf('=', StringComparison.Ordinal); + if (close > 7 && equals > close) + { + var listedName = line[7..close]; + var hash = line[(equals + 1)..].Trim(); + if (IsHash(hash) && string.Equals(listedName, fileName, StringComparison.OrdinalIgnoreCase)) + { + expectedHash = hash.ToUpperInvariant(); + return true; + } + } + } + + var parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2 && IsHash(parts[0])) + { + var listedName = parts[^1].TrimStart('*'); + if (string.Equals(listedName, fileName, StringComparison.OrdinalIgnoreCase)) + { + expectedHash = parts[0].ToUpperInvariant(); + return true; + } + } + } + + return false; + } + + public static string ComputeSha256(string filePath) + { + using var stream = File.OpenRead(filePath); + var hash = SHA256.HashData(stream); + return string.Concat(hash.Select(b => b.ToString("X2", CultureInfo.InvariantCulture))); + } + + public static bool Verify(string filePath, string expectedHash) + { + return string.Equals(ComputeSha256(filePath), expectedHash, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsHash(string value) + { + return value.Length == 64 && value.All(Uri.IsHexDigit); + } + } +} diff --git a/Services/UpdateDownloadService.cs b/Services/UpdateDownloadService.cs index e14c343..54c9181 100644 --- a/Services/UpdateDownloadService.cs +++ b/Services/UpdateDownloadService.cs @@ -1,103 +1,103 @@ -/* - * ThreadPilot - secure update installer download and verification. - */ -namespace ThreadPilot.Services -{ - using System; - using System.IO; - using System.Threading; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - - public sealed class UpdateDownloadService : IUpdateDownloadService - { - private readonly IUpdateDownloadClient downloadClient; - private readonly IUpdateTempDirectoryProvider tempDirectoryProvider; - private readonly IUpdateSignatureVerifier signatureVerifier; - private readonly ILogger logger; - - public UpdateDownloadService( - IUpdateDownloadClient downloadClient, - IUpdateTempDirectoryProvider tempDirectoryProvider, - IUpdateSignatureVerifier signatureVerifier, - ILogger logger) - { - this.downloadClient = downloadClient ?? throw new ArgumentNullException(nameof(downloadClient)); - this.tempDirectoryProvider = tempDirectoryProvider ?? throw new ArgumentNullException(nameof(tempDirectoryProvider)); - this.signatureVerifier = signatureVerifier ?? throw new ArgumentNullException(nameof(signatureVerifier)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task DownloadInstallerAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default) - { - if (!UpdateAssetSelector.TrySelectInstaller(release, out var installerAsset)) - { - throw new InvalidOperationException("Release does not contain a ThreadPilot installer asset."); - } - - var tempDirectory = this.tempDirectoryProvider.CreateUpdateTempDirectory(release.Version); - try - { - var installerPath = Path.Combine(tempDirectory, installerAsset.Name); - await this.downloadClient.DownloadFileAsync(installerAsset.DownloadUrl, installerPath, cancellationToken) - .ConfigureAwait(false); - - var checksumVerified = false; - var checksumAsset = UpdateAssetSelector.SelectChecksumAsset(release); - if (checksumAsset != null) - { - var checksumText = await this.downloadClient.TryDownloadStringAsync(checksumAsset.DownloadUrl, cancellationToken) - .ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(checksumText) || - !UpdateChecksumVerifier.TryFindExpectedHash(checksumText, installerAsset.Name, out var expectedHash)) - { - throw new InvalidOperationException("SHA256SUMS.txt did not contain the installer checksum."); - } - - if (!UpdateChecksumVerifier.Verify(installerPath, expectedHash)) - { - throw new InvalidOperationException("Installer SHA256 checksum did not match SHA256SUMS.txt."); - } - - checksumVerified = true; - } - - var signatureStatus = this.signatureVerifier.Verify(installerPath); - if (signatureStatus == UpdateSignatureStatus.Invalid) - { - throw new InvalidOperationException("Installer Authenticode signature is invalid."); - } - - this.logger.LogInformation( - "Downloaded ThreadPilot update installer {InstallerName}; checksum verified: {ChecksumVerified}; signature: {SignatureStatus}", - installerAsset.Name, - checksumVerified, - signatureStatus); - - return new UpdateDownloadResult( - installerPath, - tempDirectory, - checksumVerified, - signatureStatus, - checksumVerified ? "Installer checksum verified." : "No SHA256SUMS.txt asset was available."); - } - catch - { - this.TryCleanup(tempDirectory); - throw; - } - } - - private void TryCleanup(string tempDirectory) - { - try - { - this.tempDirectoryProvider.Cleanup(tempDirectory); - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to clean update temp directory {TempDirectory}", tempDirectory); - } - } - } -} +/* + * ThreadPilot - secure update installer download and verification. + */ +namespace ThreadPilot.Services +{ + using System; + using System.IO; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + + public sealed class UpdateDownloadService : IUpdateDownloadService + { + private readonly IUpdateDownloadClient downloadClient; + private readonly IUpdateTempDirectoryProvider tempDirectoryProvider; + private readonly IUpdateSignatureVerifier signatureVerifier; + private readonly ILogger logger; + + public UpdateDownloadService( + IUpdateDownloadClient downloadClient, + IUpdateTempDirectoryProvider tempDirectoryProvider, + IUpdateSignatureVerifier signatureVerifier, + ILogger logger) + { + this.downloadClient = downloadClient ?? throw new ArgumentNullException(nameof(downloadClient)); + this.tempDirectoryProvider = tempDirectoryProvider ?? throw new ArgumentNullException(nameof(tempDirectoryProvider)); + this.signatureVerifier = signatureVerifier ?? throw new ArgumentNullException(nameof(signatureVerifier)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task DownloadInstallerAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default) + { + if (!UpdateAssetSelector.TrySelectInstaller(release, out var installerAsset)) + { + throw new InvalidOperationException("Release does not contain a ThreadPilot installer asset."); + } + + var tempDirectory = this.tempDirectoryProvider.CreateUpdateTempDirectory(release.Version); + try + { + var installerPath = Path.Combine(tempDirectory, installerAsset.Name); + await this.downloadClient.DownloadFileAsync(installerAsset.DownloadUrl, installerPath, cancellationToken) + .ConfigureAwait(false); + + var checksumVerified = false; + var checksumAsset = UpdateAssetSelector.SelectChecksumAsset(release); + if (checksumAsset != null) + { + var checksumText = await this.downloadClient.TryDownloadStringAsync(checksumAsset.DownloadUrl, cancellationToken) + .ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(checksumText) || + !UpdateChecksumVerifier.TryFindExpectedHash(checksumText, installerAsset.Name, out var expectedHash)) + { + throw new InvalidOperationException("SHA256SUMS.txt did not contain the installer checksum."); + } + + if (!UpdateChecksumVerifier.Verify(installerPath, expectedHash)) + { + throw new InvalidOperationException("Installer SHA256 checksum did not match SHA256SUMS.txt."); + } + + checksumVerified = true; + } + + var signatureStatus = this.signatureVerifier.Verify(installerPath); + if (signatureStatus == UpdateSignatureStatus.Invalid) + { + throw new InvalidOperationException("Installer Authenticode signature is invalid."); + } + + this.logger.LogInformation( + "Downloaded ThreadPilot update installer {InstallerName}; checksum verified: {ChecksumVerified}; signature: {SignatureStatus}", + installerAsset.Name, + checksumVerified, + signatureStatus); + + return new UpdateDownloadResult( + installerPath, + tempDirectory, + checksumVerified, + signatureStatus, + checksumVerified ? "Installer checksum verified." : "No SHA256SUMS.txt asset was available."); + } + catch + { + this.TryCleanup(tempDirectory); + throw; + } + } + + private void TryCleanup(string tempDirectory) + { + try + { + this.tempDirectoryProvider.Cleanup(tempDirectory); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to clean update temp directory {TempDirectory}", tempDirectory); + } + } + } +} diff --git a/Services/UpdateInstallerService.cs b/Services/UpdateInstallerService.cs index d710d65..4331106 100644 --- a/Services/UpdateInstallerService.cs +++ b/Services/UpdateInstallerService.cs @@ -1,64 +1,64 @@ -/* - * ThreadPilot - elevated update installer launch. - */ -namespace ThreadPilot.Services -{ - using System; - using System.Collections.Generic; - using System.Diagnostics; - using System.IO; - using System.Threading; - using System.Threading.Tasks; - - public sealed class UpdateInstallerService : IUpdateInstallerService - { - private readonly IUpdateTempDirectoryProvider tempDirectoryProvider; - private readonly IUpdateProcessLauncher processLauncher; - - public UpdateInstallerService( - IUpdateTempDirectoryProvider tempDirectoryProvider, - IUpdateProcessLauncher processLauncher) - { - this.tempDirectoryProvider = tempDirectoryProvider ?? throw new ArgumentNullException(nameof(tempDirectoryProvider)); - this.processLauncher = processLauncher ?? throw new ArgumentNullException(nameof(processLauncher)); - } - - public Task LaunchInstallerElevatedAsync(string installerPath, CancellationToken cancellationToken = default) - { - if (!File.Exists(installerPath)) - { - throw new FileNotFoundException("Update installer was not found.", installerPath); - } - - if (!string.Equals(Path.GetExtension(installerPath), ".exe", StringComparison.OrdinalIgnoreCase) || - !this.tempDirectoryProvider.IsSafeUpdateTempPath(installerPath)) - { - throw new InvalidOperationException("Update installer path is not trusted."); - } - - return this.processLauncher.LaunchElevatedAsync(installerPath, Array.Empty(), cancellationToken); - } - } - - public sealed class ShellUpdateProcessLauncher : IUpdateProcessLauncher - { - public Task LaunchElevatedAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken = default) - { - var startInfo = new ProcessStartInfo - { - FileName = fileName, - UseShellExecute = true, - Verb = "runas", - WorkingDirectory = Path.GetDirectoryName(fileName) ?? Environment.CurrentDirectory, - }; - - foreach (var argument in arguments) - { - startInfo.ArgumentList.Add(argument); - } - - Process.Start(startInfo); - return Task.CompletedTask; - } - } -} +/* + * ThreadPilot - elevated update installer launch. + */ +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.IO; + using System.Threading; + using System.Threading.Tasks; + + public sealed class UpdateInstallerService : IUpdateInstallerService + { + private readonly IUpdateTempDirectoryProvider tempDirectoryProvider; + private readonly IUpdateProcessLauncher processLauncher; + + public UpdateInstallerService( + IUpdateTempDirectoryProvider tempDirectoryProvider, + IUpdateProcessLauncher processLauncher) + { + this.tempDirectoryProvider = tempDirectoryProvider ?? throw new ArgumentNullException(nameof(tempDirectoryProvider)); + this.processLauncher = processLauncher ?? throw new ArgumentNullException(nameof(processLauncher)); + } + + public Task LaunchInstallerElevatedAsync(string installerPath, CancellationToken cancellationToken = default) + { + if (!File.Exists(installerPath)) + { + throw new FileNotFoundException("Update installer was not found.", installerPath); + } + + if (!string.Equals(Path.GetExtension(installerPath), ".exe", StringComparison.OrdinalIgnoreCase) || + !this.tempDirectoryProvider.IsSafeUpdateTempPath(installerPath)) + { + throw new InvalidOperationException("Update installer path is not trusted."); + } + + return this.processLauncher.LaunchElevatedAsync(installerPath, Array.Empty(), cancellationToken); + } + } + + public sealed class ShellUpdateProcessLauncher : IUpdateProcessLauncher + { + public Task LaunchElevatedAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken = default) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + UseShellExecute = true, + Verb = "runas", + WorkingDirectory = Path.GetDirectoryName(fileName) ?? Environment.CurrentDirectory, + }; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + Process.Start(startInfo); + return Task.CompletedTask; + } + } +} diff --git a/Services/UpdateModels.cs b/Services/UpdateModels.cs index 85cfe3e..68f8f05 100644 --- a/Services/UpdateModels.cs +++ b/Services/UpdateModels.cs @@ -1,126 +1,126 @@ -/* - * ThreadPilot - updater models and abstractions. - */ -namespace ThreadPilot.Services -{ - using System; - using System.Collections.Generic; - using System.Threading; - using System.Threading.Tasks; - - public enum UpdateCheckTrigger - { - Startup, - Manual, - } - - public enum UpdateCheckStatus - { - Skipped, - UpToDate, - UpdateAvailable, - Failed, - } - - public enum UpdateInstallStatus - { - Started, - Failed, - } - - public enum UpdateSignatureStatus - { - Valid, - Invalid, - Unknown, - } - - public sealed record UpdateCheckRequest(UpdateCheckTrigger Trigger); - - public sealed record UpdateAsset(string Name, Uri DownloadUrl, long Size); - - public sealed record UpdateReleaseInfo( - SemanticVersion Version, - string TagName, - Uri ReleasePageUrl, - bool IsPrerelease, - IReadOnlyList Assets); - - public sealed record UpdateCheckResult( - UpdateCheckStatus Status, - SemanticVersion CurrentVersion, - UpdateReleaseInfo? Release, - string Message) - { - public bool IsUpdateAvailable => this.Status == UpdateCheckStatus.UpdateAvailable && this.Release != null; - } - - public sealed record UpdateDownloadResult( - string InstallerPath, - string TempDirectory, - bool ChecksumVerified, - UpdateSignatureStatus SignatureStatus, - string Message); - - public sealed record UpdateInstallResult(UpdateInstallStatus Status, string Message); - - public interface IApplicationVersionProvider - { - SemanticVersion CurrentVersion { get; } - - string DisplayVersion { get; } - } - - public interface IUpdateClock - { - DateTimeOffset UtcNow { get; } - } - - public interface IUpdateService - { - Task CheckForUpdatesAsync(UpdateCheckRequest request, CancellationToken cancellationToken = default); - - Task DownloadAndInstallAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default); - } - - public interface IUpdateDownloadService - { - Task DownloadInstallerAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default); - } - - public interface IUpdateInstallerService - { - Task LaunchInstallerElevatedAsync(string installerPath, CancellationToken cancellationToken = default); - } - - public interface IUpdateDownloadClient - { - Task DownloadFileAsync(Uri uri, string destinationPath, CancellationToken cancellationToken = default); - - Task TryDownloadStringAsync(Uri uri, CancellationToken cancellationToken = default); - } - - public interface IUpdateTempDirectoryProvider - { - string CreateUpdateTempDirectory(SemanticVersion version); - - bool IsSafeUpdateTempPath(string path); - - void Cleanup(string path); - } - - public interface IUpdateSignatureVerifier - { - UpdateSignatureStatus Verify(string installerPath); - } - - public interface IUpdateProcessLauncher - { - Task LaunchElevatedAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken = default); - } - - public interface IApplicationShutdownService - { - void RequestShutdownForUpdate(); - } -} +/* + * ThreadPilot - updater models and abstractions. + */ +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + + public enum UpdateCheckTrigger + { + Startup, + Manual, + } + + public enum UpdateCheckStatus + { + Skipped, + UpToDate, + UpdateAvailable, + Failed, + } + + public enum UpdateInstallStatus + { + Started, + Failed, + } + + public enum UpdateSignatureStatus + { + Valid, + Invalid, + Unknown, + } + + public sealed record UpdateCheckRequest(UpdateCheckTrigger Trigger); + + public sealed record UpdateAsset(string Name, Uri DownloadUrl, long Size); + + public sealed record UpdateReleaseInfo( + SemanticVersion Version, + string TagName, + Uri ReleasePageUrl, + bool IsPrerelease, + IReadOnlyList Assets); + + public sealed record UpdateCheckResult( + UpdateCheckStatus Status, + SemanticVersion CurrentVersion, + UpdateReleaseInfo? Release, + string Message) + { + public bool IsUpdateAvailable => this.Status == UpdateCheckStatus.UpdateAvailable && this.Release != null; + } + + public sealed record UpdateDownloadResult( + string InstallerPath, + string TempDirectory, + bool ChecksumVerified, + UpdateSignatureStatus SignatureStatus, + string Message); + + public sealed record UpdateInstallResult(UpdateInstallStatus Status, string Message); + + public interface IApplicationVersionProvider + { + SemanticVersion CurrentVersion { get; } + + string DisplayVersion { get; } + } + + public interface IUpdateClock + { + DateTimeOffset UtcNow { get; } + } + + public interface IUpdateService + { + Task CheckForUpdatesAsync(UpdateCheckRequest request, CancellationToken cancellationToken = default); + + Task DownloadAndInstallAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default); + } + + public interface IUpdateDownloadService + { + Task DownloadInstallerAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default); + } + + public interface IUpdateInstallerService + { + Task LaunchInstallerElevatedAsync(string installerPath, CancellationToken cancellationToken = default); + } + + public interface IUpdateDownloadClient + { + Task DownloadFileAsync(Uri uri, string destinationPath, CancellationToken cancellationToken = default); + + Task TryDownloadStringAsync(Uri uri, CancellationToken cancellationToken = default); + } + + public interface IUpdateTempDirectoryProvider + { + string CreateUpdateTempDirectory(SemanticVersion version); + + bool IsSafeUpdateTempPath(string path); + + void Cleanup(string path); + } + + public interface IUpdateSignatureVerifier + { + UpdateSignatureStatus Verify(string installerPath); + } + + public interface IUpdateProcessLauncher + { + Task LaunchElevatedAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken = default); + } + + public interface IApplicationShutdownService + { + void RequestShutdownForUpdate(); + } +} diff --git a/Services/UpdateService.cs b/Services/UpdateService.cs index 5cc3256..ba9d2ea 100644 --- a/Services/UpdateService.cs +++ b/Services/UpdateService.cs @@ -1,158 +1,158 @@ -/* - * ThreadPilot - safe in-app update orchestration. - */ -namespace ThreadPilot.Services -{ - using System; - using System.Threading; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - - public sealed class UpdateService : IUpdateService - { - private const string OfficialOwner = "PrimeBuild-pc"; - private const string OfficialRepository = "ThreadPilot"; - - private readonly GitHubUpdateChecker updateChecker; - private readonly IApplicationSettingsService settingsService; - private readonly IApplicationVersionProvider versionProvider; - private readonly IUpdateDownloadService downloadService; - private readonly IUpdateInstallerService installerService; - private readonly IUpdateTempDirectoryProvider tempDirectoryProvider; - private readonly IApplicationShutdownService shutdownService; - private readonly IUpdateClock clock; - private readonly ILogger logger; - private readonly SemaphoreSlim checkGate = new(1, 1); - private readonly SemaphoreSlim installGate = new(1, 1); - - public UpdateService( - GitHubUpdateChecker updateChecker, - IApplicationSettingsService settingsService, - IApplicationVersionProvider versionProvider, - IUpdateDownloadService downloadService, - IUpdateInstallerService installerService, - IUpdateTempDirectoryProvider tempDirectoryProvider, - IApplicationShutdownService shutdownService, - IUpdateClock clock, - ILogger logger) - { - this.updateChecker = updateChecker ?? throw new ArgumentNullException(nameof(updateChecker)); - this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); - this.versionProvider = versionProvider ?? throw new ArgumentNullException(nameof(versionProvider)); - this.downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService)); - this.installerService = installerService ?? throw new ArgumentNullException(nameof(installerService)); - this.tempDirectoryProvider = tempDirectoryProvider ?? throw new ArgumentNullException(nameof(tempDirectoryProvider)); - this.shutdownService = shutdownService ?? throw new ArgumentNullException(nameof(shutdownService)); - this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task CheckForUpdatesAsync(UpdateCheckRequest request, CancellationToken cancellationToken = default) - { - var currentVersion = this.versionProvider.CurrentVersion; - var settings = this.settingsService.Settings; - - if (request.Trigger == UpdateCheckTrigger.Startup) - { - if (!settings.EnableAutomaticUpdateChecks) - { - return new UpdateCheckResult(UpdateCheckStatus.Skipped, currentVersion, null, "Automatic update checks are disabled."); - } - - var intervalDays = Math.Max(1, settings.UpdateCheckIntervalDays); - if (settings.LastUpdateCheckUtc.HasValue && - this.clock.UtcNow - settings.LastUpdateCheckUtc.Value < TimeSpan.FromDays(intervalDays)) - { - return new UpdateCheckResult(UpdateCheckStatus.Skipped, currentVersion, null, "Startup update check throttled."); - } - } - - await this.checkGate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - await this.MarkUpdateCheckAttemptAsync(cancellationToken).ConfigureAwait(false); - - var release = await this.updateChecker.GetLatestReleaseInfoAsync( - OfficialOwner, - OfficialRepository, - settings.IncludePrereleaseUpdates, - cancellationToken).ConfigureAwait(false); - - if (release == null) - { - return new UpdateCheckResult(UpdateCheckStatus.Failed, currentVersion, null, "Unable to determine the latest ThreadPilot release."); - } - - if (release.Version > currentVersion) - { - this.logger.LogInformation( - "ThreadPilot update available: current {CurrentVersion}, latest {LatestVersion}", - currentVersion, - release.Version); - return new UpdateCheckResult(UpdateCheckStatus.UpdateAvailable, currentVersion, release, "A newer ThreadPilot version is available."); - } - - return new UpdateCheckResult(UpdateCheckStatus.UpToDate, currentVersion, release, "ThreadPilot is up to date."); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - this.logger.LogWarning(ex, "ThreadPilot update check failed"); - return new UpdateCheckResult(UpdateCheckStatus.Failed, currentVersion, null, ex.Message); - } - finally - { - this.checkGate.Release(); - } - } - - public async Task DownloadAndInstallAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default) - { - if (!await this.installGate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) - { - return new UpdateInstallResult(UpdateInstallStatus.Failed, "Another update is already in progress."); - } - - UpdateDownloadResult? download = null; - try - { - download = await this.downloadService.DownloadInstallerAsync(release, cancellationToken).ConfigureAwait(false); - await this.installerService.LaunchInstallerElevatedAsync(download.InstallerPath, cancellationToken).ConfigureAwait(false); - this.shutdownService.RequestShutdownForUpdate(); - return new UpdateInstallResult(UpdateInstallStatus.Started, "Update installer started."); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - this.logger.LogWarning(ex, "ThreadPilot update install failed"); - return new UpdateInstallResult(UpdateInstallStatus.Failed, ex.Message); - } - finally - { - if (download != null) - { - this.TryCleanup(download.TempDirectory); - } - - this.installGate.Release(); - } - } - - private async Task MarkUpdateCheckAttemptAsync(CancellationToken cancellationToken) - { - var settings = this.settingsService.Settings; - settings.LastUpdateCheckUtc = this.clock.UtcNow; - await this.settingsService.UpdateSettingsAsync(settings).ConfigureAwait(false); - } - - private void TryCleanup(string tempDirectory) - { - try - { - this.tempDirectoryProvider.Cleanup(tempDirectory); - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to clean update temp directory {TempDirectory}", tempDirectory); - } - } - } -} +/* + * ThreadPilot - safe in-app update orchestration. + */ +namespace ThreadPilot.Services +{ + using System; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + + public sealed class UpdateService : IUpdateService + { + private const string OfficialOwner = "PrimeBuild-pc"; + private const string OfficialRepository = "ThreadPilot"; + + private readonly GitHubUpdateChecker updateChecker; + private readonly IApplicationSettingsService settingsService; + private readonly IApplicationVersionProvider versionProvider; + private readonly IUpdateDownloadService downloadService; + private readonly IUpdateInstallerService installerService; + private readonly IUpdateTempDirectoryProvider tempDirectoryProvider; + private readonly IApplicationShutdownService shutdownService; + private readonly IUpdateClock clock; + private readonly ILogger logger; + private readonly SemaphoreSlim checkGate = new(1, 1); + private readonly SemaphoreSlim installGate = new(1, 1); + + public UpdateService( + GitHubUpdateChecker updateChecker, + IApplicationSettingsService settingsService, + IApplicationVersionProvider versionProvider, + IUpdateDownloadService downloadService, + IUpdateInstallerService installerService, + IUpdateTempDirectoryProvider tempDirectoryProvider, + IApplicationShutdownService shutdownService, + IUpdateClock clock, + ILogger logger) + { + this.updateChecker = updateChecker ?? throw new ArgumentNullException(nameof(updateChecker)); + this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + this.versionProvider = versionProvider ?? throw new ArgumentNullException(nameof(versionProvider)); + this.downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService)); + this.installerService = installerService ?? throw new ArgumentNullException(nameof(installerService)); + this.tempDirectoryProvider = tempDirectoryProvider ?? throw new ArgumentNullException(nameof(tempDirectoryProvider)); + this.shutdownService = shutdownService ?? throw new ArgumentNullException(nameof(shutdownService)); + this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task CheckForUpdatesAsync(UpdateCheckRequest request, CancellationToken cancellationToken = default) + { + var currentVersion = this.versionProvider.CurrentVersion; + var settings = this.settingsService.Settings; + + if (request.Trigger == UpdateCheckTrigger.Startup) + { + if (!settings.EnableAutomaticUpdateChecks) + { + return new UpdateCheckResult(UpdateCheckStatus.Skipped, currentVersion, null, "Automatic update checks are disabled."); + } + + var intervalDays = Math.Max(1, settings.UpdateCheckIntervalDays); + if (settings.LastUpdateCheckUtc.HasValue && + this.clock.UtcNow - settings.LastUpdateCheckUtc.Value < TimeSpan.FromDays(intervalDays)) + { + return new UpdateCheckResult(UpdateCheckStatus.Skipped, currentVersion, null, "Startup update check throttled."); + } + } + + await this.checkGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await this.MarkUpdateCheckAttemptAsync(cancellationToken).ConfigureAwait(false); + + var release = await this.updateChecker.GetLatestReleaseInfoAsync( + OfficialOwner, + OfficialRepository, + settings.IncludePrereleaseUpdates, + cancellationToken).ConfigureAwait(false); + + if (release == null) + { + return new UpdateCheckResult(UpdateCheckStatus.Failed, currentVersion, null, "Unable to determine the latest ThreadPilot release."); + } + + if (release.Version > currentVersion) + { + this.logger.LogInformation( + "ThreadPilot update available: current {CurrentVersion}, latest {LatestVersion}", + currentVersion, + release.Version); + return new UpdateCheckResult(UpdateCheckStatus.UpdateAvailable, currentVersion, release, "A newer ThreadPilot version is available."); + } + + return new UpdateCheckResult(UpdateCheckStatus.UpToDate, currentVersion, release, "ThreadPilot is up to date."); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogWarning(ex, "ThreadPilot update check failed"); + return new UpdateCheckResult(UpdateCheckStatus.Failed, currentVersion, null, ex.Message); + } + finally + { + this.checkGate.Release(); + } + } + + public async Task DownloadAndInstallAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default) + { + if (!await this.installGate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + { + return new UpdateInstallResult(UpdateInstallStatus.Failed, "Another update is already in progress."); + } + + UpdateDownloadResult? download = null; + try + { + download = await this.downloadService.DownloadInstallerAsync(release, cancellationToken).ConfigureAwait(false); + await this.installerService.LaunchInstallerElevatedAsync(download.InstallerPath, cancellationToken).ConfigureAwait(false); + this.shutdownService.RequestShutdownForUpdate(); + return new UpdateInstallResult(UpdateInstallStatus.Started, "Update installer started."); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogWarning(ex, "ThreadPilot update install failed"); + return new UpdateInstallResult(UpdateInstallStatus.Failed, ex.Message); + } + finally + { + if (download != null) + { + this.TryCleanup(download.TempDirectory); + } + + this.installGate.Release(); + } + } + + private async Task MarkUpdateCheckAttemptAsync(CancellationToken cancellationToken) + { + var settings = this.settingsService.Settings; + settings.LastUpdateCheckUtc = this.clock.UtcNow; + await this.settingsService.UpdateSettingsAsync(settings).ConfigureAwait(false); + } + + private void TryCleanup(string tempDirectory) + { + try + { + this.tempDirectoryProvider.Cleanup(tempDirectory); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to clean update temp directory {TempDirectory}", tempDirectory); + } + } + } +} diff --git a/Services/UpdateTempDirectoryProvider.cs b/Services/UpdateTempDirectoryProvider.cs index d2ac116..4f9a8d0 100644 --- a/Services/UpdateTempDirectoryProvider.cs +++ b/Services/UpdateTempDirectoryProvider.cs @@ -1,68 +1,68 @@ -/* - * ThreadPilot - safe temporary directory management for update downloads. - */ -namespace ThreadPilot.Services -{ - using System; - using System.IO; - - public sealed class UpdateTempDirectoryProvider : IUpdateTempDirectoryProvider - { - private readonly string rootDirectory; - - public UpdateTempDirectoryProvider() - : this(Path.Combine(Path.GetTempPath(), "ThreadPilot", "Updates")) - { - } - - public UpdateTempDirectoryProvider(string rootDirectory) - { - this.rootDirectory = Path.GetFullPath(rootDirectory ?? throw new ArgumentNullException(nameof(rootDirectory))); - } - - public string CreateUpdateTempDirectory(SemanticVersion version) - { - var directory = Path.Combine(this.rootDirectory, version.ToString(), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(directory); - return directory; - } - - public bool IsSafeUpdateTempPath(string path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return false; - } - - var fullPath = Path.GetFullPath(path); - var rootWithSeparator = this.rootDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) - + Path.DirectorySeparatorChar; - return fullPath.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase); - } - - public void Cleanup(string path) - { - if (!this.IsSafeUpdateTempPath(path) || !Directory.Exists(path)) - { - return; - } - - Directory.Delete(path, recursive: true); - this.DeleteEmptyParentsUntilRoot(Path.GetDirectoryName(Path.GetFullPath(path))); - } - - private void DeleteEmptyParentsUntilRoot(string? directory) - { - while (!string.IsNullOrWhiteSpace(directory) && this.IsSafeUpdateTempPath(directory)) - { - if (Directory.GetFileSystemEntries(directory).Length > 0) - { - return; - } - - Directory.Delete(directory); - directory = Path.GetDirectoryName(directory); - } - } - } -} +/* + * ThreadPilot - safe temporary directory management for update downloads. + */ +namespace ThreadPilot.Services +{ + using System; + using System.IO; + + public sealed class UpdateTempDirectoryProvider : IUpdateTempDirectoryProvider + { + private readonly string rootDirectory; + + public UpdateTempDirectoryProvider() + : this(Path.Combine(Path.GetTempPath(), "ThreadPilot", "Updates")) + { + } + + public UpdateTempDirectoryProvider(string rootDirectory) + { + this.rootDirectory = Path.GetFullPath(rootDirectory ?? throw new ArgumentNullException(nameof(rootDirectory))); + } + + public string CreateUpdateTempDirectory(SemanticVersion version) + { + var directory = Path.Combine(this.rootDirectory, version.ToString(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + return directory; + } + + public bool IsSafeUpdateTempPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + var fullPath = Path.GetFullPath(path); + var rootWithSeparator = this.rootDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + return fullPath.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase); + } + + public void Cleanup(string path) + { + if (!this.IsSafeUpdateTempPath(path) || !Directory.Exists(path)) + { + return; + } + + Directory.Delete(path, recursive: true); + this.DeleteEmptyParentsUntilRoot(Path.GetDirectoryName(Path.GetFullPath(path))); + } + + private void DeleteEmptyParentsUntilRoot(string? directory) + { + while (!string.IsNullOrWhiteSpace(directory) && this.IsSafeUpdateTempPath(directory)) + { + if (Directory.GetFileSystemEntries(directory).Length > 0) + { + return; + } + + Directory.Delete(directory); + directory = Path.GetDirectoryName(directory); + } + } + } +} diff --git a/Services/VirtualizedProcessService.cs b/Services/VirtualizedProcessService.cs index ff2bc5b..be0e474 100644 --- a/Services/VirtualizedProcessService.cs +++ b/Services/VirtualizedProcessService.cs @@ -1,321 +1,292 @@ -/* - * 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; - using System.Collections.Generic; - using System.Diagnostics; - using System.Linq; - using System.Threading; - using System.Threading.Tasks; - using Microsoft.Extensions.Caching.Memory; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - - /// - /// Implementation of virtualized process service with batch loading and caching. - /// - public class VirtualizedProcessService : IVirtualizedProcessService, IDisposable - { - private readonly IProcessService processService; - private readonly IMemoryCache cache; - private readonly ILogger logger; - private readonly IRetryPolicyService retryPolicy; - private readonly SemaphoreSlim loadingSemaphore = new(1, 1); - private readonly ConcurrentDictionary batchCache = new(); - private readonly System.Threading.Timer backgroundPreloadTimer; - - private List? allProcesses; - private DateTime lastFullRefresh = DateTime.MinValue; - private bool disposed; - - public VirtualizedProcessConfig Configuration { get; set; } = new(); - - public event EventHandler? BatchLoadProgress; - - public event EventHandler? BackgroundBatchLoaded; - - public VirtualizedProcessService( - IProcessService processService, - IMemoryCache cache, - ILogger logger, - IRetryPolicyService retryPolicy) - { - this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); - this.cache = cache ?? throw new ArgumentNullException(nameof(cache)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.retryPolicy = retryPolicy ?? throw new ArgumentNullException(nameof(retryPolicy)); - - // Set up background preloading timer - this.backgroundPreloadTimer = new System.Threading.Timer(this.BackgroundPreloadCallback, null, Timeout.Infinite, Timeout.Infinite); - } - - public async Task InitializeAsync() - { - this.logger.LogDebug("Initializing VirtualizedProcessService with batch size: {BatchSize}", this.Configuration.BatchSize); - - // Perform initial load to get total count - await this.RefreshAllProcessesAsync(false); - } - - public async Task GetTotalProcessCountAsync(bool activeApplicationsOnly = false) - { - await this.EnsureProcessesLoadedAsync(activeApplicationsOnly); - - if (activeApplicationsOnly) - { - return this.allProcesses?.Count(p => p.HasVisibleWindow) ?? 0; - } - - return this.allProcesses?.Count ?? 0; - } - - public async Task LoadProcessBatchAsync(int batchIndex, bool activeApplicationsOnly = false) - { - var cacheKey = $"batch_{batchIndex}_{activeApplicationsOnly}"; - - if (this.batchCache.TryGetValue(cacheKey.GetHashCode(), out var cachedBatch)) - { - this.logger.LogDebug("Returning cached batch {BatchIndex}", batchIndex); - return cachedBatch; - } - - return await this.retryPolicy.ExecuteAsync( - async () => - { - var stopwatch = Stopwatch.StartNew(); - - await this.EnsureProcessesLoadedAsync(activeApplicationsOnly); - - var filteredProcesses = activeApplicationsOnly - ? this.allProcesses?.Where(p => p.HasVisibleWindow).ToList() ?? new List() - : this.allProcesses ?? new List(); - - var totalCount = filteredProcesses.Count; - var totalBatches = (int)Math.Ceiling((double)totalCount / this.Configuration.BatchSize); - - var startIndex = batchIndex * this.Configuration.BatchSize; - var batchProcesses = filteredProcesses - .Skip(startIndex) - .Take(this.Configuration.BatchSize) - .ToList(); - - var result = new ProcessBatchResult - { - Processes = batchProcesses, - BatchIndex = batchIndex, - TotalBatches = totalBatches, - TotalProcessCount = totalCount, - HasMoreBatches = batchIndex < totalBatches - 1, - LoadTime = stopwatch.Elapsed, - }; - - // Cache the result - this.batchCache.TryAdd(cacheKey.GetHashCode(), result); - - this.logger.LogDebug( - "Loaded batch {BatchIndex}/{TotalBatches} with {ProcessCount} processes in {LoadTime}ms", - batchIndex, totalBatches, batchProcesses.Count, stopwatch.ElapsedMilliseconds); - - return result; - }, this.retryPolicy.CreateProcessOperationPolicy()); - } - - public async Task> LoadProcessBatchesAsync(int startBatchIndex, int batchCount, bool activeApplicationsOnly = false) - { - var results = new List(); - var totalBatches = await this.GetTotalBatchCountAsync(activeApplicationsOnly); - - for (int i = 0; i < batchCount && (startBatchIndex + i) < totalBatches; i++) - { - var batchIndex = startBatchIndex + i; - var batch = await this.LoadProcessBatchAsync(batchIndex, activeApplicationsOnly); - results.Add(batch); - - // Report progress - this.BatchLoadProgress?.Invoke(this, new BatchLoadProgressEventArgs - { - LoadedBatches = i + 1, - TotalBatches = batchCount, - LoadedProcesses = results.Sum(r => r.Processes.Count), - TotalProcesses = batch.TotalProcessCount, - StatusMessage = $"Loaded batch {batchIndex + 1} of {totalBatches}", - }); - } - - return results; - } - - public async Task PreloadNextBatchAsync(int currentBatchIndex, bool activeApplicationsOnly = false) - { - if (!this.Configuration.EnableBackgroundLoading) - { - return; - } - - var nextBatchIndex = currentBatchIndex + 1; - var totalBatches = await this.GetTotalBatchCountAsync(activeApplicationsOnly); - - if (nextBatchIndex < totalBatches) - { - _ = Task.Run(async () => - { - try - { - if (!this.Configuration.EnableBackgroundLoading) - { - return; - } - - var batch = await this.LoadProcessBatchAsync(nextBatchIndex, activeApplicationsOnly); - this.BackgroundBatchLoaded?.Invoke(this, batch); - } - catch (Exception ex) - { - this.logger.LogDebug(ex, "Failed to preload batch {BatchIndex}", nextBatchIndex); - } - }); - } - } - - public async Task> SearchProcessesAsync(string searchTerm, bool activeApplicationsOnly = false) - { - if (string.IsNullOrWhiteSpace(searchTerm)) - { - return new List(); - } - - await this.EnsureProcessesLoadedAsync(activeApplicationsOnly); - - var filteredProcesses = activeApplicationsOnly - ? this.allProcesses?.Where(p => p.HasVisibleWindow) ?? Enumerable.Empty() - : this.allProcesses ?? Enumerable.Empty(); - - return filteredProcesses - .Where(p => p.Name.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) || - (p.MainWindowTitle?.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) ?? false)) - .ToList(); - } - - public async Task RefreshBatchAsync(int batchIndex, bool activeApplicationsOnly = false) - { - var cacheKey = $"batch_{batchIndex}_{activeApplicationsOnly}"; - this.batchCache.TryRemove(cacheKey.GetHashCode(), out _); - - // Force refresh of all processes - await this.RefreshAllProcessesAsync(activeApplicationsOnly); - - return await this.LoadProcessBatchAsync(batchIndex, activeApplicationsOnly); - } - - public void ClearCache() - { - this.batchCache.Clear(); - this.allProcesses = null; - this.lastFullRefresh = DateTime.MinValue; - this.logger.LogInformation("Cleared virtualized process cache"); - } - - private async Task EnsureProcessesLoadedAsync(bool activeApplicationsOnly) - { - if (this.allProcesses == null || DateTime.UtcNow - this.lastFullRefresh > this.Configuration.RefreshInterval) - { - await this.RefreshAllProcessesAsync(activeApplicationsOnly); - } - } - - private async Task RefreshAllProcessesAsync(bool activeApplicationsOnly) - { - await this.loadingSemaphore.WaitAsync(); - try - { - this.logger.LogDebug("Refreshing all processes (activeOnly: {ActiveOnly})", activeApplicationsOnly); - - var processes = activeApplicationsOnly - ? await this.processService.GetActiveApplicationsAsync() - : await this.processService.GetProcessesAsync(); - - this.allProcesses = processes.ToList(); - this.lastFullRefresh = DateTime.UtcNow; - - // Clear batch cache since underlying data changed - this.batchCache.Clear(); - - this.logger.LogDebug("Refreshed {ProcessCount} processes", this.allProcesses.Count); - } - finally - { - this.loadingSemaphore.Release(); - } - } - - private async Task GetTotalBatchCountAsync(bool activeApplicationsOnly) - { - var totalCount = await this.GetTotalProcessCountAsync(activeApplicationsOnly); - return (int)Math.Ceiling((double)totalCount / this.Configuration.BatchSize); - } - - private void BackgroundPreloadCallback(object? state) - { - TaskSafety.FireAndForget(this.BackgroundPreloadCallbackAsync(), ex => - { - this.logger.LogDebug(ex, "Background process refresh failed"); - }); - } - - private async Task BackgroundPreloadCallbackAsync() - { - try - { - if (!this.Configuration.EnableBackgroundLoading) - { - return; - } - - // Refresh processes in background - await this.RefreshAllProcessesAsync(false); - } - catch (Exception ex) - { - this.logger.LogDebug(ex, "Background process refresh failed"); - } - } - - protected virtual void Dispose(bool disposing) - { - if (!this.disposed) - { - if (disposing) - { - this.backgroundPreloadTimer?.Dispose(); - this.loadingSemaphore?.Dispose(); - this.batchCache.Clear(); - this.logger.LogInformation("VirtualizedProcessService disposed"); - } - this.disposed = true; - } - } - - public void Dispose() - { - this.Dispose(disposing: true); - GC.SuppressFinalize(this); - } - } -} - +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Diagnostics; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public class VirtualizedProcessService : IVirtualizedProcessService, IDisposable + { + private readonly IProcessService processService; + private readonly ILogger logger; + private readonly SemaphoreSlim loadingSemaphore = new(1, 1); + private readonly ConcurrentDictionary batchCache = new(); + private readonly System.Threading.Timer backgroundPreloadTimer; + + private List? allProcesses; + private DateTime lastFullRefresh = DateTime.MinValue; + private bool disposed; + + public VirtualizedProcessConfig Configuration { get; set; } = new(); + + public event EventHandler? BatchLoadProgress; + + public event EventHandler? BackgroundBatchLoaded; + + public VirtualizedProcessService( + IProcessService processService, + ILogger logger) + { + this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + // Set up background preloading timer + this.backgroundPreloadTimer = new System.Threading.Timer(this.BackgroundPreloadCallback, null, Timeout.Infinite, Timeout.Infinite); + } + + public async Task InitializeAsync() + { + this.logger.LogDebug("Initializing VirtualizedProcessService with batch size: {BatchSize}", this.Configuration.BatchSize); + + // Perform initial load to get total count + await this.RefreshAllProcessesAsync(false); + } + + public async Task GetTotalProcessCountAsync(bool activeApplicationsOnly = false) + { + await this.EnsureProcessesLoadedAsync(activeApplicationsOnly); + + if (activeApplicationsOnly) + { + return this.allProcesses?.Count(p => p.HasVisibleWindow) ?? 0; + } + + return this.allProcesses?.Count ?? 0; + } + + public async Task LoadProcessBatchAsync(int batchIndex, bool activeApplicationsOnly = false) + { + var cacheKey = $"batch_{batchIndex}_{activeApplicationsOnly}"; + + if (this.batchCache.TryGetValue(cacheKey.GetHashCode(), out var cachedBatch)) + { + this.logger.LogDebug("Returning cached batch {BatchIndex}", batchIndex); + return cachedBatch; + } + + var stopwatch = Stopwatch.StartNew(); + + await this.EnsureProcessesLoadedAsync(activeApplicationsOnly); + + var filteredProcesses = activeApplicationsOnly + ? this.allProcesses?.Where(p => p.HasVisibleWindow).ToList() ?? new List() + : this.allProcesses ?? new List(); + + var totalCount = filteredProcesses.Count; + var totalBatches = (int)Math.Ceiling((double)totalCount / this.Configuration.BatchSize); + + var startIndex = batchIndex * this.Configuration.BatchSize; + var batchProcesses = filteredProcesses + .Skip(startIndex) + .Take(this.Configuration.BatchSize) + .ToList(); + + var result = new ProcessBatchResult + { + Processes = batchProcesses, + BatchIndex = batchIndex, + TotalBatches = totalBatches, + TotalProcessCount = totalCount, + HasMoreBatches = batchIndex < totalBatches - 1, + LoadTime = stopwatch.Elapsed, + }; + + // Cache the result + this.batchCache.TryAdd(cacheKey.GetHashCode(), result); + + this.logger.LogDebug( + "Loaded batch {BatchIndex}/{TotalBatches} with {ProcessCount} processes in {LoadTime}ms", + batchIndex, totalBatches, batchProcesses.Count, stopwatch.ElapsedMilliseconds); + + return result; + } + + + public async Task> LoadProcessBatchesAsync(int startBatchIndex, int batchCount, bool activeApplicationsOnly = false) + { + var results = new List(); + var totalBatches = await this.GetTotalBatchCountAsync(activeApplicationsOnly); + + for (int i = 0; i < batchCount && (startBatchIndex + i) < totalBatches; i++) + { + var batchIndex = startBatchIndex + i; + var batch = await this.LoadProcessBatchAsync(batchIndex, activeApplicationsOnly); + results.Add(batch); + + // Report progress + this.BatchLoadProgress?.Invoke(this, new BatchLoadProgressEventArgs + { + LoadedBatches = i + 1, + TotalBatches = batchCount, + LoadedProcesses = results.Sum(r => r.Processes.Count), + TotalProcesses = batch.TotalProcessCount, + StatusMessage = $"Loaded batch {batchIndex + 1} of {totalBatches}", + }); + } + + return results; + } + + public async Task PreloadNextBatchAsync(int currentBatchIndex, bool activeApplicationsOnly = false) + { + if (!this.Configuration.EnableBackgroundLoading) + { + return; + } + + var nextBatchIndex = currentBatchIndex + 1; + var totalBatches = await this.GetTotalBatchCountAsync(activeApplicationsOnly); + + if (nextBatchIndex < totalBatches) + { + _ = Task.Run(async () => + { + try + { + if (!this.Configuration.EnableBackgroundLoading) + { + return; + } + + var batch = await this.LoadProcessBatchAsync(nextBatchIndex, activeApplicationsOnly); + this.BackgroundBatchLoaded?.Invoke(this, batch); + } + catch (Exception ex) + { + this.logger.LogDebug(ex, "Failed to preload batch {BatchIndex}", nextBatchIndex); + } + }); + } + } + + public async Task> SearchProcessesAsync(string searchTerm, bool activeApplicationsOnly = false) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + { + return new List(); + } + + await this.EnsureProcessesLoadedAsync(activeApplicationsOnly); + + var filteredProcesses = activeApplicationsOnly + ? this.allProcesses?.Where(p => p.HasVisibleWindow) ?? Enumerable.Empty() + : this.allProcesses ?? Enumerable.Empty(); + + return filteredProcesses + .Where(p => p.Name.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) || + (p.MainWindowTitle?.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) ?? false)) + .ToList(); + } + + public async Task RefreshBatchAsync(int batchIndex, bool activeApplicationsOnly = false) + { + var cacheKey = $"batch_{batchIndex}_{activeApplicationsOnly}"; + this.batchCache.TryRemove(cacheKey.GetHashCode(), out _); + + // Force refresh of all processes + await this.RefreshAllProcessesAsync(activeApplicationsOnly); + + return await this.LoadProcessBatchAsync(batchIndex, activeApplicationsOnly); + } + + public void ClearCache() + { + this.batchCache.Clear(); + this.allProcesses = null; + this.lastFullRefresh = DateTime.MinValue; + this.logger.LogInformation("Cleared virtualized process cache"); + } + + private async Task EnsureProcessesLoadedAsync(bool activeApplicationsOnly) + { + if (this.allProcesses == null || DateTime.UtcNow - this.lastFullRefresh > this.Configuration.RefreshInterval) + { + await this.RefreshAllProcessesAsync(activeApplicationsOnly); + } + } + + private async Task RefreshAllProcessesAsync(bool activeApplicationsOnly) + { + await this.loadingSemaphore.WaitAsync(); + try + { + this.logger.LogDebug("Refreshing all processes (activeOnly: {ActiveOnly})", activeApplicationsOnly); + + var processes = activeApplicationsOnly + ? await this.processService.GetActiveApplicationsAsync() + : await this.processService.GetProcessesAsync(); + + this.allProcesses = processes.ToList(); + this.lastFullRefresh = DateTime.UtcNow; + + // Clear batch cache since underlying data changed + this.batchCache.Clear(); + + this.logger.LogDebug("Refreshed {ProcessCount} processes", this.allProcesses.Count); + } + finally + { + this.loadingSemaphore.Release(); + } + } + + private async Task GetTotalBatchCountAsync(bool activeApplicationsOnly) + { + var totalCount = await this.GetTotalProcessCountAsync(activeApplicationsOnly); + return (int)Math.Ceiling((double)totalCount / this.Configuration.BatchSize); + } + + private void BackgroundPreloadCallback(object? state) + { + TaskSafety.FireAndForget(this.BackgroundPreloadCallbackAsync(), ex => + { + this.logger.LogDebug(ex, "Background process refresh failed"); + }); + } + + private async Task BackgroundPreloadCallbackAsync() + { + try + { + if (!this.Configuration.EnableBackgroundLoading) + { + return; + } + + // Refresh processes in background + await this.RefreshAllProcessesAsync(false); + } + catch (Exception ex) + { + this.logger.LogDebug(ex, "Background process refresh failed"); + } + } + + protected virtual void Dispose(bool disposing) + { + if (!this.disposed) + { + if (disposing) + { + this.backgroundPreloadTimer?.Dispose(); + this.loadingSemaphore?.Dispose(); + this.batchCache.Clear(); + this.logger.LogInformation("VirtualizedProcessService disposed"); + } + this.disposed = true; + } + } + + public void Dispose() + { + this.Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} + diff --git a/Services/WindowsCpuTopologyNativeLayout.cs b/Services/WindowsCpuTopologyNativeLayout.cs index 02bcae2..6717520 100644 --- a/Services/WindowsCpuTopologyNativeLayout.cs +++ b/Services/WindowsCpuTopologyNativeLayout.cs @@ -1,164 +1,148 @@ -/* - * 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.Linq; - using System.Runtime.InteropServices; - using ThreadPilot.Models; - - internal static class WindowsCpuTopologyNativeLayout - { - public static int GroupAffinitySize => Marshal.SizeOf(); - - public static int ProcessorGroupCountOffset => Marshal.OffsetOf(nameof(ProcessorRelationship.GroupCount)).ToInt32(); - - public static int ProcessorGroupMaskOffset => Marshal.OffsetOf(nameof(ProcessorRelationship.GroupMask)).ToInt32(); - - public static int CacheReservedOffset => Marshal.OffsetOf(nameof(CacheRelationship.Reserved)).ToInt32(); - - public static int CacheGroupCountOffset => Marshal.OffsetOf(nameof(CacheRelationship.GroupCount)).ToInt32(); - - public static int CacheGroupMaskOffset => Marshal.OffsetOf(nameof(CacheRelationship.GroupMask)).ToInt32(); - - public static int NumaReservedOffset => Marshal.OffsetOf(nameof(NumaNodeRelationship.Reserved)).ToInt32(); - - public static int NumaGroupCountOffset => Marshal.OffsetOf(nameof(NumaNodeRelationship.GroupCount)).ToInt32(); - - public static int NumaGroupMaskOffset => Marshal.OffsetOf(nameof(NumaNodeRelationship.GroupMask)).ToInt32(); - - internal enum ProcessorCacheType - { - CacheUnified = 0, - CacheInstruction = 1, - CacheData = 2, - CacheTrace = 3, - } - - [StructLayout(LayoutKind.Sequential)] - internal struct GroupAffinity - { - public UIntPtr Mask; - public ushort Group; - public ushort Reserved0; - public ushort Reserved1; - public ushort Reserved2; - } - - [StructLayout(LayoutKind.Sequential)] - internal unsafe struct ProcessorRelationship - { - public byte Flags; - public byte EfficiencyClass; - public fixed byte Reserved[20]; - public ushort GroupCount; - public GroupAffinity GroupMask; - } - - [StructLayout(LayoutKind.Sequential)] - internal unsafe struct CacheRelationship - { - public byte Level; - public byte Associativity; - public ushort LineSize; - public uint CacheSize; - public ProcessorCacheType Type; - public fixed byte Reserved[18]; - public ushort GroupCount; - public GroupAffinity GroupMask; - } - - [StructLayout(LayoutKind.Sequential)] - internal unsafe struct NumaNodeRelationship - { - public uint NodeNumber; - public fixed byte Reserved[18]; - public ushort GroupCount; - public GroupAffinity GroupMask; - } - - public static IReadOnlyList ReadProcessorRelationshipProcessors(IntPtr relationshipPtr, ushort groupCount) - { - return ReadProcessorsFromGroupMasks(relationshipPtr, ProcessorGroupMaskOffset, groupCount).ToList(); - } - - public static bool TryReadL3CacheProcessors(IntPtr relationshipPtr, out IReadOnlyList processors) - { - var cache = Marshal.PtrToStructure(relationshipPtr); - if (cache.Level != 3 || cache.GroupCount == 0) - { - processors = []; - return false; - } - - processors = ReadProcessorsFromGroupMasks(relationshipPtr, CacheGroupMaskOffset, cache.GroupCount).ToList(); - return processors.Count > 0; - } - - public static IReadOnlyList ReadNumaNodeProcessors(IntPtr relationshipPtr, out int nodeNumber) - { - var numaNode = Marshal.PtrToStructure(relationshipPtr); - nodeNumber = unchecked((int)numaNode.NodeNumber); - var groupCount = numaNode.GroupCount == 0 - ? (ushort)1 - : numaNode.GroupCount; - - return ReadProcessorsFromGroupMasks(relationshipPtr, NumaGroupMaskOffset, groupCount).ToList(); - } - - public static IEnumerable CreateFallbackProcessors(int logicalProcessorCount) - { - return Enumerable.Range(0, logicalProcessorCount) - .Select(index => new ProcessorRef((ushort)(index / 64), (byte)(index % 64), index)); - } - - private static IEnumerable ReadProcessorsFromGroupMasks( - IntPtr relationshipPtr, - int groupMaskOffset, - ushort groupCount) - { - var firstGroupMaskPtr = IntPtr.Add(relationshipPtr, groupMaskOffset); - var stride = GroupAffinitySize; - for (var index = 0; index < groupCount; index++) - { - var groupAffinity = Marshal.PtrToStructure(IntPtr.Add(firstGroupMaskPtr, index * stride)); - foreach (var logicalProcessor in ReadProcessorsFromGroupAffinity(groupAffinity)) - { - yield return logicalProcessor; - } - } - } - - private static IEnumerable ReadProcessorsFromGroupAffinity(GroupAffinity groupAffinity) - { - var mask = groupAffinity.Mask.ToUInt64(); - for (var bit = 0; bit < 64; bit++) - { - if ((mask & (1UL << bit)) != 0) - { - yield return CreateProcessorRef(groupAffinity.Group, (byte)bit); - } - } - } - - public static ProcessorRef CreateProcessorRef(ushort group, byte logicalProcessorNumber) - { - return new ProcessorRef(group, logicalProcessorNumber, (group * 64) + logicalProcessorNumber); - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Runtime.InteropServices; + using ThreadPilot.Models; + + internal static class WindowsCpuTopologyNativeLayout + { + public static int GroupAffinitySize => Marshal.SizeOf(); + + public static int ProcessorGroupCountOffset => Marshal.OffsetOf(nameof(ProcessorRelationship.GroupCount)).ToInt32(); + + public static int ProcessorGroupMaskOffset => Marshal.OffsetOf(nameof(ProcessorRelationship.GroupMask)).ToInt32(); + + public static int CacheReservedOffset => Marshal.OffsetOf(nameof(CacheRelationship.Reserved)).ToInt32(); + + public static int CacheGroupCountOffset => Marshal.OffsetOf(nameof(CacheRelationship.GroupCount)).ToInt32(); + + public static int CacheGroupMaskOffset => Marshal.OffsetOf(nameof(CacheRelationship.GroupMask)).ToInt32(); + + public static int NumaReservedOffset => Marshal.OffsetOf(nameof(NumaNodeRelationship.Reserved)).ToInt32(); + + public static int NumaGroupCountOffset => Marshal.OffsetOf(nameof(NumaNodeRelationship.GroupCount)).ToInt32(); + + public static int NumaGroupMaskOffset => Marshal.OffsetOf(nameof(NumaNodeRelationship.GroupMask)).ToInt32(); + + internal enum ProcessorCacheType + { + CacheUnified = 0, + CacheInstruction = 1, + CacheData = 2, + CacheTrace = 3, + } + + [StructLayout(LayoutKind.Sequential)] + internal struct GroupAffinity + { + public UIntPtr Mask; + public ushort Group; + public ushort Reserved0; + public ushort Reserved1; + public ushort Reserved2; + } + + [StructLayout(LayoutKind.Sequential)] + internal unsafe struct ProcessorRelationship + { + public byte Flags; + public byte EfficiencyClass; + public fixed byte Reserved[20]; + public ushort GroupCount; + public GroupAffinity GroupMask; + } + + [StructLayout(LayoutKind.Sequential)] + internal unsafe struct CacheRelationship + { + public byte Level; + public byte Associativity; + public ushort LineSize; + public uint CacheSize; + public ProcessorCacheType Type; + public fixed byte Reserved[18]; + public ushort GroupCount; + public GroupAffinity GroupMask; + } + + [StructLayout(LayoutKind.Sequential)] + internal unsafe struct NumaNodeRelationship + { + public uint NodeNumber; + public fixed byte Reserved[18]; + public ushort GroupCount; + public GroupAffinity GroupMask; + } + + public static IReadOnlyList ReadProcessorRelationshipProcessors(IntPtr relationshipPtr, ushort groupCount) + { + return ReadProcessorsFromGroupMasks(relationshipPtr, ProcessorGroupMaskOffset, groupCount).ToList(); + } + + public static bool TryReadL3CacheProcessors(IntPtr relationshipPtr, out IReadOnlyList processors) + { + var cache = Marshal.PtrToStructure(relationshipPtr); + if (cache.Level != 3 || cache.GroupCount == 0) + { + processors = []; + return false; + } + + processors = ReadProcessorsFromGroupMasks(relationshipPtr, CacheGroupMaskOffset, cache.GroupCount).ToList(); + return processors.Count > 0; + } + + public static IReadOnlyList ReadNumaNodeProcessors(IntPtr relationshipPtr, out int nodeNumber) + { + var numaNode = Marshal.PtrToStructure(relationshipPtr); + nodeNumber = unchecked((int)numaNode.NodeNumber); + var groupCount = numaNode.GroupCount == 0 + ? (ushort)1 + : numaNode.GroupCount; + + return ReadProcessorsFromGroupMasks(relationshipPtr, NumaGroupMaskOffset, groupCount).ToList(); + } + + public static IEnumerable CreateFallbackProcessors(int logicalProcessorCount) + { + return Enumerable.Range(0, logicalProcessorCount) + .Select(index => new ProcessorRef((ushort)(index / 64), (byte)(index % 64), index)); + } + + private static IEnumerable ReadProcessorsFromGroupMasks( + IntPtr relationshipPtr, + int groupMaskOffset, + ushort groupCount) + { + var firstGroupMaskPtr = IntPtr.Add(relationshipPtr, groupMaskOffset); + var stride = GroupAffinitySize; + for (var index = 0; index < groupCount; index++) + { + var groupAffinity = Marshal.PtrToStructure(IntPtr.Add(firstGroupMaskPtr, index * stride)); + foreach (var logicalProcessor in ReadProcessorsFromGroupAffinity(groupAffinity)) + { + yield return logicalProcessor; + } + } + } + + private static IEnumerable ReadProcessorsFromGroupAffinity(GroupAffinity groupAffinity) + { + var mask = groupAffinity.Mask.ToUInt64(); + for (var bit = 0; bit < 64; bit++) + { + if ((mask & (1UL << bit)) != 0) + { + yield return CreateProcessorRef(groupAffinity.Group, (byte)bit); + } + } + } + + public static ProcessorRef CreateProcessorRef(ushort group, byte logicalProcessorNumber) + { + return new ProcessorRef(group, logicalProcessorNumber, (group * 64) + logicalProcessorNumber); + } + } +} diff --git a/Services/WindowsCpuTopologyProvider.cs b/Services/WindowsCpuTopologyProvider.cs index 4a71ac2..6651eab 100644 --- a/Services/WindowsCpuTopologyProvider.cs +++ b/Services/WindowsCpuTopologyProvider.cs @@ -1,398 +1,378 @@ -/* - * 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.Linq; - using System.Runtime.InteropServices; - using System.Threading; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - using Microsoft.Extensions.Logging.Abstractions; - using ThreadPilot.Models; - - /// - /// Builds instances from Windows CPU Sets and processor relationship APIs. - /// This provider is introduced for CPU topology v2 and is not wired into runtime affinity application yet. - /// - public sealed class WindowsCpuTopologyProvider : ICpuTopologyProvider - { - private const int ErrorInsufficientBuffer = 122; - - private readonly ILogger logger; - - public WindowsCpuTopologyProvider(ILogger? logger = null) - { - this.logger = logger ?? NullLogger.Instance; - } - - private enum CpuSetInformationType - { - CpuSetInformation = 0, - } - - private enum LogicalProcessorRelationship - { - RelationProcessorCore = 0, - RelationNumaNode = 1, - RelationCache = 2, - RelationProcessorPackage = 3, - RelationGroup = 4, - RelationProcessorDie = 5, - RelationNumaNodeEx = 6, - RelationProcessorModule = 7, - RelationAll = 0xFFFF, - } - - [StructLayout(LayoutKind.Sequential)] - private struct SystemCpuSetInformation - { - public uint Size; - public CpuSetInformationType Type; - public uint Id; - public ushort Group; - public byte LogicalProcessorIndex; - public byte CoreIndex; - public byte LastLevelCacheIndex; - public byte NumaNodeIndex; - public byte EfficiencyClass; - public byte AllFlags; - public uint SchedulingClassOrReserved; - public ulong AllocationTag; - } - - [StructLayout(LayoutKind.Sequential)] - private struct SystemLogicalProcessorInformationExHeader - { - public LogicalProcessorRelationship Relationship; - public int Size; - } - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool GetSystemCpuSetInformation( - IntPtr information, - uint bufferLength, - out uint returnedLength, - IntPtr process, - uint flags); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool GetLogicalProcessorInformationEx( - LogicalProcessorRelationship relationshipType, - IntPtr buffer, - ref int returnedLength); - - public Task GetTopologySnapshotAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(this.CreateSnapshot(cancellationToken)); - } - - private CpuTopologySnapshot CreateSnapshot(CancellationToken cancellationToken) - { - var logicalProcessors = new HashSet(); - var cpuSetIds = new Dictionary(); - var efficiencyClasses = new Dictionary(); - var coreIndexes = new Dictionary(); - var numaNodeIndexes = new Dictionary(); - var lastLevelCacheIndexes = new Dictionary(); - var packageIndexes = new Dictionary(); - var smtSiblingGlobalIndexes = new Dictionary>(); - - this.ReadCpuSetInformation( - logicalProcessors, - cpuSetIds, - efficiencyClasses, - coreIndexes, - numaNodeIndexes, - lastLevelCacheIndexes, - cancellationToken); - - this.ReadLogicalProcessorRelationships( - logicalProcessors, - efficiencyClasses, - coreIndexes, - numaNodeIndexes, - lastLevelCacheIndexes, - packageIndexes, - smtSiblingGlobalIndexes, - cancellationToken); - - if (efficiencyClasses.Values.Distinct().Count() <= 1) - { - efficiencyClasses.Clear(); - } - - if (logicalProcessors.Count == 0) - { - this.logger.LogWarning("CPU topology provider could not read Windows topology APIs; using Environment.ProcessorCount fallback"); - foreach (var processor in WindowsCpuTopologyNativeLayout.CreateFallbackProcessors(Environment.ProcessorCount)) - { - logicalProcessors.Add(processor); - coreIndexes[processor] = processor.GlobalIndex; - } - } - - var processors = logicalProcessors - .OrderBy(processor => processor.GlobalIndex) - .ThenBy(processor => processor.Group) - .ThenBy(processor => processor.LogicalProcessorNumber) - .ToList(); - - var signature = new CpuTopologySignature - { - LogicalProcessorCount = processors.Count, - PhysicalCoreCount = coreIndexes.Count == 0 - ? processors.Count - : coreIndexes.Values.Distinct().Count(), - ProcessorGroupCount = processors.Select(processor => processor.Group).Distinct().Count(), - NumaNodeCount = numaNodeIndexes.Values.Distinct().Count(), - LastLevelCacheGroupCount = lastLevelCacheIndexes.Values.Distinct().Count(), - PackageCount = packageIndexes.Values.Distinct().Count(), - Source = nameof(WindowsCpuTopologyProvider), - }; - - return CpuTopologySnapshot.Create( - processors, - cpuSetIds, - efficiencyClasses, - signature, - coreIndexes, - numaNodeIndexes, - lastLevelCacheIndexes, - packageIndexes, - smtSiblingGlobalIndexes); - } - - private void ReadCpuSetInformation( - HashSet logicalProcessors, - IDictionary cpuSetIds, - IDictionary efficiencyClasses, - IDictionary coreIndexes, - IDictionary numaNodeIndexes, - IDictionary lastLevelCacheIndexes, - CancellationToken cancellationToken) - { - uint requiredLength = 0; - if (GetSystemCpuSetInformation(IntPtr.Zero, 0, out requiredLength, IntPtr.Zero, 0)) - { - return; - } - - var firstError = Marshal.GetLastWin32Error(); - if (firstError != ErrorInsufficientBuffer || requiredLength == 0) - { - this.logger.LogDebug("GetSystemCpuSetInformation probe failed with Win32 error {Error}", firstError); - return; - } - - var buffer = Marshal.AllocHGlobal((int)requiredLength); - try - { - cancellationToken.ThrowIfCancellationRequested(); - if (!GetSystemCpuSetInformation(buffer, requiredLength, out requiredLength, IntPtr.Zero, 0)) - { - this.logger.LogDebug("GetSystemCpuSetInformation read failed with Win32 error {Error}", Marshal.GetLastWin32Error()); - return; - } - - var offset = 0; - while (offset < requiredLength) - { - cancellationToken.ThrowIfCancellationRequested(); - var itemPtr = IntPtr.Add(buffer, offset); - var info = Marshal.PtrToStructure(itemPtr); - if (info.Size == 0) - { - break; - } - - if (info.Type == CpuSetInformationType.CpuSetInformation) - { - var processor = WindowsCpuTopologyNativeLayout.CreateProcessorRef(info.Group, info.LogicalProcessorIndex); - logicalProcessors.Add(processor); - cpuSetIds[processor] = info.Id; - efficiencyClasses[processor] = info.EfficiencyClass; - coreIndexes.TryAdd(processor, info.CoreIndex); - numaNodeIndexes[processor] = info.NumaNodeIndex; - lastLevelCacheIndexes[processor] = info.LastLevelCacheIndex; - } - - offset += (int)info.Size; - } - } - finally - { - Marshal.FreeHGlobal(buffer); - } - } - - private void ReadLogicalProcessorRelationships( - HashSet logicalProcessors, - IDictionary efficiencyClasses, - IDictionary coreIndexes, - IDictionary numaNodeIndexes, - IDictionary lastLevelCacheIndexes, - IDictionary packageIndexes, - IDictionary> smtSiblingGlobalIndexes, - CancellationToken cancellationToken) - { - var requiredLength = 0; - if (GetLogicalProcessorInformationEx(LogicalProcessorRelationship.RelationAll, IntPtr.Zero, ref requiredLength)) - { - return; - } - - var firstError = Marshal.GetLastWin32Error(); - if (firstError != ErrorInsufficientBuffer || requiredLength <= 0) - { - this.logger.LogDebug("GetLogicalProcessorInformationEx probe failed with Win32 error {Error}", firstError); - return; - } - - var buffer = Marshal.AllocHGlobal(requiredLength); - try - { - cancellationToken.ThrowIfCancellationRequested(); - if (!GetLogicalProcessorInformationEx(LogicalProcessorRelationship.RelationAll, buffer, ref requiredLength)) - { - this.logger.LogDebug("GetLogicalProcessorInformationEx read failed with Win32 error {Error}", Marshal.GetLastWin32Error()); - return; - } - - var coreIndex = 0; - var packageIndex = 0; - var lastLevelCacheIndex = 0; - var offset = 0; - while (offset < requiredLength) - { - cancellationToken.ThrowIfCancellationRequested(); - var itemPtr = IntPtr.Add(buffer, offset); - var header = Marshal.PtrToStructure(itemPtr); - if (header.Size <= 0) - { - break; - } - - switch (header.Relationship) - { - case LogicalProcessorRelationship.RelationProcessorCore: - this.ReadProcessorCoreRelationship( - itemPtr, - coreIndex++, - logicalProcessors, - efficiencyClasses, - coreIndexes, - smtSiblingGlobalIndexes); - break; - case LogicalProcessorRelationship.RelationProcessorPackage: - this.ReadIndexedProcessorRelationship( - itemPtr, - packageIndex++, - logicalProcessors, - packageIndexes); - break; - case LogicalProcessorRelationship.RelationCache: - if (TryReadL3CacheProcessors(itemPtr, out var cacheProcessors)) - { - foreach (var processor in cacheProcessors) - { - logicalProcessors.Add(processor); - lastLevelCacheIndexes[processor] = lastLevelCacheIndex; - } - - lastLevelCacheIndex++; - } - - break; - case LogicalProcessorRelationship.RelationNumaNode: - case LogicalProcessorRelationship.RelationNumaNodeEx: - this.ReadNumaNodeRelationship(itemPtr, logicalProcessors, numaNodeIndexes); - break; - } - - offset += header.Size; - } - } - finally - { - Marshal.FreeHGlobal(buffer); - } - } - - private void ReadProcessorCoreRelationship( - IntPtr itemPtr, - int coreIndex, - HashSet logicalProcessors, - IDictionary efficiencyClasses, - IDictionary coreIndexes, - IDictionary> smtSiblingGlobalIndexes) - { - var relationshipPtr = IntPtr.Add(itemPtr, 8); - var processor = Marshal.PtrToStructure(relationshipPtr); - var processorsInCore = WindowsCpuTopologyNativeLayout - .ReadProcessorRelationshipProcessors(relationshipPtr, processor.GroupCount) - .ToList(); - var siblingIndexes = processorsInCore.Select(item => item.GlobalIndex).ToList(); - - foreach (var logicalProcessor in processorsInCore) - { - logicalProcessors.Add(logicalProcessor); - efficiencyClasses[logicalProcessor] = processor.EfficiencyClass; - coreIndexes[logicalProcessor] = coreIndex; - smtSiblingGlobalIndexes[logicalProcessor] = siblingIndexes - .Where(index => index != logicalProcessor.GlobalIndex) - .OrderBy(index => index) - .ToList(); - } - } - - private void ReadIndexedProcessorRelationship( - IntPtr itemPtr, - int index, - HashSet logicalProcessors, - IDictionary indexMap) - { - var relationshipPtr = IntPtr.Add(itemPtr, 8); - var processor = Marshal.PtrToStructure(relationshipPtr); - foreach (var logicalProcessor in WindowsCpuTopologyNativeLayout.ReadProcessorRelationshipProcessors(relationshipPtr, processor.GroupCount)) - { - logicalProcessors.Add(logicalProcessor); - indexMap[logicalProcessor] = index; - } - } - - private static bool TryReadL3CacheProcessors(IntPtr itemPtr, out IReadOnlyList processors) - { - return WindowsCpuTopologyNativeLayout.TryReadL3CacheProcessors(IntPtr.Add(itemPtr, 8), out processors); - } - - private void ReadNumaNodeRelationship( - IntPtr itemPtr, - HashSet logicalProcessors, - IDictionary numaNodeIndexes) - { - var processors = WindowsCpuTopologyNativeLayout.ReadNumaNodeProcessors(IntPtr.Add(itemPtr, 8), out var nodeNumber); - foreach (var processor in processors) - { - logicalProcessors.Add(processor); - numaNodeIndexes[processor] = nodeNumber; - } - } - } -} +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Runtime.InteropServices; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; + using ThreadPilot.Models; + + public sealed class WindowsCpuTopologyProvider : ICpuTopologyProvider + { + private const int ErrorInsufficientBuffer = 122; + + private readonly ILogger logger; + + public WindowsCpuTopologyProvider(ILogger? logger = null) + { + this.logger = logger ?? NullLogger.Instance; + } + + private enum CpuSetInformationType + { + CpuSetInformation = 0, + } + + private enum LogicalProcessorRelationship + { + RelationProcessorCore = 0, + RelationNumaNode = 1, + RelationCache = 2, + RelationProcessorPackage = 3, + RelationGroup = 4, + RelationProcessorDie = 5, + RelationNumaNodeEx = 6, + RelationProcessorModule = 7, + RelationAll = 0xFFFF, + } + + [StructLayout(LayoutKind.Sequential)] + private struct SystemCpuSetInformation + { + public uint Size; + public CpuSetInformationType Type; + public uint Id; + public ushort Group; + public byte LogicalProcessorIndex; + public byte CoreIndex; + public byte LastLevelCacheIndex; + public byte NumaNodeIndex; + public byte EfficiencyClass; + public byte AllFlags; + public uint SchedulingClassOrReserved; + public ulong AllocationTag; + } + + [StructLayout(LayoutKind.Sequential)] + private struct SystemLogicalProcessorInformationExHeader + { + public LogicalProcessorRelationship Relationship; + public int Size; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetSystemCpuSetInformation( + IntPtr information, + uint bufferLength, + out uint returnedLength, + IntPtr process, + uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetLogicalProcessorInformationEx( + LogicalProcessorRelationship relationshipType, + IntPtr buffer, + ref int returnedLength); + + public Task GetTopologySnapshotAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(this.CreateSnapshot(cancellationToken)); + } + + private CpuTopologySnapshot CreateSnapshot(CancellationToken cancellationToken) + { + var logicalProcessors = new HashSet(); + var cpuSetIds = new Dictionary(); + var efficiencyClasses = new Dictionary(); + var coreIndexes = new Dictionary(); + var numaNodeIndexes = new Dictionary(); + var lastLevelCacheIndexes = new Dictionary(); + var packageIndexes = new Dictionary(); + var smtSiblingGlobalIndexes = new Dictionary>(); + + this.ReadCpuSetInformation( + logicalProcessors, + cpuSetIds, + efficiencyClasses, + coreIndexes, + numaNodeIndexes, + lastLevelCacheIndexes, + cancellationToken); + + this.ReadLogicalProcessorRelationships( + logicalProcessors, + efficiencyClasses, + coreIndexes, + numaNodeIndexes, + lastLevelCacheIndexes, + packageIndexes, + smtSiblingGlobalIndexes, + cancellationToken); + + if (efficiencyClasses.Values.Distinct().Count() <= 1) + { + efficiencyClasses.Clear(); + } + + if (logicalProcessors.Count == 0) + { + this.logger.LogWarning("CPU topology provider could not read Windows topology APIs; using Environment.ProcessorCount fallback"); + foreach (var processor in WindowsCpuTopologyNativeLayout.CreateFallbackProcessors(Environment.ProcessorCount)) + { + logicalProcessors.Add(processor); + coreIndexes[processor] = processor.GlobalIndex; + } + } + + var processors = logicalProcessors + .OrderBy(processor => processor.GlobalIndex) + .ThenBy(processor => processor.Group) + .ThenBy(processor => processor.LogicalProcessorNumber) + .ToList(); + + var signature = new CpuTopologySignature + { + LogicalProcessorCount = processors.Count, + PhysicalCoreCount = coreIndexes.Count == 0 + ? processors.Count + : coreIndexes.Values.Distinct().Count(), + ProcessorGroupCount = processors.Select(processor => processor.Group).Distinct().Count(), + NumaNodeCount = numaNodeIndexes.Values.Distinct().Count(), + LastLevelCacheGroupCount = lastLevelCacheIndexes.Values.Distinct().Count(), + PackageCount = packageIndexes.Values.Distinct().Count(), + Source = nameof(WindowsCpuTopologyProvider), + }; + + return CpuTopologySnapshot.Create( + processors, + cpuSetIds, + efficiencyClasses, + signature, + coreIndexes, + numaNodeIndexes, + lastLevelCacheIndexes, + packageIndexes, + smtSiblingGlobalIndexes); + } + + private void ReadCpuSetInformation( + HashSet logicalProcessors, + IDictionary cpuSetIds, + IDictionary efficiencyClasses, + IDictionary coreIndexes, + IDictionary numaNodeIndexes, + IDictionary lastLevelCacheIndexes, + CancellationToken cancellationToken) + { + uint requiredLength = 0; + if (GetSystemCpuSetInformation(IntPtr.Zero, 0, out requiredLength, IntPtr.Zero, 0)) + { + return; + } + + var firstError = Marshal.GetLastWin32Error(); + if (firstError != ErrorInsufficientBuffer || requiredLength == 0) + { + this.logger.LogDebug("GetSystemCpuSetInformation probe failed with Win32 error {Error}", firstError); + return; + } + + var buffer = Marshal.AllocHGlobal((int)requiredLength); + try + { + cancellationToken.ThrowIfCancellationRequested(); + if (!GetSystemCpuSetInformation(buffer, requiredLength, out requiredLength, IntPtr.Zero, 0)) + { + this.logger.LogDebug("GetSystemCpuSetInformation read failed with Win32 error {Error}", Marshal.GetLastWin32Error()); + return; + } + + var offset = 0; + while (offset < requiredLength) + { + cancellationToken.ThrowIfCancellationRequested(); + var itemPtr = IntPtr.Add(buffer, offset); + var info = Marshal.PtrToStructure(itemPtr); + if (info.Size == 0) + { + break; + } + + if (info.Type == CpuSetInformationType.CpuSetInformation) + { + var processor = WindowsCpuTopologyNativeLayout.CreateProcessorRef(info.Group, info.LogicalProcessorIndex); + logicalProcessors.Add(processor); + cpuSetIds[processor] = info.Id; + efficiencyClasses[processor] = info.EfficiencyClass; + coreIndexes.TryAdd(processor, info.CoreIndex); + numaNodeIndexes[processor] = info.NumaNodeIndex; + lastLevelCacheIndexes[processor] = info.LastLevelCacheIndex; + } + + offset += (int)info.Size; + } + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + private void ReadLogicalProcessorRelationships( + HashSet logicalProcessors, + IDictionary efficiencyClasses, + IDictionary coreIndexes, + IDictionary numaNodeIndexes, + IDictionary lastLevelCacheIndexes, + IDictionary packageIndexes, + IDictionary> smtSiblingGlobalIndexes, + CancellationToken cancellationToken) + { + var requiredLength = 0; + if (GetLogicalProcessorInformationEx(LogicalProcessorRelationship.RelationAll, IntPtr.Zero, ref requiredLength)) + { + return; + } + + var firstError = Marshal.GetLastWin32Error(); + if (firstError != ErrorInsufficientBuffer || requiredLength <= 0) + { + this.logger.LogDebug("GetLogicalProcessorInformationEx probe failed with Win32 error {Error}", firstError); + return; + } + + var buffer = Marshal.AllocHGlobal(requiredLength); + try + { + cancellationToken.ThrowIfCancellationRequested(); + if (!GetLogicalProcessorInformationEx(LogicalProcessorRelationship.RelationAll, buffer, ref requiredLength)) + { + this.logger.LogDebug("GetLogicalProcessorInformationEx read failed with Win32 error {Error}", Marshal.GetLastWin32Error()); + return; + } + + var coreIndex = 0; + var packageIndex = 0; + var lastLevelCacheIndex = 0; + var offset = 0; + while (offset < requiredLength) + { + cancellationToken.ThrowIfCancellationRequested(); + var itemPtr = IntPtr.Add(buffer, offset); + var header = Marshal.PtrToStructure(itemPtr); + if (header.Size <= 0) + { + break; + } + + switch (header.Relationship) + { + case LogicalProcessorRelationship.RelationProcessorCore: + this.ReadProcessorCoreRelationship( + itemPtr, + coreIndex++, + logicalProcessors, + efficiencyClasses, + coreIndexes, + smtSiblingGlobalIndexes); + break; + case LogicalProcessorRelationship.RelationProcessorPackage: + this.ReadIndexedProcessorRelationship( + itemPtr, + packageIndex++, + logicalProcessors, + packageIndexes); + break; + case LogicalProcessorRelationship.RelationCache: + if (TryReadL3CacheProcessors(itemPtr, out var cacheProcessors)) + { + foreach (var processor in cacheProcessors) + { + logicalProcessors.Add(processor); + lastLevelCacheIndexes[processor] = lastLevelCacheIndex; + } + + lastLevelCacheIndex++; + } + + break; + case LogicalProcessorRelationship.RelationNumaNode: + case LogicalProcessorRelationship.RelationNumaNodeEx: + this.ReadNumaNodeRelationship(itemPtr, logicalProcessors, numaNodeIndexes); + break; + } + + offset += header.Size; + } + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + private void ReadProcessorCoreRelationship( + IntPtr itemPtr, + int coreIndex, + HashSet logicalProcessors, + IDictionary efficiencyClasses, + IDictionary coreIndexes, + IDictionary> smtSiblingGlobalIndexes) + { + var relationshipPtr = IntPtr.Add(itemPtr, 8); + var processor = Marshal.PtrToStructure(relationshipPtr); + var processorsInCore = WindowsCpuTopologyNativeLayout + .ReadProcessorRelationshipProcessors(relationshipPtr, processor.GroupCount) + .ToList(); + var siblingIndexes = processorsInCore.Select(item => item.GlobalIndex).ToList(); + + foreach (var logicalProcessor in processorsInCore) + { + logicalProcessors.Add(logicalProcessor); + efficiencyClasses[logicalProcessor] = processor.EfficiencyClass; + coreIndexes[logicalProcessor] = coreIndex; + smtSiblingGlobalIndexes[logicalProcessor] = siblingIndexes + .Where(index => index != logicalProcessor.GlobalIndex) + .OrderBy(index => index) + .ToList(); + } + } + + private void ReadIndexedProcessorRelationship( + IntPtr itemPtr, + int index, + HashSet logicalProcessors, + IDictionary indexMap) + { + var relationshipPtr = IntPtr.Add(itemPtr, 8); + var processor = Marshal.PtrToStructure(relationshipPtr); + foreach (var logicalProcessor in WindowsCpuTopologyNativeLayout.ReadProcessorRelationshipProcessors(relationshipPtr, processor.GroupCount)) + { + logicalProcessors.Add(logicalProcessor); + indexMap[logicalProcessor] = index; + } + } + + private static bool TryReadL3CacheProcessors(IntPtr itemPtr, out IReadOnlyList processors) + { + return WindowsCpuTopologyNativeLayout.TryReadL3CacheProcessors(IntPtr.Add(itemPtr, 8), out processors); + } + + private void ReadNumaNodeRelationship( + IntPtr itemPtr, + HashSet logicalProcessors, + IDictionary numaNodeIndexes) + { + var processors = WindowsCpuTopologyNativeLayout.ReadNumaNodeProcessors(IntPtr.Add(itemPtr, 8), out var nodeNumber); + foreach (var processor in processors) + { + logicalProcessors.Add(processor); + numaNodeIndexes[processor] = nodeNumber; + } + } + } +} diff --git a/Services/WindowsForegroundWindowProvider.cs b/Services/WindowsForegroundWindowProvider.cs index 57fc17e..b4de578 100644 --- a/Services/WindowsForegroundWindowProvider.cs +++ b/Services/WindowsForegroundWindowProvider.cs @@ -1,78 +1,62 @@ -/* - * 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); - } -} +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/Services/WpfApplicationShutdownService.cs b/Services/WpfApplicationShutdownService.cs index 2ae900d..fcca799 100644 --- a/Services/WpfApplicationShutdownService.cs +++ b/Services/WpfApplicationShutdownService.cs @@ -1,21 +1,21 @@ -/* - * ThreadPilot - graceful shutdown hook after updater launch. - */ -namespace ThreadPilot.Services -{ - using System.Windows; - - public sealed class WpfApplicationShutdownService : IApplicationShutdownService - { - public void RequestShutdownForUpdate() - { - var application = Application.Current; - if (application == null) - { - return; - } - - application.Dispatcher.InvokeAsync(application.Shutdown); - } - } -} +/* + * ThreadPilot - graceful shutdown hook after updater launch. + */ +namespace ThreadPilot.Services +{ + using System.Windows; + + public sealed class WpfApplicationShutdownService : IApplicationShutdownService + { + public void RequestShutdownForUpdate() + { + var application = Application.Current; + if (application == null) + { + return; + } + + application.Dispatcher.InvokeAsync(application.Shutdown); + } + } +} diff --git a/Tests/ActiveApplicationsTest.cs b/Tests/ActiveApplicationsTest.cs index ed5b18a..285cfe8 100644 --- a/Tests/ActiveApplicationsTest.cs +++ b/Tests/ActiveApplicationsTest.cs @@ -1,149 +1,121 @@ -/* - * 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.Tests -{ - using System; - using System.Diagnostics; - using System.Linq; - using System.Threading.Tasks; - using ThreadPilot.Models; - using ThreadPilot.Services; - - /// - /// Test class to validate the Active Applications filtering functionality. - /// - public class ActiveApplicationsTest - { - private readonly ProcessService processService; - - public ActiveApplicationsTest() - { - this.processService = new ProcessService(); - } - - /// - /// Test that demonstrates the difference between all processes and active applications. - /// - public async Task TestActiveApplicationsFiltering() - { - Console.WriteLine("=== Active Applications Test ==="); - Console.WriteLine(); - - // Get all processes - var allProcesses = await this.processService.GetProcessesAsync(); - Console.WriteLine($"Total processes found: {allProcesses.Count}"); - - // Get only active applications - var activeApplications = await this.processService.GetActiveApplicationsAsync(); - Console.WriteLine($"Active applications found: {activeApplications.Count}"); - Console.WriteLine(); - - // Show some examples of active applications - Console.WriteLine("=== Active Applications (with visible windows) ==="); - foreach (var app in activeApplications.Take(10)) - { - Console.WriteLine($"- {app.Name} (PID: {app.ProcessId})"); - Console.WriteLine($" Window Title: '{app.MainWindowTitle}'"); - Console.WriteLine($" Has Visible Window: {app.HasVisibleWindow}"); - Console.WriteLine($" Window Handle: {app.MainWindowHandle}"); - Console.WriteLine(); - } - - // Show some examples of background processes (processes without windows) - var backgroundProcesses = allProcesses.Where(p => !p.HasVisibleWindow).Take(10); - Console.WriteLine("=== Background Processes (no visible windows) ==="); - foreach (var process in backgroundProcesses) - { - Console.WriteLine($"- {process.Name} (PID: {process.ProcessId})"); - Console.WriteLine($" Window Title: '{process.MainWindowTitle}'"); - Console.WriteLine($" Has Visible Window: {process.HasVisibleWindow}"); - Console.WriteLine(); - } - - // Validate that active applications are a subset of all processes - var activeAppIds = activeApplications.Select(a => a.ProcessId).ToHashSet(); - var allProcessIds = allProcesses.Select(p => p.ProcessId).ToHashSet(); - - bool isSubset = activeAppIds.IsSubsetOf(allProcessIds); - Console.WriteLine($"Active applications are subset of all processes: {isSubset}"); - - // Validate that all active applications have visible windows - bool allHaveWindows = activeApplications.All(a => a.HasVisibleWindow); - Console.WriteLine($"All active applications have visible windows: {allHaveWindows}"); - - // Show filtering effectiveness - double filteringRatio = (double)activeApplications.Count / allProcesses.Count * 100; - Console.WriteLine($"Filtering effectiveness: {filteringRatio:F1}% of processes are active applications"); - } - - /// - /// Test specific process properties for window information. - /// - public async Task TestProcessWindowProperties() - { - Console.WriteLine("\n=== Process Window Properties Test ==="); - - var allProcesses = await this.processService.GetProcessesAsync(); - - // Find some common applications that should have windows - var commonApps = new[] { "explorer", "chrome", "firefox", "notepad", "code", "devenv" }; - - foreach (var appName in commonApps) - { - var matchingProcesses = allProcesses.Where(p => - p.Name.Contains(appName, StringComparison.OrdinalIgnoreCase)).ToList(); - - if (matchingProcesses.Any()) - { - Console.WriteLine($"\n--- {appName.ToUpper()} Processes ---"); - foreach (var process in matchingProcesses) - { - Console.WriteLine($"Name: {process.Name}"); - Console.WriteLine($"PID: {process.ProcessId}"); - Console.WriteLine($"Window Handle: {process.MainWindowHandle}"); - Console.WriteLine($"Window Title: '{process.MainWindowTitle}'"); - Console.WriteLine($"Has Visible Window: {process.HasVisibleWindow}"); - Console.WriteLine($"Executable Path: {process.ExecutablePath}"); - Console.WriteLine(); - } - } - } - } - - /// - /// Run all tests. - /// - public static async Task RunTests() - { - var test = new ActiveApplicationsTest(); - - try - { - await test.TestActiveApplicationsFiltering(); - await test.TestProcessWindowProperties(); - - Console.WriteLine("\n=== All tests completed successfully! ==="); - } - catch (Exception ex) - { - Console.WriteLine($"\nTest failed with error: {ex.Message}"); - Console.WriteLine($"Stack trace: {ex.StackTrace}"); - } - } - } -} - +namespace ThreadPilot.Tests +{ + using System; + using System.Diagnostics; + using System.Linq; + using System.Threading.Tasks; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public class ActiveApplicationsTest + { + private readonly ProcessService processService; + + public ActiveApplicationsTest() + { + this.processService = new ProcessService(); + } + + public async Task TestActiveApplicationsFiltering() + { + Console.WriteLine("=== Active Applications Test ==="); + Console.WriteLine(); + + // Get all processes + var allProcesses = await this.processService.GetProcessesAsync(); + Console.WriteLine($"Total processes found: {allProcesses.Count}"); + + // Get only active applications + var activeApplications = await this.processService.GetActiveApplicationsAsync(); + Console.WriteLine($"Active applications found: {activeApplications.Count}"); + Console.WriteLine(); + + // Show some examples of active applications + Console.WriteLine("=== Active Applications (with visible windows) ==="); + foreach (var app in activeApplications.Take(10)) + { + Console.WriteLine($"- {app.Name} (PID: {app.ProcessId})"); + Console.WriteLine($" Window Title: '{app.MainWindowTitle}'"); + Console.WriteLine($" Has Visible Window: {app.HasVisibleWindow}"); + Console.WriteLine($" Window Handle: {app.MainWindowHandle}"); + Console.WriteLine(); + } + + // Show some examples of background processes (processes without windows) + var backgroundProcesses = allProcesses.Where(p => !p.HasVisibleWindow).Take(10); + Console.WriteLine("=== Background Processes (no visible windows) ==="); + foreach (var process in backgroundProcesses) + { + Console.WriteLine($"- {process.Name} (PID: {process.ProcessId})"); + Console.WriteLine($" Window Title: '{process.MainWindowTitle}'"); + Console.WriteLine($" Has Visible Window: {process.HasVisibleWindow}"); + Console.WriteLine(); + } + + // Validate that active applications are a subset of all processes + var activeAppIds = activeApplications.Select(a => a.ProcessId).ToHashSet(); + var allProcessIds = allProcesses.Select(p => p.ProcessId).ToHashSet(); + + bool isSubset = activeAppIds.IsSubsetOf(allProcessIds); + Console.WriteLine($"Active applications are subset of all processes: {isSubset}"); + + // Validate that all active applications have visible windows + bool allHaveWindows = activeApplications.All(a => a.HasVisibleWindow); + Console.WriteLine($"All active applications have visible windows: {allHaveWindows}"); + + // Show filtering effectiveness + double filteringRatio = (double)activeApplications.Count / allProcesses.Count * 100; + Console.WriteLine($"Filtering effectiveness: {filteringRatio:F1}% of processes are active applications"); + } + + public async Task TestProcessWindowProperties() + { + Console.WriteLine("\n=== Process Window Properties Test ==="); + + var allProcesses = await this.processService.GetProcessesAsync(); + + // Find some common applications that should have windows + var commonApps = new[] { "explorer", "chrome", "firefox", "notepad", "code", "devenv" }; + + foreach (var appName in commonApps) + { + var matchingProcesses = allProcesses.Where(p => + p.Name.Contains(appName, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (matchingProcesses.Any()) + { + Console.WriteLine($"\n--- {appName.ToUpper()} Processes ---"); + foreach (var process in matchingProcesses) + { + Console.WriteLine($"Name: {process.Name}"); + Console.WriteLine($"PID: {process.ProcessId}"); + Console.WriteLine($"Window Handle: {process.MainWindowHandle}"); + Console.WriteLine($"Window Title: '{process.MainWindowTitle}'"); + Console.WriteLine($"Has Visible Window: {process.HasVisibleWindow}"); + Console.WriteLine($"Executable Path: {process.ExecutablePath}"); + Console.WriteLine(); + } + } + } + } + + public static async Task RunTests() + { + var test = new ActiveApplicationsTest(); + + try + { + await test.TestActiveApplicationsFiltering(); + await test.TestProcessWindowProperties(); + + Console.WriteLine("\n=== All tests completed successfully! ==="); + } + catch (Exception ex) + { + Console.WriteLine($"\nTest failed with error: {ex.Message}"); + Console.WriteLine($"Stack trace: {ex.StackTrace}"); + } + } + } +} + diff --git a/Tests/CpuTopologyServiceTests.cs b/Tests/CpuTopologyServiceTests.cs index 7cfb45f..4d054af 100644 --- a/Tests/CpuTopologyServiceTests.cs +++ b/Tests/CpuTopologyServiceTests.cs @@ -1,136 +1,114 @@ -/* - * 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.Tests -{ - using System; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - using ThreadPilot.Services; - - /// - /// Simple test class for CPU topology detection. - /// - public static class CpuTopologyServiceTests - { - /// - /// Test CPU topology detection. - /// - public static async Task TestCpuTopologyDetection() - { - Console.WriteLine("=== CPU Topology Detection Test ==="); - - try - { - // Create logger - using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); - var logger = loggerFactory.CreateLogger(); - - // Create service - var service = new CpuTopologyService(logger); - - // Subscribe to events - service.TopologyDetected += (sender, e) => - { - Console.WriteLine($"Topology detected: Success={e.DetectionSuccessful}"); - if (e.Topology != null) - { - PrintTopologyInfo(e.Topology); - } - }; - - // Detect topology - Console.WriteLine("Starting topology detection..."); - await service.DetectTopologyAsync(); - - // Get current topology - var topology = service.CurrentTopology; - if (topology != null) - { - Console.WriteLine("\n=== Current Topology ==="); - PrintTopologyInfo(topology); - - // Test affinity presets - Console.WriteLine("\n=== Affinity Presets ==="); - var presets = service.GetAffinityPresets(); - foreach (var preset in presets) - { - Console.WriteLine($"- {preset.Name}: {preset.Description}"); - Console.WriteLine($" Mask: 0x{preset.AffinityMask:X}"); - } - - // Test affinity validation - Console.WriteLine("\n=== Affinity Validation ==="); - var testMask = topology.CalculateAffinityMask(topology.LogicalCores); - var isValid = service.IsAffinityMaskValid(testMask); - Console.WriteLine($"Test mask 0x{testMask:X} is valid: {isValid}"); - } - else - { - Console.WriteLine("No topology detected"); - } - } - catch (Exception ex) - { - Console.WriteLine($"Error: {ex.Message}"); - Console.WriteLine($"Stack trace: {ex.StackTrace}"); - } - - Console.WriteLine("\n=== Test Complete ==="); - } - - private static void PrintTopologyInfo(CpuTopologyModel topology) - { - Console.WriteLine($"Total Logical Cores: {topology.LogicalCores.Count}"); - Console.WriteLine($"Total Physical Cores: {topology.PhysicalCores.Count()}"); - Console.WriteLine($"Socket Count: {topology.SocketCount}"); - Console.WriteLine($"CCD Count: {topology.CcdCount}"); - Console.WriteLine($"Has Hybrid Architecture: {topology.HasHybridArchitecture}"); - Console.WriteLine($"Has SMT: {topology.HasSmt}"); - Console.WriteLine($"Architecture: {topology.Architecture}"); - - if (topology.HasHybridArchitecture) - { - var pCores = topology.LogicalCores.Count(c => c.CoreType == CpuCoreType.PerformanceCore); - var eCores = topology.LogicalCores.Count(c => c.CoreType == CpuCoreType.EfficiencyCore); - Console.WriteLine($"P-Cores: {pCores}, E-Cores: {eCores}"); - } - - if (topology.CcdCount > 1) - { - for (int i = 0; i < topology.CcdCount; i++) - { - var ccdCores = topology.LogicalCores.Count(c => c.CcdId == i); - Console.WriteLine($"CCD {i}: {ccdCores} cores"); - } - } - - Console.WriteLine("\nCore Details:"); - foreach (var core in topology.LogicalCores.Take(Math.Min(8, topology.LogicalCores.Count))) - { - Console.WriteLine($" Core {core.LogicalCoreId}: Physical={core.PhysicalCoreId}, " + - $"CCD={core.CcdId}, Type={core.CoreType}, HT={core.IsHyperThreaded}"); - } - - if (topology.LogicalCores.Count > 8) - { - Console.WriteLine($" ... and {topology.LogicalCores.Count - 8} more cores"); - } - } - } -} - +namespace ThreadPilot.Tests +{ + using System; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public static class CpuTopologyServiceTests + { + public static async Task TestCpuTopologyDetection() + { + Console.WriteLine("=== CPU Topology Detection Test ==="); + + try + { + // Create logger + using var loggerFactory = LoggerFactory.Create(builder => { }); + var logger = loggerFactory.CreateLogger(); + + // Create service + var service = new CpuTopologyService(logger); + + // Subscribe to events + service.TopologyDetected += (sender, e) => + { + Console.WriteLine($"Topology detected: Success={e.DetectionSuccessful}"); + if (e.Topology != null) + { + PrintTopologyInfo(e.Topology); + } + }; + + // Detect topology + Console.WriteLine("Starting topology detection..."); + await service.DetectTopologyAsync(); + + // Get current topology + var topology = service.CurrentTopology; + if (topology != null) + { + Console.WriteLine("\n=== Current Topology ==="); + PrintTopologyInfo(topology); + + // Test affinity presets + Console.WriteLine("\n=== Affinity Presets ==="); + var presets = service.GetAffinityPresets(); + foreach (var preset in presets) + { + Console.WriteLine($"- {preset.Name}: {preset.Description}"); + Console.WriteLine($" Mask: 0x{preset.AffinityMask:X}"); + } + + // Test affinity validation + Console.WriteLine("\n=== Affinity Validation ==="); + var testMask = topology.CalculateAffinityMask(topology.LogicalCores); + var isValid = service.IsAffinityMaskValid(testMask); + Console.WriteLine($"Test mask 0x{testMask:X} is valid: {isValid}"); + } + else + { + Console.WriteLine("No topology detected"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + Console.WriteLine($"Stack trace: {ex.StackTrace}"); + } + + Console.WriteLine("\n=== Test Complete ==="); + } + + private static void PrintTopologyInfo(CpuTopologyModel topology) + { + Console.WriteLine($"Total Logical Cores: {topology.LogicalCores.Count}"); + Console.WriteLine($"Total Physical Cores: {topology.PhysicalCores.Count()}"); + Console.WriteLine($"Socket Count: {topology.SocketCount}"); + Console.WriteLine($"CCD Count: {topology.CcdCount}"); + Console.WriteLine($"Has Hybrid Architecture: {topology.HasHybridArchitecture}"); + Console.WriteLine($"Has SMT: {topology.HasSmt}"); + Console.WriteLine($"Architecture: {topology.Architecture}"); + + if (topology.HasHybridArchitecture) + { + var pCores = topology.LogicalCores.Count(c => c.CoreType == CpuCoreType.PerformanceCore); + var eCores = topology.LogicalCores.Count(c => c.CoreType == CpuCoreType.EfficiencyCore); + Console.WriteLine($"P-Cores: {pCores}, E-Cores: {eCores}"); + } + + if (topology.CcdCount > 1) + { + for (int i = 0; i < topology.CcdCount; i++) + { + var ccdCores = topology.LogicalCores.Count(c => c.CcdId == i); + Console.WriteLine($"CCD {i}: {ccdCores} cores"); + } + } + + Console.WriteLine("\nCore Details:"); + foreach (var core in topology.LogicalCores.Take(Math.Min(8, topology.LogicalCores.Count))) + { + Console.WriteLine($" Core {core.LogicalCoreId}: Physical={core.PhysicalCoreId}, " + + $"CCD={core.CcdId}, Type={core.CoreType}, HT={core.IsHyperThreaded}"); + } + + if (topology.LogicalCores.Count > 8) + { + Console.WriteLine($" ... and {topology.LogicalCores.Count - 8} more cores"); + } + } + } +} + diff --git a/Tests/ExecutableBrowseTest.cs b/Tests/ExecutableBrowseTest.cs index 8772fe3..97fb6b9 100644 --- a/Tests/ExecutableBrowseTest.cs +++ b/Tests/ExecutableBrowseTest.cs @@ -1,166 +1,135 @@ -/* - * 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.Tests -{ - using System; - using System.IO; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - using ThreadPilot.Services; - using ThreadPilot.ViewModels; - - /// - /// Test class to validate the new executable browse functionality. - /// - public class ExecutableBrowseTest - { - public ExecutableBrowseTest() - { - // Simple test class without complex dependencies - } - - /// - /// Test the executable validation logic (static test). - /// - public bool TestExecutableValidation() - { - try - { - Console.WriteLine("Testing executable validation logic..."); - - // Test with a known Windows executable - string windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Windows); - string notepadPath = Path.Combine(windowsDir, "notepad.exe"); - - bool notepadExists = File.Exists(notepadPath); - Console.WriteLine($"Notepad.exe exists: {notepadExists}"); - - if (notepadExists) - { - // Test file extension validation - string extension = Path.GetExtension(notepadPath); - bool hasExeExtension = string.Equals(extension, ".exe", StringComparison.OrdinalIgnoreCase); - Console.WriteLine($"Has .exe extension: {hasExeExtension}"); - - // Test with non-exe file - string systemIni = Path.Combine(windowsDir, "system.ini"); - bool systemIniExists = File.Exists(systemIni); - if (systemIniExists) - { - string iniExtension = Path.GetExtension(systemIni); - bool isNotExe = !string.Equals(iniExtension, ".exe", StringComparison.OrdinalIgnoreCase); - Console.WriteLine($"system.ini is not .exe: {isNotExe}"); - } - - bool testPassed = notepadExists && hasExeExtension; - Console.WriteLine($"Executable validation test: {(testPassed ? "PASSED" : "FAILED")}"); - return testPassed; - } - - Console.WriteLine("Could not find notepad.exe for testing"); - return false; - } - catch (Exception ex) - { - Console.WriteLine($"Executable validation test FAILED: {ex.Message}"); - return false; - } - } - - /// - /// Test path extraction functionality. - /// - public bool TestPathExtraction() - { - try - { - Console.WriteLine("Testing path extraction functionality..."); - - // Test extracting filename from full path - string testPath = @"C:\Program Files\MyApp\myapp.exe"; - string extractedName = Path.GetFileName(testPath); - - Console.WriteLine($"Full path: {testPath}"); - Console.WriteLine($"Extracted name: {extractedName}"); - - bool testPassed = extractedName == "myapp.exe"; - Console.WriteLine($"Path extraction test: {(testPassed ? "PASSED" : "FAILED")}"); - return testPassed; - } - catch (Exception ex) - { - Console.WriteLine($"Path extraction test FAILED: {ex.Message}"); - return false; - } - } - - /// - /// Test file dialog filter validation. - /// - public bool TestFileDialogFilter() - { - try - { - Console.WriteLine("Testing file dialog filter logic..."); - - // Test the filter string that would be used - string expectedFilter = "Executable Files (*.exe)|*.exe|All Files (*.*)|*.*"; - Console.WriteLine($"Expected filter: {expectedFilter}"); - - // Test that the filter contains the right patterns - bool hasExeFilter = expectedFilter.Contains("*.exe"); - bool hasAllFilesFilter = expectedFilter.Contains("*.*"); - - Console.WriteLine($"Has .exe filter: {hasExeFilter}"); - Console.WriteLine($"Has all files filter: {hasAllFilesFilter}"); - - bool testPassed = hasExeFilter && hasAllFilesFilter; - Console.WriteLine($"File dialog filter test: {(testPassed ? "PASSED" : "FAILED")}"); - return testPassed; - } - catch (Exception ex) - { - Console.WriteLine($"File dialog filter test FAILED: {ex.Message}"); - return false; - } - } - - /// - /// Run all tests. - /// - public bool RunAllTests() - { - Console.WriteLine("=== Executable Browse Functionality Tests ==="); - Console.WriteLine(); - - bool test1 = this.TestExecutableValidation(); - Console.WriteLine(); - - bool test2 = this.TestPathExtraction(); - Console.WriteLine(); - - bool test3 = this.TestFileDialogFilter(); - Console.WriteLine(); - - bool allPassed = test1 && test2 && test3; - Console.WriteLine($"=== Overall Test Result: {(allPassed ? "ALL TESTS PASSED" : "SOME TESTS FAILED")} ==="); - - return allPassed; - } - } -} - +namespace ThreadPilot.Tests +{ + using System; + using System.IO; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public class ExecutableBrowseTest + { + public ExecutableBrowseTest() + { + // Simple test class without complex dependencies + } + + public bool TestExecutableValidation() + { + try + { + Console.WriteLine("Testing executable validation logic..."); + + // Test with a known Windows executable + string windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + string notepadPath = Path.Combine(windowsDir, "notepad.exe"); + + bool notepadExists = File.Exists(notepadPath); + Console.WriteLine($"Notepad.exe exists: {notepadExists}"); + + if (notepadExists) + { + // Test file extension validation + string extension = Path.GetExtension(notepadPath); + bool hasExeExtension = string.Equals(extension, ".exe", StringComparison.OrdinalIgnoreCase); + Console.WriteLine($"Has .exe extension: {hasExeExtension}"); + + // Test with non-exe file + string systemIni = Path.Combine(windowsDir, "system.ini"); + bool systemIniExists = File.Exists(systemIni); + if (systemIniExists) + { + string iniExtension = Path.GetExtension(systemIni); + bool isNotExe = !string.Equals(iniExtension, ".exe", StringComparison.OrdinalIgnoreCase); + Console.WriteLine($"system.ini is not .exe: {isNotExe}"); + } + + bool testPassed = notepadExists && hasExeExtension; + Console.WriteLine($"Executable validation test: {(testPassed ? "PASSED" : "FAILED")}"); + return testPassed; + } + + Console.WriteLine("Could not find notepad.exe for testing"); + return false; + } + catch (Exception ex) + { + Console.WriteLine($"Executable validation test FAILED: {ex.Message}"); + return false; + } + } + + public bool TestPathExtraction() + { + try + { + Console.WriteLine("Testing path extraction functionality..."); + + // Test extracting filename from full path + string testPath = @"C:\Program Files\MyApp\myapp.exe"; + string extractedName = Path.GetFileName(testPath); + + Console.WriteLine($"Full path: {testPath}"); + Console.WriteLine($"Extracted name: {extractedName}"); + + bool testPassed = extractedName == "myapp.exe"; + Console.WriteLine($"Path extraction test: {(testPassed ? "PASSED" : "FAILED")}"); + return testPassed; + } + catch (Exception ex) + { + Console.WriteLine($"Path extraction test FAILED: {ex.Message}"); + return false; + } + } + + public bool TestFileDialogFilter() + { + try + { + Console.WriteLine("Testing file dialog filter logic..."); + + // Test the filter string that would be used + string expectedFilter = "Executable Files (*.exe)|*.exe|All Files (*.*)|*.*"; + Console.WriteLine($"Expected filter: {expectedFilter}"); + + // Test that the filter contains the right patterns + bool hasExeFilter = expectedFilter.Contains("*.exe"); + bool hasAllFilesFilter = expectedFilter.Contains("*.*"); + + Console.WriteLine($"Has .exe filter: {hasExeFilter}"); + Console.WriteLine($"Has all files filter: {hasAllFilesFilter}"); + + bool testPassed = hasExeFilter && hasAllFilesFilter; + Console.WriteLine($"File dialog filter test: {(testPassed ? "PASSED" : "FAILED")}"); + return testPassed; + } + catch (Exception ex) + { + Console.WriteLine($"File dialog filter test FAILED: {ex.Message}"); + return false; + } + } + + public bool RunAllTests() + { + Console.WriteLine("=== Executable Browse Functionality Tests ==="); + Console.WriteLine(); + + bool test1 = this.TestExecutableValidation(); + Console.WriteLine(); + + bool test2 = this.TestPathExtraction(); + Console.WriteLine(); + + bool test3 = this.TestFileDialogFilter(); + Console.WriteLine(); + + bool allPassed = test1 && test2 && test3; + Console.WriteLine($"=== Overall Test Result: {(allPassed ? "ALL TESTS PASSED" : "SOME TESTS FAILED")} ==="); + + return allPassed; + } + } +} + diff --git a/Tests/GameBoostIntegrationTest.cs b/Tests/GameBoostIntegrationTest.cs deleted file mode 100644 index 90f17fc..0000000 --- a/Tests/GameBoostIntegrationTest.cs +++ /dev/null @@ -1,228 +0,0 @@ -/* - * 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 . - */ -using System; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using ThreadPilot.Services; -using ThreadPilot.Models; - -namespace ThreadPilot.Tests -{ - /// - /// Integration test for Game Boost functionality - /// - public class GameBoostIntegrationTest - { - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - - public GameBoostIntegrationTest(IServiceProvider serviceProvider) - { - _serviceProvider = serviceProvider; - _logger = serviceProvider.GetRequiredService>(); - } - - /// - /// Tests the complete Game Boost workflow - /// - public async Task RunIntegrationTestAsync() - { - try - { - _logger.LogInformation("Starting Game Boost integration test..."); - - // Test 1: Service Resolution - if (!await TestServiceResolutionAsync()) - { - _logger.LogError("Service resolution test failed"); - return false; - } - - // Test 2: Game Detection Logic - if (!await TestGameDetectionAsync()) - { - _logger.LogError("Game detection test failed"); - return false; - } - - // Test 3: Known Games Management - if (!await TestKnownGamesManagementAsync()) - { - _logger.LogError("Known games management test failed"); - return false; - } - - // Test 4: System Tray Integration - if (!await TestSystemTrayIntegrationAsync()) - { - _logger.LogError("System tray integration test failed"); - return false; - } - - _logger.LogInformation("All Game Boost integration tests passed!"); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Game Boost integration test failed with exception"); - return false; - } - } - - private async Task TestServiceResolutionAsync() - { - try - { - _logger.LogInformation("Testing service resolution..."); - - var gameBoostService = _serviceProvider.GetRequiredService(); - var systemTrayService = _serviceProvider.GetRequiredService(); - var processMonitorService = _serviceProvider.GetRequiredService(); - var settingsService = _serviceProvider.GetRequiredService(); - - if (gameBoostService == null || systemTrayService == null || - processMonitorService == null || settingsService == null) - { - _logger.LogError("One or more required services could not be resolved"); - return false; - } - - _logger.LogInformation("Service resolution test passed"); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Service resolution test failed"); - return false; - } - } - - private async Task TestGameDetectionAsync() - { - try - { - _logger.LogInformation("Testing game detection logic..."); - - var gameBoostService = _serviceProvider.GetRequiredService(); - - // Test with known game processes - var testProcesses = new[] - { - new ProcessModel { Name = "steam.exe", ProcessId = 1234, ExecutablePath = @"C:\Program Files\Steam\steam.exe" }, - new ProcessModel { Name = "notepad.exe", ProcessId = 5678, ExecutablePath = @"C:\Windows\System32\notepad.exe" }, - new ProcessModel { Name = "csgo.exe", ProcessId = 9999, ExecutablePath = @"C:\Games\Counter-Strike\csgo.exe" } - }; - - foreach (var process in testProcesses) - { - var isGame = gameBoostService.IsGameProcess(process); - _logger.LogInformation("Process {ProcessName}: IsGame = {IsGame}", process.Name, isGame); - } - - _logger.LogInformation("Game detection test passed"); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Game detection test failed"); - return false; - } - } - - private async Task TestKnownGamesManagementAsync() - { - try - { - _logger.LogInformation("Testing known games management..."); - - var gameBoostService = _serviceProvider.GetRequiredService(); - - // Get initial count - var initialCount = gameBoostService.KnownGameExecutables.Count; - _logger.LogInformation("Initial known games count: {Count}", initialCount); - - // Test adding a game - var testGame = "testgame.exe"; - var addResult = await gameBoostService.AddKnownGameAsync(testGame); - if (!addResult) - { - _logger.LogError("Failed to add test game"); - return false; - } - - // Verify it was added - var newCount = gameBoostService.KnownGameExecutables.Count; - if (newCount != initialCount + 1) - { - _logger.LogError("Game count did not increase after adding game"); - return false; - } - - // Test removing the game - var removeResult = await gameBoostService.RemoveKnownGameAsync(testGame); - if (!removeResult) - { - _logger.LogError("Failed to remove test game"); - return false; - } - - // Verify it was removed - var finalCount = gameBoostService.KnownGameExecutables.Count; - if (finalCount != initialCount) - { - _logger.LogError("Game count did not return to initial value after removing game"); - return false; - } - - _logger.LogInformation("Known games management test passed"); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Known games management test failed"); - return false; - } - } - - private async Task TestSystemTrayIntegrationAsync() - { - try - { - _logger.LogInformation("Testing system tray integration..."); - - var systemTrayService = _serviceProvider.GetRequiredService(); - - // Test updating Game Boost status - systemTrayService.UpdateGameBoostStatus(true, "TestGame"); - await Task.Delay(100); // Allow UI to update - - systemTrayService.UpdateGameBoostStatus(false); - await Task.Delay(100); // Allow UI to update - - _logger.LogInformation("System tray integration test passed"); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "System tray integration test failed"); - return false; - } - } - } -} - diff --git a/Tests/ProcessSelectionTest.cs b/Tests/ProcessSelectionTest.cs index f73f8e0..581584b 100644 --- a/Tests/ProcessSelectionTest.cs +++ b/Tests/ProcessSelectionTest.cs @@ -1,320 +1,283 @@ -/* - * 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.Tests -{ - using System; - using System.Diagnostics; - using System.Linq; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - using ThreadPilot.Services; - - /// - /// Test class to validate the improved process selection and real-time data sync functionality. - /// - public class ProcessSelectionTest - { - private readonly ProcessService processService; - private readonly CpuTopologyService cpuTopologyService; - - public ProcessSelectionTest() - { - this.processService = new ProcessService(); - - // Create a simple logger for the CPU topology service - var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); - var logger = loggerFactory.CreateLogger(); - this.cpuTopologyService = new CpuTopologyService(logger); - } - - /// - /// Test that process information is correctly refreshed and reflects actual OS state. - /// - public async Task TestProcessInfoRefresh() - { - try - { - Console.WriteLine("Testing process info refresh..."); - - // Get current process as test subject - var currentProcess = Process.GetCurrentProcess(); - var processModel = this.processService.CreateProcessModel(currentProcess); - - Console.WriteLine($"Initial process info - PID: {processModel.ProcessId}, Priority: {processModel.Priority}, Affinity: 0x{processModel.ProcessorAffinity:X}"); - - // Refresh the process info - await this.processService.RefreshProcessInfo(processModel); - - Console.WriteLine($"After refresh - PID: {processModel.ProcessId}, Priority: {processModel.Priority}, Affinity: 0x{processModel.ProcessorAffinity:X}"); - - // Verify the data is consistent - bool isValid = processModel.ProcessId == currentProcess.Id && - processModel.Priority == currentProcess.PriorityClass && - processModel.ProcessorAffinity == (long)currentProcess.ProcessorAffinity; - - Console.WriteLine($"Process info refresh test: {(isValid ? "PASSED" : "FAILED")}"); - return isValid; - } - catch (Exception ex) - { - Console.WriteLine($"Process info refresh test FAILED: {ex.Message}"); - return false; - } - } - - /// - /// Test that process termination is properly detected. - /// - public async Task TestProcessTerminationDetection() - { - try - { - Console.WriteLine("Testing process termination detection..."); - - // Start a short-lived process - var notepadProcess = Process.Start("notepad.exe"); - if (notepadProcess == null) - { - Console.WriteLine("Could not start test process"); - return false; - } - - var processModel = this.processService.CreateProcessModel(notepadProcess); - Console.WriteLine($"Started test process - PID: {processModel.ProcessId}"); - - // Verify process is running - bool isRunning = await this.processService.IsProcessStillRunning(processModel); - Console.WriteLine($"Process running check: {isRunning}"); - - // Terminate the process - notepadProcess.Kill(); - await Task.Delay(1000); // Wait for termination - - // Check if termination is detected - bool isStillRunning = await this.processService.IsProcessStillRunning(processModel); - Console.WriteLine($"Process running after termination: {isStillRunning}"); - - bool testPassed = isRunning && !isStillRunning; - Console.WriteLine($"Process termination detection test: {(testPassed ? "PASSED" : "FAILED")}"); - - return testPassed; - } - catch (Exception ex) - { - Console.WriteLine($"Process termination detection test FAILED: {ex.Message}"); - return false; - } - } - - /// - /// Test active applications filtering. - /// - public async Task TestActiveApplicationsFiltering() - { - try - { - Console.WriteLine("Testing active applications filtering..."); - - var allProcesses = await this.processService.GetProcessesAsync(); - var activeApps = await this.processService.GetActiveApplicationsAsync(); - - Console.WriteLine($"Total processes: {allProcesses.Count}"); - Console.WriteLine($"Active applications: {activeApps.Count}"); - - // Verify that active apps is a subset of all processes - bool isSubset = activeApps.Count <= allProcesses.Count; - - // Verify that all active apps have visible windows - bool allHaveWindows = true; - foreach (var app in activeApps) - { - if (!app.HasVisibleWindow) - { - allHaveWindows = false; - Console.WriteLine($"Process {app.Name} marked as active but has no visible window"); - break; - } - } - - bool testPassed = isSubset && allHaveWindows; - Console.WriteLine($"Active applications filtering test: {(testPassed ? "PASSED" : "FAILED")}"); - - return testPassed; - } - catch (Exception ex) - { - Console.WriteLine($"Active applications filtering test FAILED: {ex.Message}"); - return false; - } - } - - /// - /// Test CPU affinity mask conversion and core selection. - /// - public async Task TestCpuAffinityMaskConversion() - { - try - { - Console.WriteLine("Testing CPU affinity mask conversion..."); - - // Get current process as test subject - var currentProcess = Process.GetCurrentProcess(); - var processModel = this.processService.CreateProcessModel(currentProcess); - - Console.WriteLine($"Process affinity mask: 0x{processModel.ProcessorAffinity:X} ({Convert.ToString(processModel.ProcessorAffinity, 2).PadLeft(Environment.ProcessorCount, '0')})"); - - // Test affinity mask bit calculations - var totalCores = Environment.ProcessorCount; - var expectedSelectedCores = new List(); - - for (int i = 0; i < totalCores; i++) - { - long coreMask = 1L << i; - if ((processModel.ProcessorAffinity & coreMask) != 0) - { - expectedSelectedCores.Add(i); - } - } - - Console.WriteLine($"Expected selected cores based on affinity mask: [{string.Join(", ", expectedSelectedCores)}]"); - Console.WriteLine($"Total cores: {totalCores}, Selected cores: {expectedSelectedCores.Count}"); - - // Verify that at least one core is selected (process must run on something) - bool hasSelectedCores = expectedSelectedCores.Count > 0; - - // Verify that selected cores don't exceed total cores - bool validCoreCount = expectedSelectedCores.Count <= totalCores; - - // Verify that all selected core IDs are within valid range - bool validCoreIds = expectedSelectedCores.All(id => id >= 0 && id < totalCores); - - bool testPassed = hasSelectedCores && validCoreCount && validCoreIds; - Console.WriteLine($"CPU affinity mask conversion test: {(testPassed ? "PASSED" : "FAILED")}"); - - if (!testPassed) - { - Console.WriteLine($" - Has selected cores: {hasSelectedCores}"); - Console.WriteLine($" - Valid core count: {validCoreCount}"); - Console.WriteLine($" - Valid core IDs: {validCoreIds}"); - } - - return testPassed; - } - catch (Exception ex) - { - Console.WriteLine($"CPU affinity mask conversion test FAILED: {ex.Message}"); - return false; - } - } - - /// - /// Test hyperthreading/SMT status detection and display. - /// - public async Task TestHyperThreadingStatusDetection() - { - try - { - Console.WriteLine("Testing hyperthreading/SMT status detection..."); - - // Get CPU topology information - await this.cpuTopologyService.DetectTopologyAsync(); - var topology = this.cpuTopologyService.CurrentTopology; - - if (topology == null) - { - Console.WriteLine("Hyperthreading status test FAILED: Could not detect CPU topology"); - return false; - } - - Console.WriteLine($"CPU Brand: {topology.CpuBrand}"); - Console.WriteLine($"Total Logical Cores: {topology.TotalLogicalCores}"); - Console.WriteLine($"Total Physical Cores: {topology.TotalPhysicalCores}"); - Console.WriteLine($"Has Hyperthreading/SMT: {topology.HasHyperThreading}"); - - // Determine expected technology name - string expectedTechName = "Multi-Threading"; - if (topology.CpuBrand.Contains("Intel", StringComparison.OrdinalIgnoreCase)) - { - expectedTechName = "Hyper-Threading"; - } - else if (topology.CpuBrand.Contains("AMD", StringComparison.OrdinalIgnoreCase)) - { - expectedTechName = "SMT"; - } - - Console.WriteLine($"Expected technology name: {expectedTechName}"); - - // Verify hyperthreading detection logic - bool expectedHasHT = topology.TotalLogicalCores > topology.TotalPhysicalCores; - bool actualHasHT = topology.HasHyperThreading; - - bool detectionCorrect = expectedHasHT == actualHasHT; - Console.WriteLine($"Hyperthreading detection: Expected={expectedHasHT}, Actual={actualHasHT}, Correct={detectionCorrect}"); - - // Verify that if HT is detected, there are actually HT cores marked - bool htCoresMarkedCorrectly = true; - if (actualHasHT) - { - var htCores = topology.LogicalCores.Where(c => c.IsHyperThreaded).ToList(); - htCoresMarkedCorrectly = htCores.Count > 0; - Console.WriteLine($"HyperThreaded cores found: {htCores.Count}"); - } - - bool testPassed = detectionCorrect && htCoresMarkedCorrectly; - Console.WriteLine($"Hyperthreading status detection test: {(testPassed ? "PASSED" : "FAILED")}"); - - return testPassed; - } - catch (Exception ex) - { - Console.WriteLine($"Hyperthreading status detection test FAILED: {ex.Message}"); - return false; - } - } - - /// - /// Run all tests. - /// - public async Task RunAllTests() - { - Console.WriteLine("=== Process Selection and Real-time Data Sync Tests ==="); - Console.WriteLine(); - - bool test1 = await this.TestProcessInfoRefresh(); - Console.WriteLine(); - - bool test2 = await this.TestProcessTerminationDetection(); - Console.WriteLine(); - - bool test3 = await this.TestActiveApplicationsFiltering(); - Console.WriteLine(); - - bool test4 = await this.TestCpuAffinityMaskConversion(); - Console.WriteLine(); - - bool test5 = await this.TestHyperThreadingStatusDetection(); - Console.WriteLine(); - - bool allPassed = test1 && test2 && test3 && test4 && test5; - Console.WriteLine($"=== Overall Test Result: {(allPassed ? "ALL TESTS PASSED" : "SOME TESTS FAILED")} ==="); - - return allPassed; - } - } -} - +namespace ThreadPilot.Tests +{ + using System; + using System.Diagnostics; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public class ProcessSelectionTest + { + private readonly ProcessService processService; + private readonly CpuTopologyService cpuTopologyService; + + public ProcessSelectionTest() + { + this.processService = new ProcessService(); + + // Create a simple logger for the CPU topology service + var loggerFactory = LoggerFactory.Create(builder => { }); + var logger = loggerFactory.CreateLogger(); + this.cpuTopologyService = new CpuTopologyService(logger); + } + + public async Task TestProcessInfoRefresh() + { + try + { + Console.WriteLine("Testing process info refresh..."); + + // Get current process as test subject + var currentProcess = Process.GetCurrentProcess(); + var processModel = this.processService.CreateProcessModel(currentProcess); + + Console.WriteLine($"Initial process info - PID: {processModel.ProcessId}, Priority: {processModel.Priority}, Affinity: 0x{processModel.ProcessorAffinity:X}"); + + // Refresh the process info + await this.processService.RefreshProcessInfo(processModel); + + Console.WriteLine($"After refresh - PID: {processModel.ProcessId}, Priority: {processModel.Priority}, Affinity: 0x{processModel.ProcessorAffinity:X}"); + + // Verify the data is consistent + bool isValid = processModel.ProcessId == currentProcess.Id && + processModel.Priority == currentProcess.PriorityClass && + processModel.ProcessorAffinity == (long)currentProcess.ProcessorAffinity; + + Console.WriteLine($"Process info refresh test: {(isValid ? "PASSED" : "FAILED")}"); + return isValid; + } + catch (Exception ex) + { + Console.WriteLine($"Process info refresh test FAILED: {ex.Message}"); + return false; + } + } + + public async Task TestProcessTerminationDetection() + { + try + { + Console.WriteLine("Testing process termination detection..."); + + // Start a short-lived process + var notepadProcess = Process.Start("notepad.exe"); + if (notepadProcess == null) + { + Console.WriteLine("Could not start test process"); + return false; + } + + var processModel = this.processService.CreateProcessModel(notepadProcess); + Console.WriteLine($"Started test process - PID: {processModel.ProcessId}"); + + // Verify process is running + bool isRunning = await this.processService.IsProcessStillRunning(processModel); + Console.WriteLine($"Process running check: {isRunning}"); + + // Terminate the process + notepadProcess.Kill(); + await Task.Delay(1000); // Wait for termination + + // Check if termination is detected + bool isStillRunning = await this.processService.IsProcessStillRunning(processModel); + Console.WriteLine($"Process running after termination: {isStillRunning}"); + + bool testPassed = isRunning && !isStillRunning; + Console.WriteLine($"Process termination detection test: {(testPassed ? "PASSED" : "FAILED")}"); + + return testPassed; + } + catch (Exception ex) + { + Console.WriteLine($"Process termination detection test FAILED: {ex.Message}"); + return false; + } + } + + public async Task TestActiveApplicationsFiltering() + { + try + { + Console.WriteLine("Testing active applications filtering..."); + + var allProcesses = await this.processService.GetProcessesAsync(); + var activeApps = await this.processService.GetActiveApplicationsAsync(); + + Console.WriteLine($"Total processes: {allProcesses.Count}"); + Console.WriteLine($"Active applications: {activeApps.Count}"); + + // Verify that active apps is a subset of all processes + bool isSubset = activeApps.Count <= allProcesses.Count; + + // Verify that all active apps have visible windows + bool allHaveWindows = true; + foreach (var app in activeApps) + { + if (!app.HasVisibleWindow) + { + allHaveWindows = false; + Console.WriteLine($"Process {app.Name} marked as active but has no visible window"); + break; + } + } + + bool testPassed = isSubset && allHaveWindows; + Console.WriteLine($"Active applications filtering test: {(testPassed ? "PASSED" : "FAILED")}"); + + return testPassed; + } + catch (Exception ex) + { + Console.WriteLine($"Active applications filtering test FAILED: {ex.Message}"); + return false; + } + } + + public async Task TestCpuAffinityMaskConversion() + { + try + { + Console.WriteLine("Testing CPU affinity mask conversion..."); + + // Get current process as test subject + var currentProcess = Process.GetCurrentProcess(); + var processModel = this.processService.CreateProcessModel(currentProcess); + + Console.WriteLine($"Process affinity mask: 0x{processModel.ProcessorAffinity:X} ({Convert.ToString(processModel.ProcessorAffinity, 2).PadLeft(Environment.ProcessorCount, '0')})"); + + // Test affinity mask bit calculations + var totalCores = Environment.ProcessorCount; + var expectedSelectedCores = new List(); + + for (int i = 0; i < totalCores; i++) + { + long coreMask = 1L << i; + if ((processModel.ProcessorAffinity & coreMask) != 0) + { + expectedSelectedCores.Add(i); + } + } + + Console.WriteLine($"Expected selected cores based on affinity mask: [{string.Join(", ", expectedSelectedCores)}]"); + Console.WriteLine($"Total cores: {totalCores}, Selected cores: {expectedSelectedCores.Count}"); + + // Verify that at least one core is selected (process must run on something) + bool hasSelectedCores = expectedSelectedCores.Count > 0; + + // Verify that selected cores don't exceed total cores + bool validCoreCount = expectedSelectedCores.Count <= totalCores; + + // Verify that all selected core IDs are within valid range + bool validCoreIds = expectedSelectedCores.All(id => id >= 0 && id < totalCores); + + bool testPassed = hasSelectedCores && validCoreCount && validCoreIds; + Console.WriteLine($"CPU affinity mask conversion test: {(testPassed ? "PASSED" : "FAILED")}"); + + if (!testPassed) + { + Console.WriteLine($" - Has selected cores: {hasSelectedCores}"); + Console.WriteLine($" - Valid core count: {validCoreCount}"); + Console.WriteLine($" - Valid core IDs: {validCoreIds}"); + } + + return testPassed; + } + catch (Exception ex) + { + Console.WriteLine($"CPU affinity mask conversion test FAILED: {ex.Message}"); + return false; + } + } + + public async Task TestHyperThreadingStatusDetection() + { + try + { + Console.WriteLine("Testing hyperthreading/SMT status detection..."); + + // Get CPU topology information + await this.cpuTopologyService.DetectTopologyAsync(); + var topology = this.cpuTopologyService.CurrentTopology; + + if (topology == null) + { + Console.WriteLine("Hyperthreading status test FAILED: Could not detect CPU topology"); + return false; + } + + Console.WriteLine($"CPU Brand: {topology.CpuBrand}"); + Console.WriteLine($"Total Logical Cores: {topology.TotalLogicalCores}"); + Console.WriteLine($"Total Physical Cores: {topology.TotalPhysicalCores}"); + Console.WriteLine($"Has Hyperthreading/SMT: {topology.HasHyperThreading}"); + + // Determine expected technology name + string expectedTechName = "Multi-Threading"; + if (topology.CpuBrand.Contains("Intel", StringComparison.OrdinalIgnoreCase)) + { + expectedTechName = "Hyper-Threading"; + } + else if (topology.CpuBrand.Contains("AMD", StringComparison.OrdinalIgnoreCase)) + { + expectedTechName = "SMT"; + } + + Console.WriteLine($"Expected technology name: {expectedTechName}"); + + // Verify hyperthreading detection logic + bool expectedHasHT = topology.TotalLogicalCores > topology.TotalPhysicalCores; + bool actualHasHT = topology.HasHyperThreading; + + bool detectionCorrect = expectedHasHT == actualHasHT; + Console.WriteLine($"Hyperthreading detection: Expected={expectedHasHT}, Actual={actualHasHT}, Correct={detectionCorrect}"); + + // Verify that if HT is detected, there are actually HT cores marked + bool htCoresMarkedCorrectly = true; + if (actualHasHT) + { + var htCores = topology.LogicalCores.Where(c => c.IsHyperThreaded).ToList(); + htCoresMarkedCorrectly = htCores.Count > 0; + Console.WriteLine($"HyperThreaded cores found: {htCores.Count}"); + } + + bool testPassed = detectionCorrect && htCoresMarkedCorrectly; + Console.WriteLine($"Hyperthreading status detection test: {(testPassed ? "PASSED" : "FAILED")}"); + + return testPassed; + } + catch (Exception ex) + { + Console.WriteLine($"Hyperthreading status detection test FAILED: {ex.Message}"); + return false; + } + } + + public async Task RunAllTests() + { + Console.WriteLine("=== Process Selection and Real-time Data Sync Tests ==="); + Console.WriteLine(); + + bool test1 = await this.TestProcessInfoRefresh(); + Console.WriteLine(); + + bool test2 = await this.TestProcessTerminationDetection(); + Console.WriteLine(); + + bool test3 = await this.TestActiveApplicationsFiltering(); + Console.WriteLine(); + + bool test4 = await this.TestCpuAffinityMaskConversion(); + Console.WriteLine(); + + bool test5 = await this.TestHyperThreadingStatusDetection(); + Console.WriteLine(); + + bool allPassed = test1 && test2 && test3 && test4 && test5; + Console.WriteLine($"=== Overall Test Result: {(allPassed ? "ALL TESTS PASSED" : "SOME TESTS FAILED")} ==="); + + return allPassed; + } + } +} + diff --git a/Tests/TestRunner.cs b/Tests/TestRunner.cs index 12fe769..34b0a4f 100644 --- a/Tests/TestRunner.cs +++ b/Tests/TestRunner.cs @@ -1,59 +1,40 @@ -/* - * 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.Tests -{ - using System; - using System.Threading.Tasks; - - /// - /// Legacy runtime smoke test runner used by debug-only --test mode. - /// - public static class TestRunner - { - public static async Task RunTests() - { - Console.WriteLine("ThreadPilot Integrated Test Runner"); - Console.WriteLine("================================"); - - try - { - await CpuTopologyServiceTests.TestCpuTopologyDetection(); - Console.WriteLine(); - - var processSelectionTest = new ProcessSelectionTest(); - await processSelectionTest.RunAllTests(); - Console.WriteLine(); - - var executableBrowseTest = new ExecutableBrowseTest(); - var browsePassed = executableBrowseTest.RunAllTests(); - Console.WriteLine(); - - Console.WriteLine(browsePassed - ? "Integrated tests completed." - : "Integrated tests completed with failures."); - } - catch (Exception ex) - { - Console.WriteLine($"Test runner failed with exception: {ex.Message}"); - Console.WriteLine($"Stack trace: {ex.StackTrace}"); - } - - Console.WriteLine("\nPress any key to exit..."); - Console.ReadKey(); - } - } -} +namespace ThreadPilot.Tests +{ + using System; + using System.Threading.Tasks; + + public static class TestRunner + { + public static async Task RunTests() + { + Console.WriteLine("ThreadPilot Integrated Test Runner"); + Console.WriteLine("================================"); + + try + { + await CpuTopologyServiceTests.TestCpuTopologyDetection(); + Console.WriteLine(); + + var processSelectionTest = new ProcessSelectionTest(); + await processSelectionTest.RunAllTests(); + Console.WriteLine(); + + var executableBrowseTest = new ExecutableBrowseTest(); + var browsePassed = executableBrowseTest.RunAllTests(); + Console.WriteLine(); + + Console.WriteLine(browsePassed + ? "Integrated tests completed." + : "Integrated tests completed with failures."); + } + catch (Exception ex) + { + Console.WriteLine($"Test runner failed with exception: {ex.Message}"); + Console.WriteLine($"Stack trace: {ex.StackTrace}"); + } + + Console.WriteLine("\nPress any key to exit..."); + Console.ReadKey(); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ActivityAuditServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ActivityAuditServiceTests.cs index 7aa3c54..22ac49f 100644 --- a/Tests/ThreadPilot.Core.Tests/ActivityAuditServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ActivityAuditServiceTests.cs @@ -1,65 +1,65 @@ -namespace ThreadPilot.Core.Tests -{ - using Microsoft.Extensions.Logging.Abstractions; - using ThreadPilot.Services; - - public sealed class ActivityAuditServiceTests - { - [Theory] - [InlineData("ThemeChanged", "Theme changed to Dark", "Settings", ActivityAuditSeverity.Success)] - [InlineData("SystemTweakApplied", "Core Parking enabled", "Tweaks", ActivityAuditSeverity.Success)] - [InlineData("SystemTweakFailed", "Failed to enable Core Parking", "Tweaks", ActivityAuditSeverity.Error)] - [InlineData("OptimizationMonitoringStarted", "Performance monitoring started", "Optimization", ActivityAuditSeverity.Success)] - [InlineData("OptimizationActionFailed", "Failed to start performance monitoring: unavailable", "Optimization", ActivityAuditSeverity.Error)] - [InlineData("PowerPlanApplied", "Applied power plan Gaming", "Power Plans", ActivityAuditSeverity.Success)] - [InlineData("PowerPlanDeleted", "Deleted power plan Gaming", "Power Plans", ActivityAuditSeverity.Success)] - [InlineData("PowerPlansRefreshed", "Refreshed power plan list", "Power Plans", ActivityAuditSeverity.Success)] - [InlineData("ProcessPriorityChanged", "CPU priority changed for Game.exe: High", "Priority", ActivityAuditSeverity.Success)] - [InlineData("ProcessPriorityChangeFailed", "Windows denied this change.", "Priority", ActivityAuditSeverity.Warning)] - [InlineData("ProcessPriorityBlocked", "Realtime priority is blocked by ThreadPilot.", "Priority", ActivityAuditSeverity.Warning)] - [InlineData("ProcessMemoryPriorityChanged", "Memory priority changed for Game.exe: Low", "Memory Priority", ActivityAuditSeverity.Success)] - [InlineData("ProcessMemoryPriorityFailed", "The process appears protected by anti-cheat or process protection.", "Memory Priority", ActivityAuditSeverity.Warning)] - [InlineData("CpuSetsCleared", "CPU Sets cleared for Game.exe", "Affinity", ActivityAuditSeverity.Success)] - [InlineData("CpuSetsClearFailed", "The process exited before ThreadPilot could apply the change.", "Affinity", ActivityAuditSeverity.Error)] - [InlineData("ProcessAffinityApplied", "Affinity applied successfully to Game.exe", "Affinity", ActivityAuditSeverity.Success)] - [InlineData("ProcessAffinityFailed", "The process appears protected by anti-cheat or process protection.", "Affinity", ActivityAuditSeverity.Warning)] - [InlineData("PersistentRuleSaved", "Saved rule for Game.exe.", "Rules", ActivityAuditSeverity.Success)] - [InlineData("PersistentRuleSaveFailed", "Failed to save rule for Game.exe.", "Rules", ActivityAuditSeverity.Error)] - [InlineData("PersistentRuleAutoApplied", "Auto-applied saved rule for Game.exe.", "Rules", ActivityAuditSeverity.Success)] - [InlineData("PersistentRuleAutoApplyFailed", "Failed to auto-apply saved rule for Game.exe: protected process.", "Rules", ActivityAuditSeverity.Warning)] - public async Task LogUserActionAsync_CreatesVisibleActivityEntry( - string action, - string details, - string expectedCategory, - ActivityAuditSeverity expectedSeverity) - { - var service = new ActivityAuditService(NullLogger.Instance); - - await service.LogUserActionAsync(action, details, "PID: 42"); - - var entry = Assert.Single(await service.GetEntriesAsync()); - Assert.Equal(expectedCategory, entry.Category); - Assert.Equal(expectedSeverity, entry.Severity); - Assert.Equal(details, entry.Message); - Assert.Equal("PID: 42", entry.Details); - } - - [Fact] - public async Task GetEntriesAsync_ReturnsMostRecentFirstAndPreservesTimestamp() - { - var service = new ActivityAuditService(NullLogger.Instance); - - await service.LogInfoAsync("Diagnostics", "First"); - await Task.Delay(5); - await service.LogSuccessAsync("Power Plans", "Second"); - - var entries = await service.GetEntriesAsync(); - - Assert.Collection( - entries, - entry => Assert.Equal("Second", entry.Message), - entry => Assert.Equal("First", entry.Message)); - Assert.All(entries, entry => Assert.NotEqual(default, entry.Timestamp)); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using ThreadPilot.Services; + + public sealed class ActivityAuditServiceTests + { + [Theory] + [InlineData("ThemeChanged", "Theme changed to Dark", "Settings", ActivityAuditSeverity.Success)] + [InlineData("SystemTweakApplied", "Core Parking enabled", "Tweaks", ActivityAuditSeverity.Success)] + [InlineData("SystemTweakFailed", "Failed to enable Core Parking", "Tweaks", ActivityAuditSeverity.Error)] + [InlineData("OptimizationMonitoringStarted", "Performance monitoring started", "Optimization", ActivityAuditSeverity.Success)] + [InlineData("OptimizationActionFailed", "Failed to start performance monitoring: unavailable", "Optimization", ActivityAuditSeverity.Error)] + [InlineData("PowerPlanApplied", "Applied power plan Gaming", "Power Plans", ActivityAuditSeverity.Success)] + [InlineData("PowerPlanDeleted", "Deleted power plan Gaming", "Power Plans", ActivityAuditSeverity.Success)] + [InlineData("PowerPlansRefreshed", "Refreshed power plan list", "Power Plans", ActivityAuditSeverity.Success)] + [InlineData("ProcessPriorityChanged", "CPU priority changed for Game.exe: High", "Priority", ActivityAuditSeverity.Success)] + [InlineData("ProcessPriorityChangeFailed", "Windows denied this change.", "Priority", ActivityAuditSeverity.Warning)] + [InlineData("ProcessPriorityBlocked", "Realtime priority is blocked by ThreadPilot.", "Priority", ActivityAuditSeverity.Warning)] + [InlineData("ProcessMemoryPriorityChanged", "Memory priority changed for Game.exe: Low", "Memory Priority", ActivityAuditSeverity.Success)] + [InlineData("ProcessMemoryPriorityFailed", "The process appears protected by anti-cheat or process protection.", "Memory Priority", ActivityAuditSeverity.Warning)] + [InlineData("CpuSetsCleared", "CPU Sets cleared for Game.exe", "Affinity", ActivityAuditSeverity.Success)] + [InlineData("CpuSetsClearFailed", "The process exited before ThreadPilot could apply the change.", "Affinity", ActivityAuditSeverity.Error)] + [InlineData("ProcessAffinityApplied", "Affinity applied successfully to Game.exe", "Affinity", ActivityAuditSeverity.Success)] + [InlineData("ProcessAffinityFailed", "The process appears protected by anti-cheat or process protection.", "Affinity", ActivityAuditSeverity.Warning)] + [InlineData("PersistentRuleSaved", "Saved rule for Game.exe.", "Rules", ActivityAuditSeverity.Success)] + [InlineData("PersistentRuleSaveFailed", "Failed to save rule for Game.exe.", "Rules", ActivityAuditSeverity.Error)] + [InlineData("PersistentRuleAutoApplied", "Auto-applied saved rule for Game.exe.", "Rules", ActivityAuditSeverity.Success)] + [InlineData("PersistentRuleAutoApplyFailed", "Failed to auto-apply saved rule for Game.exe: protected process.", "Rules", ActivityAuditSeverity.Warning)] + public async Task LogUserActionAsync_CreatesVisibleActivityEntry( + string action, + string details, + string expectedCategory, + ActivityAuditSeverity expectedSeverity) + { + var service = new ActivityAuditService(NullLogger.Instance); + + await service.LogUserActionAsync(action, details, "PID: 42"); + + var entry = Assert.Single(await service.GetEntriesAsync()); + Assert.Equal(expectedCategory, entry.Category); + Assert.Equal(expectedSeverity, entry.Severity); + Assert.Equal(details, entry.Message); + Assert.Equal("PID: 42", entry.Details); + } + + [Fact] + public async Task GetEntriesAsync_ReturnsMostRecentFirstAndPreservesTimestamp() + { + var service = new ActivityAuditService(NullLogger.Instance); + + await service.LogInfoAsync("Diagnostics", "First"); + await Task.Delay(5); + await service.LogSuccessAsync("Power Plans", "Second"); + + var entries = await service.GetEntriesAsync(); + + Assert.Collection( + entries, + entry => Assert.Equal("Second", entry.Message), + entry => Assert.Equal("First", entry.Message)); + Assert.All(entries, entry => Assert.NotEqual(default, entry.Timestamp)); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs b/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs index 1b3f115..de637aa 100644 --- a/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs @@ -1,628 +1,628 @@ -namespace ThreadPilot.Core.Tests -{ - using System.ComponentModel; - using Microsoft.Extensions.Logging.Abstractions; - using Moq; - using ThreadPilot.Models; - using ThreadPilot.Platforms.Windows; - 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); - Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); - 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(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode); - Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); - Assert.True(result.IsAccessDenied); - Assert.False(result.UserMessage.Contains("bypass", StringComparison.OrdinalIgnoreCase)); - Assert.Equal(3, result.VerifiedMask); - } - - [Fact] - public async Task ApplyAsync_WhenAntiCheatProtected_ReturnsProtectedMessageWithoutBypassSuggestion() - { - 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("Protected by anti-cheat.")); - - var service = CreateService(processService); - - var result = await service.ApplyAsync(process, 1); - - Assert.False(result.Success); - Assert.Equal(AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely, result.ErrorCode); - Assert.Equal(ProcessOperationUserMessages.AntiCheatProtectedLikely, result.UserMessage); - Assert.True(result.IsAccessDenied); - Assert.True(result.IsAntiCheatLikely); - Assert.Equal( - "The process appears protected by anti-cheat or process protection. ThreadPilot will not try to bypass it.", - ProcessOperationUserMessages.AntiCheatProtectedLikely); - Assert.DoesNotContain("disable anti-cheat", result.UserMessage, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("administrator", result.UserMessage, StringComparison.OrdinalIgnoreCase); - } - - [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); - Assert.Equal(ProcessOperationUserMessages.InvalidTopology, result.UserMessage); - } - - [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); - Assert.Equal(AffinityApplyErrorCodes.InvalidTopology, result.ErrorCode); - Assert.True(result.IsInvalidTopology); - Assert.Equal(ProcessOperationUserMessages.InvalidTopology, result.UserMessage); - processService.Verify( - service => service.SetProcessorAffinity(It.IsAny(), It.IsAny()), - Times.Never); - } - - [Fact] - public void AdminClarification_DoesNotPromiseAntiCheatBypass() - { - Assert.Contains("Administrator mode may help", ProcessOperationUserMessages.AdminClarification); - Assert.Contains("cannot bypass anti-cheat", ProcessOperationUserMessages.AdminClarification); - } - - [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); - } - - [Fact] - public async Task CpuSelectionApply_WhenCpuSetsFailAndSelectionIsSingleGroupBelow64_UsesLegacyFallback() - { - var process = new ProcessModel { ProcessId = 42, Name = "Game" }; - var cpuSets = new FakeCpuSetHandler { ApplyCpuSelectionResult = false }; - var legacy = new RecordingLegacyAffinityApplier(); - var service = CreateCpuSelectionApplier(cpuSets, legacy); - var selection = CreateSelection( - new ProcessorRef(0, 0, 0), - new ProcessorRef(0, 2, 2)); - - var result = await service.ApplyAsync(process, selection); - - Assert.True(result.Success); - Assert.Equal(AffinityApplyErrorCodes.None, result.ErrorCode); - Assert.False(result.UsedCpuSets); - Assert.True(result.UsedLegacyAffinity); - Assert.Equal(0x05, legacy.LastMask); - Assert.Equal(1, legacy.CallCount); - Assert.Equal(1, cpuSets.ApplyCpuSelectionCalls); - } - - [Fact] - public async Task CpuSelectionApply_WhenCpuSetsFailAndSelectionHasMultipleGroups_BlocksLegacyFallback() - { - var process = new ProcessModel { ProcessId = 42, Name = "Game" }; - var cpuSets = new FakeCpuSetHandler { ApplyCpuSelectionResult = false }; - var legacy = new RecordingLegacyAffinityApplier(); - var service = CreateCpuSelectionApplier(cpuSets, legacy); - var selection = CreateSelection( - new ProcessorRef(0, 0, 0), - new ProcessorRef(1, 0, 1)); - - var result = await service.ApplyAsync(process, selection); - - Assert.False(result.Success); - Assert.Equal(AffinityApplyErrorCodes.LegacyFallbackUnsafe, result.ErrorCode); - Assert.True(result.IsLegacyFallbackBlocked); - Assert.Equal(ProcessOperationUserMessages.LegacyFallbackBlocked, result.UserMessage); - Assert.False(result.UsedLegacyAffinity); - Assert.Equal(0, legacy.CallCount); - } - - [Fact] - public async Task CpuSelectionApply_WhenCpuSetsFailAndSelectionContainsCpu64_BlocksLegacyFallback() - { - var process = new ProcessModel { ProcessId = 42, Name = "Game" }; - var cpuSets = new FakeCpuSetHandler { ApplyCpuSelectionResult = false }; - var legacy = new RecordingLegacyAffinityApplier(); - var service = CreateCpuSelectionApplier(cpuSets, legacy); - var selection = CreateSelection(new ProcessorRef(0, 64, 64)); - - var result = await service.ApplyAsync(process, selection); - - Assert.False(result.Success); - Assert.Equal(AffinityApplyErrorCodes.LegacyFallbackUnsafe, result.ErrorCode); - Assert.True(result.IsLegacyFallbackBlocked); - Assert.Equal(0, legacy.CallCount); - } - - [Fact] - public async Task CpuSelectionApply_WhenSelectionHasExplicitCpuSetIds_TriesCpuSets() - { - var process = new ProcessModel { ProcessId = 42, Name = "Game" }; - var cpuSets = new FakeCpuSetHandler { ApplyCpuSelectionResult = true }; - var legacy = new RecordingLegacyAffinityApplier(); - var service = CreateCpuSelectionApplier(cpuSets, legacy); - var selection = new CpuSelection { CpuSetIds = [101, 103] }; - - var result = await service.ApplyAsync(process, selection); - - Assert.True(result.Success); - Assert.True(result.UsedCpuSets); - Assert.False(result.UsedLegacyAffinity); - Assert.Same(selection, cpuSets.LastSelection); - Assert.Equal(0, legacy.CallCount); - } - - [Fact] - public async Task CpuSelectionApply_WhenCpuSetsSucceed_AuditsSuccessAndSkipsLegacyFallback() - { - var process = new ProcessModel { ProcessId = 42, Name = "Game" }; - var cpuSets = new FakeCpuSetHandler { ApplyCpuSelectionResult = true }; - var legacy = new RecordingLegacyAffinityApplier(); - var audit = new RecordingAffinityAudit(); - var service = CreateCpuSelectionApplier(cpuSets, legacy, audit); - var selection = CreateSelection(new ProcessorRef(0, 0, 0)); - - var result = await service.ApplyAsync(process, selection); - - Assert.True(result.Success); - Assert.True(result.UsedCpuSets); - Assert.False(result.UsedLegacyAffinity); - Assert.Equal(0, legacy.CallCount); - Assert.Equal([(process, true)], audit.Calls); - } - - [Fact] - public async Task CpuSelectionApply_WhenSelectionIsEmpty_ReturnsInvalidSelection() - { - var process = new ProcessModel { ProcessId = 42, Name = "Game" }; - var cpuSets = new FakeCpuSetHandler(); - var legacy = new RecordingLegacyAffinityApplier(); - var service = CreateCpuSelectionApplier(cpuSets, legacy); - - var result = await service.ApplyAsync(process, new CpuSelection()); - - Assert.False(result.Success); - Assert.Equal(AffinityApplyErrorCodes.InvalidSelection, result.ErrorCode); - Assert.Equal(ProcessOperationUserMessages.InvalidTopology, result.UserMessage); - Assert.True(result.IsInvalidTopology); - Assert.False(result.UsedCpuSets); - Assert.False(result.UsedLegacyAffinity); - Assert.Equal(0, cpuSets.ApplyCpuSelectionCalls); - Assert.Equal(0, legacy.CallCount); - } - - [Fact] - public async Task CpuSelectionApply_WhenSelectionIsEmpty_AuditsFailure() - { - var process = new ProcessModel { ProcessId = 42, Name = "Game" }; - var cpuSets = new FakeCpuSetHandler(); - var legacy = new RecordingLegacyAffinityApplier(); - var audit = new RecordingAffinityAudit(); - var service = CreateCpuSelectionApplier(cpuSets, legacy, audit); - - var result = await service.ApplyAsync(process, new CpuSelection()); - - Assert.False(result.Success); - Assert.Equal(AffinityApplyErrorCodes.InvalidSelection, result.ErrorCode); - Assert.Equal([(process, false)], audit.Calls); - } - - [Fact] - public async Task CpuSelectionApply_WhenLegacyFallbackIsUnsafe_AuditsFailure() - { - var process = new ProcessModel { ProcessId = 42, Name = "Game" }; - var cpuSets = new FakeCpuSetHandler { ApplyCpuSelectionResult = false }; - var legacy = new RecordingLegacyAffinityApplier(); - var audit = new RecordingAffinityAudit(); - var service = CreateCpuSelectionApplier(cpuSets, legacy, audit); - var selection = CreateSelection(new ProcessorRef(1, 64, 64)); - - var result = await service.ApplyAsync(process, selection); - - Assert.False(result.Success); - Assert.True(result.IsLegacyFallbackBlocked); - Assert.Equal(0, legacy.CallCount); - Assert.Equal([(process, false)], audit.Calls); - } - - [Fact] - public async Task CpuSelectionApply_WhenLegacyFallbackSucceeds_DoesNotAuditTwice() - { - var process = new ProcessModel { ProcessId = 42, Name = "Game" }; - var cpuSets = new FakeCpuSetHandler { ApplyCpuSelectionResult = false }; - var legacy = new RecordingLegacyAffinityApplier(); - var audit = new RecordingAffinityAudit(); - var service = CreateCpuSelectionApplier(cpuSets, legacy, audit); - var selection = CreateSelection(new ProcessorRef(0, 0, 0)); - - var result = await service.ApplyAsync(process, selection); - - Assert.True(result.Success); - Assert.True(result.UsedLegacyAffinity); - Assert.Equal(1, legacy.CallCount); - Assert.Empty(audit.Calls); - } - - [Fact] - public async Task CpuSelectionApply_WhenCpuSetsThrowAccessDenied_ReturnsAccessDenied() - { - var process = new ProcessModel { ProcessId = 42, Name = "Game" }; - var cpuSets = new FakeCpuSetHandler - { - ApplyCpuSelectionException = new Win32Exception(5, "Access is denied."), - }; - var legacy = new RecordingLegacyAffinityApplier(); - var service = CreateCpuSelectionApplier(cpuSets, legacy); - var selection = CreateSelection(new ProcessorRef(0, 0, 0)); - - var result = await service.ApplyAsync(process, selection); - - Assert.False(result.Success); - Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode); - Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); - Assert.True(result.IsAccessDenied); - Assert.Equal(0, legacy.CallCount); - } - - [Fact] - public async Task CpuSelectionApply_WhenFallbackThrowsAccessDenied_ReturnsAccessDenied() - { - var process = new ProcessModel { ProcessId = 42, Name = "Game" }; - var cpuSets = new FakeCpuSetHandler { ApplyCpuSelectionResult = false }; - var legacy = new RecordingLegacyAffinityApplier - { - ExceptionToThrow = new UnauthorizedAccessException("Access denied."), - }; - var service = CreateCpuSelectionApplier(cpuSets, legacy); - var selection = CreateSelection(new ProcessorRef(0, 0, 0)); - - var result = await service.ApplyAsync(process, selection); - - Assert.False(result.Success); - Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode); - Assert.True(result.IsAccessDenied); - Assert.Equal(1, legacy.CallCount); - } - - [Fact] - public async Task CpuSelectionApply_WhenFallbackThrowsProcessExited_ReturnsProcessExited() - { - var process = new ProcessModel { ProcessId = 42, Name = "Game" }; - var cpuSets = new FakeCpuSetHandler { ApplyCpuSelectionResult = false }; - var legacy = new RecordingLegacyAffinityApplier - { - ExceptionToThrow = new ArgumentException("Process has exited."), - }; - var service = CreateCpuSelectionApplier(cpuSets, legacy); - var selection = CreateSelection(new ProcessorRef(0, 0, 0)); - - var result = await service.ApplyAsync(process, selection); - - Assert.False(result.Success); - Assert.Equal(AffinityApplyErrorCodes.ProcessExited, result.ErrorCode); - Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); - Assert.False(result.UsedLegacyAffinity); - } - - [Fact] - public async Task ApplyCpuSelectionAsync_WhenProcessIsNull_ReturnsProcessExitedWithoutDelegating() - { - var processService = new Mock(MockBehavior.Strict); - var service = CreateService(processService); - - var result = await service.ApplyAsync(null!, CreateSelection(new ProcessorRef(0, 0, 0))); - - Assert.False(result.Success); - Assert.Equal(AffinityApplyErrorCodes.ProcessExited, result.ErrorCode); - Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); - processService.Verify( - service => service.SetProcessorAffinity(It.IsAny(), It.IsAny()), - Times.Never); - } - - [Fact] - public async Task ApplyCpuSelectionAsync_WhenSelectionIsNull_ReturnsInvalidSelectionWithoutDelegating() - { - var process = new ProcessModel { ProcessId = 42, Name = "Game" }; - var processService = new Mock(MockBehavior.Strict); - var service = CreateService(processService); - - var result = await service.ApplyAsync(process, null!); - - Assert.False(result.Success); - Assert.Equal(AffinityApplyErrorCodes.InvalidSelection, result.ErrorCode); - Assert.Equal(ProcessOperationUserMessages.InvalidTopology, result.UserMessage); - processService.Verify( - service => service.SetProcessorAffinity(It.IsAny(), It.IsAny()), - Times.Never); - } - - 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 CpuSelectionAffinityApplier CreateCpuSelectionApplier( - FakeCpuSetHandler cpuSets, - RecordingLegacyAffinityApplier legacy, - RecordingAffinityAudit? audit = null) => - new( - _ => cpuSets, - legacy.ApplyAsync, - NullLogger.Instance, - null, - audit is null ? null : audit.Record); - - private static Mock CreateProcessService(bool processStillRunning) - { - var processService = new Mock(MockBehavior.Strict); - processService - .Setup(service => service.IsProcessStillRunning(It.IsAny())) - .ReturnsAsync(processStillRunning); - return processService; - } - - private static CpuSelection CreateSelection(params ProcessorRef[] processors) => - new() - { - LogicalProcessors = processors.ToList(), - GlobalLogicalProcessorIndexes = processors.Select(processor => processor.GlobalIndex).ToList(), - }; - - private sealed class RecordingLegacyAffinityApplier - { - public int CallCount { get; private set; } - - public long? LastMask { get; private set; } - - public Exception? ExceptionToThrow { get; init; } - - public Task ApplyAsync(ProcessModel process, long affinityMask) - { - this.CallCount++; - this.LastMask = affinityMask; - - if (this.ExceptionToThrow != null) - { - throw this.ExceptionToThrow; - } - - process.ProcessorAffinity = affinityMask; - return Task.FromResult(affinityMask); - } - } - - private sealed class RecordingAffinityAudit - { - public List<(ProcessModel Process, bool Success)> Calls { get; } = new(); - - public void Record(ProcessModel process, bool success) => - this.Calls.Add((process, success)); - } - - private sealed class FakeCpuSetHandler : IProcessCpuSetHandler - { - public uint ProcessId => 42; - - public string ExecutableName => "Game"; - - public bool IsValid { get; init; } = true; - - public bool ApplyCpuSelectionResult { get; init; } - - public Exception? ApplyCpuSelectionException { get; init; } - - public int ApplyCpuSelectionCalls { get; private set; } - - public CpuSelection? LastSelection { get; private set; } - - public bool ApplyCpuSetMask(long affinityMask, bool clearMask = false) => false; - - public CpuSetApplyResult ApplyCpuSetMaskDetailed(long affinityMask, bool clearMask = false) => - CpuSetApplyResult.Failed( - AffinityApplyErrorCodes.CpuSetsUnavailable, - ProcessOperationUserMessages.CpuSetsUnavailable, - "Fake CPU Sets handler rejected the legacy mask."); - - public bool ApplyCpuSelection(CpuSelection? selection, bool clearSelection = false) - { - this.ApplyCpuSelectionCalls++; - this.LastSelection = selection; - - if (this.ApplyCpuSelectionException != null) - { - throw this.ApplyCpuSelectionException; - } - - return this.ApplyCpuSelectionResult; - } - - public CpuSetApplyResult ApplyCpuSelectionDetailed(CpuSelection? selection, bool clearSelection = false) - { - this.ApplyCpuSelectionCalls++; - this.LastSelection = selection; - - if (this.ApplyCpuSelectionException != null) - { - throw this.ApplyCpuSelectionException; - } - - return this.ApplyCpuSelectionResult - ? CpuSetApplyResult.Succeeded("Fake CPU Sets handler applied the selection.") - : CpuSetApplyResult.Failed( - AffinityApplyErrorCodes.CpuSetsUnavailable, - ProcessOperationUserMessages.CpuSetsUnavailable, - "Fake CPU Sets handler rejected the selection."); - } - - public double GetAverageCpuUsage() => 0; - - public void Dispose() - { - } - } - } -} +namespace ThreadPilot.Core.Tests +{ + using System.ComponentModel; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Platforms.Windows; + 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); + Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); + 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(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); + Assert.True(result.IsAccessDenied); + Assert.False(result.UserMessage.Contains("bypass", StringComparison.OrdinalIgnoreCase)); + Assert.Equal(3, result.VerifiedMask); + } + + [Fact] + public async Task ApplyAsync_WhenAntiCheatProtected_ReturnsProtectedMessageWithoutBypassSuggestion() + { + 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("Protected by anti-cheat.")); + + var service = CreateService(processService); + + var result = await service.ApplyAsync(process, 1); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.AntiCheatProtectedLikely, result.UserMessage); + Assert.True(result.IsAccessDenied); + Assert.True(result.IsAntiCheatLikely); + Assert.Equal( + "The process appears protected by anti-cheat or process protection. ThreadPilot will not try to bypass it.", + ProcessOperationUserMessages.AntiCheatProtectedLikely); + Assert.DoesNotContain("disable anti-cheat", result.UserMessage, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("administrator", result.UserMessage, StringComparison.OrdinalIgnoreCase); + } + + [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); + Assert.Equal(ProcessOperationUserMessages.InvalidTopology, result.UserMessage); + } + + [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); + Assert.Equal(AffinityApplyErrorCodes.InvalidTopology, result.ErrorCode); + Assert.True(result.IsInvalidTopology); + Assert.Equal(ProcessOperationUserMessages.InvalidTopology, result.UserMessage); + processService.Verify( + service => service.SetProcessorAffinity(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public void AdminClarification_DoesNotPromiseAntiCheatBypass() + { + Assert.Contains("Administrator mode may help", ProcessOperationUserMessages.AdminClarification); + Assert.Contains("cannot bypass anti-cheat", ProcessOperationUserMessages.AdminClarification); + } + + [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); + } + + [Fact] + public async Task CpuSelectionApply_WhenCpuSetsFailAndSelectionIsSingleGroupBelow64_UsesLegacyFallback() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game" }; + var cpuSets = new FakeCpuSetHandler { ApplyCpuSelectionResult = false }; + var legacy = new RecordingLegacyAffinityApplier(); + var service = CreateCpuSelectionApplier(cpuSets, legacy); + var selection = CreateSelection( + new ProcessorRef(0, 0, 0), + new ProcessorRef(0, 2, 2)); + + var result = await service.ApplyAsync(process, selection); + + Assert.True(result.Success); + Assert.Equal(AffinityApplyErrorCodes.None, result.ErrorCode); + Assert.False(result.UsedCpuSets); + Assert.True(result.UsedLegacyAffinity); + Assert.Equal(0x05, legacy.LastMask); + Assert.Equal(1, legacy.CallCount); + Assert.Equal(1, cpuSets.ApplyCpuSelectionCalls); + } + + [Fact] + public async Task CpuSelectionApply_WhenCpuSetsFailAndSelectionHasMultipleGroups_BlocksLegacyFallback() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game" }; + var cpuSets = new FakeCpuSetHandler { ApplyCpuSelectionResult = false }; + var legacy = new RecordingLegacyAffinityApplier(); + var service = CreateCpuSelectionApplier(cpuSets, legacy); + var selection = CreateSelection( + new ProcessorRef(0, 0, 0), + new ProcessorRef(1, 0, 1)); + + var result = await service.ApplyAsync(process, selection); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.LegacyFallbackUnsafe, result.ErrorCode); + Assert.True(result.IsLegacyFallbackBlocked); + Assert.Equal(ProcessOperationUserMessages.LegacyFallbackBlocked, result.UserMessage); + Assert.False(result.UsedLegacyAffinity); + Assert.Equal(0, legacy.CallCount); + } + + [Fact] + public async Task CpuSelectionApply_WhenCpuSetsFailAndSelectionContainsCpu64_BlocksLegacyFallback() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game" }; + var cpuSets = new FakeCpuSetHandler { ApplyCpuSelectionResult = false }; + var legacy = new RecordingLegacyAffinityApplier(); + var service = CreateCpuSelectionApplier(cpuSets, legacy); + var selection = CreateSelection(new ProcessorRef(0, 64, 64)); + + var result = await service.ApplyAsync(process, selection); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.LegacyFallbackUnsafe, result.ErrorCode); + Assert.True(result.IsLegacyFallbackBlocked); + Assert.Equal(0, legacy.CallCount); + } + + [Fact] + public async Task CpuSelectionApply_WhenSelectionHasExplicitCpuSetIds_TriesCpuSets() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game" }; + var cpuSets = new FakeCpuSetHandler { ApplyCpuSelectionResult = true }; + var legacy = new RecordingLegacyAffinityApplier(); + var service = CreateCpuSelectionApplier(cpuSets, legacy); + var selection = new CpuSelection { CpuSetIds = [101, 103] }; + + var result = await service.ApplyAsync(process, selection); + + Assert.True(result.Success); + Assert.True(result.UsedCpuSets); + Assert.False(result.UsedLegacyAffinity); + Assert.Same(selection, cpuSets.LastSelection); + Assert.Equal(0, legacy.CallCount); + } + + [Fact] + public async Task CpuSelectionApply_WhenCpuSetsSucceed_AuditsSuccessAndSkipsLegacyFallback() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game" }; + var cpuSets = new FakeCpuSetHandler { ApplyCpuSelectionResult = true }; + var legacy = new RecordingLegacyAffinityApplier(); + var audit = new RecordingAffinityAudit(); + var service = CreateCpuSelectionApplier(cpuSets, legacy, audit); + var selection = CreateSelection(new ProcessorRef(0, 0, 0)); + + var result = await service.ApplyAsync(process, selection); + + Assert.True(result.Success); + Assert.True(result.UsedCpuSets); + Assert.False(result.UsedLegacyAffinity); + Assert.Equal(0, legacy.CallCount); + Assert.Equal([(process, true)], audit.Calls); + } + + [Fact] + public async Task CpuSelectionApply_WhenSelectionIsEmpty_ReturnsInvalidSelection() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game" }; + var cpuSets = new FakeCpuSetHandler(); + var legacy = new RecordingLegacyAffinityApplier(); + var service = CreateCpuSelectionApplier(cpuSets, legacy); + + var result = await service.ApplyAsync(process, new CpuSelection()); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.InvalidSelection, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.InvalidTopology, result.UserMessage); + Assert.True(result.IsInvalidTopology); + Assert.False(result.UsedCpuSets); + Assert.False(result.UsedLegacyAffinity); + Assert.Equal(0, cpuSets.ApplyCpuSelectionCalls); + Assert.Equal(0, legacy.CallCount); + } + + [Fact] + public async Task CpuSelectionApply_WhenSelectionIsEmpty_AuditsFailure() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game" }; + var cpuSets = new FakeCpuSetHandler(); + var legacy = new RecordingLegacyAffinityApplier(); + var audit = new RecordingAffinityAudit(); + var service = CreateCpuSelectionApplier(cpuSets, legacy, audit); + + var result = await service.ApplyAsync(process, new CpuSelection()); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.InvalidSelection, result.ErrorCode); + Assert.Equal([(process, false)], audit.Calls); + } + + [Fact] + public async Task CpuSelectionApply_WhenLegacyFallbackIsUnsafe_AuditsFailure() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game" }; + var cpuSets = new FakeCpuSetHandler { ApplyCpuSelectionResult = false }; + var legacy = new RecordingLegacyAffinityApplier(); + var audit = new RecordingAffinityAudit(); + var service = CreateCpuSelectionApplier(cpuSets, legacy, audit); + var selection = CreateSelection(new ProcessorRef(1, 64, 64)); + + var result = await service.ApplyAsync(process, selection); + + Assert.False(result.Success); + Assert.True(result.IsLegacyFallbackBlocked); + Assert.Equal(0, legacy.CallCount); + Assert.Equal([(process, false)], audit.Calls); + } + + [Fact] + public async Task CpuSelectionApply_WhenLegacyFallbackSucceeds_DoesNotAuditTwice() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game" }; + var cpuSets = new FakeCpuSetHandler { ApplyCpuSelectionResult = false }; + var legacy = new RecordingLegacyAffinityApplier(); + var audit = new RecordingAffinityAudit(); + var service = CreateCpuSelectionApplier(cpuSets, legacy, audit); + var selection = CreateSelection(new ProcessorRef(0, 0, 0)); + + var result = await service.ApplyAsync(process, selection); + + Assert.True(result.Success); + Assert.True(result.UsedLegacyAffinity); + Assert.Equal(1, legacy.CallCount); + Assert.Empty(audit.Calls); + } + + [Fact] + public async Task CpuSelectionApply_WhenCpuSetsThrowAccessDenied_ReturnsAccessDenied() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game" }; + var cpuSets = new FakeCpuSetHandler + { + ApplyCpuSelectionException = new Win32Exception(5, "Access is denied."), + }; + var legacy = new RecordingLegacyAffinityApplier(); + var service = CreateCpuSelectionApplier(cpuSets, legacy); + var selection = CreateSelection(new ProcessorRef(0, 0, 0)); + + var result = await service.ApplyAsync(process, selection); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); + Assert.True(result.IsAccessDenied); + Assert.Equal(0, legacy.CallCount); + } + + [Fact] + public async Task CpuSelectionApply_WhenFallbackThrowsAccessDenied_ReturnsAccessDenied() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game" }; + var cpuSets = new FakeCpuSetHandler { ApplyCpuSelectionResult = false }; + var legacy = new RecordingLegacyAffinityApplier + { + ExceptionToThrow = new UnauthorizedAccessException("Access denied."), + }; + var service = CreateCpuSelectionApplier(cpuSets, legacy); + var selection = CreateSelection(new ProcessorRef(0, 0, 0)); + + var result = await service.ApplyAsync(process, selection); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode); + Assert.True(result.IsAccessDenied); + Assert.Equal(1, legacy.CallCount); + } + + [Fact] + public async Task CpuSelectionApply_WhenFallbackThrowsProcessExited_ReturnsProcessExited() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game" }; + var cpuSets = new FakeCpuSetHandler { ApplyCpuSelectionResult = false }; + var legacy = new RecordingLegacyAffinityApplier + { + ExceptionToThrow = new ArgumentException("Process has exited."), + }; + var service = CreateCpuSelectionApplier(cpuSets, legacy); + var selection = CreateSelection(new ProcessorRef(0, 0, 0)); + + var result = await service.ApplyAsync(process, selection); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.ProcessExited, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); + Assert.False(result.UsedLegacyAffinity); + } + + [Fact] + public async Task ApplyCpuSelectionAsync_WhenProcessIsNull_ReturnsProcessExitedWithoutDelegating() + { + var processService = new Mock(MockBehavior.Strict); + var service = CreateService(processService); + + var result = await service.ApplyAsync(null!, CreateSelection(new ProcessorRef(0, 0, 0))); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.ProcessExited, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); + processService.Verify( + service => service.SetProcessorAffinity(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ApplyCpuSelectionAsync_WhenSelectionIsNull_ReturnsInvalidSelectionWithoutDelegating() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game" }; + var processService = new Mock(MockBehavior.Strict); + var service = CreateService(processService); + + var result = await service.ApplyAsync(process, null!); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.InvalidSelection, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.InvalidTopology, result.UserMessage); + processService.Verify( + service => service.SetProcessorAffinity(It.IsAny(), It.IsAny()), + Times.Never); + } + + 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 CpuSelectionAffinityApplier CreateCpuSelectionApplier( + FakeCpuSetHandler cpuSets, + RecordingLegacyAffinityApplier legacy, + RecordingAffinityAudit? audit = null) => + new( + _ => cpuSets, + legacy.ApplyAsync, + NullLogger.Instance, + null, + audit is null ? null : audit.Record); + + private static Mock CreateProcessService(bool processStillRunning) + { + var processService = new Mock(MockBehavior.Strict); + processService + .Setup(service => service.IsProcessStillRunning(It.IsAny())) + .ReturnsAsync(processStillRunning); + return processService; + } + + private static CpuSelection CreateSelection(params ProcessorRef[] processors) => + new() + { + LogicalProcessors = processors.ToList(), + GlobalLogicalProcessorIndexes = processors.Select(processor => processor.GlobalIndex).ToList(), + }; + + private sealed class RecordingLegacyAffinityApplier + { + public int CallCount { get; private set; } + + public long? LastMask { get; private set; } + + public Exception? ExceptionToThrow { get; init; } + + public Task ApplyAsync(ProcessModel process, long affinityMask) + { + this.CallCount++; + this.LastMask = affinityMask; + + if (this.ExceptionToThrow != null) + { + throw this.ExceptionToThrow; + } + + process.ProcessorAffinity = affinityMask; + return Task.FromResult(affinityMask); + } + } + + private sealed class RecordingAffinityAudit + { + public List<(ProcessModel Process, bool Success)> Calls { get; } = new(); + + public void Record(ProcessModel process, bool success) => + this.Calls.Add((process, success)); + } + + private sealed class FakeCpuSetHandler : IProcessCpuSetHandler + { + public uint ProcessId => 42; + + public string ExecutableName => "Game"; + + public bool IsValid { get; init; } = true; + + public bool ApplyCpuSelectionResult { get; init; } + + public Exception? ApplyCpuSelectionException { get; init; } + + public int ApplyCpuSelectionCalls { get; private set; } + + public CpuSelection? LastSelection { get; private set; } + + public bool ApplyCpuSetMask(long affinityMask, bool clearMask = false) => false; + + public CpuSetApplyResult ApplyCpuSetMaskDetailed(long affinityMask, bool clearMask = false) => + CpuSetApplyResult.Failed( + AffinityApplyErrorCodes.CpuSetsUnavailable, + ProcessOperationUserMessages.CpuSetsUnavailable, + "Fake CPU Sets handler rejected the legacy mask."); + + public bool ApplyCpuSelection(CpuSelection? selection, bool clearSelection = false) + { + this.ApplyCpuSelectionCalls++; + this.LastSelection = selection; + + if (this.ApplyCpuSelectionException != null) + { + throw this.ApplyCpuSelectionException; + } + + return this.ApplyCpuSelectionResult; + } + + public CpuSetApplyResult ApplyCpuSelectionDetailed(CpuSelection? selection, bool clearSelection = false) + { + this.ApplyCpuSelectionCalls++; + this.LastSelection = selection; + + if (this.ApplyCpuSelectionException != null) + { + throw this.ApplyCpuSelectionException; + } + + return this.ApplyCpuSelectionResult + ? CpuSetApplyResult.Succeeded("Fake CPU Sets handler applied the selection.") + : CpuSetApplyResult.Failed( + AffinityApplyErrorCodes.CpuSetsUnavailable, + ProcessOperationUserMessages.CpuSetsUnavailable, + "Fake CPU Sets handler rejected the selection."); + } + + public double GetAverageCpuUsage() => 0; + + public void Dispose() + { + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/AppRefreshPolicyTests.cs b/Tests/ThreadPilot.Core.Tests/AppRefreshPolicyTests.cs index 8c66ed0..494df32 100644 --- a/Tests/ThreadPilot.Core.Tests/AppRefreshPolicyTests.cs +++ b/Tests/ThreadPilot.Core.Tests/AppRefreshPolicyTests.cs @@ -1,63 +1,63 @@ -namespace ThreadPilot.Core.Tests -{ - using ThreadPilot.Services; - - public sealed class AppRefreshPolicyTests - { - [Theory] - [InlineData(AppActivityState.ForegroundProcessView, true, true, true, false, true, true)] - [InlineData(AppActivityState.ForegroundDiagnosticsView, false, false, false, true, true, true)] - [InlineData(AppActivityState.ForegroundOtherTab, false, false, false, false, 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); - } - - [Theory] - [InlineData(null, AppActivityState.ForegroundProcessView, true)] - [InlineData(AppActivityState.ForegroundProcessView, AppActivityState.ForegroundProcessView, false)] - [InlineData(AppActivityState.ForegroundDiagnosticsView, AppActivityState.ForegroundDiagnosticsView, false)] - [InlineData(AppActivityState.ForegroundOtherTab, AppActivityState.ForegroundOtherTab, false)] - [InlineData(AppActivityState.Minimized, AppActivityState.Minimized, false)] - [InlineData(AppActivityState.TrayHidden, AppActivityState.TrayHidden, false)] - [InlineData(AppActivityState.TrayHidden, AppActivityState.ForegroundProcessView, true)] - [InlineData(AppActivityState.ForegroundProcessView, AppActivityState.ForegroundOtherTab, true)] - [InlineData(AppActivityState.ForegroundOtherTab, AppActivityState.ForegroundDiagnosticsView, true)] - public void ShouldApplyTransition_SkipsRedundantStateTransitions( - AppActivityState? previousState, - AppActivityState nextState, - bool expected) - { - Assert.Equal(expected, AppRefreshPolicy.ShouldApplyTransition(previousState, nextState)); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Services; + + public sealed class AppRefreshPolicyTests + { + [Theory] + [InlineData(AppActivityState.ForegroundProcessView, true, true, true, false, true, true)] + [InlineData(AppActivityState.ForegroundDiagnosticsView, false, false, false, true, true, true)] + [InlineData(AppActivityState.ForegroundOtherTab, false, false, false, false, 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); + } + + [Theory] + [InlineData(null, AppActivityState.ForegroundProcessView, true)] + [InlineData(AppActivityState.ForegroundProcessView, AppActivityState.ForegroundProcessView, false)] + [InlineData(AppActivityState.ForegroundDiagnosticsView, AppActivityState.ForegroundDiagnosticsView, false)] + [InlineData(AppActivityState.ForegroundOtherTab, AppActivityState.ForegroundOtherTab, false)] + [InlineData(AppActivityState.Minimized, AppActivityState.Minimized, false)] + [InlineData(AppActivityState.TrayHidden, AppActivityState.TrayHidden, false)] + [InlineData(AppActivityState.TrayHidden, AppActivityState.ForegroundProcessView, true)] + [InlineData(AppActivityState.ForegroundProcessView, AppActivityState.ForegroundOtherTab, true)] + [InlineData(AppActivityState.ForegroundOtherTab, AppActivityState.ForegroundDiagnosticsView, true)] + public void ShouldApplyTransition_SkipsRedundantStateTransitions( + AppActivityState? previousState, + AppActivityState nextState, + bool expected) + { + Assert.Equal(expected, AppRefreshPolicy.ShouldApplyTransition(previousState, nextState)); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/AppSmokeTestStartupTests.cs b/Tests/ThreadPilot.Core.Tests/AppSmokeTestStartupTests.cs index 882acdd..4a82bc8 100644 --- a/Tests/ThreadPilot.Core.Tests/AppSmokeTestStartupTests.cs +++ b/Tests/ThreadPilot.Core.Tests/AppSmokeTestStartupTests.cs @@ -1,78 +1,78 @@ -namespace ThreadPilot.Core.Tests -{ - public sealed class AppSmokeTestStartupTests - { - [Fact] - public void OnStartup_HandlesSmokeTestBeforeElevationSingleInstanceAndWindowStartup() - { - var source = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "App.xaml.cs")); - - var smokeTestBranchIndex = source.IndexOf("if (startupMode.IsSmokeTest)", StringComparison.Ordinal); - var elevationIndex = source.IndexOf("GetRequiredService", StringComparison.Ordinal); - var mutexIndex = source.IndexOf("Global\\\\ThreadPilot_SingleInstance", StringComparison.Ordinal); - var baseStartupIndex = source.IndexOf("base.OnStartup(e);", StringComparison.Ordinal); - var mainWindowIndex = source.IndexOf("GetRequiredService", StringComparison.Ordinal); - - Assert.NotEqual(-1, smokeTestBranchIndex); - Assert.True(smokeTestBranchIndex < elevationIndex); - Assert.True(smokeTestBranchIndex < mutexIndex); - Assert.True(smokeTestBranchIndex < baseStartupIndex); - Assert.True(smokeTestBranchIndex < mainWindowIndex); - } - - [Fact] - public void SmokeTestMode_ExitsTheProcessAfterShutdownToAvoidDispatcherOrTimerHangs() - { - var source = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "App.xaml.cs")); - - var smokeTestBranch = ExtractSection( - source, - "if (startupMode.IsSmokeTest)", - " // Set up global exception handlers first"); - - Assert.Contains("this.Shutdown(smokeTestResult);", smokeTestBranch, StringComparison.Ordinal); - Assert.Contains("Environment.Exit(smokeTestResult);", smokeTestBranch, StringComparison.Ordinal); - } - - [Fact] - public void RunSmokeTest_DoesNotResolveUiViewModelsOrMainWindow() - { - var source = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "App.xaml.cs")); - var smokeTestMethod = ExtractSection( - source, - "private int RunSmokeTest", - "protected override void OnExit"); - - Assert.DoesNotContain("ProcessViewModel", smokeTestMethod, StringComparison.Ordinal); - Assert.DoesNotContain("PowerPlanViewModel", smokeTestMethod, StringComparison.Ordinal); - Assert.DoesNotContain("MainWindow", smokeTestMethod, StringComparison.Ordinal); - } - - private static string ExtractSection(string source, string startMarker, string endMarker) - { - var startIndex = source.IndexOf(startMarker, StringComparison.Ordinal); - Assert.NotEqual(-1, startIndex); - - var endIndex = source.IndexOf(endMarker, startIndex, StringComparison.Ordinal); - Assert.NotEqual(-1, endIndex); - - return source[startIndex..endIndex]; - } - - private static string GetRepositoryRoot() - { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory != null && !File.Exists(Path.Combine(directory.FullName, "ThreadPilot.csproj"))) - { - directory = directory.Parent; - } - - if (directory == null) - { - throw new InvalidOperationException("Repository root was not found."); - } - - return directory.FullName; - } - } -} +namespace ThreadPilot.Core.Tests +{ + public sealed class AppSmokeTestStartupTests + { + [Fact] + public void OnStartup_HandlesSmokeTestBeforeElevationSingleInstanceAndWindowStartup() + { + var source = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "App.xaml.cs")); + + var smokeTestBranchIndex = source.IndexOf("if (startupMode.IsSmokeTest)", StringComparison.Ordinal); + var elevationIndex = source.IndexOf("GetRequiredService", StringComparison.Ordinal); + var mutexIndex = source.IndexOf("Global\\\\ThreadPilot_SingleInstance", StringComparison.Ordinal); + var baseStartupIndex = source.IndexOf("base.OnStartup(e);", StringComparison.Ordinal); + var mainWindowIndex = source.IndexOf("GetRequiredService", StringComparison.Ordinal); + + Assert.NotEqual(-1, smokeTestBranchIndex); + Assert.True(smokeTestBranchIndex < elevationIndex); + Assert.True(smokeTestBranchIndex < mutexIndex); + Assert.True(smokeTestBranchIndex < baseStartupIndex); + Assert.True(smokeTestBranchIndex < mainWindowIndex); + } + + [Fact] + public void SmokeTestMode_ExitsTheProcessAfterShutdownToAvoidDispatcherOrTimerHangs() + { + var source = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "App.xaml.cs")); + + var smokeTestBranch = ExtractSection( + source, + "if (startupMode.IsSmokeTest)", + " // Set up global exception handlers first"); + + Assert.Contains("this.Shutdown(smokeTestResult);", smokeTestBranch, StringComparison.Ordinal); + Assert.Contains("Environment.Exit(smokeTestResult);", smokeTestBranch, StringComparison.Ordinal); + } + + [Fact] + public void RunSmokeTest_DoesNotResolveUiViewModelsOrMainWindow() + { + var source = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "App.xaml.cs")); + var smokeTestMethod = ExtractSection( + source, + "private int RunSmokeTest", + "protected override void OnExit"); + + Assert.DoesNotContain("ProcessViewModel", smokeTestMethod, StringComparison.Ordinal); + Assert.DoesNotContain("PowerPlanViewModel", smokeTestMethod, StringComparison.Ordinal); + Assert.DoesNotContain("MainWindow", smokeTestMethod, StringComparison.Ordinal); + } + + private static string ExtractSection(string source, string startMarker, string endMarker) + { + var startIndex = source.IndexOf(startMarker, StringComparison.Ordinal); + Assert.NotEqual(-1, startIndex); + + var endIndex = source.IndexOf(endMarker, startIndex, StringComparison.Ordinal); + Assert.NotEqual(-1, endIndex); + + return source[startIndex..endIndex]; + } + + private static string GetRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "ThreadPilot.csproj"))) + { + directory = directory.Parent; + } + + if (directory == null) + { + throw new InvalidOperationException("Repository root was not found."); + } + + return directory.FullName; + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs index 6eed3cb..df1d848 100644 --- a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs @@ -1,67 +1,67 @@ -namespace ThreadPilot.Core.Tests -{ - using ThreadPilot.Models; - - public sealed class ApplicationSettingsModelTests - { - [Fact] - public void Constructor_StartMinimizedDefaultsFalse_ForManualLaunchVisibility() - { - var settings = new ApplicationSettingsModel(); - - Assert.True(settings.AutostartWithWindows); - Assert.False(settings.StartMinimized); - Assert.True(settings.ApplyPersistentRulesOnProcessStart); - Assert.False(settings.HasSeenStartupMinimizedSuggestion); - Assert.Equal("en-US", settings.Language); - Assert.True(settings.EnableAutomaticUpdateChecks); - Assert.Equal(7, settings.UpdateCheckIntervalDays); - Assert.False(settings.IncludePrereleaseUpdates); - Assert.Null(settings.LastUpdateCheckUtc); - } - - [Fact] - public void CopyFrom_CopiesLanguage() - { - var source = new ApplicationSettingsModel - { - Language = "zh-CN", - }; - var target = new ApplicationSettingsModel(); - - target.CopyFrom(source); - - Assert.Equal("zh-CN", target.Language); - } - - [Fact] - public void HasSameUserSettingsAs_ReturnsTrue_WhenChangedSettingIsRestored() - { - var savedSettings = new ApplicationSettingsModel - { - EnableNotifications = true, - }; - - var editableSettings = (ApplicationSettingsModel)savedSettings.Clone(); - editableSettings.EnableNotifications = false; - Assert.False(editableSettings.HasSameUserSettingsAs(savedSettings)); - - editableSettings.EnableNotifications = true; - - Assert.True(editableSettings.HasSameUserSettingsAs(savedSettings)); - } - - [Fact] - public void HasSameUserSettingsAs_IgnoresMetadataTimestamps() - { - var savedSettings = new ApplicationSettingsModel - { - UpdatedAt = new System.DateTime(2026, 5, 16, 10, 0, 0, System.DateTimeKind.Utc), - }; - var editableSettings = (ApplicationSettingsModel)savedSettings.Clone(); - editableSettings.UpdatedAt = savedSettings.UpdatedAt.AddMinutes(5); - - Assert.True(editableSettings.HasSameUserSettingsAs(savedSettings)); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Models; + + public sealed class ApplicationSettingsModelTests + { + [Fact] + public void Constructor_StartMinimizedDefaultsFalse_ForManualLaunchVisibility() + { + var settings = new ApplicationSettingsModel(); + + Assert.True(settings.AutostartWithWindows); + Assert.False(settings.StartMinimized); + Assert.True(settings.ApplyPersistentRulesOnProcessStart); + Assert.False(settings.HasSeenStartupMinimizedSuggestion); + Assert.Equal("en-US", settings.Language); + Assert.True(settings.EnableAutomaticUpdateChecks); + Assert.Equal(7, settings.UpdateCheckIntervalDays); + Assert.False(settings.IncludePrereleaseUpdates); + Assert.Null(settings.LastUpdateCheckUtc); + } + + [Fact] + public void CopyFrom_CopiesLanguage() + { + var source = new ApplicationSettingsModel + { + Language = "zh-CN", + }; + var target = new ApplicationSettingsModel(); + + target.CopyFrom(source); + + Assert.Equal("zh-CN", target.Language); + } + + [Fact] + public void HasSameUserSettingsAs_ReturnsTrue_WhenChangedSettingIsRestored() + { + var savedSettings = new ApplicationSettingsModel + { + EnableNotifications = true, + }; + + var editableSettings = (ApplicationSettingsModel)savedSettings.Clone(); + editableSettings.EnableNotifications = false; + Assert.False(editableSettings.HasSameUserSettingsAs(savedSettings)); + + editableSettings.EnableNotifications = true; + + Assert.True(editableSettings.HasSameUserSettingsAs(savedSettings)); + } + + [Fact] + public void HasSameUserSettingsAs_IgnoresMetadataTimestamps() + { + var savedSettings = new ApplicationSettingsModel + { + UpdatedAt = new System.DateTime(2026, 5, 16, 10, 0, 0, System.DateTimeKind.Utc), + }; + var editableSettings = (ApplicationSettingsModel)savedSettings.Clone(); + editableSettings.UpdatedAt = savedSettings.UpdatedAt.AddMinutes(5); + + Assert.True(editableSettings.HasSameUserSettingsAs(savedSettings)); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs index eb712c4..ebd54b1 100644 --- a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs @@ -1,271 +1,271 @@ -namespace ThreadPilot.Core.Tests -{ - using System; - using System.Collections.Generic; - using System.IO; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging.Abstractions; - using ThreadPilot.Models; - using ThreadPilot.Services; - using ThreadPilot.Services.Abstractions; - - public sealed class ApplicationSettingsServiceTests - { - [Fact] - public async Task LoadSettingsAsync_CreatesDefaults_WhenFileIsMissing() - { - var storage = new FakeSettingsStorage(); - var service = CreateService(storage); - - await service.LoadSettingsAsync(); - - Assert.True(storage.Writes.ContainsKey(TestPaths.SettingsFilePath)); - Assert.Equal(3000, service.Settings.NotificationDisplayDurationMs); - Assert.Equal(5000, service.Settings.BalloonNotificationTimeoutMs); - Assert.True(service.Settings.EnableSelfLowImpactMode); - Assert.False(service.Settings.EnableSelfAffinityLimit); - Assert.True(service.Settings.AutostartWithWindows); - Assert.False(service.Settings.StartMinimized); - Assert.Equal("en-US", service.Settings.Language); - } - - [Fact] - public async Task LoadSettingsAsync_FallsBackToDefaults_WhenJsonIsMalformed() - { - var storage = new FakeSettingsStorage(); - storage.Files[TestPaths.SettingsFilePath] = "{ invalid json"; - var service = CreateService(storage); - - await service.LoadSettingsAsync(); - - Assert.Equal(3000, service.Settings.NotificationDisplayDurationMs); - Assert.Equal(string.Empty, service.Settings.CustomTrayIconPath); - Assert.True(service.Settings.EnableSelfLowImpactMode); - Assert.False(service.Settings.EnableSelfAffinityLimit); - } - - [Fact] - public async Task LoadSettingsAsync_EnablesSafeSelfLowImpactDefault_ForOlderSettingsJson() - { - var storage = new FakeSettingsStorage(); - storage.Files[TestPaths.SettingsFilePath] = """ - { - "notificationDisplayDurationMs": 3000, - "balloonNotificationTimeoutMs": 5000 - } - """; - var service = CreateService(storage); - - await service.LoadSettingsAsync(); - - Assert.True(service.Settings.EnableSelfLowImpactMode); - Assert.False(service.Settings.EnableSelfAffinityLimit); - } - - [Fact] - public async Task LoadSettingsAsync_PreservesExplicitSelfLowImpactOptOut() - { - var storage = new FakeSettingsStorage(); - storage.Files[TestPaths.SettingsFilePath] = """ - { - "enableSelfLowImpactMode": false, - "enableSelfAffinityLimit": true - } - """; - var service = CreateService(storage); - - await service.LoadSettingsAsync(); - - Assert.False(service.Settings.EnableSelfLowImpactMode); - Assert.True(service.Settings.EnableSelfAffinityLimit); - } - - [Fact] - public async Task LoadSettingsAsync_DefaultsStartMinimizedFalse_ForOlderAutostartSettingsJson() - { - var storage = new FakeSettingsStorage(); - storage.Files[TestPaths.SettingsFilePath] = """ - { - "autostartWithWindows": true - } - """; - var service = CreateService(storage); - - await service.LoadSettingsAsync(); - - Assert.True(service.Settings.AutostartWithWindows); - Assert.False(service.Settings.StartMinimized); - } - - [Fact] - public async Task LoadSettingsAsync_PreservesExplicitStartMinimizedOptOut() - { - var storage = new FakeSettingsStorage(); - storage.Files[TestPaths.SettingsFilePath] = """ - { - "autostartWithWindows": true, - "startMinimized": false - } - """; - var service = CreateService(storage); - - await service.LoadSettingsAsync(); - - Assert.True(service.Settings.AutostartWithWindows); - Assert.False(service.Settings.StartMinimized); - } - - [Fact] - public async Task LoadSettingsAsync_PreservesExplicitStartMinimizedOptIn() - { - var storage = new FakeSettingsStorage(); - storage.Files[TestPaths.SettingsFilePath] = """ - { - "autostartWithWindows": true, - "startMinimized": true - } - """; - var service = CreateService(storage); - - await service.LoadSettingsAsync(); - - Assert.True(service.Settings.AutostartWithWindows); - Assert.True(service.Settings.StartMinimized); - } - - [Fact] - public async Task LoadSettingsAsync_PreservesStartupMinimizedSuggestionDismissal() - { - var storage = new FakeSettingsStorage(); - storage.Files[TestPaths.SettingsFilePath] = """ - { - "hasSeenStartupMinimizedSuggestion": true - } - """; - var service = CreateService(storage); - - await service.LoadSettingsAsync(); - - Assert.True(service.Settings.HasSeenStartupMinimizedSuggestion); - } - - [Fact] - public async Task LoadSettingsAsync_PreservesSupportedLanguage() - { - var storage = new FakeSettingsStorage(); - storage.Files[TestPaths.SettingsFilePath] = """ - { - "language": "zh-CN" - } - """; - var service = CreateService(storage); - - await service.LoadSettingsAsync(); - - Assert.Equal("zh-CN", service.Settings.Language); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData("fr-FR")] - [InlineData("zh")] - public async Task LoadSettingsAsync_FallsBackToEnglish_WhenLanguageIsInvalid(string language) - { - var storage = new FakeSettingsStorage(); - storage.Files[TestPaths.SettingsFilePath] = $$""" - { - "language": "{{language}}" - } - """; - var service = CreateService(storage); - - await service.LoadSettingsAsync(); - - Assert.Equal("en-US", service.Settings.Language); - } - - [Fact] - public async Task ImportSettingsAsync_Throws_WhenFileIsMissing() - { - var storage = new FakeSettingsStorage(); - var service = CreateService(storage); - - await Assert.ThrowsAsync(() => service.ImportSettingsAsync("missing-settings.json")); - } - - [Fact] - public async Task ValidateAndFixSettings_DisablesMissingCustomTrayIcon() - { - var storage = new FakeSettingsStorage(); - var service = CreateService(storage); - var updatedSettings = new ApplicationSettingsModel - { - UseCustomTrayIcon = true, - CustomTrayIconPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.ico"), - }; - - await service.UpdateSettingsAsync(updatedSettings); - - Assert.False(service.Settings.UseCustomTrayIcon); - } - - private static ApplicationSettingsService CreateService(FakeSettingsStorage storage) - { - return new ApplicationSettingsService( - NullLogger.Instance, - storage, - TestPaths.SettingsFilePath, - legacySettingsPath: null); - } - - private static class TestPaths - { - public const string SettingsFilePath = "settings-under-test.json"; - } - - private sealed class FakeSettingsStorage : ISettingsStorage - { - public Dictionary Files { get; } = new(StringComparer.OrdinalIgnoreCase); - - public Dictionary Writes { get; } = new(StringComparer.OrdinalIgnoreCase); - - public void Copy(string sourcePath, string destinationPath, bool overwrite) - { - if (!this.Files.TryGetValue(sourcePath, out var content)) - { - throw new FileNotFoundException("Source file not found.", sourcePath); - } - - if (!overwrite && this.Files.ContainsKey(destinationPath)) - { - throw new IOException("Destination already exists."); - } - - this.Files[destinationPath] = content; - } - - public void EnsureDirectoryForFile(string path) - { - } - - public bool Exists(string path) - { - return this.Files.ContainsKey(path); - } - - public Task ReadAsync(string path) - { - this.Files.TryGetValue(path, out var content); - return Task.FromResult(content); - } - - public Task WriteAsync(string path, string content) - { - this.Files[path] = content; - this.Writes[path] = content; - return Task.CompletedTask; - } - } - } -} +namespace ThreadPilot.Core.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging.Abstractions; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.Services.Abstractions; + + public sealed class ApplicationSettingsServiceTests + { + [Fact] + public async Task LoadSettingsAsync_CreatesDefaults_WhenFileIsMissing() + { + var storage = new FakeSettingsStorage(); + var service = CreateService(storage); + + await service.LoadSettingsAsync(); + + Assert.True(storage.Writes.ContainsKey(TestPaths.SettingsFilePath)); + Assert.Equal(3000, service.Settings.NotificationDisplayDurationMs); + Assert.Equal(5000, service.Settings.BalloonNotificationTimeoutMs); + Assert.True(service.Settings.EnableSelfLowImpactMode); + Assert.False(service.Settings.EnableSelfAffinityLimit); + Assert.True(service.Settings.AutostartWithWindows); + Assert.False(service.Settings.StartMinimized); + Assert.Equal("en-US", service.Settings.Language); + } + + [Fact] + public async Task LoadSettingsAsync_FallsBackToDefaults_WhenJsonIsMalformed() + { + var storage = new FakeSettingsStorage(); + storage.Files[TestPaths.SettingsFilePath] = "{ invalid json"; + var service = CreateService(storage); + + await service.LoadSettingsAsync(); + + Assert.Equal(3000, service.Settings.NotificationDisplayDurationMs); + Assert.Equal(string.Empty, service.Settings.CustomTrayIconPath); + Assert.True(service.Settings.EnableSelfLowImpactMode); + Assert.False(service.Settings.EnableSelfAffinityLimit); + } + + [Fact] + public async Task LoadSettingsAsync_EnablesSafeSelfLowImpactDefault_ForOlderSettingsJson() + { + var storage = new FakeSettingsStorage(); + storage.Files[TestPaths.SettingsFilePath] = """ + { + "notificationDisplayDurationMs": 3000, + "balloonNotificationTimeoutMs": 5000 + } + """; + var service = CreateService(storage); + + await service.LoadSettingsAsync(); + + Assert.True(service.Settings.EnableSelfLowImpactMode); + Assert.False(service.Settings.EnableSelfAffinityLimit); + } + + [Fact] + public async Task LoadSettingsAsync_PreservesExplicitSelfLowImpactOptOut() + { + var storage = new FakeSettingsStorage(); + storage.Files[TestPaths.SettingsFilePath] = """ + { + "enableSelfLowImpactMode": false, + "enableSelfAffinityLimit": true + } + """; + var service = CreateService(storage); + + await service.LoadSettingsAsync(); + + Assert.False(service.Settings.EnableSelfLowImpactMode); + Assert.True(service.Settings.EnableSelfAffinityLimit); + } + + [Fact] + public async Task LoadSettingsAsync_DefaultsStartMinimizedFalse_ForOlderAutostartSettingsJson() + { + var storage = new FakeSettingsStorage(); + storage.Files[TestPaths.SettingsFilePath] = """ + { + "autostartWithWindows": true + } + """; + var service = CreateService(storage); + + await service.LoadSettingsAsync(); + + Assert.True(service.Settings.AutostartWithWindows); + Assert.False(service.Settings.StartMinimized); + } + + [Fact] + public async Task LoadSettingsAsync_PreservesExplicitStartMinimizedOptOut() + { + var storage = new FakeSettingsStorage(); + storage.Files[TestPaths.SettingsFilePath] = """ + { + "autostartWithWindows": true, + "startMinimized": false + } + """; + var service = CreateService(storage); + + await service.LoadSettingsAsync(); + + Assert.True(service.Settings.AutostartWithWindows); + Assert.False(service.Settings.StartMinimized); + } + + [Fact] + public async Task LoadSettingsAsync_PreservesExplicitStartMinimizedOptIn() + { + var storage = new FakeSettingsStorage(); + storage.Files[TestPaths.SettingsFilePath] = """ + { + "autostartWithWindows": true, + "startMinimized": true + } + """; + var service = CreateService(storage); + + await service.LoadSettingsAsync(); + + Assert.True(service.Settings.AutostartWithWindows); + Assert.True(service.Settings.StartMinimized); + } + + [Fact] + public async Task LoadSettingsAsync_PreservesStartupMinimizedSuggestionDismissal() + { + var storage = new FakeSettingsStorage(); + storage.Files[TestPaths.SettingsFilePath] = """ + { + "hasSeenStartupMinimizedSuggestion": true + } + """; + var service = CreateService(storage); + + await service.LoadSettingsAsync(); + + Assert.True(service.Settings.HasSeenStartupMinimizedSuggestion); + } + + [Fact] + public async Task LoadSettingsAsync_PreservesSupportedLanguage() + { + var storage = new FakeSettingsStorage(); + storage.Files[TestPaths.SettingsFilePath] = """ + { + "language": "zh-CN" + } + """; + var service = CreateService(storage); + + await service.LoadSettingsAsync(); + + Assert.Equal("zh-CN", service.Settings.Language); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("fr-FR")] + [InlineData("zh")] + public async Task LoadSettingsAsync_FallsBackToEnglish_WhenLanguageIsInvalid(string language) + { + var storage = new FakeSettingsStorage(); + storage.Files[TestPaths.SettingsFilePath] = $$""" + { + "language": "{{language}}" + } + """; + var service = CreateService(storage); + + await service.LoadSettingsAsync(); + + Assert.Equal("en-US", service.Settings.Language); + } + + [Fact] + public async Task ImportSettingsAsync_Throws_WhenFileIsMissing() + { + var storage = new FakeSettingsStorage(); + var service = CreateService(storage); + + await Assert.ThrowsAsync(() => service.ImportSettingsAsync("missing-settings.json")); + } + + [Fact] + public async Task ValidateAndFixSettings_DisablesMissingCustomTrayIcon() + { + var storage = new FakeSettingsStorage(); + var service = CreateService(storage); + var updatedSettings = new ApplicationSettingsModel + { + UseCustomTrayIcon = true, + CustomTrayIconPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.ico"), + }; + + await service.UpdateSettingsAsync(updatedSettings); + + Assert.False(service.Settings.UseCustomTrayIcon); + } + + private static ApplicationSettingsService CreateService(FakeSettingsStorage storage) + { + return new ApplicationSettingsService( + NullLogger.Instance, + storage, + TestPaths.SettingsFilePath, + legacySettingsPath: null); + } + + private static class TestPaths + { + public const string SettingsFilePath = "settings-under-test.json"; + } + + private sealed class FakeSettingsStorage : ISettingsStorage + { + public Dictionary Files { get; } = new(StringComparer.OrdinalIgnoreCase); + + public Dictionary Writes { get; } = new(StringComparer.OrdinalIgnoreCase); + + public void Copy(string sourcePath, string destinationPath, bool overwrite) + { + if (!this.Files.TryGetValue(sourcePath, out var content)) + { + throw new FileNotFoundException("Source file not found.", sourcePath); + } + + if (!overwrite && this.Files.ContainsKey(destinationPath)) + { + throw new IOException("Destination already exists."); + } + + this.Files[destinationPath] = content; + } + + public void EnsureDirectoryForFile(string path) + { + } + + public bool Exists(string path) + { + return this.Files.ContainsKey(path); + } + + public Task ReadAsync(string path) + { + this.Files.TryGetValue(path, out var content); + return Task.FromResult(content); + } + + public Task WriteAsync(string path, string content) + { + this.Files[path] = content; + this.Writes[path] = content; + return Task.CompletedTask; + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/AutostartServiceTests.cs b/Tests/ThreadPilot.Core.Tests/AutostartServiceTests.cs index b67f814..91307f7 100644 --- a/Tests/ThreadPilot.Core.Tests/AutostartServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/AutostartServiceTests.cs @@ -1,119 +1,110 @@ -/* - * ThreadPilot - autostart service unit tests. - */ -namespace ThreadPilot.Core.Tests -{ - using Microsoft.Extensions.Logging.Abstractions; - using Moq; - using ThreadPilot.Services; - - /// - /// Unit tests for non-registry behavior in . - /// - public sealed class AutostartServiceTests - { - /// - /// Ensures autostart arguments include both autostart and start-minimized flags when requested. - /// - [Fact] - public void GetAutostartArguments_IncludesStartMinimized_WhenRequested() - { - var service = CreateService(); - - var args = service.GetAutostartArguments(startMinimized: true); - - Assert.Contains("--start-minimized", args, StringComparison.Ordinal); - Assert.Contains("--autostart", args, StringComparison.Ordinal); - } - - /// - /// Ensures start-minimized is omitted when not requested. - /// - [Fact] - public void GetAutostartArguments_OmitsStartMinimized_WhenNotRequested() - { - var service = CreateService(); - - var args = service.GetAutostartArguments(startMinimized: false); - - Assert.DoesNotContain("--start-minimized", args, StringComparison.Ordinal); - Assert.Equal("--autostart", args); - } - - [Fact] - public async Task EnableAutostartAsync_WhenNotAdmin_RequestsElevation_AndReturnsFalse() - { - var elevationService = new Mock(MockBehavior.Strict); - elevationService.Setup(x => x.IsRunningAsAdministrator()).Returns(false); - elevationService.Setup(x => x.RequestElevationIfNeeded()).ReturnsAsync(true); - - var elevatedTaskService = new Mock(MockBehavior.Loose); - elevatedTaskService.Setup(x => x.IsAutostartTaskRegisteredAsync()).ReturnsAsync(false); - - var service = CreateService(elevationService, elevatedTaskService); - - var result = await service.EnableAutostartAsync(startMinimized: true); - - Assert.False(result); - elevationService.Verify(x => x.RequestElevationIfNeeded(), Times.Once); - elevatedTaskService.Verify( - x => x.EnsureAutostartTaskAsync(It.IsAny(), It.IsAny()), - Times.Never); - } - - [Fact] - public async Task EnableAutostartAsync_WhenAdmin_EnsuresScheduledTask() - { - var elevationService = new Mock(MockBehavior.Strict); - elevationService.Setup(x => x.IsRunningAsAdministrator()).Returns(true); - - var elevatedTaskService = new Mock(MockBehavior.Strict); - elevatedTaskService.Setup(x => x.IsAutostartTaskRegisteredAsync()).ReturnsAsync(false); - elevatedTaskService - .Setup(x => x.EnsureAutostartTaskAsync(It.IsAny(), It.Is(args => args.Contains("--autostart", StringComparison.Ordinal)))) - .ReturnsAsync(true); - - var service = CreateService(elevationService, elevatedTaskService); - - var result = await service.EnableAutostartAsync(startMinimized: true); - - Assert.True(result); - elevatedTaskService.Verify( - x => x.EnsureAutostartTaskAsync( - It.IsAny(), - It.Is(args => - args.Contains("--autostart", StringComparison.Ordinal) && - args.Contains("--start-minimized", StringComparison.Ordinal))), - Times.Once); - } - - [Fact] - public async Task DisableAutostartAsync_WhenAdmin_RemovesScheduledTask() - { - var elevationService = new Mock(MockBehavior.Strict); - elevationService.Setup(x => x.IsRunningAsAdministrator()).Returns(true); - - var elevatedTaskService = new Mock(MockBehavior.Strict); - elevatedTaskService.Setup(x => x.IsAutostartTaskRegisteredAsync()).ReturnsAsync(false); - elevatedTaskService.Setup(x => x.RemoveAutostartTaskAsync()).ReturnsAsync(true); - - var service = CreateService(elevationService, elevatedTaskService); - - var result = await service.DisableAutostartAsync(); - - Assert.True(result); - elevatedTaskService.Verify(x => x.RemoveAutostartTaskAsync(), Times.Once); - } - - private static AutostartService CreateService( - Mock? elevationService = null, - Mock? elevatedTaskService = null) - { - var elevation = elevationService ?? new Mock(MockBehavior.Loose); - var elevatedTask = elevatedTaskService ?? new Mock(MockBehavior.Loose); - elevatedTask.Setup(x => x.IsAutostartTaskRegisteredAsync()).ReturnsAsync(false); - - return new AutostartService(NullLogger.Instance, elevation.Object, elevatedTask.Object); - } - } -} +/* + * ThreadPilot - autostart service unit tests. + */ +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Services; + + public sealed class AutostartServiceTests + { + [Fact] + public void GetAutostartArguments_IncludesStartMinimized_WhenRequested() + { + var service = CreateService(); + + var args = service.GetAutostartArguments(startMinimized: true); + + Assert.Contains("--start-minimized", args, StringComparison.Ordinal); + Assert.Contains("--autostart", args, StringComparison.Ordinal); + } + + [Fact] + public void GetAutostartArguments_OmitsStartMinimized_WhenNotRequested() + { + var service = CreateService(); + + var args = service.GetAutostartArguments(startMinimized: false); + + Assert.DoesNotContain("--start-minimized", args, StringComparison.Ordinal); + Assert.Equal("--autostart", args); + } + + [Fact] + public async Task EnableAutostartAsync_WhenNotAdmin_RequestsElevation_AndReturnsFalse() + { + var elevationService = new Mock(MockBehavior.Strict); + elevationService.Setup(x => x.IsRunningAsAdministrator()).Returns(false); + elevationService.Setup(x => x.RequestElevationIfNeeded()).ReturnsAsync(true); + + var elevatedTaskService = new Mock(MockBehavior.Loose); + elevatedTaskService.Setup(x => x.IsAutostartTaskRegisteredAsync()).ReturnsAsync(false); + + var service = CreateService(elevationService, elevatedTaskService); + + var result = await service.EnableAutostartAsync(startMinimized: true); + + Assert.False(result); + elevationService.Verify(x => x.RequestElevationIfNeeded(), Times.Once); + elevatedTaskService.Verify( + x => x.EnsureAutostartTaskAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task EnableAutostartAsync_WhenAdmin_EnsuresScheduledTask() + { + var elevationService = new Mock(MockBehavior.Strict); + elevationService.Setup(x => x.IsRunningAsAdministrator()).Returns(true); + + var elevatedTaskService = new Mock(MockBehavior.Strict); + elevatedTaskService.Setup(x => x.IsAutostartTaskRegisteredAsync()).ReturnsAsync(false); + elevatedTaskService + .Setup(x => x.EnsureAutostartTaskAsync(It.IsAny(), It.Is(args => args.Contains("--autostart", StringComparison.Ordinal)))) + .ReturnsAsync(true); + + var service = CreateService(elevationService, elevatedTaskService); + + var result = await service.EnableAutostartAsync(startMinimized: true); + + Assert.True(result); + elevatedTaskService.Verify( + x => x.EnsureAutostartTaskAsync( + It.IsAny(), + It.Is(args => + args.Contains("--autostart", StringComparison.Ordinal) && + args.Contains("--start-minimized", StringComparison.Ordinal))), + Times.Once); + } + + [Fact] + public async Task DisableAutostartAsync_WhenAdmin_RemovesScheduledTask() + { + var elevationService = new Mock(MockBehavior.Strict); + elevationService.Setup(x => x.IsRunningAsAdministrator()).Returns(true); + + var elevatedTaskService = new Mock(MockBehavior.Strict); + elevatedTaskService.Setup(x => x.IsAutostartTaskRegisteredAsync()).ReturnsAsync(false); + elevatedTaskService.Setup(x => x.RemoveAutostartTaskAsync()).ReturnsAsync(true); + + var service = CreateService(elevationService, elevatedTaskService); + + var result = await service.DisableAutostartAsync(); + + Assert.True(result); + elevatedTaskService.Verify(x => x.RemoveAutostartTaskAsync(), Times.Once); + } + + private static AutostartService CreateService( + Mock? elevationService = null, + Mock? elevatedTaskService = null) + { + var elevation = elevationService ?? new Mock(MockBehavior.Loose); + var elevatedTask = elevatedTaskService ?? new Mock(MockBehavior.Loose); + elevatedTask.Setup(x => x.IsAutostartTaskRegisteredAsync()).ReturnsAsync(false); + + return new AutostartService(NullLogger.Instance, elevation.Object, elevatedTask.Object); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/BaseViewModelStatusTests.cs b/Tests/ThreadPilot.Core.Tests/BaseViewModelStatusTests.cs index cc923af..b60a09f 100644 --- a/Tests/ThreadPilot.Core.Tests/BaseViewModelStatusTests.cs +++ b/Tests/ThreadPilot.Core.Tests/BaseViewModelStatusTests.cs @@ -1,32 +1,32 @@ -namespace ThreadPilot.Core.Tests -{ - using Microsoft.Extensions.Logging.Abstractions; - using ThreadPilot.ViewModels; - - public sealed class BaseViewModelStatusTests - { - [Fact] - public void ClearStatus_DoesNotClearCriticalStatus() - { - var viewModel = new TestViewModel(); - - viewModel.SetCritical("Realtime priority is blocked."); - viewModel.Clear(); - - Assert.Equal("Realtime priority is blocked.", viewModel.StatusMessage); - Assert.False(viewModel.IsBusy); - } - - private sealed class TestViewModel : BaseViewModel - { - public TestViewModel() - : base(NullLogger.Instance) - { - } - - public void SetCritical(string message) => this.SetCriticalStatus(message); - - public void Clear() => this.ClearStatus(); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using ThreadPilot.ViewModels; + + public sealed class BaseViewModelStatusTests + { + [Fact] + public void ClearStatus_DoesNotClearCriticalStatus() + { + var viewModel = new TestViewModel(); + + viewModel.SetCritical("Realtime priority is blocked."); + viewModel.Clear(); + + Assert.Equal("Realtime priority is blocked.", viewModel.StatusMessage); + Assert.False(viewModel.IsBusy); + } + + private sealed class TestViewModel : BaseViewModel + { + public TestViewModel() + : base(NullLogger.Instance) + { + } + + public void SetCritical(string message) => this.SetCriticalStatus(message); + + public void Clear() => this.ClearStatus(); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/CoreMaskServiceTests.cs b/Tests/ThreadPilot.Core.Tests/CoreMaskServiceTests.cs index 94fa0dc..7324bb0 100644 --- a/Tests/ThreadPilot.Core.Tests/CoreMaskServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/CoreMaskServiceTests.cs @@ -1,225 +1,225 @@ -namespace ThreadPilot.Core.Tests -{ - using System.Text.Json; - using Microsoft.Extensions.Logging.Abstractions; - using Moq; - using ThreadPilot.Models; - using ThreadPilot.Services; - - public sealed class CoreMaskServiceTests - { - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - }; - - [Fact] - public async Task InitializeAsync_WhenNoMaskFile_CreatesAllCoresAndNoCoreZero() - { - var masksFilePath = CreateTempMasksPath(); - var service = CreateService(CreateTopology(logicalCoreCount: 4), masksFilePath); - - await service.InitializeAsync(); - - Assert.Contains(service.AvailableMasks, mask => mask.Name == "All Cores"); - var noCoreZero = Assert.Single(service.AvailableMasks, mask => mask.Name == "No Core 0"); - Assert.Equal(new[] { false, true, true, true }, noCoreZero.BoolMask); - } - - [Fact] - public async Task InitializeAsync_WithSmtTopology_CreatesAllNoSmt() - { - var masksFilePath = CreateTempMasksPath(); - var service = CreateService(CreateAmdSmtTopology(physicalCoreCount: 8, threadsPerCore: 2), masksFilePath); - - await service.InitializeAsync(); - - var allNoSmt = Assert.Single(service.AvailableMasks, mask => mask.Name == "All no SMT"); - Assert.Equal(16, allNoSmt.BoolMask.Count); - Assert.Equal(8, allNoSmt.SelectedCoreCount); - Assert.Equal( - Enumerable.Range(0, 16).Select(index => index % 2 == 0), - allNoSmt.BoolMask); - } - - [Fact] - public async Task InitializeAsync_WhenExistingFileHasOnlyAllCores_BackfillsMissingBuiltIns() - { - var masksFilePath = CreateTempMasksPath(); - var existingId = "existing-all-cores"; - await WriteMasksAsync( - masksFilePath, - CreateStoredMask(existingId, "All Cores", [true, true, true, true], isDefault: true)); - var service = CreateService(CreateTopology(logicalCoreCount: 4), masksFilePath); - - await service.InitializeAsync(); - - Assert.Equal(existingId, Assert.Single(service.AvailableMasks, mask => mask.Name == "All Cores").Id); - Assert.Contains(service.AvailableMasks, mask => mask.Name == "No Core 0"); - } - - [Fact] - public async Task InitializeAsync_BackfillDoesNotDuplicateBuiltIns() - { - var masksFilePath = CreateTempMasksPath(); - await WriteMasksAsync( - masksFilePath, - CreateStoredMask("all-cores", "All Cores", [true, true, true, true], isDefault: true), - CreateStoredMask("no-core-zero", "No Core 0", [false, true, true, true])); - var service = CreateService(CreateTopology(logicalCoreCount: 4), masksFilePath); - - await service.InitializeAsync(); - await service.InitializeAsync(); - - Assert.Equal(1, service.AvailableMasks.Count(mask => mask.Name == "All Cores")); - Assert.Equal(1, service.AvailableMasks.Count(mask => mask.Name == "No Core 0")); - } - - [Fact] - public async Task InitializeAsync_BackfillPreservesUserMasks() - { - var masksFilePath = CreateTempMasksPath(); - await WriteMasksAsync( - masksFilePath, - CreateStoredMask("all-cores", "All Cores", [true, true, true, true], isDefault: true), - CreateStoredMask("custom-mask", "My Game Mask", [false, true, true, false])); - var service = CreateService(CreateTopology(logicalCoreCount: 4), masksFilePath); - - await service.InitializeAsync(); - - var customMask = Assert.Single(service.AvailableMasks, mask => mask.Id == "custom-mask"); - Assert.Equal("My Game Mask", customMask.Name); - Assert.Equal(new[] { false, true, true, false }, customMask.BoolMask); - Assert.Contains(service.AvailableMasks, mask => mask.Name == "No Core 0"); - } - - [Fact] - public async Task TopologyDetected_AfterInitialLoad_BackfillsSmtDefaults() - { - var masksFilePath = CreateTempMasksPath(); - await WriteMasksAsync( - masksFilePath, - CreateStoredMask("all-cores", "All Cores", [true, true, true, true], isDefault: true)); - CpuTopologyModel? currentTopology = null; - var topologyService = new Mock(MockBehavior.Strict); - topologyService.SetupGet(service => service.CurrentTopology).Returns(() => currentTopology); - var service = new CoreMaskService( - NullLogger.Instance, - topologyService.Object, - Mock.Of(), - masksFilePath: masksFilePath); - - await service.InitializeAsync(); - Assert.DoesNotContain(service.AvailableMasks, mask => mask.Name == "All no SMT"); - - currentTopology = CreateAmdSmtTopology(physicalCoreCount: 8, threadsPerCore: 2); - topologyService.Raise( - mock => mock.TopologyDetected += null, - new CpuTopologyDetectedEventArgs(currentTopology, successful: true)); - - Assert.True(SpinWait.SpinUntil( - () => service.AvailableMasks.Any(mask => mask.Name == "All no SMT"), - TimeSpan.FromSeconds(3))); - Assert.Equal(1, service.AvailableMasks.Count(mask => mask.Name == "All no SMT")); - } - - private static CoreMaskService CreateService(CpuTopologyModel topology, string masksFilePath) - { - var topologyService = new Mock(MockBehavior.Strict); - topologyService.SetupGet(service => service.CurrentTopology).Returns(topology); - - return new CoreMaskService( - NullLogger.Instance, - topologyService.Object, - Mock.Of(), - masksFilePath: masksFilePath); - } - - private static CpuTopologyModel CreateTopology(int logicalCoreCount) - { - var topology = new CpuTopologyModel - { - CpuBrand = "Generic CPU", - TopologyDetectionSuccessful = true, - }; - - for (var index = 0; index < logicalCoreCount; index++) - { - topology.LogicalCores.Add(new CpuCoreModel - { - LogicalCoreId = index, - PhysicalCoreId = index, - SocketId = 0, - LogicalProcessorName = $"CPU{index}", - }); - } - - return topology; - } - - private static CpuTopologyModel CreateAmdSmtTopology(int physicalCoreCount, int threadsPerCore) - { - var topology = new CpuTopologyModel - { - CpuBrand = "AMD Ryzen", - TopologyDetectionSuccessful = true, - }; - - for (var physicalCore = 0; physicalCore < physicalCoreCount; physicalCore++) - { - var firstLogicalCore = physicalCore * threadsPerCore; - for (var thread = 0; thread < threadsPerCore; thread++) - { - var logicalCore = firstLogicalCore + thread; - topology.LogicalCores.Add(new CpuCoreModel - { - LogicalCoreId = logicalCore, - PhysicalCoreId = physicalCore, - SocketId = 0, - CoreType = CpuCoreType.Zen4, - IsHyperThreaded = threadsPerCore > 1, - HyperThreadSibling = threadsPerCore > 1 - ? firstLogicalCore + ((thread + 1) % threadsPerCore) - : null, - LogicalProcessorName = $"CPU{physicalCore}_T{thread}", - }); - } - } - - return topology; - } - - private static string CreateTempMasksPath() - { - var directory = Path.Combine(Path.GetTempPath(), "ThreadPilot-CoreMaskServiceTests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(directory); - return Path.Combine(directory, "core_masks.json"); - } - - private static object CreateStoredMask( - string id, - string name, - IEnumerable boolMask, - bool isDefault = false) => - new - { - id, - name, - description = $"{name} description", - boolMask = boolMask.ToList(), - profileSchemaVersion = CpuAffinityProfileSchemaVersions.Legacy, - cpuSelection = (CpuSelection?)null, - cpuSelectionMigration = (CpuSelectionMigrationMetadata?)null, - isDefault, - isEnabled = true, - createdAt = DateTime.UtcNow.AddDays(-1), - updatedAt = DateTime.UtcNow.AddDays(-1), - }; - - private static Task WriteMasksAsync(string masksFilePath, params object[] masks) - { - var json = JsonSerializer.Serialize(masks, JsonOptions); - return File.WriteAllTextAsync(masksFilePath, json); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using System.Text.Json; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class CoreMaskServiceTests + { + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + }; + + [Fact] + public async Task InitializeAsync_WhenNoMaskFile_CreatesAllCoresAndNoCoreZero() + { + var masksFilePath = CreateTempMasksPath(); + var service = CreateService(CreateTopology(logicalCoreCount: 4), masksFilePath); + + await service.InitializeAsync(); + + Assert.Contains(service.AvailableMasks, mask => mask.Name == "All Cores"); + var noCoreZero = Assert.Single(service.AvailableMasks, mask => mask.Name == "No Core 0"); + Assert.Equal(new[] { false, true, true, true }, noCoreZero.BoolMask); + } + + [Fact] + public async Task InitializeAsync_WithSmtTopology_CreatesAllNoSmt() + { + var masksFilePath = CreateTempMasksPath(); + var service = CreateService(CreateAmdSmtTopology(physicalCoreCount: 8, threadsPerCore: 2), masksFilePath); + + await service.InitializeAsync(); + + var allNoSmt = Assert.Single(service.AvailableMasks, mask => mask.Name == "All no SMT"); + Assert.Equal(16, allNoSmt.BoolMask.Count); + Assert.Equal(8, allNoSmt.SelectedCoreCount); + Assert.Equal( + Enumerable.Range(0, 16).Select(index => index % 2 == 0), + allNoSmt.BoolMask); + } + + [Fact] + public async Task InitializeAsync_WhenExistingFileHasOnlyAllCores_BackfillsMissingBuiltIns() + { + var masksFilePath = CreateTempMasksPath(); + var existingId = "existing-all-cores"; + await WriteMasksAsync( + masksFilePath, + CreateStoredMask(existingId, "All Cores", [true, true, true, true], isDefault: true)); + var service = CreateService(CreateTopology(logicalCoreCount: 4), masksFilePath); + + await service.InitializeAsync(); + + Assert.Equal(existingId, Assert.Single(service.AvailableMasks, mask => mask.Name == "All Cores").Id); + Assert.Contains(service.AvailableMasks, mask => mask.Name == "No Core 0"); + } + + [Fact] + public async Task InitializeAsync_BackfillDoesNotDuplicateBuiltIns() + { + var masksFilePath = CreateTempMasksPath(); + await WriteMasksAsync( + masksFilePath, + CreateStoredMask("all-cores", "All Cores", [true, true, true, true], isDefault: true), + CreateStoredMask("no-core-zero", "No Core 0", [false, true, true, true])); + var service = CreateService(CreateTopology(logicalCoreCount: 4), masksFilePath); + + await service.InitializeAsync(); + await service.InitializeAsync(); + + Assert.Equal(1, service.AvailableMasks.Count(mask => mask.Name == "All Cores")); + Assert.Equal(1, service.AvailableMasks.Count(mask => mask.Name == "No Core 0")); + } + + [Fact] + public async Task InitializeAsync_BackfillPreservesUserMasks() + { + var masksFilePath = CreateTempMasksPath(); + await WriteMasksAsync( + masksFilePath, + CreateStoredMask("all-cores", "All Cores", [true, true, true, true], isDefault: true), + CreateStoredMask("custom-mask", "My Game Mask", [false, true, true, false])); + var service = CreateService(CreateTopology(logicalCoreCount: 4), masksFilePath); + + await service.InitializeAsync(); + + var customMask = Assert.Single(service.AvailableMasks, mask => mask.Id == "custom-mask"); + Assert.Equal("My Game Mask", customMask.Name); + Assert.Equal(new[] { false, true, true, false }, customMask.BoolMask); + Assert.Contains(service.AvailableMasks, mask => mask.Name == "No Core 0"); + } + + [Fact] + public async Task TopologyDetected_AfterInitialLoad_BackfillsSmtDefaults() + { + var masksFilePath = CreateTempMasksPath(); + await WriteMasksAsync( + masksFilePath, + CreateStoredMask("all-cores", "All Cores", [true, true, true, true], isDefault: true)); + CpuTopologyModel? currentTopology = null; + var topologyService = new Mock(MockBehavior.Strict); + topologyService.SetupGet(service => service.CurrentTopology).Returns(() => currentTopology); + var service = new CoreMaskService( + NullLogger.Instance, + topologyService.Object, + Mock.Of(), + masksFilePath: masksFilePath); + + await service.InitializeAsync(); + Assert.DoesNotContain(service.AvailableMasks, mask => mask.Name == "All no SMT"); + + currentTopology = CreateAmdSmtTopology(physicalCoreCount: 8, threadsPerCore: 2); + topologyService.Raise( + mock => mock.TopologyDetected += null, + new CpuTopologyDetectedEventArgs(currentTopology, successful: true)); + + Assert.True(SpinWait.SpinUntil( + () => service.AvailableMasks.Any(mask => mask.Name == "All no SMT"), + TimeSpan.FromSeconds(3))); + Assert.Equal(1, service.AvailableMasks.Count(mask => mask.Name == "All no SMT")); + } + + private static CoreMaskService CreateService(CpuTopologyModel topology, string masksFilePath) + { + var topologyService = new Mock(MockBehavior.Strict); + topologyService.SetupGet(service => service.CurrentTopology).Returns(topology); + + return new CoreMaskService( + NullLogger.Instance, + topologyService.Object, + Mock.Of(), + masksFilePath: masksFilePath); + } + + private static CpuTopologyModel CreateTopology(int logicalCoreCount) + { + var topology = new CpuTopologyModel + { + CpuBrand = "Generic CPU", + TopologyDetectionSuccessful = true, + }; + + for (var index = 0; index < logicalCoreCount; index++) + { + topology.LogicalCores.Add(new CpuCoreModel + { + LogicalCoreId = index, + PhysicalCoreId = index, + SocketId = 0, + LogicalProcessorName = $"CPU{index}", + }); + } + + return topology; + } + + private static CpuTopologyModel CreateAmdSmtTopology(int physicalCoreCount, int threadsPerCore) + { + var topology = new CpuTopologyModel + { + CpuBrand = "AMD Ryzen", + TopologyDetectionSuccessful = true, + }; + + for (var physicalCore = 0; physicalCore < physicalCoreCount; physicalCore++) + { + var firstLogicalCore = physicalCore * threadsPerCore; + for (var thread = 0; thread < threadsPerCore; thread++) + { + var logicalCore = firstLogicalCore + thread; + topology.LogicalCores.Add(new CpuCoreModel + { + LogicalCoreId = logicalCore, + PhysicalCoreId = physicalCore, + SocketId = 0, + CoreType = CpuCoreType.Zen4, + IsHyperThreaded = threadsPerCore > 1, + HyperThreadSibling = threadsPerCore > 1 + ? firstLogicalCore + ((thread + 1) % threadsPerCore) + : null, + LogicalProcessorName = $"CPU{physicalCore}_T{thread}", + }); + } + } + + return topology; + } + + private static string CreateTempMasksPath() + { + var directory = Path.Combine(Path.GetTempPath(), "ThreadPilot-CoreMaskServiceTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + return Path.Combine(directory, "core_masks.json"); + } + + private static object CreateStoredMask( + string id, + string name, + IEnumerable boolMask, + bool isDefault = false) => + new + { + id, + name, + description = $"{name} description", + boolMask = boolMask.ToList(), + profileSchemaVersion = CpuAffinityProfileSchemaVersions.Legacy, + cpuSelection = (CpuSelection?)null, + cpuSelectionMigration = (CpuSelectionMigrationMetadata?)null, + isDefault, + isEnabled = true, + createdAt = DateTime.UtcNow.AddDays(-1), + updatedAt = DateTime.UtcNow.AddDays(-1), + }; + + private static Task WriteMasksAsync(string masksFilePath, params object[] masks) + { + var json = JsonSerializer.Serialize(masks, JsonOptions); + return File.WriteAllTextAsync(masksFilePath, json); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/CpuPresetGeneratorTests.cs b/Tests/ThreadPilot.Core.Tests/CpuPresetGeneratorTests.cs index 674eaf0..bc06dcf 100644 --- a/Tests/ThreadPilot.Core.Tests/CpuPresetGeneratorTests.cs +++ b/Tests/ThreadPilot.Core.Tests/CpuPresetGeneratorTests.cs @@ -1,359 +1,359 @@ -namespace ThreadPilot.Core.Tests -{ - using ThreadPilot.Models; - using ThreadPilot.Services; - - public sealed class CpuPresetGeneratorTests - { - [Fact] - public void Generate_WithFourCoreEightThreadSmt_GeneratesSafeBasePresets() - { - var topology = CreateSmtTopology(physicalCoreCount: 4, threadsPerCore: 2); - var generator = new CpuPresetGenerator(); - - var presets = generator.Generate(topology); - - AssertPresetIdsContain( - presets, - "all-cores", - "all-physical-cores", - "all-except-cpu0", - "best-gaming", - "safe-compatibility"); - Assert.Equal(4, GetPreset(presets, "all-physical-cores").Selection.LogicalProcessors.Count); - AssertBestGamingSource(GetPreset(presets, "best-gaming"), "all-physical-cores"); - AssertValidPresets(presets, topology); - AssertStableIdsAndOrder(generator, topology, presets); - } - - [Fact] - public void Generate_WithEightCoreEightThreadSmtOff_KeepsBestGamingValid() - { - var topology = CreateSmtTopology(physicalCoreCount: 8, threadsPerCore: 1); - var generator = new CpuPresetGenerator(); - - var presets = generator.Generate(topology); - - var allCores = GetPreset(presets, "all-cores"); - var physical = presets.SingleOrDefault(preset => preset.PresetId == "all-physical-cores"); - if (physical != null) - { - AssertSameSelection(allCores, physical); - Assert.NotEqual(allCores.Reason, physical.Reason); - } - - var bestGaming = GetPreset(presets, "best-gaming"); - Assert.NotEmpty(bestGaming.Selection.LogicalProcessors); - AssertBestGamingSource(bestGaming, "all-physical-cores"); - AssertValidPresets(presets, topology); - } - - [Fact] - public void Generate_WithHybridPAndECoresWithHt_GeneratesHybridPresets() - { - var topology = CreateHybridTopology(pCoreCount: 4, eCoreCount: 4, pCoreThreads: 2); - var generator = new CpuPresetGenerator(); - - var presets = generator.Generate(topology); - - AssertPresetIdsContain(presets, "p-cores-only", "p-cores-no-smt", "e-cores-only"); - Assert.Equal(8, GetPreset(presets, "p-cores-only").Selection.LogicalProcessors.Count); - Assert.Equal(4, GetPreset(presets, "p-cores-no-smt").Selection.LogicalProcessors.Count); - Assert.Equal(4, GetPreset(presets, "e-cores-only").Selection.LogicalProcessors.Count); - AssertBestGamingSource(GetPreset(presets, "best-gaming"), "p-cores-no-smt"); - AssertValidPresets(presets, topology); - } - - [Fact] - public void Generate_WithHybridPAndECoresWithoutHt_HandlesNoSmtDuplicate() - { - var topology = CreateHybridTopology(pCoreCount: 4, eCoreCount: 4, pCoreThreads: 1); - var generator = new CpuPresetGenerator(); - - var presets = generator.Generate(topology); - - var pCoresOnly = GetPreset(presets, "p-cores-only"); - var pCoresNoSmt = presets.SingleOrDefault(preset => preset.PresetId == "p-cores-no-smt"); - if (pCoresNoSmt != null) - { - AssertSameSelection(pCoresOnly, pCoresNoSmt); - Assert.NotEqual(pCoresOnly.Reason, pCoresNoSmt.Reason); - } - - AssertValidPresets(presets, topology); - } - - [Fact] - public void Generate_WithRyzenDualCcdSixPlusSix_GeneratesL3PhysicalPresets() - { - var topology = CreateDualCcdTopology(physicalCoresPerCcd: 6); - var generator = new CpuPresetGenerator(); - - var presets = generator.Generate(topology); - - AssertPresetIdsContain(presets, "l3-group-0-physical", "l3-group-1-physical"); - Assert.Equal(6, GetPreset(presets, "l3-group-0-physical").Selection.LogicalProcessors.Count); - Assert.Equal(6, GetPreset(presets, "l3-group-1-physical").Selection.LogicalProcessors.Count); - AssertBestGamingSource(GetPreset(presets, "best-gaming"), "l3-group-0-physical"); - Assert.Contains("L3", GetPreset(presets, "l3-group-0-physical").Reason, StringComparison.OrdinalIgnoreCase); - AssertValidPresets(presets, topology); - } - - [Fact] - public void Generate_WithRyzenDualCcdEightPlusEight_GeneratesEightPhysicalPerL3Preset() - { - var topology = CreateDualCcdTopology(physicalCoresPerCcd: 8); - var generator = new CpuPresetGenerator(); - - var presets = generator.Generate(topology); - - Assert.Equal(8, GetPreset(presets, "l3-group-0-physical").Selection.LogicalProcessors.Count); - Assert.Equal(8, GetPreset(presets, "l3-group-1-physical").Selection.LogicalProcessors.Count); - AssertBestGamingSource(GetPreset(presets, "best-gaming"), "l3-group-0-physical"); - AssertValidPresets(presets, topology); - } - - [Fact] - public void Generate_WithMoreThan64LogicalProcessors_UsesCpuSelectionWithoutCpu64Alias() - { - var topology = CreateSmtTopology(physicalCoreCount: 40, threadsPerCore: 2); - var generator = new CpuPresetGenerator(); - - var presets = generator.Generate(topology); - - var allCores = GetPreset(presets, "all-cores"); - Assert.Contains(allCores.Selection.LogicalProcessors, processor => processor.GlobalIndex == 64); - Assert.NotEqual( - allCores.Selection.LogicalProcessors.Single(processor => processor.GlobalIndex == 64), - allCores.Selection.LogicalProcessors.Single(processor => processor.GlobalIndex == 0)); - Assert.Null(CpuSelection.ToLegacyAffinityMaskOrNull(allCores.Selection)); - AssertValidPresets(presets, topology); - } - - [Fact] - public void Generate_WhenGeneratedPresetWasDeleted_DoesNotRegenerateIt() - { - var topology = CreateSmtTopology(physicalCoreCount: 4, threadsPerCore: 2); - var generator = new CpuPresetGenerator(); - var options = new CpuPresetGenerationOptions - { - DeletedGeneratedPresetIds = new HashSet(StringComparer.Ordinal) - { - "best-gaming", - }, - }; - - var presets = generator.Generate(topology, options); - - Assert.DoesNotContain(presets, preset => preset.PresetId == "best-gaming"); - AssertPresetIdsContain(presets, "all-cores", "safe-compatibility"); - } - - [Fact] - public void Generate_WithoutCoreIndex_SkipsPhysicalPresets() - { - var topology = CpuTopologySnapshot.Create( - CreateProcessorRefs(8), - signature: CreateSignature(logicalProcessorCount: 8, physicalCoreCount: 0)); - var generator = new CpuPresetGenerator(); - - var presets = generator.Generate(topology); - - Assert.DoesNotContain(presets, preset => preset.PresetId == "all-physical-cores"); - Assert.DoesNotContain(presets, preset => preset.PresetId == "p-cores-no-smt"); - Assert.DoesNotContain(presets, preset => preset.PresetId.StartsWith("l3-group-", StringComparison.Ordinal)); - AssertPresetIdsContain(presets, "all-cores", "all-except-cpu0", "best-gaming", "safe-compatibility"); - AssertBestGamingSource(GetPreset(presets, "best-gaming"), "all-except-cpu0"); - AssertValidPresets(presets, topology); - } - - [Fact] - public void Generate_DoesNotReturnEmptySelections() - { - var topology = CreateHybridTopology(pCoreCount: 2, eCoreCount: 2, pCoreThreads: 2); - var generator = new CpuPresetGenerator(); - - var presets = generator.Generate(topology); - - Assert.All(presets, preset => Assert.NotEmpty(preset.Selection.LogicalProcessors)); - } - - private static CpuPreset GetPreset(IReadOnlyList presets, string presetId) => - presets.Single(preset => preset.PresetId == presetId); - - private static void AssertPresetIdsContain(IReadOnlyList presets, params string[] presetIds) - { - foreach (var presetId in presetIds) - { - Assert.Contains(presets, preset => preset.PresetId == presetId); - } - } - - private static void AssertSameSelection(CpuPreset expected, CpuPreset actual) => - Assert.Equal( - expected.Selection.GlobalLogicalProcessorIndexes, - actual.Selection.GlobalLogicalProcessorIndexes); - - private static void AssertBestGamingSource(CpuPreset bestGaming, string expectedSourcePresetId) - { - Assert.Equal("best-gaming", bestGaming.PresetId); - Assert.Equal(expectedSourcePresetId, bestGaming.SourcePresetId); - Assert.NotEqual(bestGaming.SourcePresetId, bestGaming.Reason); - Assert.False(string.IsNullOrWhiteSpace(bestGaming.Reason)); - } - - private static void AssertStableIdsAndOrder( - CpuPresetGenerator generator, - CpuTopologySnapshot topology, - IReadOnlyList firstRun) - { - var secondRun = generator.Generate(topology); - Assert.Equal( - firstRun.Select(preset => preset.PresetId), - secondRun.Select(preset => preset.PresetId)); - } - - private static void AssertValidPresets(IReadOnlyList presets, CpuTopologySnapshot topology) - { - Assert.NotEmpty(presets); - Assert.Equal(presets.Count, presets.Select(preset => preset.PresetId).Distinct(StringComparer.Ordinal).Count()); - - var topologyProcessors = topology.LogicalProcessors.ToHashSet(); - foreach (var preset in presets) - { - Assert.False(string.IsNullOrWhiteSpace(preset.PresetId)); - Assert.False(string.IsNullOrWhiteSpace(preset.Name)); - Assert.False(string.IsNullOrWhiteSpace(preset.Description)); - Assert.False(string.IsNullOrWhiteSpace(preset.Reason)); - Assert.True(preset.IsGenerated); - Assert.True(preset.IsUserEditable); - Assert.NotEmpty(preset.Selection.LogicalProcessors); - Assert.Equal(topology.Signature, preset.GeneratedByTopologySignature); - Assert.Equal(topology.Signature, preset.Selection.Metadata.TopologySignature); - Assert.All(preset.Selection.LogicalProcessors, processor => Assert.Contains(processor, topologyProcessors)); - } - } - - private static CpuTopologySnapshot CreateSmtTopology(int physicalCoreCount, int threadsPerCore) - { - var processors = new List(); - var coreIndexes = new Dictionary(); - var siblings = new Dictionary>(); - - for (var core = 0; core < physicalCoreCount; core++) - { - var coreProcessors = new List(); - for (var thread = 0; thread < threadsPerCore; thread++) - { - var globalIndex = (core * threadsPerCore) + thread; - var processor = CreateProcessorRef(globalIndex); - processors.Add(processor); - coreIndexes[processor] = core; - coreProcessors.Add(processor); - } - - foreach (var processor in coreProcessors) - { - siblings[processor] = coreProcessors - .Where(sibling => sibling != processor) - .Select(sibling => sibling.GlobalIndex) - .ToList(); - } - } - - return CpuTopologySnapshot.Create( - processors, - signature: CreateSignature(processors.Count, physicalCoreCount), - coreIndexes: coreIndexes, - smtSiblingGlobalIndexes: siblings); - } - - private static CpuTopologySnapshot CreateHybridTopology(int pCoreCount, int eCoreCount, int pCoreThreads) - { - var processors = new List(); - var coreIndexes = new Dictionary(); - var siblings = new Dictionary>(); - var efficiency = new Dictionary(); - - for (var core = 0; core < pCoreCount; core++) - { - var coreProcessors = new List(); - for (var thread = 0; thread < pCoreThreads; thread++) - { - var processor = CreateProcessorRef(processors.Count); - processors.Add(processor); - coreIndexes[processor] = core; - efficiency[processor] = 2; - coreProcessors.Add(processor); - } - - foreach (var processor in coreProcessors) - { - siblings[processor] = coreProcessors - .Where(sibling => sibling != processor) - .Select(sibling => sibling.GlobalIndex) - .ToList(); - } - } - - for (var core = 0; core < eCoreCount; core++) - { - var processor = CreateProcessorRef(processors.Count); - processors.Add(processor); - coreIndexes[processor] = pCoreCount + core; - efficiency[processor] = 0; - siblings[processor] = []; - } - - return CpuTopologySnapshot.Create( - processors, - efficiencyClasses: efficiency, - signature: CreateSignature(processors.Count, pCoreCount + eCoreCount), - coreIndexes: coreIndexes, - smtSiblingGlobalIndexes: siblings); - } - - private static CpuTopologySnapshot CreateDualCcdTopology(int physicalCoresPerCcd) - { - var processorCount = physicalCoresPerCcd * 2; - var processors = CreateProcessorRefs(processorCount).ToList(); - var coreIndexes = processors.ToDictionary(processor => processor, processor => processor.GlobalIndex); - var siblings = processors.ToDictionary( - processor => processor, - _ => (IReadOnlyList)[]); - var l3Indexes = processors.ToDictionary( - processor => processor, - processor => processor.GlobalIndex < physicalCoresPerCcd ? 0 : 1); - - return CpuTopologySnapshot.Create( - processors, - signature: CreateSignature( - logicalProcessorCount: processorCount, - physicalCoreCount: processorCount, - lastLevelCacheGroupCount: 2), - coreIndexes: coreIndexes, - lastLevelCacheIndexes: l3Indexes, - smtSiblingGlobalIndexes: siblings); - } - - private static IEnumerable CreateProcessorRefs(int count) => - Enumerable.Range(0, count).Select(CreateProcessorRef); - - private static ProcessorRef CreateProcessorRef(int globalIndex) => - new((ushort)(globalIndex / 64), (byte)(globalIndex % 64), globalIndex); - - private static CpuTopologySignature CreateSignature( - int logicalProcessorCount, - int physicalCoreCount, - int lastLevelCacheGroupCount = 0) => - new() - { - CpuBrand = "Synthetic CPU", - LogicalProcessorCount = logicalProcessorCount, - PhysicalCoreCount = physicalCoreCount, - ProcessorGroupCount = Math.Max(1, (logicalProcessorCount + 63) / 64), - LastLevelCacheGroupCount = lastLevelCacheGroupCount, - Source = "Test", - }; - } -} +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class CpuPresetGeneratorTests + { + [Fact] + public void Generate_WithFourCoreEightThreadSmt_GeneratesSafeBasePresets() + { + var topology = CreateSmtTopology(physicalCoreCount: 4, threadsPerCore: 2); + var generator = new CpuPresetGenerator(); + + var presets = generator.Generate(topology); + + AssertPresetIdsContain( + presets, + "all-cores", + "all-physical-cores", + "all-except-cpu0", + "best-gaming", + "safe-compatibility"); + Assert.Equal(4, GetPreset(presets, "all-physical-cores").Selection.LogicalProcessors.Count); + AssertBestGamingSource(GetPreset(presets, "best-gaming"), "all-physical-cores"); + AssertValidPresets(presets, topology); + AssertStableIdsAndOrder(generator, topology, presets); + } + + [Fact] + public void Generate_WithEightCoreEightThreadSmtOff_KeepsBestGamingValid() + { + var topology = CreateSmtTopology(physicalCoreCount: 8, threadsPerCore: 1); + var generator = new CpuPresetGenerator(); + + var presets = generator.Generate(topology); + + var allCores = GetPreset(presets, "all-cores"); + var physical = presets.SingleOrDefault(preset => preset.PresetId == "all-physical-cores"); + if (physical != null) + { + AssertSameSelection(allCores, physical); + Assert.NotEqual(allCores.Reason, physical.Reason); + } + + var bestGaming = GetPreset(presets, "best-gaming"); + Assert.NotEmpty(bestGaming.Selection.LogicalProcessors); + AssertBestGamingSource(bestGaming, "all-physical-cores"); + AssertValidPresets(presets, topology); + } + + [Fact] + public void Generate_WithHybridPAndECoresWithHt_GeneratesHybridPresets() + { + var topology = CreateHybridTopology(pCoreCount: 4, eCoreCount: 4, pCoreThreads: 2); + var generator = new CpuPresetGenerator(); + + var presets = generator.Generate(topology); + + AssertPresetIdsContain(presets, "p-cores-only", "p-cores-no-smt", "e-cores-only"); + Assert.Equal(8, GetPreset(presets, "p-cores-only").Selection.LogicalProcessors.Count); + Assert.Equal(4, GetPreset(presets, "p-cores-no-smt").Selection.LogicalProcessors.Count); + Assert.Equal(4, GetPreset(presets, "e-cores-only").Selection.LogicalProcessors.Count); + AssertBestGamingSource(GetPreset(presets, "best-gaming"), "p-cores-no-smt"); + AssertValidPresets(presets, topology); + } + + [Fact] + public void Generate_WithHybridPAndECoresWithoutHt_HandlesNoSmtDuplicate() + { + var topology = CreateHybridTopology(pCoreCount: 4, eCoreCount: 4, pCoreThreads: 1); + var generator = new CpuPresetGenerator(); + + var presets = generator.Generate(topology); + + var pCoresOnly = GetPreset(presets, "p-cores-only"); + var pCoresNoSmt = presets.SingleOrDefault(preset => preset.PresetId == "p-cores-no-smt"); + if (pCoresNoSmt != null) + { + AssertSameSelection(pCoresOnly, pCoresNoSmt); + Assert.NotEqual(pCoresOnly.Reason, pCoresNoSmt.Reason); + } + + AssertValidPresets(presets, topology); + } + + [Fact] + public void Generate_WithRyzenDualCcdSixPlusSix_GeneratesL3PhysicalPresets() + { + var topology = CreateDualCcdTopology(physicalCoresPerCcd: 6); + var generator = new CpuPresetGenerator(); + + var presets = generator.Generate(topology); + + AssertPresetIdsContain(presets, "l3-group-0-physical", "l3-group-1-physical"); + Assert.Equal(6, GetPreset(presets, "l3-group-0-physical").Selection.LogicalProcessors.Count); + Assert.Equal(6, GetPreset(presets, "l3-group-1-physical").Selection.LogicalProcessors.Count); + AssertBestGamingSource(GetPreset(presets, "best-gaming"), "l3-group-0-physical"); + Assert.Contains("L3", GetPreset(presets, "l3-group-0-physical").Reason, StringComparison.OrdinalIgnoreCase); + AssertValidPresets(presets, topology); + } + + [Fact] + public void Generate_WithRyzenDualCcdEightPlusEight_GeneratesEightPhysicalPerL3Preset() + { + var topology = CreateDualCcdTopology(physicalCoresPerCcd: 8); + var generator = new CpuPresetGenerator(); + + var presets = generator.Generate(topology); + + Assert.Equal(8, GetPreset(presets, "l3-group-0-physical").Selection.LogicalProcessors.Count); + Assert.Equal(8, GetPreset(presets, "l3-group-1-physical").Selection.LogicalProcessors.Count); + AssertBestGamingSource(GetPreset(presets, "best-gaming"), "l3-group-0-physical"); + AssertValidPresets(presets, topology); + } + + [Fact] + public void Generate_WithMoreThan64LogicalProcessors_UsesCpuSelectionWithoutCpu64Alias() + { + var topology = CreateSmtTopology(physicalCoreCount: 40, threadsPerCore: 2); + var generator = new CpuPresetGenerator(); + + var presets = generator.Generate(topology); + + var allCores = GetPreset(presets, "all-cores"); + Assert.Contains(allCores.Selection.LogicalProcessors, processor => processor.GlobalIndex == 64); + Assert.NotEqual( + allCores.Selection.LogicalProcessors.Single(processor => processor.GlobalIndex == 64), + allCores.Selection.LogicalProcessors.Single(processor => processor.GlobalIndex == 0)); + Assert.Null(CpuSelection.ToLegacyAffinityMaskOrNull(allCores.Selection)); + AssertValidPresets(presets, topology); + } + + [Fact] + public void Generate_WhenGeneratedPresetWasDeleted_DoesNotRegenerateIt() + { + var topology = CreateSmtTopology(physicalCoreCount: 4, threadsPerCore: 2); + var generator = new CpuPresetGenerator(); + var options = new CpuPresetGenerationOptions + { + DeletedGeneratedPresetIds = new HashSet(StringComparer.Ordinal) + { + "best-gaming", + }, + }; + + var presets = generator.Generate(topology, options); + + Assert.DoesNotContain(presets, preset => preset.PresetId == "best-gaming"); + AssertPresetIdsContain(presets, "all-cores", "safe-compatibility"); + } + + [Fact] + public void Generate_WithoutCoreIndex_SkipsPhysicalPresets() + { + var topology = CpuTopologySnapshot.Create( + CreateProcessorRefs(8), + signature: CreateSignature(logicalProcessorCount: 8, physicalCoreCount: 0)); + var generator = new CpuPresetGenerator(); + + var presets = generator.Generate(topology); + + Assert.DoesNotContain(presets, preset => preset.PresetId == "all-physical-cores"); + Assert.DoesNotContain(presets, preset => preset.PresetId == "p-cores-no-smt"); + Assert.DoesNotContain(presets, preset => preset.PresetId.StartsWith("l3-group-", StringComparison.Ordinal)); + AssertPresetIdsContain(presets, "all-cores", "all-except-cpu0", "best-gaming", "safe-compatibility"); + AssertBestGamingSource(GetPreset(presets, "best-gaming"), "all-except-cpu0"); + AssertValidPresets(presets, topology); + } + + [Fact] + public void Generate_DoesNotReturnEmptySelections() + { + var topology = CreateHybridTopology(pCoreCount: 2, eCoreCount: 2, pCoreThreads: 2); + var generator = new CpuPresetGenerator(); + + var presets = generator.Generate(topology); + + Assert.All(presets, preset => Assert.NotEmpty(preset.Selection.LogicalProcessors)); + } + + private static CpuPreset GetPreset(IReadOnlyList presets, string presetId) => + presets.Single(preset => preset.PresetId == presetId); + + private static void AssertPresetIdsContain(IReadOnlyList presets, params string[] presetIds) + { + foreach (var presetId in presetIds) + { + Assert.Contains(presets, preset => preset.PresetId == presetId); + } + } + + private static void AssertSameSelection(CpuPreset expected, CpuPreset actual) => + Assert.Equal( + expected.Selection.GlobalLogicalProcessorIndexes, + actual.Selection.GlobalLogicalProcessorIndexes); + + private static void AssertBestGamingSource(CpuPreset bestGaming, string expectedSourcePresetId) + { + Assert.Equal("best-gaming", bestGaming.PresetId); + Assert.Equal(expectedSourcePresetId, bestGaming.SourcePresetId); + Assert.NotEqual(bestGaming.SourcePresetId, bestGaming.Reason); + Assert.False(string.IsNullOrWhiteSpace(bestGaming.Reason)); + } + + private static void AssertStableIdsAndOrder( + CpuPresetGenerator generator, + CpuTopologySnapshot topology, + IReadOnlyList firstRun) + { + var secondRun = generator.Generate(topology); + Assert.Equal( + firstRun.Select(preset => preset.PresetId), + secondRun.Select(preset => preset.PresetId)); + } + + private static void AssertValidPresets(IReadOnlyList presets, CpuTopologySnapshot topology) + { + Assert.NotEmpty(presets); + Assert.Equal(presets.Count, presets.Select(preset => preset.PresetId).Distinct(StringComparer.Ordinal).Count()); + + var topologyProcessors = topology.LogicalProcessors.ToHashSet(); + foreach (var preset in presets) + { + Assert.False(string.IsNullOrWhiteSpace(preset.PresetId)); + Assert.False(string.IsNullOrWhiteSpace(preset.Name)); + Assert.False(string.IsNullOrWhiteSpace(preset.Description)); + Assert.False(string.IsNullOrWhiteSpace(preset.Reason)); + Assert.True(preset.IsGenerated); + Assert.True(preset.IsUserEditable); + Assert.NotEmpty(preset.Selection.LogicalProcessors); + Assert.Equal(topology.Signature, preset.GeneratedByTopologySignature); + Assert.Equal(topology.Signature, preset.Selection.Metadata.TopologySignature); + Assert.All(preset.Selection.LogicalProcessors, processor => Assert.Contains(processor, topologyProcessors)); + } + } + + private static CpuTopologySnapshot CreateSmtTopology(int physicalCoreCount, int threadsPerCore) + { + var processors = new List(); + var coreIndexes = new Dictionary(); + var siblings = new Dictionary>(); + + for (var core = 0; core < physicalCoreCount; core++) + { + var coreProcessors = new List(); + for (var thread = 0; thread < threadsPerCore; thread++) + { + var globalIndex = (core * threadsPerCore) + thread; + var processor = CreateProcessorRef(globalIndex); + processors.Add(processor); + coreIndexes[processor] = core; + coreProcessors.Add(processor); + } + + foreach (var processor in coreProcessors) + { + siblings[processor] = coreProcessors + .Where(sibling => sibling != processor) + .Select(sibling => sibling.GlobalIndex) + .ToList(); + } + } + + return CpuTopologySnapshot.Create( + processors, + signature: CreateSignature(processors.Count, physicalCoreCount), + coreIndexes: coreIndexes, + smtSiblingGlobalIndexes: siblings); + } + + private static CpuTopologySnapshot CreateHybridTopology(int pCoreCount, int eCoreCount, int pCoreThreads) + { + var processors = new List(); + var coreIndexes = new Dictionary(); + var siblings = new Dictionary>(); + var efficiency = new Dictionary(); + + for (var core = 0; core < pCoreCount; core++) + { + var coreProcessors = new List(); + for (var thread = 0; thread < pCoreThreads; thread++) + { + var processor = CreateProcessorRef(processors.Count); + processors.Add(processor); + coreIndexes[processor] = core; + efficiency[processor] = 2; + coreProcessors.Add(processor); + } + + foreach (var processor in coreProcessors) + { + siblings[processor] = coreProcessors + .Where(sibling => sibling != processor) + .Select(sibling => sibling.GlobalIndex) + .ToList(); + } + } + + for (var core = 0; core < eCoreCount; core++) + { + var processor = CreateProcessorRef(processors.Count); + processors.Add(processor); + coreIndexes[processor] = pCoreCount + core; + efficiency[processor] = 0; + siblings[processor] = []; + } + + return CpuTopologySnapshot.Create( + processors, + efficiencyClasses: efficiency, + signature: CreateSignature(processors.Count, pCoreCount + eCoreCount), + coreIndexes: coreIndexes, + smtSiblingGlobalIndexes: siblings); + } + + private static CpuTopologySnapshot CreateDualCcdTopology(int physicalCoresPerCcd) + { + var processorCount = physicalCoresPerCcd * 2; + var processors = CreateProcessorRefs(processorCount).ToList(); + var coreIndexes = processors.ToDictionary(processor => processor, processor => processor.GlobalIndex); + var siblings = processors.ToDictionary( + processor => processor, + _ => (IReadOnlyList)[]); + var l3Indexes = processors.ToDictionary( + processor => processor, + processor => processor.GlobalIndex < physicalCoresPerCcd ? 0 : 1); + + return CpuTopologySnapshot.Create( + processors, + signature: CreateSignature( + logicalProcessorCount: processorCount, + physicalCoreCount: processorCount, + lastLevelCacheGroupCount: 2), + coreIndexes: coreIndexes, + lastLevelCacheIndexes: l3Indexes, + smtSiblingGlobalIndexes: siblings); + } + + private static IEnumerable CreateProcessorRefs(int count) => + Enumerable.Range(0, count).Select(CreateProcessorRef); + + private static ProcessorRef CreateProcessorRef(int globalIndex) => + new((ushort)(globalIndex / 64), (byte)(globalIndex % 64), globalIndex); + + private static CpuTopologySignature CreateSignature( + int logicalProcessorCount, + int physicalCoreCount, + int lastLevelCacheGroupCount = 0) => + new() + { + CpuBrand = "Synthetic CPU", + LogicalProcessorCount = logicalProcessorCount, + PhysicalCoreCount = physicalCoreCount, + ProcessorGroupCount = Math.Max(1, (logicalProcessorCount + 63) / 64), + LastLevelCacheGroupCount = lastLevelCacheGroupCount, + Source = "Test", + }; + } +} diff --git a/Tests/ThreadPilot.Core.Tests/CpuSelectionMigrationServiceTests.cs b/Tests/ThreadPilot.Core.Tests/CpuSelectionMigrationServiceTests.cs index fef721d..25188de 100644 --- a/Tests/ThreadPilot.Core.Tests/CpuSelectionMigrationServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/CpuSelectionMigrationServiceTests.cs @@ -1,261 +1,261 @@ -namespace ThreadPilot.Core.Tests -{ - using System.Diagnostics; - using System.Text.Json; - using ThreadPilot.Models; - using ThreadPilot.Services; - - public sealed class CpuSelectionMigrationServiceTests - { - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true, - ReadCommentHandling = JsonCommentHandling.Skip, - AllowTrailingCommas = true, - }; - - [Fact] - public void MigrateFromLegacyAffinityMask_WithSingleGroupBelow64_SelectsExpectedProcessors() - { - var topology = CreateTopology(8); - var service = new CpuSelectionMigrationService(); - - var result = service.MigrateFromLegacyAffinityMask(0b0101, topology); - - Assert.Equal([0, 2], result.Selection.GlobalLogicalProcessorIndexes); - Assert.True(result.Metadata.CreatedFromLegacyAffinityMask); - Assert.False(result.Metadata.ReviewRequired); - Assert.Equal(0b0101, service.BuildLegacyAffinityMaskIfRepresentable(result.Selection)); - } - - [Fact] - public void MigrateFromLegacyAffinityMask_OnTopologyAbove64_DoesNotAliasCpu64ToCpu0() - { - var topology = CreateTopology(65); - var service = new CpuSelectionMigrationService(); - - var result = service.MigrateFromLegacyAffinityMask(1, topology); - var cpu64Selection = CpuSelection.FromProcessors( - [topology.LogicalProcessors.Single(processor => processor.GlobalIndex == 64)], - topology); - - Assert.Equal([0], result.Selection.GlobalLogicalProcessorIndexes); - Assert.DoesNotContain(result.Selection.LogicalProcessors, processor => processor.GlobalIndex == 64); - Assert.Null(service.BuildLegacyAffinityMaskIfRepresentable(cpu64Selection)); - } - - [Fact] - public void BuildLegacyAffinityMaskIfRepresentable_WithGroupOneCpuZero_ReturnsNull() - { - var group1Cpu0 = new ProcessorRef(1, 0, 64); - var topology = CpuTopologySnapshot.Create([new ProcessorRef(0, 0, 0), group1Cpu0]); - var selection = CpuSelection.FromProcessors([group1Cpu0], topology); - var service = new CpuSelectionMigrationService(); - - var legacyMask = service.BuildLegacyAffinityMaskIfRepresentable(selection); - - Assert.Null(legacyMask); - } - - [Fact] - public void MigrateFromLegacyCoreMask_WhenShorterThanTopology_SelectsPresentIndexesAndRequiresReview() - { - var topology = CreateTopology(4); - var service = new CpuSelectionMigrationService(); - - var result = service.MigrateFromLegacyCoreMask([true, false], topology); - - Assert.Equal([0], result.Selection.GlobalLogicalProcessorIndexes); - Assert.True(result.Metadata.CreatedFromLegacyCoreMask); - Assert.True(result.Metadata.ReviewRequired); - } - - [Fact] - public void MigrateFromLegacyCoreMask_WhenLongerThanTopology_IgnoresExtrasAndRequiresReview() - { - var topology = CreateTopology(2); - var service = new CpuSelectionMigrationService(); - - var result = service.MigrateFromLegacyCoreMask([false, true, true, true], topology); - - Assert.Equal([1], result.Selection.GlobalLogicalProcessorIndexes); - Assert.True(result.Metadata.ReviewRequired); - } - - [Fact] - public void MigrateProcessProfile_WithExistingCpuSelection_DoesNotOverwriteFromLegacyMask() - { - var topology = CreateTopology(4); - var existingSelection = CpuSelection.FromProcessors([topology.LogicalProcessors[1]], topology); - var profile = new ProcessProfileSnapshot - { - ProcessName = "game.exe", - Priority = ProcessPriorityClass.High, - ProcessorAffinity = 0b0101, - CpuSelection = existingSelection, - }; - var service = new CpuSelectionMigrationService(); - - var migrated = service.MigrateProcessProfile(profile, topology); - - Assert.Equal([1], migrated.CpuSelection!.GlobalLogicalProcessorIndexes); - Assert.Equal(0b0101, migrated.ProcessorAffinity); - } - - [Fact] - public void ShouldRequireReview_TracksTopologySignatureChanges() - { - var topology = CreateTopology(4); - var changedTopology = CreateTopology(6); - var selection = CpuSelection.FromProcessors([topology.LogicalProcessors[0]], topology); - var service = new CpuSelectionMigrationService(); - - Assert.False(service.ShouldRequireReview(selection, topology.Signature, topology)); - Assert.True(service.ShouldRequireReview(selection, topology.Signature, changedTopology)); - } - - [Fact] - public void PrepareProcessProfileForSave_WithSingleGroupBelow64_SavesLegacyMask() - { - var topology = CreateTopology(4); - var selection = CpuSelection.FromProcessors([topology.LogicalProcessors[0], topology.LogicalProcessors[2]], topology); - var profile = new ProcessProfileSnapshot - { - ProcessName = "game.exe", - Priority = ProcessPriorityClass.Normal, - ProcessorAffinity = 0, - CpuSelection = selection, - }; - var service = new CpuSelectionMigrationService(); - - var prepared = service.PrepareProcessProfileForSave(profile, topology); - - Assert.Equal(CpuAffinityProfileSchemaVersions.CpuSelection, prepared.ProfileSchemaVersion); - Assert.Equal(0b0101, prepared.ProcessorAffinity); - Assert.NotNull(prepared.CpuSelection); - } - - [Fact] - public void PrepareProcessProfileForSave_WithCpu64_DoesNotProduceLegacyMask() - { - var topology = CreateTopology(65); - var selection = CpuSelection.FromProcessors([topology.LogicalProcessors[64]], topology); - var profile = new ProcessProfileSnapshot - { - ProcessName = "game.exe", - Priority = ProcessPriorityClass.Normal, - ProcessorAffinity = 0b11, - CpuSelection = selection, - }; - var service = new CpuSelectionMigrationService(); - - var prepared = service.PrepareProcessProfileForSave(profile, topology); - - Assert.Equal(0b11, prepared.ProcessorAffinity); - Assert.Null(service.BuildLegacyAffinityMaskIfRepresentable(prepared.CpuSelection!)); - } - - [Fact] - public void LegacyProcessProfileWithoutSchemaVersion_DeserializesAsVersionOne() - { - const string json = """ - { - "processName": "game.exe", - "priority": 2, - "processorAffinity": 5 - } - """; - - var profile = JsonSerializer.Deserialize(json, JsonOptions); - - Assert.NotNull(profile); - Assert.Equal(CpuAffinityProfileSchemaVersions.Legacy, profile.ProfileSchemaVersion); - Assert.Equal(5, profile.ProcessorAffinity); - } - - [Fact] - public void ProcessProfileWithCpuSelection_DeserializesAsVersionTwo() - { - var topology = CreateTopology(2); - var profile = new ProcessProfileSnapshot - { - ProcessName = "game.exe", - Priority = ProcessPriorityClass.High, - ProcessorAffinity = 1, - ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection, - CpuSelection = CpuSelection.FromProcessors([topology.LogicalProcessors[0]], topology), - }; - - var json = JsonSerializer.Serialize(profile); - var deserialized = JsonSerializer.Deserialize(json, JsonOptions); - - Assert.NotNull(deserialized); - Assert.Equal(CpuAffinityProfileSchemaVersions.CpuSelection, deserialized.ProfileSchemaVersion); - Assert.NotNull(deserialized.CpuSelection); - Assert.Equal([0], deserialized.CpuSelection!.GlobalLogicalProcessorIndexes); - } - - [Fact] - public void LegacyCoreMaskWithoutSchemaVersion_DeserializesAsVersionOne() - { - const string json = """ - { - "id": "mask-1", - "name": "Legacy mask", - "description": "legacy", - "boolMask": [true, false, true], - "isDefault": false, - "isEnabled": true - } - """; - - var mask = JsonSerializer.Deserialize(json, JsonOptions); - - Assert.NotNull(mask); - Assert.Equal(CpuAffinityProfileSchemaVersions.Legacy, mask.ProfileSchemaVersion); - Assert.Equal([true, false, true], mask.BoolMask.ToArray()); - } - - [Fact] - public void CoreMaskWithCpuSelection_DeserializesAsVersionTwo() - { - var topology = CreateTopology(2); - var mask = new CoreMask - { - Name = "V2 mask", - ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection, - CpuSelection = CpuSelection.FromProcessors([topology.LogicalProcessors[1]], topology), - }; - mask.BoolMask.Add(false); - mask.BoolMask.Add(true); - - var json = JsonSerializer.Serialize(mask); - var deserialized = JsonSerializer.Deserialize(json, JsonOptions); - - Assert.NotNull(deserialized); - Assert.Equal(CpuAffinityProfileSchemaVersions.CpuSelection, deserialized.ProfileSchemaVersion); - Assert.NotNull(deserialized.CpuSelection); - Assert.Equal([1], deserialized.CpuSelection!.GlobalLogicalProcessorIndexes); - Assert.Equal([false, true], deserialized.BoolMask.ToArray()); - } - - private static CpuTopologySnapshot CreateTopology(int processorCount) - { - var processors = Enumerable - .Range(0, processorCount) - .Select(index => new ProcessorRef((ushort)(index / 64), (byte)(index % 64), index)) - .ToList(); - - return CpuTopologySnapshot.Create( - processors, - signature: new CpuTopologySignature - { - CpuBrand = "Synthetic CPU", - LogicalProcessorCount = processorCount, - PhysicalCoreCount = processorCount, - ProcessorGroupCount = Math.Max(1, (processorCount + 63) / 64), - Source = "Test", - }); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using System.Diagnostics; + using System.Text.Json; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class CpuSelectionMigrationServiceTests + { + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + [Fact] + public void MigrateFromLegacyAffinityMask_WithSingleGroupBelow64_SelectsExpectedProcessors() + { + var topology = CreateTopology(8); + var service = new CpuSelectionMigrationService(); + + var result = service.MigrateFromLegacyAffinityMask(0b0101, topology); + + Assert.Equal([0, 2], result.Selection.GlobalLogicalProcessorIndexes); + Assert.True(result.Metadata.CreatedFromLegacyAffinityMask); + Assert.False(result.Metadata.ReviewRequired); + Assert.Equal(0b0101, service.BuildLegacyAffinityMaskIfRepresentable(result.Selection)); + } + + [Fact] + public void MigrateFromLegacyAffinityMask_OnTopologyAbove64_DoesNotAliasCpu64ToCpu0() + { + var topology = CreateTopology(65); + var service = new CpuSelectionMigrationService(); + + var result = service.MigrateFromLegacyAffinityMask(1, topology); + var cpu64Selection = CpuSelection.FromProcessors( + [topology.LogicalProcessors.Single(processor => processor.GlobalIndex == 64)], + topology); + + Assert.Equal([0], result.Selection.GlobalLogicalProcessorIndexes); + Assert.DoesNotContain(result.Selection.LogicalProcessors, processor => processor.GlobalIndex == 64); + Assert.Null(service.BuildLegacyAffinityMaskIfRepresentable(cpu64Selection)); + } + + [Fact] + public void BuildLegacyAffinityMaskIfRepresentable_WithGroupOneCpuZero_ReturnsNull() + { + var group1Cpu0 = new ProcessorRef(1, 0, 64); + var topology = CpuTopologySnapshot.Create([new ProcessorRef(0, 0, 0), group1Cpu0]); + var selection = CpuSelection.FromProcessors([group1Cpu0], topology); + var service = new CpuSelectionMigrationService(); + + var legacyMask = service.BuildLegacyAffinityMaskIfRepresentable(selection); + + Assert.Null(legacyMask); + } + + [Fact] + public void MigrateFromLegacyCoreMask_WhenShorterThanTopology_SelectsPresentIndexesAndRequiresReview() + { + var topology = CreateTopology(4); + var service = new CpuSelectionMigrationService(); + + var result = service.MigrateFromLegacyCoreMask([true, false], topology); + + Assert.Equal([0], result.Selection.GlobalLogicalProcessorIndexes); + Assert.True(result.Metadata.CreatedFromLegacyCoreMask); + Assert.True(result.Metadata.ReviewRequired); + } + + [Fact] + public void MigrateFromLegacyCoreMask_WhenLongerThanTopology_IgnoresExtrasAndRequiresReview() + { + var topology = CreateTopology(2); + var service = new CpuSelectionMigrationService(); + + var result = service.MigrateFromLegacyCoreMask([false, true, true, true], topology); + + Assert.Equal([1], result.Selection.GlobalLogicalProcessorIndexes); + Assert.True(result.Metadata.ReviewRequired); + } + + [Fact] + public void MigrateProcessProfile_WithExistingCpuSelection_DoesNotOverwriteFromLegacyMask() + { + var topology = CreateTopology(4); + var existingSelection = CpuSelection.FromProcessors([topology.LogicalProcessors[1]], topology); + var profile = new ProcessProfileSnapshot + { + ProcessName = "game.exe", + Priority = ProcessPriorityClass.High, + ProcessorAffinity = 0b0101, + CpuSelection = existingSelection, + }; + var service = new CpuSelectionMigrationService(); + + var migrated = service.MigrateProcessProfile(profile, topology); + + Assert.Equal([1], migrated.CpuSelection!.GlobalLogicalProcessorIndexes); + Assert.Equal(0b0101, migrated.ProcessorAffinity); + } + + [Fact] + public void ShouldRequireReview_TracksTopologySignatureChanges() + { + var topology = CreateTopology(4); + var changedTopology = CreateTopology(6); + var selection = CpuSelection.FromProcessors([topology.LogicalProcessors[0]], topology); + var service = new CpuSelectionMigrationService(); + + Assert.False(service.ShouldRequireReview(selection, topology.Signature, topology)); + Assert.True(service.ShouldRequireReview(selection, topology.Signature, changedTopology)); + } + + [Fact] + public void PrepareProcessProfileForSave_WithSingleGroupBelow64_SavesLegacyMask() + { + var topology = CreateTopology(4); + var selection = CpuSelection.FromProcessors([topology.LogicalProcessors[0], topology.LogicalProcessors[2]], topology); + var profile = new ProcessProfileSnapshot + { + ProcessName = "game.exe", + Priority = ProcessPriorityClass.Normal, + ProcessorAffinity = 0, + CpuSelection = selection, + }; + var service = new CpuSelectionMigrationService(); + + var prepared = service.PrepareProcessProfileForSave(profile, topology); + + Assert.Equal(CpuAffinityProfileSchemaVersions.CpuSelection, prepared.ProfileSchemaVersion); + Assert.Equal(0b0101, prepared.ProcessorAffinity); + Assert.NotNull(prepared.CpuSelection); + } + + [Fact] + public void PrepareProcessProfileForSave_WithCpu64_DoesNotProduceLegacyMask() + { + var topology = CreateTopology(65); + var selection = CpuSelection.FromProcessors([topology.LogicalProcessors[64]], topology); + var profile = new ProcessProfileSnapshot + { + ProcessName = "game.exe", + Priority = ProcessPriorityClass.Normal, + ProcessorAffinity = 0b11, + CpuSelection = selection, + }; + var service = new CpuSelectionMigrationService(); + + var prepared = service.PrepareProcessProfileForSave(profile, topology); + + Assert.Equal(0b11, prepared.ProcessorAffinity); + Assert.Null(service.BuildLegacyAffinityMaskIfRepresentable(prepared.CpuSelection!)); + } + + [Fact] + public void LegacyProcessProfileWithoutSchemaVersion_DeserializesAsVersionOne() + { + const string json = """ + { + "processName": "game.exe", + "priority": 2, + "processorAffinity": 5 + } + """; + + var profile = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(profile); + Assert.Equal(CpuAffinityProfileSchemaVersions.Legacy, profile.ProfileSchemaVersion); + Assert.Equal(5, profile.ProcessorAffinity); + } + + [Fact] + public void ProcessProfileWithCpuSelection_DeserializesAsVersionTwo() + { + var topology = CreateTopology(2); + var profile = new ProcessProfileSnapshot + { + ProcessName = "game.exe", + Priority = ProcessPriorityClass.High, + ProcessorAffinity = 1, + ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection, + CpuSelection = CpuSelection.FromProcessors([topology.LogicalProcessors[0]], topology), + }; + + var json = JsonSerializer.Serialize(profile); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(deserialized); + Assert.Equal(CpuAffinityProfileSchemaVersions.CpuSelection, deserialized.ProfileSchemaVersion); + Assert.NotNull(deserialized.CpuSelection); + Assert.Equal([0], deserialized.CpuSelection!.GlobalLogicalProcessorIndexes); + } + + [Fact] + public void LegacyCoreMaskWithoutSchemaVersion_DeserializesAsVersionOne() + { + const string json = """ + { + "id": "mask-1", + "name": "Legacy mask", + "description": "legacy", + "boolMask": [true, false, true], + "isDefault": false, + "isEnabled": true + } + """; + + var mask = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(mask); + Assert.Equal(CpuAffinityProfileSchemaVersions.Legacy, mask.ProfileSchemaVersion); + Assert.Equal([true, false, true], mask.BoolMask.ToArray()); + } + + [Fact] + public void CoreMaskWithCpuSelection_DeserializesAsVersionTwo() + { + var topology = CreateTopology(2); + var mask = new CoreMask + { + Name = "V2 mask", + ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection, + CpuSelection = CpuSelection.FromProcessors([topology.LogicalProcessors[1]], topology), + }; + mask.BoolMask.Add(false); + mask.BoolMask.Add(true); + + var json = JsonSerializer.Serialize(mask); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(deserialized); + Assert.Equal(CpuAffinityProfileSchemaVersions.CpuSelection, deserialized.ProfileSchemaVersion); + Assert.NotNull(deserialized.CpuSelection); + Assert.Equal([1], deserialized.CpuSelection!.GlobalLogicalProcessorIndexes); + Assert.Equal([false, true], deserialized.BoolMask.ToArray()); + } + + private static CpuTopologySnapshot CreateTopology(int processorCount) + { + var processors = Enumerable + .Range(0, processorCount) + .Select(index => new ProcessorRef((ushort)(index / 64), (byte)(index % 64), index)) + .ToList(); + + return CpuTopologySnapshot.Create( + processors, + signature: new CpuTopologySignature + { + CpuBrand = "Synthetic CPU", + LogicalProcessorCount = processorCount, + PhysicalCoreCount = processorCount, + ProcessorGroupCount = Math.Max(1, (processorCount + 63) / 64), + Source = "Test", + }); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/CpuSelectionTests.cs b/Tests/ThreadPilot.Core.Tests/CpuSelectionTests.cs index 2ea8da7..8dc2399 100644 --- a/Tests/ThreadPilot.Core.Tests/CpuSelectionTests.cs +++ b/Tests/ThreadPilot.Core.Tests/CpuSelectionTests.cs @@ -1,230 +1,230 @@ -namespace ThreadPilot.Core.Tests -{ - using ThreadPilot.Models; - - public sealed class CpuSelectionTests - { - [Fact] - public void CpuSelection_WithGlobalIndex64_DoesNotAliasCpu0InLegacyMask() - { - var topology = CpuTopologySnapshot.Create( - [ - new ProcessorRef(0, 0, 0), - new ProcessorRef(1, 0, 64), - ]); - - var selection = CpuSelection.FromProcessors( - [new ProcessorRef(1, 0, 64)], - topology); - - var legacyMask = CpuSelection.ToLegacyAffinityMaskOrNull(selection); - - Assert.Null(legacyMask); - Assert.Contains(selection.LogicalProcessors, p => p.GlobalIndex == 64); - Assert.DoesNotContain(selection.LogicalProcessors, p => p.GlobalIndex == 0); - } - - [Fact] - public void CoreMask_ToProcessorAffinity_WithCpu64Only_DocumentsLegacyAliasBug() - { - var mask = new CoreMask { Name = "CPU64 Only" }; - for (var i = 0; i < 65; i++) - { - mask.BoolMask.Add(i == 64); - } - - var legacyAffinity = mask.ToProcessorAffinity(); - - Assert.Equal(1, legacyAffinity); - Assert.True((legacyAffinity & 1L) != 0); - } - - [Fact] - public void CpuTopologySnapshot_KeepsProcessorsWithSameLogicalIndexInDifferentGroupsDistinct() - { - var group0Cpu0 = new ProcessorRef(0, 0, 0); - var group1Cpu0 = new ProcessorRef(1, 0, 64); - var topology = CpuTopologySnapshot.Create( - [group0Cpu0, group1Cpu0], - new Dictionary - { - [group0Cpu0] = 100, - [group1Cpu0] = 200, - }); - - Assert.True(topology.TryGetCpuSetId(group0Cpu0, out var group0CpuSetId)); - Assert.True(topology.TryGetCpuSetId(group1Cpu0, out var group1CpuSetId)); - Assert.Equal(100U, group0CpuSetId); - Assert.Equal(200U, group1CpuSetId); - Assert.Equal(2, topology.LogicalProcessors.Count); - } - - [Fact] - public void CpuTopologySnapshot_Create_ThrowsWhenGlobalIndexIsDuplicated() - { - var processors = new[] - { - new ProcessorRef(0, 0, 0), - new ProcessorRef(0, 1, 0), - }; - - var exception = Assert.Throws(() => CpuTopologySnapshot.Create(processors)); - Assert.Contains("GlobalIndex", exception.Message, StringComparison.Ordinal); - } - - [Fact] - public void CpuTopologySnapshot_Create_ThrowsWhenLogicalProcessorsIsNull() - { - Assert.Throws(() => - CpuTopologySnapshot.Create(null!)); - } - - [Fact] - public void CpuTopologySnapshot_PerformanceEfficiencyClass_IsHighestNumericValue() - { - var eCore = new ProcessorRef(0, 8, 8); - var pCore = new ProcessorRef(0, 0, 0); - var topology = CpuTopologySnapshot.Create( - [pCore, eCore], - efficiencyClasses: new Dictionary - { - [pCore] = 2, - [eCore] = 0, - }); - - Assert.Equal(2, topology.GetPerformanceEfficiencyClass()); - } - - [Fact] - public void CpuTopologySnapshot_GetPerformanceEfficiencyClass_ReturnsNullWhenNoEfficiencyClassesExist() - { - var topology = CpuTopologySnapshot.Create([new ProcessorRef(0, 0, 0)]); - - var performanceClass = topology.GetPerformanceEfficiencyClass(); - - Assert.Null(performanceClass); - } - - [Fact] - public void FromLegacyAffinityMask_SelectsOnlyRepresentableProcessors() - { - var topology = CpuTopologySnapshot.Create( - [ - new ProcessorRef(0, 0, 0), - new ProcessorRef(0, 1, 1), - new ProcessorRef(1, 0, 64), - ]); - - var selection = CpuSelection.FromLegacyAffinityMask(0b11, topology); - - Assert.Equal([0, 1], selection.GlobalLogicalProcessorIndexes); - Assert.DoesNotContain(selection.LogicalProcessors, p => p.GlobalIndex == 64); - } - - [Fact] - public void FromLegacyAffinityMask_WithCpuSetId_SetsMigrationMetadataAndIndexes() - { - var cpu0 = new ProcessorRef(0, 0, 0); - var cpu2 = new ProcessorRef(0, 2, 2); - var topology = CpuTopologySnapshot.Create( - [cpu0, cpu2], - new Dictionary - { - [cpu0] = 300, - [cpu2] = 100, - }); - - var selection = CpuSelection.FromLegacyAffinityMask(0b101, topology); - - Assert.True(selection.Metadata.CreatedFromLegacyAffinityMask); - Assert.Equal("Migrated from legacy affinity mask", selection.Metadata.SelectionReason); - Assert.Equal([0, 2], selection.GlobalLogicalProcessorIndexes); - Assert.Equal([100U, 300U], selection.CpuSetIds); - } - - [Fact] - public void ToLegacyAffinityMaskOrNull_ReturnsMaskForSingleGroupBelow64() - { - var topology = CpuTopologySnapshot.Create( - [ - new ProcessorRef(0, 0, 0), - new ProcessorRef(0, 3, 3), - ]); - var selection = CpuSelection.FromProcessors(topology.LogicalProcessors, topology); - - var legacyMask = CpuSelection.ToLegacyAffinityMaskOrNull(selection); - - Assert.Equal(0b1001, legacyMask); - } - - [Fact] - public void FromProcessors_ThrowsWhenProcessorsIsNull() - { - var topology = CpuTopologySnapshot.Create([new ProcessorRef(0, 0, 0)]); - - Assert.Throws(() => - CpuSelection.FromProcessors(null!, topology)); - } - - [Fact] - public void FromProcessors_ThrowsWhenTopologyIsNull() - { - Assert.Throws(() => - CpuSelection.FromProcessors([new ProcessorRef(0, 0, 0)], null!)); - } - - [Fact] - public void FromProcessors_ThrowsWhenProcessorIsNotInTopology() - { - var topology = CpuTopologySnapshot.Create([new ProcessorRef(0, 0, 0)]); - var missingProcessor = new ProcessorRef(0, 1, 1); - - var exception = Assert.Throws(() => - CpuSelection.FromProcessors([missingProcessor], topology)); - - Assert.Contains("topology", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void FromProcessors_WithCpuSetIds_PopulatesDistinctOrderedCpuSetIds() - { - var cpu0 = new ProcessorRef(0, 0, 0); - var cpu1 = new ProcessorRef(0, 1, 1); - var cpu2 = new ProcessorRef(0, 2, 2); - var topology = CpuTopologySnapshot.Create( - [cpu0, cpu1, cpu2], - new Dictionary - { - [cpu0] = 200, - [cpu1] = 100, - [cpu2] = 200, - }); - - var selection = CpuSelection.FromProcessors([cpu0, cpu1, cpu2], topology); - - Assert.Equal([100U, 200U], selection.CpuSetIds); - } - - [Fact] - public void ToLegacyAffinityMaskOrNull_ThrowsWhenSelectionIsNull() - { - Assert.Throws(() => - CpuSelection.ToLegacyAffinityMaskOrNull(null!)); - } - - [Fact] - public void ToLegacyAffinityMaskOrNull_ReturnsNullForMultipleProcessorGroups() - { - var topology = CpuTopologySnapshot.Create( - [ - new ProcessorRef(0, 0, 0), - new ProcessorRef(1, 0, 64), - ]); - var selection = CpuSelection.FromProcessors(topology.LogicalProcessors, topology); - - var legacyMask = CpuSelection.ToLegacyAffinityMaskOrNull(selection); - - Assert.Null(legacyMask); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Models; + + public sealed class CpuSelectionTests + { + [Fact] + public void CpuSelection_WithGlobalIndex64_DoesNotAliasCpu0InLegacyMask() + { + var topology = CpuTopologySnapshot.Create( + [ + new ProcessorRef(0, 0, 0), + new ProcessorRef(1, 0, 64), + ]); + + var selection = CpuSelection.FromProcessors( + [new ProcessorRef(1, 0, 64)], + topology); + + var legacyMask = CpuSelection.ToLegacyAffinityMaskOrNull(selection); + + Assert.Null(legacyMask); + Assert.Contains(selection.LogicalProcessors, p => p.GlobalIndex == 64); + Assert.DoesNotContain(selection.LogicalProcessors, p => p.GlobalIndex == 0); + } + + [Fact] + public void CoreMask_ToProcessorAffinity_WithCpu64Only_DocumentsLegacyAliasBug() + { + var mask = new CoreMask { Name = "CPU64 Only" }; + for (var i = 0; i < 65; i++) + { + mask.BoolMask.Add(i == 64); + } + + var legacyAffinity = mask.ToProcessorAffinity(); + + Assert.Equal(1, legacyAffinity); + Assert.True((legacyAffinity & 1L) != 0); + } + + [Fact] + public void CpuTopologySnapshot_KeepsProcessorsWithSameLogicalIndexInDifferentGroupsDistinct() + { + var group0Cpu0 = new ProcessorRef(0, 0, 0); + var group1Cpu0 = new ProcessorRef(1, 0, 64); + var topology = CpuTopologySnapshot.Create( + [group0Cpu0, group1Cpu0], + new Dictionary + { + [group0Cpu0] = 100, + [group1Cpu0] = 200, + }); + + Assert.True(topology.TryGetCpuSetId(group0Cpu0, out var group0CpuSetId)); + Assert.True(topology.TryGetCpuSetId(group1Cpu0, out var group1CpuSetId)); + Assert.Equal(100U, group0CpuSetId); + Assert.Equal(200U, group1CpuSetId); + Assert.Equal(2, topology.LogicalProcessors.Count); + } + + [Fact] + public void CpuTopologySnapshot_Create_ThrowsWhenGlobalIndexIsDuplicated() + { + var processors = new[] + { + new ProcessorRef(0, 0, 0), + new ProcessorRef(0, 1, 0), + }; + + var exception = Assert.Throws(() => CpuTopologySnapshot.Create(processors)); + Assert.Contains("GlobalIndex", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void CpuTopologySnapshot_Create_ThrowsWhenLogicalProcessorsIsNull() + { + Assert.Throws(() => + CpuTopologySnapshot.Create(null!)); + } + + [Fact] + public void CpuTopologySnapshot_PerformanceEfficiencyClass_IsHighestNumericValue() + { + var eCore = new ProcessorRef(0, 8, 8); + var pCore = new ProcessorRef(0, 0, 0); + var topology = CpuTopologySnapshot.Create( + [pCore, eCore], + efficiencyClasses: new Dictionary + { + [pCore] = 2, + [eCore] = 0, + }); + + Assert.Equal(2, topology.GetPerformanceEfficiencyClass()); + } + + [Fact] + public void CpuTopologySnapshot_GetPerformanceEfficiencyClass_ReturnsNullWhenNoEfficiencyClassesExist() + { + var topology = CpuTopologySnapshot.Create([new ProcessorRef(0, 0, 0)]); + + var performanceClass = topology.GetPerformanceEfficiencyClass(); + + Assert.Null(performanceClass); + } + + [Fact] + public void FromLegacyAffinityMask_SelectsOnlyRepresentableProcessors() + { + var topology = CpuTopologySnapshot.Create( + [ + new ProcessorRef(0, 0, 0), + new ProcessorRef(0, 1, 1), + new ProcessorRef(1, 0, 64), + ]); + + var selection = CpuSelection.FromLegacyAffinityMask(0b11, topology); + + Assert.Equal([0, 1], selection.GlobalLogicalProcessorIndexes); + Assert.DoesNotContain(selection.LogicalProcessors, p => p.GlobalIndex == 64); + } + + [Fact] + public void FromLegacyAffinityMask_WithCpuSetId_SetsMigrationMetadataAndIndexes() + { + var cpu0 = new ProcessorRef(0, 0, 0); + var cpu2 = new ProcessorRef(0, 2, 2); + var topology = CpuTopologySnapshot.Create( + [cpu0, cpu2], + new Dictionary + { + [cpu0] = 300, + [cpu2] = 100, + }); + + var selection = CpuSelection.FromLegacyAffinityMask(0b101, topology); + + Assert.True(selection.Metadata.CreatedFromLegacyAffinityMask); + Assert.Equal("Migrated from legacy affinity mask", selection.Metadata.SelectionReason); + Assert.Equal([0, 2], selection.GlobalLogicalProcessorIndexes); + Assert.Equal([100U, 300U], selection.CpuSetIds); + } + + [Fact] + public void ToLegacyAffinityMaskOrNull_ReturnsMaskForSingleGroupBelow64() + { + var topology = CpuTopologySnapshot.Create( + [ + new ProcessorRef(0, 0, 0), + new ProcessorRef(0, 3, 3), + ]); + var selection = CpuSelection.FromProcessors(topology.LogicalProcessors, topology); + + var legacyMask = CpuSelection.ToLegacyAffinityMaskOrNull(selection); + + Assert.Equal(0b1001, legacyMask); + } + + [Fact] + public void FromProcessors_ThrowsWhenProcessorsIsNull() + { + var topology = CpuTopologySnapshot.Create([new ProcessorRef(0, 0, 0)]); + + Assert.Throws(() => + CpuSelection.FromProcessors(null!, topology)); + } + + [Fact] + public void FromProcessors_ThrowsWhenTopologyIsNull() + { + Assert.Throws(() => + CpuSelection.FromProcessors([new ProcessorRef(0, 0, 0)], null!)); + } + + [Fact] + public void FromProcessors_ThrowsWhenProcessorIsNotInTopology() + { + var topology = CpuTopologySnapshot.Create([new ProcessorRef(0, 0, 0)]); + var missingProcessor = new ProcessorRef(0, 1, 1); + + var exception = Assert.Throws(() => + CpuSelection.FromProcessors([missingProcessor], topology)); + + Assert.Contains("topology", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void FromProcessors_WithCpuSetIds_PopulatesDistinctOrderedCpuSetIds() + { + var cpu0 = new ProcessorRef(0, 0, 0); + var cpu1 = new ProcessorRef(0, 1, 1); + var cpu2 = new ProcessorRef(0, 2, 2); + var topology = CpuTopologySnapshot.Create( + [cpu0, cpu1, cpu2], + new Dictionary + { + [cpu0] = 200, + [cpu1] = 100, + [cpu2] = 200, + }); + + var selection = CpuSelection.FromProcessors([cpu0, cpu1, cpu2], topology); + + Assert.Equal([100U, 200U], selection.CpuSetIds); + } + + [Fact] + public void ToLegacyAffinityMaskOrNull_ThrowsWhenSelectionIsNull() + { + Assert.Throws(() => + CpuSelection.ToLegacyAffinityMaskOrNull(null!)); + } + + [Fact] + public void ToLegacyAffinityMaskOrNull_ReturnsNullForMultipleProcessorGroups() + { + var topology = CpuTopologySnapshot.Create( + [ + new ProcessorRef(0, 0, 0), + new ProcessorRef(1, 0, 64), + ]); + var selection = CpuSelection.FromProcessors(topology.LogicalProcessors, topology); + + var legacyMask = CpuSelection.ToLegacyAffinityMaskOrNull(selection); + + Assert.Null(legacyMask); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/CpuTopologyProviderTests.cs b/Tests/ThreadPilot.Core.Tests/CpuTopologyProviderTests.cs index c85b52b..0c97420 100644 --- a/Tests/ThreadPilot.Core.Tests/CpuTopologyProviderTests.cs +++ b/Tests/ThreadPilot.Core.Tests/CpuTopologyProviderTests.cs @@ -1,310 +1,310 @@ -namespace ThreadPilot.Core.Tests -{ - using System.Runtime.InteropServices; - using System.Threading; - using ThreadPilot.Models; - using ThreadPilot.Services; - - public sealed class CpuTopologyProviderTests - { - [Fact] - public async Task FakeProvider_ReturnsSingleGroupEightLogicalProcessors() - { - var processors = CreateSequentialProcessors(8).ToList(); - var topology = CpuTopologySnapshot.Create(processors); - var provider = new FakeCpuTopologyProvider(topology); - - var snapshot = await provider.GetTopologySnapshotAsync(CancellationToken.None); - - Assert.Equal(8, snapshot.LogicalProcessors.Count); - Assert.All(snapshot.LogicalProcessors, processor => Assert.Equal(0, processor.Group)); - Assert.Equal(1, snapshot.Signature.ProcessorGroupCount); - } - - [Fact] - public void Snapshot_MultiGroupCpuZeroEntriesRemainDistinct() - { - var group0Cpu0 = new ProcessorRef(0, 0, 0); - var group1Cpu0 = new ProcessorRef(1, 0, 64); - - var topology = CpuTopologySnapshot.Create( - [group0Cpu0, group1Cpu0], - cpuSetIds: new Dictionary - { - [group0Cpu0] = 100, - [group1Cpu0] = 200, - }); - - Assert.True(topology.TryGetCpuSetId(group0Cpu0, out var group0CpuSetId)); - Assert.True(topology.TryGetCpuSetId(group1Cpu0, out var group1CpuSetId)); - Assert.Equal(100U, group0CpuSetId); - Assert.Equal(200U, group1CpuSetId); - Assert.Equal(2, topology.Signature.ProcessorGroupCount); - } - - [Fact] - public void Snapshot_PerformanceEfficiencyClass_UsesHighestClass() - { - var pCore = new ProcessorRef(0, 0, 0); - var eCore = new ProcessorRef(0, 1, 1); - - var topology = CpuTopologySnapshot.Create( - [pCore, eCore], - efficiencyClasses: new Dictionary - { - [pCore] = 2, - [eCore] = 0, - }); - - Assert.Equal(2, topology.GetPerformanceEfficiencyClass()); - Assert.True(topology.TryGetEfficiencyClass(pCore, out var pCoreClass)); - Assert.Equal(2, pCoreClass); - } - - [Fact] - public void Snapshot_WithoutEfficiencyClasses_IsValid() - { - var topology = CpuTopologySnapshot.Create(CreateSequentialProcessors(4)); - - Assert.Null(topology.GetPerformanceEfficiencyClass()); - Assert.False(topology.TryGetEfficiencyClass(new ProcessorRef(0, 0, 0), out _)); - Assert.Equal(4, topology.Signature.LogicalProcessorCount); - } - - [Fact] - public void Snapshot_WithSmtOn_MapsSiblingGroupsByCore() - { - var cpu0 = new ProcessorRef(0, 0, 0); - var cpu1 = new ProcessorRef(0, 1, 1); - var cpu2 = new ProcessorRef(0, 2, 2); - var cpu3 = new ProcessorRef(0, 3, 3); - - var topology = CpuTopologySnapshot.Create( - [cpu0, cpu1, cpu2, cpu3], - coreIndexes: new Dictionary - { - [cpu0] = 0, - [cpu1] = 0, - [cpu2] = 1, - [cpu3] = 1, - }, - smtSiblingGlobalIndexes: new Dictionary> - { - [cpu0] = [1], - [cpu1] = [0], - [cpu2] = [3], - [cpu3] = [2], - }, - signature: new CpuTopologySignature - { - LogicalProcessorCount = 4, - PhysicalCoreCount = 2, - ProcessorGroupCount = 1, - Source = "Test", - }); - - Assert.Equal(2, topology.Signature.PhysicalCoreCount); - Assert.True(topology.TryGetCoreIndex(cpu0, out var cpu0CoreIndex)); - Assert.True(topology.TryGetCoreIndex(cpu1, out var cpu1CoreIndex)); - Assert.Equal(0, cpu0CoreIndex); - Assert.Equal(cpu0CoreIndex, cpu1CoreIndex); - Assert.Equal([1], topology.GetSmtSiblingGlobalIndexes(cpu0)); - Assert.Equal([0], topology.GetSmtSiblingGlobalIndexes(cpu1)); - } - - [Fact] - public void Snapshot_WithSmtOff_HasOneLogicalProcessorPerCore() - { - var processors = CreateSequentialProcessors(8).ToList(); - var coreIndexes = processors.ToDictionary(processor => processor, processor => processor.GlobalIndex); - - var topology = CpuTopologySnapshot.Create( - processors, - coreIndexes: coreIndexes, - signature: new CpuTopologySignature - { - LogicalProcessorCount = 8, - PhysicalCoreCount = 8, - ProcessorGroupCount = 1, - Source = "Test", - }); - - Assert.Equal(8, topology.Signature.PhysicalCoreCount); - Assert.All(processors, processor => - { - Assert.True(topology.TryGetCoreIndex(processor, out var coreIndex)); - Assert.Equal(processor.GlobalIndex, coreIndex); - Assert.Empty(topology.GetSmtSiblingGlobalIndexes(processor)); - }); - } - - [Fact] - public void Snapshot_DualCcdCacheGroups_AreRepresentedByLastLevelCacheIndex() - { - var processors = CreateSequentialProcessors(12).ToList(); - var l3Indexes = processors.ToDictionary( - processor => processor, - processor => processor.GlobalIndex < 6 ? 0 : 1); - - var topology = CpuTopologySnapshot.Create( - processors, - lastLevelCacheIndexes: l3Indexes, - signature: new CpuTopologySignature - { - LogicalProcessorCount = 12, - PhysicalCoreCount = 12, - ProcessorGroupCount = 1, - LastLevelCacheGroupCount = 2, - Source = "Test", - }); - - Assert.Equal(2, topology.Signature.LastLevelCacheGroupCount); - Assert.All(processors.Take(6), processor => - { - Assert.True(topology.TryGetLastLevelCacheIndex(processor, out var cacheIndex)); - Assert.Equal(0, cacheIndex); - }); - Assert.All(processors.Skip(6), processor => - { - Assert.True(topology.TryGetLastLevelCacheIndex(processor, out var cacheIndex)); - Assert.Equal(1, cacheIndex); - }); - } - - [Fact] - public void Snapshot_WithMoreThan64LogicalProcessors_IsValid() - { - var processors = Enumerable.Range(0, 72) - .Select(index => new ProcessorRef((ushort)(index / 64), (byte)(index % 64), index)) - .ToList(); - - var topology = CpuTopologySnapshot.Create(processors); - - Assert.Equal(72, topology.LogicalProcessors.Count); - Assert.Equal(2, topology.Signature.ProcessorGroupCount); - Assert.Contains(topology.LogicalProcessors, processor => processor.GlobalIndex == 64 && processor.Group == 1); - } - - [Fact] - public void NativeLayout_CacheRelationshipOffsets_MatchWin32Layout() - { - Assert.Equal(12, WindowsCpuTopologyNativeLayout.CacheReservedOffset); - Assert.Equal(30, WindowsCpuTopologyNativeLayout.CacheGroupCountOffset); - Assert.Equal(32, WindowsCpuTopologyNativeLayout.CacheGroupMaskOffset); - } - - [Fact] - public void NativeLayout_NumaNodeRelationshipOffsets_MatchWin32Layout() - { - Assert.Equal(4, WindowsCpuTopologyNativeLayout.NumaReservedOffset); - Assert.Equal(22, WindowsCpuTopologyNativeLayout.NumaGroupCountOffset); - Assert.Equal(24, WindowsCpuTopologyNativeLayout.NumaGroupMaskOffset); - } - - [Fact] - public void NativeLayout_NumaNodeWithZeroGroupCount_UsesSingleGroupMask() - { - using var buffer = NativeRelationshipBuffer.Allocate(WindowsCpuTopologyNativeLayout.NumaGroupMaskOffset + WindowsCpuTopologyNativeLayout.GroupAffinitySize); - buffer.WriteUInt32(0, 7); - buffer.WriteUInt16(WindowsCpuTopologyNativeLayout.NumaGroupCountOffset, 0); - buffer.WriteGroupAffinity(WindowsCpuTopologyNativeLayout.NumaGroupMaskOffset, group: 1, mask: 0b101UL); - - var processors = WindowsCpuTopologyNativeLayout.ReadNumaNodeProcessors(buffer.Pointer, out var nodeNumber); - - Assert.Equal(7, nodeNumber); - Assert.Equal( - [new ProcessorRef(1, 0, 64), new ProcessorRef(1, 2, 66)], - processors); - } - - [Fact] - public void NativeLayout_L3CacheWithGroupCount_ReadsAllGroupMasks() - { - var size = WindowsCpuTopologyNativeLayout.CacheGroupMaskOffset + (WindowsCpuTopologyNativeLayout.GroupAffinitySize * 2); - using var buffer = NativeRelationshipBuffer.Allocate(size); - buffer.WriteByte(0, 3); - buffer.WriteUInt16(WindowsCpuTopologyNativeLayout.CacheGroupCountOffset, 2); - buffer.WriteGroupAffinity(WindowsCpuTopologyNativeLayout.CacheGroupMaskOffset, group: 0, mask: 0b11UL); - buffer.WriteGroupAffinity( - WindowsCpuTopologyNativeLayout.CacheGroupMaskOffset + WindowsCpuTopologyNativeLayout.GroupAffinitySize, - group: 1, - mask: 0b1UL); - - var wasRead = WindowsCpuTopologyNativeLayout.TryReadL3CacheProcessors(buffer.Pointer, out var processors); - - Assert.True(wasRead); - Assert.Equal( - [new ProcessorRef(0, 0, 0), new ProcessorRef(0, 1, 1), new ProcessorRef(1, 0, 64)], - processors); - } - - [Fact] - public void NativeLayout_CreateFallbackProcessors_UsesProcessorGroupsBeyond64() - { - var processors = WindowsCpuTopologyNativeLayout.CreateFallbackProcessors(66).ToList(); - - Assert.Equal(new ProcessorRef(0, 63, 63), processors[63]); - Assert.Equal(new ProcessorRef(1, 0, 64), processors[64]); - Assert.Equal(new ProcessorRef(1, 1, 65), processors[65]); - } - - private static IEnumerable CreateSequentialProcessors(int count) - { - return Enumerable.Range(0, count) - .Select(index => new ProcessorRef(0, (byte)index, index)); - } - - private sealed class FakeCpuTopologyProvider(CpuTopologySnapshot snapshot) : ICpuTopologyProvider - { - public Task GetTopologySnapshotAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(snapshot); - } - } - - private sealed class NativeRelationshipBuffer : IDisposable - { - private NativeRelationshipBuffer(IntPtr pointer) - { - this.Pointer = pointer; - } - - public IntPtr Pointer { get; } - - public static NativeRelationshipBuffer Allocate(int size) - { - var pointer = Marshal.AllocHGlobal(size); - var bytes = new byte[size]; - Marshal.Copy(bytes, 0, pointer, bytes.Length); - return new NativeRelationshipBuffer(pointer); - } - - public void WriteByte(int offset, byte value) - { - Marshal.WriteByte(this.Pointer, offset, value); - } - - public void WriteUInt16(int offset, ushort value) - { - Marshal.WriteInt16(this.Pointer, offset, unchecked((short)value)); - } - - public void WriteUInt32(int offset, uint value) - { - Marshal.WriteInt32(this.Pointer, offset, unchecked((int)value)); - } - - public void WriteGroupAffinity(int offset, ushort group, ulong mask) - { - Marshal.WriteIntPtr(this.Pointer, offset, unchecked((nint)mask)); - this.WriteUInt16(offset + IntPtr.Size, group); - } - - public void Dispose() - { - Marshal.FreeHGlobal(this.Pointer); - } - } - } -} +namespace ThreadPilot.Core.Tests +{ + using System.Runtime.InteropServices; + using System.Threading; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class CpuTopologyProviderTests + { + [Fact] + public async Task FakeProvider_ReturnsSingleGroupEightLogicalProcessors() + { + var processors = CreateSequentialProcessors(8).ToList(); + var topology = CpuTopologySnapshot.Create(processors); + var provider = new FakeCpuTopologyProvider(topology); + + var snapshot = await provider.GetTopologySnapshotAsync(CancellationToken.None); + + Assert.Equal(8, snapshot.LogicalProcessors.Count); + Assert.All(snapshot.LogicalProcessors, processor => Assert.Equal(0, processor.Group)); + Assert.Equal(1, snapshot.Signature.ProcessorGroupCount); + } + + [Fact] + public void Snapshot_MultiGroupCpuZeroEntriesRemainDistinct() + { + var group0Cpu0 = new ProcessorRef(0, 0, 0); + var group1Cpu0 = new ProcessorRef(1, 0, 64); + + var topology = CpuTopologySnapshot.Create( + [group0Cpu0, group1Cpu0], + cpuSetIds: new Dictionary + { + [group0Cpu0] = 100, + [group1Cpu0] = 200, + }); + + Assert.True(topology.TryGetCpuSetId(group0Cpu0, out var group0CpuSetId)); + Assert.True(topology.TryGetCpuSetId(group1Cpu0, out var group1CpuSetId)); + Assert.Equal(100U, group0CpuSetId); + Assert.Equal(200U, group1CpuSetId); + Assert.Equal(2, topology.Signature.ProcessorGroupCount); + } + + [Fact] + public void Snapshot_PerformanceEfficiencyClass_UsesHighestClass() + { + var pCore = new ProcessorRef(0, 0, 0); + var eCore = new ProcessorRef(0, 1, 1); + + var topology = CpuTopologySnapshot.Create( + [pCore, eCore], + efficiencyClasses: new Dictionary + { + [pCore] = 2, + [eCore] = 0, + }); + + Assert.Equal(2, topology.GetPerformanceEfficiencyClass()); + Assert.True(topology.TryGetEfficiencyClass(pCore, out var pCoreClass)); + Assert.Equal(2, pCoreClass); + } + + [Fact] + public void Snapshot_WithoutEfficiencyClasses_IsValid() + { + var topology = CpuTopologySnapshot.Create(CreateSequentialProcessors(4)); + + Assert.Null(topology.GetPerformanceEfficiencyClass()); + Assert.False(topology.TryGetEfficiencyClass(new ProcessorRef(0, 0, 0), out _)); + Assert.Equal(4, topology.Signature.LogicalProcessorCount); + } + + [Fact] + public void Snapshot_WithSmtOn_MapsSiblingGroupsByCore() + { + var cpu0 = new ProcessorRef(0, 0, 0); + var cpu1 = new ProcessorRef(0, 1, 1); + var cpu2 = new ProcessorRef(0, 2, 2); + var cpu3 = new ProcessorRef(0, 3, 3); + + var topology = CpuTopologySnapshot.Create( + [cpu0, cpu1, cpu2, cpu3], + coreIndexes: new Dictionary + { + [cpu0] = 0, + [cpu1] = 0, + [cpu2] = 1, + [cpu3] = 1, + }, + smtSiblingGlobalIndexes: new Dictionary> + { + [cpu0] = [1], + [cpu1] = [0], + [cpu2] = [3], + [cpu3] = [2], + }, + signature: new CpuTopologySignature + { + LogicalProcessorCount = 4, + PhysicalCoreCount = 2, + ProcessorGroupCount = 1, + Source = "Test", + }); + + Assert.Equal(2, topology.Signature.PhysicalCoreCount); + Assert.True(topology.TryGetCoreIndex(cpu0, out var cpu0CoreIndex)); + Assert.True(topology.TryGetCoreIndex(cpu1, out var cpu1CoreIndex)); + Assert.Equal(0, cpu0CoreIndex); + Assert.Equal(cpu0CoreIndex, cpu1CoreIndex); + Assert.Equal([1], topology.GetSmtSiblingGlobalIndexes(cpu0)); + Assert.Equal([0], topology.GetSmtSiblingGlobalIndexes(cpu1)); + } + + [Fact] + public void Snapshot_WithSmtOff_HasOneLogicalProcessorPerCore() + { + var processors = CreateSequentialProcessors(8).ToList(); + var coreIndexes = processors.ToDictionary(processor => processor, processor => processor.GlobalIndex); + + var topology = CpuTopologySnapshot.Create( + processors, + coreIndexes: coreIndexes, + signature: new CpuTopologySignature + { + LogicalProcessorCount = 8, + PhysicalCoreCount = 8, + ProcessorGroupCount = 1, + Source = "Test", + }); + + Assert.Equal(8, topology.Signature.PhysicalCoreCount); + Assert.All(processors, processor => + { + Assert.True(topology.TryGetCoreIndex(processor, out var coreIndex)); + Assert.Equal(processor.GlobalIndex, coreIndex); + Assert.Empty(topology.GetSmtSiblingGlobalIndexes(processor)); + }); + } + + [Fact] + public void Snapshot_DualCcdCacheGroups_AreRepresentedByLastLevelCacheIndex() + { + var processors = CreateSequentialProcessors(12).ToList(); + var l3Indexes = processors.ToDictionary( + processor => processor, + processor => processor.GlobalIndex < 6 ? 0 : 1); + + var topology = CpuTopologySnapshot.Create( + processors, + lastLevelCacheIndexes: l3Indexes, + signature: new CpuTopologySignature + { + LogicalProcessorCount = 12, + PhysicalCoreCount = 12, + ProcessorGroupCount = 1, + LastLevelCacheGroupCount = 2, + Source = "Test", + }); + + Assert.Equal(2, topology.Signature.LastLevelCacheGroupCount); + Assert.All(processors.Take(6), processor => + { + Assert.True(topology.TryGetLastLevelCacheIndex(processor, out var cacheIndex)); + Assert.Equal(0, cacheIndex); + }); + Assert.All(processors.Skip(6), processor => + { + Assert.True(topology.TryGetLastLevelCacheIndex(processor, out var cacheIndex)); + Assert.Equal(1, cacheIndex); + }); + } + + [Fact] + public void Snapshot_WithMoreThan64LogicalProcessors_IsValid() + { + var processors = Enumerable.Range(0, 72) + .Select(index => new ProcessorRef((ushort)(index / 64), (byte)(index % 64), index)) + .ToList(); + + var topology = CpuTopologySnapshot.Create(processors); + + Assert.Equal(72, topology.LogicalProcessors.Count); + Assert.Equal(2, topology.Signature.ProcessorGroupCount); + Assert.Contains(topology.LogicalProcessors, processor => processor.GlobalIndex == 64 && processor.Group == 1); + } + + [Fact] + public void NativeLayout_CacheRelationshipOffsets_MatchWin32Layout() + { + Assert.Equal(12, WindowsCpuTopologyNativeLayout.CacheReservedOffset); + Assert.Equal(30, WindowsCpuTopologyNativeLayout.CacheGroupCountOffset); + Assert.Equal(32, WindowsCpuTopologyNativeLayout.CacheGroupMaskOffset); + } + + [Fact] + public void NativeLayout_NumaNodeRelationshipOffsets_MatchWin32Layout() + { + Assert.Equal(4, WindowsCpuTopologyNativeLayout.NumaReservedOffset); + Assert.Equal(22, WindowsCpuTopologyNativeLayout.NumaGroupCountOffset); + Assert.Equal(24, WindowsCpuTopologyNativeLayout.NumaGroupMaskOffset); + } + + [Fact] + public void NativeLayout_NumaNodeWithZeroGroupCount_UsesSingleGroupMask() + { + using var buffer = NativeRelationshipBuffer.Allocate(WindowsCpuTopologyNativeLayout.NumaGroupMaskOffset + WindowsCpuTopologyNativeLayout.GroupAffinitySize); + buffer.WriteUInt32(0, 7); + buffer.WriteUInt16(WindowsCpuTopologyNativeLayout.NumaGroupCountOffset, 0); + buffer.WriteGroupAffinity(WindowsCpuTopologyNativeLayout.NumaGroupMaskOffset, group: 1, mask: 0b101UL); + + var processors = WindowsCpuTopologyNativeLayout.ReadNumaNodeProcessors(buffer.Pointer, out var nodeNumber); + + Assert.Equal(7, nodeNumber); + Assert.Equal( + [new ProcessorRef(1, 0, 64), new ProcessorRef(1, 2, 66)], + processors); + } + + [Fact] + public void NativeLayout_L3CacheWithGroupCount_ReadsAllGroupMasks() + { + var size = WindowsCpuTopologyNativeLayout.CacheGroupMaskOffset + (WindowsCpuTopologyNativeLayout.GroupAffinitySize * 2); + using var buffer = NativeRelationshipBuffer.Allocate(size); + buffer.WriteByte(0, 3); + buffer.WriteUInt16(WindowsCpuTopologyNativeLayout.CacheGroupCountOffset, 2); + buffer.WriteGroupAffinity(WindowsCpuTopologyNativeLayout.CacheGroupMaskOffset, group: 0, mask: 0b11UL); + buffer.WriteGroupAffinity( + WindowsCpuTopologyNativeLayout.CacheGroupMaskOffset + WindowsCpuTopologyNativeLayout.GroupAffinitySize, + group: 1, + mask: 0b1UL); + + var wasRead = WindowsCpuTopologyNativeLayout.TryReadL3CacheProcessors(buffer.Pointer, out var processors); + + Assert.True(wasRead); + Assert.Equal( + [new ProcessorRef(0, 0, 0), new ProcessorRef(0, 1, 1), new ProcessorRef(1, 0, 64)], + processors); + } + + [Fact] + public void NativeLayout_CreateFallbackProcessors_UsesProcessorGroupsBeyond64() + { + var processors = WindowsCpuTopologyNativeLayout.CreateFallbackProcessors(66).ToList(); + + Assert.Equal(new ProcessorRef(0, 63, 63), processors[63]); + Assert.Equal(new ProcessorRef(1, 0, 64), processors[64]); + Assert.Equal(new ProcessorRef(1, 1, 65), processors[65]); + } + + private static IEnumerable CreateSequentialProcessors(int count) + { + return Enumerable.Range(0, count) + .Select(index => new ProcessorRef(0, (byte)index, index)); + } + + private sealed class FakeCpuTopologyProvider(CpuTopologySnapshot snapshot) : ICpuTopologyProvider + { + public Task GetTopologySnapshotAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(snapshot); + } + } + + private sealed class NativeRelationshipBuffer : IDisposable + { + private NativeRelationshipBuffer(IntPtr pointer) + { + this.Pointer = pointer; + } + + public IntPtr Pointer { get; } + + public static NativeRelationshipBuffer Allocate(int size) + { + var pointer = Marshal.AllocHGlobal(size); + var bytes = new byte[size]; + Marshal.Copy(bytes, 0, pointer, bytes.Length); + return new NativeRelationshipBuffer(pointer); + } + + public void WriteByte(int offset, byte value) + { + Marshal.WriteByte(this.Pointer, offset, value); + } + + public void WriteUInt16(int offset, ushort value) + { + Marshal.WriteInt16(this.Pointer, offset, unchecked((short)value)); + } + + public void WriteUInt32(int offset, uint value) + { + Marshal.WriteInt32(this.Pointer, offset, unchecked((int)value)); + } + + public void WriteGroupAffinity(int offset, ushort group, ulong mask) + { + Marshal.WriteIntPtr(this.Pointer, offset, unchecked((nint)mask)); + this.WriteUInt16(offset + IntPtr.Size, group); + } + + public void Dispose() + { + Marshal.FreeHGlobal(this.Pointer); + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/DiagnosticsViewModelProviderTests.cs b/Tests/ThreadPilot.Core.Tests/DiagnosticsViewModelProviderTests.cs deleted file mode 100644 index 5621521..0000000 --- a/Tests/ThreadPilot.Core.Tests/DiagnosticsViewModelProviderTests.cs +++ /dev/null @@ -1,70 +0,0 @@ -namespace ThreadPilot.Core.Tests -{ - using Microsoft.Extensions.Logging.Abstractions; - using Moq; - using ThreadPilot.Models; - using ThreadPilot.Services; - using ThreadPilot.ViewModels; - - public sealed class DiagnosticsViewModelProviderTests - { - [Fact] - public void Constructor_DoesNotCreatePerformanceViewModel() - { - var factoryCalls = 0; - var provider = new DiagnosticsViewModelProvider( - new Lazy(() => - { - factoryCalls++; - throw new InvalidOperationException("PerformanceViewModel should be lazy."); - })); - - Assert.False(provider.IsCreated); - Assert.Equal(0, factoryCalls); - } - - [Fact] - public void GetOrCreate_CreatesPerformanceViewModelOnce() - { - var performanceViewModel = CreatePerformanceViewModel(); - var factoryCalls = 0; - var provider = new DiagnosticsViewModelProvider( - new Lazy(() => - { - factoryCalls++; - return performanceViewModel; - })); - - var first = provider.GetOrCreate(); - var second = provider.GetOrCreate(); - - Assert.Same(performanceViewModel, first); - Assert.Same(first, second); - Assert.True(provider.IsCreated); - Assert.Equal(1, factoryCalls); - } - - private static PerformanceViewModel CreatePerformanceViewModel() - { - var performance = new Mock(MockBehavior.Strict); - var process = new Mock(MockBehavior.Strict); - var associations = new Mock(MockBehavior.Strict); - var powerPlan = new Mock(MockBehavior.Strict); - var processMonitorManager = new Mock(MockBehavior.Strict); - var systemTweaks = new Mock(MockBehavior.Strict); - - associations - .Setup(x => x.GetAssociationsAsync()) - .ReturnsAsync(Array.Empty()); - - return new PerformanceViewModel( - performance.Object, - process.Object, - associations.Object, - powerPlan.Object, - processMonitorManager.Object, - systemTweaks.Object, - NullLogger.Instance); - } - } -} diff --git a/Tests/ThreadPilot.Core.Tests/ElevatedTaskServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ElevatedTaskServiceTests.cs index 061362f..89dfe0e 100644 --- a/Tests/ThreadPilot.Core.Tests/ElevatedTaskServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ElevatedTaskServiceTests.cs @@ -1,130 +1,127 @@ -/* - * ThreadPilot - scheduled task service unit tests. - */ -namespace ThreadPilot.Core.Tests -{ - using System.Text; - using Microsoft.Extensions.Logging.Abstractions; - using ThreadPilot.Services; - using ThreadPilot.Services.Abstractions; - - /// - /// Unit tests for scheduled task orchestration in . - /// - public sealed class ElevatedTaskServiceTests - { - [Fact] - public async Task EnsureAutostartTaskAsync_ReturnsFalse_WhenExecutablePathIsInvalid() - { - var runner = new RecordingProcessRunner(); - var service = CreateService(runner); - - var result = await service.EnsureAutostartTaskAsync(@"C:\temp\ThreadPilot.txt", "--autostart"); - - Assert.False(result); - Assert.Empty(runner.Invocations); - } - - [Fact] - public async Task TryRunLaunchTaskAsync_ReturnsFalse_WhenSchTasksTimesOut() - { - var runner = new RecordingProcessRunner - { - ResultFactory = _ => new ProcessRunResult(-1, string.Empty, "schtasks timeout after 20 seconds"), - }; - var service = CreateService(runner); - - var result = await service.TryRunLaunchTaskAsync(); - - Assert.False(result); - - var invocation = Assert.Single(runner.Invocations); - Assert.Equal(Path.Combine(Environment.SystemDirectory, "schtasks.exe"), invocation.FileName); - Assert.Equal(TimeSpan.FromSeconds(20), invocation.Timeout); - Assert.Equal(new[] { "/Run", "/TN", service.LaunchTaskName }, invocation.Arguments); - } - - [Fact] - public async Task EnsureLaunchTaskAsync_WritesExpectedLaunchTaskDefinition() - { - var executablePath = CreateTemporaryExecutablePath(); - string? xmlPath = null; - string? xmlContent = null; - var runner = new RecordingProcessRunner - { - ResultFactory = invocation => - { - var xmlIndex = invocation.Arguments.IndexOf("/XML"); - Assert.True(xmlIndex >= 0); - xmlPath = invocation.Arguments[xmlIndex + 1]; - xmlContent = File.ReadAllText(xmlPath, Encoding.Unicode); - return new ProcessRunResult(0, string.Empty, string.Empty); - }, - }; - - try - { - var service = CreateService( - runner, - executablePathProvider: () => executablePath, - currentUserProvider: () => @"TEST\User"); - - var result = await service.EnsureLaunchTaskAsync(); - - Assert.True(result); - Assert.NotNull(xmlPath); - Assert.NotNull(xmlContent); - Assert.Contains("TEST\\User", xmlContent, StringComparison.Ordinal); - Assert.Contains($"{executablePath}", xmlContent, StringComparison.Ordinal); - Assert.Contains("--launched-via-task", xmlContent, StringComparison.Ordinal); - Assert.Contains( - $"{Path.GetDirectoryName(executablePath)}", - xmlContent, - StringComparison.Ordinal); - Assert.False(File.Exists(xmlPath)); - } - finally - { - if (File.Exists(executablePath)) - { - File.Delete(executablePath); - } - } - } - - private static ElevatedTaskService CreateService( - IProcessRunner runner, - Func? executablePathProvider = null, - Func? currentUserProvider = null) - { - return new ElevatedTaskService( - NullLogger.Instance, - runner, - executablePathProvider, - currentUserProvider); - } - - private static string CreateTemporaryExecutablePath() - { - var executablePath = Path.Combine(Path.GetTempPath(), $"threadpilot-test-{Guid.NewGuid():N}.exe"); - File.WriteAllText(executablePath, "stub"); - return executablePath; - } - - private sealed class RecordingProcessRunner : IProcessRunner - { - public List Invocations { get; } = new(); - - public Func? ResultFactory { get; init; } - - public Task RunAsync(string fileName, IReadOnlyList arguments, TimeSpan timeout) - { - var invocation = new ProcessInvocation(fileName, arguments.ToList(), timeout); - this.Invocations.Add(invocation); - return Task.FromResult(this.ResultFactory?.Invoke(invocation) ?? new ProcessRunResult(0, string.Empty, string.Empty)); - } - } - - private sealed record ProcessInvocation(string FileName, List Arguments, TimeSpan Timeout); - } -} +/* + * ThreadPilot - scheduled task service unit tests. + */ +namespace ThreadPilot.Core.Tests +{ + using System.Text; + using Microsoft.Extensions.Logging.Abstractions; + using ThreadPilot.Services; + using ThreadPilot.Services.Abstractions; + + public sealed class ElevatedTaskServiceTests + { + [Fact] + public async Task EnsureAutostartTaskAsync_ReturnsFalse_WhenExecutablePathIsInvalid() + { + var runner = new RecordingProcessRunner(); + var service = CreateService(runner); + + var result = await service.EnsureAutostartTaskAsync(@"C:\temp\ThreadPilot.txt", "--autostart"); + + Assert.False(result); + Assert.Empty(runner.Invocations); + } + + [Fact] + public async Task TryRunLaunchTaskAsync_ReturnsFalse_WhenSchTasksTimesOut() + { + var runner = new RecordingProcessRunner + { + ResultFactory = _ => new ProcessRunResult(-1, string.Empty, "schtasks timeout after 20 seconds"), + }; + var service = CreateService(runner); + + var result = await service.TryRunLaunchTaskAsync(); + + Assert.False(result); + + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(Path.Combine(Environment.SystemDirectory, "schtasks.exe"), invocation.FileName); + Assert.Equal(TimeSpan.FromSeconds(20), invocation.Timeout); + Assert.Equal(new[] { "/Run", "/TN", service.LaunchTaskName }, invocation.Arguments); + } + + [Fact] + public async Task EnsureLaunchTaskAsync_WritesExpectedLaunchTaskDefinition() + { + var executablePath = CreateTemporaryExecutablePath(); + string? xmlPath = null; + string? xmlContent = null; + var runner = new RecordingProcessRunner + { + ResultFactory = invocation => + { + var xmlIndex = invocation.Arguments.IndexOf("/XML"); + Assert.True(xmlIndex >= 0); + xmlPath = invocation.Arguments[xmlIndex + 1]; + xmlContent = File.ReadAllText(xmlPath, Encoding.Unicode); + return new ProcessRunResult(0, string.Empty, string.Empty); + }, + }; + + try + { + var service = CreateService( + runner, + executablePathProvider: () => executablePath, + currentUserProvider: () => @"TEST\User"); + + var result = await service.EnsureLaunchTaskAsync(); + + Assert.True(result); + Assert.NotNull(xmlPath); + Assert.NotNull(xmlContent); + Assert.Contains("TEST\\User", xmlContent, StringComparison.Ordinal); + Assert.Contains($"{executablePath}", xmlContent, StringComparison.Ordinal); + Assert.Contains("--launched-via-task", xmlContent, StringComparison.Ordinal); + Assert.Contains( + $"{Path.GetDirectoryName(executablePath)}", + xmlContent, + StringComparison.Ordinal); + Assert.False(File.Exists(xmlPath)); + } + finally + { + if (File.Exists(executablePath)) + { + File.Delete(executablePath); + } + } + } + + private static ElevatedTaskService CreateService( + IProcessRunner runner, + Func? executablePathProvider = null, + Func? currentUserProvider = null) + { + return new ElevatedTaskService( + NullLogger.Instance, + runner, + executablePathProvider, + currentUserProvider); + } + + private static string CreateTemporaryExecutablePath() + { + var executablePath = Path.Combine(Path.GetTempPath(), $"threadpilot-test-{Guid.NewGuid():N}.exe"); + File.WriteAllText(executablePath, "stub"); + return executablePath; + } + + private sealed class RecordingProcessRunner : IProcessRunner + { + public List Invocations { get; } = new(); + + public Func? ResultFactory { get; init; } + + public Task RunAsync(string fileName, IReadOnlyList arguments, TimeSpan timeout) + { + var invocation = new ProcessInvocation(fileName, arguments.ToList(), timeout); + this.Invocations.Add(invocation); + return Task.FromResult(this.ResultFactory?.Invoke(invocation) ?? new ProcessRunResult(0, string.Empty, string.Empty)); + } + } + + private sealed record ProcessInvocation(string FileName, List Arguments, TimeSpan Timeout); + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ForegroundProcessServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ForegroundProcessServiceTests.cs index ec31202..7b858fe 100644 --- a/Tests/ThreadPilot.Core.Tests/ForegroundProcessServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ForegroundProcessServiceTests.cs @@ -1,68 +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; - } - } - } -} +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/GitHubUpdateCheckerTests.cs b/Tests/ThreadPilot.Core.Tests/GitHubUpdateCheckerTests.cs index 51143bc..8f5b5f0 100644 --- a/Tests/ThreadPilot.Core.Tests/GitHubUpdateCheckerTests.cs +++ b/Tests/ThreadPilot.Core.Tests/GitHubUpdateCheckerTests.cs @@ -76,15 +76,15 @@ public FakeGitHubReleaseClient(string responseJson) this.responseJson = responseJson; } - public Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default) - { - return Task.FromResult(this.responseJson); - } - - public Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default) - { - return Task.FromResult($"[{this.responseJson}]"); - } - } - } -} + public Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default) + { + return Task.FromResult(this.responseJson); + } + + public Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default) + { + return Task.FromResult($"[{this.responseJson}]"); + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/LocalizationServiceTests.cs b/Tests/ThreadPilot.Core.Tests/LocalizationServiceTests.cs index 4764132..862cadb 100644 --- a/Tests/ThreadPilot.Core.Tests/LocalizationServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/LocalizationServiceTests.cs @@ -1,302 +1,302 @@ -namespace ThreadPilot.Core.Tests -{ - using System.Reflection; - using System.Text.RegularExpressions; - using System.Windows; - using Microsoft.Extensions.Logging.Abstractions; - using ThreadPilot.Services; - - public sealed partial class LocalizationServiceTests - { - [Fact] - public void Constructor_DefaultsToEnglish() - { - var service = CreateService(); - - Assert.Equal("en-US", service.CurrentLanguage); - } - - [Fact] - public void ApplyLanguage_AppliesChinese_WhenSupported() - { - var service = CreateService(); - - service.ApplyLanguage("zh-CN"); - - Assert.Equal("zh-CN", service.CurrentLanguage); - } - - [Fact] - public void ApplyLanguage_FiresLanguageChangedWithNormalizedLanguage() - { - var service = CreateService(); - var observedLanguages = new List(); - service.LanguageChanged += (_, language) => observedLanguages.Add(language); - - service.ApplyLanguage("zh-cn"); - service.ApplyLanguage("unsupported"); - - Assert.Equal(new[] { "zh-CN", "en-US" }, observedLanguages); - } - - [Fact] - public void ApplyLanguage_RemovesDuplicateAndOldLocaleDictionaries() - { - var resources = new ResourceDictionary(); - var nonLocaleDictionary = CreateDictionaryWithSource("Themes/FluentDark.xaml"); - var oldEnglishDictionary = CreateDictionaryWithSource("Locales/en-US.xaml"); - var duplicateChineseDictionary = CreateDictionaryWithSource("Locales/zh-CN.xaml"); - var matchingChineseDictionary = CreateDictionaryWithSource("Locales/zh-CN.xaml"); - resources.MergedDictionaries.Add(nonLocaleDictionary); - resources.MergedDictionaries.Add(oldEnglishDictionary); - resources.MergedDictionaries.Add(duplicateChineseDictionary); - resources.MergedDictionaries.Add(matchingChineseDictionary); - var service = CreateService(); - - InvokeApplyLanguageDictionary(service, resources, new Uri("Locales/zh-CN.xaml", UriKind.Relative)); - - Assert.Equal(2, resources.MergedDictionaries.Count); - Assert.Same(matchingChineseDictionary, resources.MergedDictionaries[0]); - Assert.Same(nonLocaleDictionary, resources.MergedDictionaries[1]); - Assert.DoesNotContain(resources.MergedDictionaries, dictionary => ReferenceEquals(dictionary, oldEnglishDictionary)); - Assert.DoesNotContain(resources.MergedDictionaries, dictionary => ReferenceEquals(dictionary, duplicateChineseDictionary)); - } - - [Fact] - public void GetString_UsesCurrentLanguageOverrideBeforeEnglishFallback() - { - var service = CreateService( - new Dictionary - { - ["Shared_Key"] = "English", - }, - new Dictionary - { - ["Shared_Key"] = "Chinese", - }); - service.ApplyLanguage("zh-CN"); - - var result = service.GetString("Shared_Key"); - - Assert.Equal("Chinese", result); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - [InlineData("fr-FR")] - [InlineData("zh")] - public void ApplyLanguage_FallsBackToEnglish_WhenLanguageIsInvalid(string? language) - { - var service = CreateService(); - service.ApplyLanguage("zh-CN"); - - service.ApplyLanguage(language); - - Assert.Equal("en-US", service.CurrentLanguage); - } - - [Fact] - public void GetString_UsesEnglishFallback_WhenActiveLanguageMissesKey() - { - var service = CreateService( - new Dictionary - { - ["Shared_Key"] = "English fallback", - }, - new Dictionary()); - service.ApplyLanguage("zh-CN"); - - var result = service.GetString("Shared_Key"); - - Assert.Equal("English fallback", result); - } - - [Fact] - public void GetString_ReturnsKey_WhenNoTranslationExists() - { - var service = CreateService(); - - var result = service.GetString("Missing_Key"); - - Assert.Equal("Missing_Key", result); - } - - [Fact] - public void GetString_ReturnsEmpty_WhenKeyIsBlank() - { - var service = CreateService(); - - Assert.Equal(string.Empty, service.GetString(string.Empty)); - Assert.Equal(string.Empty, service.GetString(" ")); - } - - [Fact] - public void LocaleFiles_DefineEnglishDefaultAndOptionalChineseLanguageLabels() - { - var root = FindRepositoryRoot(); - var english = File.ReadAllText(Path.Combine(root, "Locales", "en-US.xaml")); - var chinese = File.ReadAllText(Path.Combine(root, "Locales", "zh-CN.xaml")); - var appXaml = File.ReadAllText(Path.Combine(root, "App.xaml")); - - Assert.Contains("Source=\"Locales/en-US.xaml\"", appXaml, StringComparison.Ordinal); - Assert.DoesNotContain("Source=\"Locales/zh-CN.xaml\"", appXaml, StringComparison.Ordinal); - Assert.Contains("x:Key=\"SettingsView_LanguageEnUs\"", english, StringComparison.Ordinal); - Assert.Contains("x:Key=\"SettingsView_LanguageZhCn\"", english, StringComparison.Ordinal); - Assert.Contains("x:Key=\"SettingsView_LanguageEnUs\"", chinese, StringComparison.Ordinal); - Assert.Contains("x:Key=\"SettingsView_LanguageZhCn\"", chinese, StringComparison.Ordinal); - } - - [Fact] - public void LocaleFiles_DefineTheSameResourceKeys() - { - var root = FindRepositoryRoot(); - var english = ReadLocaleKeys(Path.Combine(root, "Locales", "en-US.xaml")); - var chinese = ReadLocaleKeys(Path.Combine(root, "Locales", "zh-CN.xaml")); - - Assert.Empty(english.Except(chinese).Order(StringComparer.Ordinal)); - Assert.Empty(chinese.Except(english).Order(StringComparer.Ordinal)); - } - - [Fact] - public void ImportantViews_DoNotUseHardcodedEnglishUiText() - { - var root = FindRepositoryRoot(); - var viewPaths = new[] - { - "MainWindow.xaml", - Path.Combine("Views", "ProcessView.xaml"), - Path.Combine("Views", "MasksView.xaml"), - Path.Combine("Views", "PowerPlanView.xaml"), - Path.Combine("Views", "ProcessPowerPlanAssociationView.xaml"), - Path.Combine("Views", "PerformanceView.xaml"), - Path.Combine("Views", "LogViewerView.xaml"), - Path.Combine("Views", "SystemTweaksView.xaml"), - Path.Combine("Views", "SettingsView.xaml"), - Path.Combine("Views", "SettingsWindow.xaml"), - }; - - var failures = new List(); - foreach (var relativePath in viewPaths) - { - var fullPath = Path.Combine(root, relativePath); - var xaml = File.ReadAllText(fullPath); - foreach (Match match in HardcodedUiAttributeRegex().Matches(xaml)) - { - var attribute = match.Groups["attribute"].Value; - var value = match.Groups["value"].Value; - if (IsAllowedHardcodedUiValue(attribute, value)) - { - continue; - } - - failures.Add($"{relativePath}: {attribute}=\"{value}\""); - } - } - - Assert.Empty(failures); - } - - private static LocalizationService CreateService( - IReadOnlyDictionary? englishStrings = null, - IReadOnlyDictionary? chineseStrings = null) - { - return new LocalizationService( - NullLogger.Instance, - englishStrings, - chineseStrings); - } - - private static string FindRepositoryRoot() - { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory != null && !File.Exists(Path.Combine(directory.FullName, "ThreadPilot_1.sln"))) - { - directory = directory.Parent; - } - - return directory?.FullName ?? throw new InvalidOperationException("Repository root was not found."); - } - - private static ResourceDictionary CreateDictionaryWithSource(string source) - { - var dictionary = new ResourceDictionary(); - var sourceField = typeof(ResourceDictionary).GetField("_source", BindingFlags.Instance | BindingFlags.NonPublic); - if (sourceField == null) - { - throw new InvalidOperationException("ResourceDictionary source field was not found."); - } - - sourceField.SetValue(dictionary, new Uri(source, UriKind.Relative)); - return dictionary; - } - - private static void InvokeApplyLanguageDictionary( - LocalizationService service, - ResourceDictionary resources, - Uri targetUri) - { - var method = typeof(LocalizationService).GetMethod( - "ApplyLanguageDictionary", - BindingFlags.Instance | BindingFlags.NonPublic); - if (method == null) - { - throw new InvalidOperationException("ApplyLanguageDictionary method was not found."); - } - - method.Invoke(service, new object[] { resources, targetUri }); - } - - private static SortedSet ReadLocaleKeys(string path) - { - var keys = new SortedSet(StringComparer.Ordinal); - var xaml = File.ReadAllText(path); - foreach (Match match in Regex.Matches(xaml, "x:Key=\"(?[^\"]+)\"", RegexOptions.CultureInvariant)) - { - keys.Add(match.Groups["key"].Value); - } - - return keys; - } - - private static bool IsAllowedHardcodedUiValue(string attribute, string value) - { - if (value.Contains('{', StringComparison.Ordinal) || - value.Contains("DynamicResource", StringComparison.Ordinal) || - value.Contains("StaticResource", StringComparison.Ordinal) || - value.Contains("Binding", StringComparison.Ordinal) || - value.Contains("x:Static", StringComparison.Ordinal)) - { - return true; - } - - if (string.Equals(attribute, "Tag", StringComparison.Ordinal) || - string.Equals(attribute, "TargetPageTag", StringComparison.Ordinal) || - string.Equals(attribute, "Name", StringComparison.Ordinal) || - string.Equals(attribute, "x:Name", StringComparison.Ordinal) || - string.Equals(attribute, "SelectedValuePath", StringComparison.Ordinal) || - string.Equals(attribute, "DisplayMemberPath", StringComparison.Ordinal)) - { - return true; - } - - var trimmedValue = value.Trim(); - - if (value.Contains("ThreadPilot", StringComparison.Ordinal) || - value.Contains("Segoe", StringComparison.Ordinal) || - value.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) || - value.EndsWith(".pow", StringComparison.OrdinalIgnoreCase) || - value.EndsWith(".json", StringComparison.OrdinalIgnoreCase) || - trimmedValue is "CPU" or "PID" or "ID" or "MB" or "AGPLv3" or "Windows" or "WMI" or "HPET" or "SMT" or "CPU %") - { - return true; - } - - return !Regex.IsMatch(value, "[A-Za-z]{3,}", RegexOptions.CultureInvariant); - } - - [GeneratedRegex("(?Text|Content|Header|Title|ToolTip|PlaceholderText|AutomationProperties\\.Name|AutomationProperties\\.HelpText)=\"(?[^\"]*[A-Za-z][^\"]*)\"", RegexOptions.CultureInvariant)] - private static partial Regex HardcodedUiAttributeRegex(); - } -} +namespace ThreadPilot.Core.Tests +{ + using System.Reflection; + using System.Text.RegularExpressions; + using System.Windows; + using Microsoft.Extensions.Logging.Abstractions; + using ThreadPilot.Services; + + public sealed partial class LocalizationServiceTests + { + [Fact] + public void Constructor_DefaultsToEnglish() + { + var service = CreateService(); + + Assert.Equal("en-US", service.CurrentLanguage); + } + + [Fact] + public void ApplyLanguage_AppliesChinese_WhenSupported() + { + var service = CreateService(); + + service.ApplyLanguage("zh-CN"); + + Assert.Equal("zh-CN", service.CurrentLanguage); + } + + [Fact] + public void ApplyLanguage_FiresLanguageChangedWithNormalizedLanguage() + { + var service = CreateService(); + var observedLanguages = new List(); + service.LanguageChanged += (_, language) => observedLanguages.Add(language); + + service.ApplyLanguage("zh-cn"); + service.ApplyLanguage("unsupported"); + + Assert.Equal(new[] { "zh-CN", "en-US" }, observedLanguages); + } + + [Fact] + public void ApplyLanguage_RemovesDuplicateAndOldLocaleDictionaries() + { + var resources = new ResourceDictionary(); + var nonLocaleDictionary = CreateDictionaryWithSource("Themes/FluentDark.xaml"); + var oldEnglishDictionary = CreateDictionaryWithSource("Locales/en-US.xaml"); + var duplicateChineseDictionary = CreateDictionaryWithSource("Locales/zh-CN.xaml"); + var matchingChineseDictionary = CreateDictionaryWithSource("Locales/zh-CN.xaml"); + resources.MergedDictionaries.Add(nonLocaleDictionary); + resources.MergedDictionaries.Add(oldEnglishDictionary); + resources.MergedDictionaries.Add(duplicateChineseDictionary); + resources.MergedDictionaries.Add(matchingChineseDictionary); + var service = CreateService(); + + InvokeApplyLanguageDictionary(service, resources, new Uri("Locales/zh-CN.xaml", UriKind.Relative)); + + Assert.Equal(2, resources.MergedDictionaries.Count); + Assert.Same(matchingChineseDictionary, resources.MergedDictionaries[0]); + Assert.Same(nonLocaleDictionary, resources.MergedDictionaries[1]); + Assert.DoesNotContain(resources.MergedDictionaries, dictionary => ReferenceEquals(dictionary, oldEnglishDictionary)); + Assert.DoesNotContain(resources.MergedDictionaries, dictionary => ReferenceEquals(dictionary, duplicateChineseDictionary)); + } + + [Fact] + public void GetString_UsesCurrentLanguageOverrideBeforeEnglishFallback() + { + var service = CreateService( + new Dictionary + { + ["Shared_Key"] = "English", + }, + new Dictionary + { + ["Shared_Key"] = "Chinese", + }); + service.ApplyLanguage("zh-CN"); + + var result = service.GetString("Shared_Key"); + + Assert.Equal("Chinese", result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("fr-FR")] + [InlineData("zh")] + public void ApplyLanguage_FallsBackToEnglish_WhenLanguageIsInvalid(string? language) + { + var service = CreateService(); + service.ApplyLanguage("zh-CN"); + + service.ApplyLanguage(language); + + Assert.Equal("en-US", service.CurrentLanguage); + } + + [Fact] + public void GetString_UsesEnglishFallback_WhenActiveLanguageMissesKey() + { + var service = CreateService( + new Dictionary + { + ["Shared_Key"] = "English fallback", + }, + new Dictionary()); + service.ApplyLanguage("zh-CN"); + + var result = service.GetString("Shared_Key"); + + Assert.Equal("English fallback", result); + } + + [Fact] + public void GetString_ReturnsKey_WhenNoTranslationExists() + { + var service = CreateService(); + + var result = service.GetString("Missing_Key"); + + Assert.Equal("Missing_Key", result); + } + + [Fact] + public void GetString_ReturnsEmpty_WhenKeyIsBlank() + { + var service = CreateService(); + + Assert.Equal(string.Empty, service.GetString(string.Empty)); + Assert.Equal(string.Empty, service.GetString(" ")); + } + + [Fact] + public void LocaleFiles_DefineEnglishDefaultAndOptionalChineseLanguageLabels() + { + var root = FindRepositoryRoot(); + var english = File.ReadAllText(Path.Combine(root, "Locales", "en-US.xaml")); + var chinese = File.ReadAllText(Path.Combine(root, "Locales", "zh-CN.xaml")); + var appXaml = File.ReadAllText(Path.Combine(root, "App.xaml")); + + Assert.Contains("Source=\"Locales/en-US.xaml\"", appXaml, StringComparison.Ordinal); + Assert.DoesNotContain("Source=\"Locales/zh-CN.xaml\"", appXaml, StringComparison.Ordinal); + Assert.Contains("x:Key=\"SettingsView_LanguageEnUs\"", english, StringComparison.Ordinal); + Assert.Contains("x:Key=\"SettingsView_LanguageZhCn\"", english, StringComparison.Ordinal); + Assert.Contains("x:Key=\"SettingsView_LanguageEnUs\"", chinese, StringComparison.Ordinal); + Assert.Contains("x:Key=\"SettingsView_LanguageZhCn\"", chinese, StringComparison.Ordinal); + } + + [Fact] + public void LocaleFiles_DefineTheSameResourceKeys() + { + var root = FindRepositoryRoot(); + var english = ReadLocaleKeys(Path.Combine(root, "Locales", "en-US.xaml")); + var chinese = ReadLocaleKeys(Path.Combine(root, "Locales", "zh-CN.xaml")); + + Assert.Empty(english.Except(chinese).Order(StringComparer.Ordinal)); + Assert.Empty(chinese.Except(english).Order(StringComparer.Ordinal)); + } + + [Fact] + public void ImportantViews_DoNotUseHardcodedEnglishUiText() + { + var root = FindRepositoryRoot(); + var viewPaths = new[] + { + "MainWindow.xaml", + Path.Combine("Views", "ProcessView.xaml"), + Path.Combine("Views", "MasksView.xaml"), + Path.Combine("Views", "PowerPlanView.xaml"), + Path.Combine("Views", "ProcessPowerPlanAssociationView.xaml"), + Path.Combine("Views", "PerformanceView.xaml"), + Path.Combine("Views", "LogViewerView.xaml"), + Path.Combine("Views", "SystemTweaksView.xaml"), + Path.Combine("Views", "SettingsView.xaml"), + Path.Combine("Views", "SettingsWindow.xaml"), + }; + + var failures = new List(); + foreach (var relativePath in viewPaths) + { + var fullPath = Path.Combine(root, relativePath); + var xaml = File.ReadAllText(fullPath); + foreach (Match match in HardcodedUiAttributeRegex().Matches(xaml)) + { + var attribute = match.Groups["attribute"].Value; + var value = match.Groups["value"].Value; + if (IsAllowedHardcodedUiValue(attribute, value)) + { + continue; + } + + failures.Add($"{relativePath}: {attribute}=\"{value}\""); + } + } + + Assert.Empty(failures); + } + + private static LocalizationService CreateService( + IReadOnlyDictionary? englishStrings = null, + IReadOnlyDictionary? chineseStrings = null) + { + return new LocalizationService( + NullLogger.Instance, + englishStrings, + chineseStrings); + } + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "ThreadPilot_1.sln"))) + { + directory = directory.Parent; + } + + return directory?.FullName ?? throw new InvalidOperationException("Repository root was not found."); + } + + private static ResourceDictionary CreateDictionaryWithSource(string source) + { + var dictionary = new ResourceDictionary(); + var sourceField = typeof(ResourceDictionary).GetField("_source", BindingFlags.Instance | BindingFlags.NonPublic); + if (sourceField == null) + { + throw new InvalidOperationException("ResourceDictionary source field was not found."); + } + + sourceField.SetValue(dictionary, new Uri(source, UriKind.Relative)); + return dictionary; + } + + private static void InvokeApplyLanguageDictionary( + LocalizationService service, + ResourceDictionary resources, + Uri targetUri) + { + var method = typeof(LocalizationService).GetMethod( + "ApplyLanguageDictionary", + BindingFlags.Instance | BindingFlags.NonPublic); + if (method == null) + { + throw new InvalidOperationException("ApplyLanguageDictionary method was not found."); + } + + method.Invoke(service, new object[] { resources, targetUri }); + } + + private static SortedSet ReadLocaleKeys(string path) + { + var keys = new SortedSet(StringComparer.Ordinal); + var xaml = File.ReadAllText(path); + foreach (Match match in Regex.Matches(xaml, "x:Key=\"(?[^\"]+)\"", RegexOptions.CultureInvariant)) + { + keys.Add(match.Groups["key"].Value); + } + + return keys; + } + + private static bool IsAllowedHardcodedUiValue(string attribute, string value) + { + if (value.Contains('{', StringComparison.Ordinal) || + value.Contains("DynamicResource", StringComparison.Ordinal) || + value.Contains("StaticResource", StringComparison.Ordinal) || + value.Contains("Binding", StringComparison.Ordinal) || + value.Contains("x:Static", StringComparison.Ordinal)) + { + return true; + } + + if (string.Equals(attribute, "Tag", StringComparison.Ordinal) || + string.Equals(attribute, "TargetPageTag", StringComparison.Ordinal) || + string.Equals(attribute, "Name", StringComparison.Ordinal) || + string.Equals(attribute, "x:Name", StringComparison.Ordinal) || + string.Equals(attribute, "SelectedValuePath", StringComparison.Ordinal) || + string.Equals(attribute, "DisplayMemberPath", StringComparison.Ordinal)) + { + return true; + } + + var trimmedValue = value.Trim(); + + if (value.Contains("ThreadPilot", StringComparison.Ordinal) || + value.Contains("Segoe", StringComparison.Ordinal) || + value.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) || + value.EndsWith(".pow", StringComparison.OrdinalIgnoreCase) || + value.EndsWith(".json", StringComparison.OrdinalIgnoreCase) || + trimmedValue is "CPU" or "PID" or "ID" or "MB" or "AGPLv3" or "Windows" or "WMI" or "HPET" or "SMT" or "CPU %") + { + return true; + } + + return !Regex.IsMatch(value, "[A-Za-z]{3,}", RegexOptions.CultureInvariant); + } + + [GeneratedRegex("(?Text|Content|Header|Title|ToolTip|PlaceholderText|AutomationProperties\\.Name|AutomationProperties\\.HelpText)=\"(?[^\"]*[A-Za-z][^\"]*)\"", RegexOptions.CultureInvariant)] + private static partial Regex HardcodedUiAttributeRegex(); + } +} diff --git a/Tests/ThreadPilot.Core.Tests/LogViewerActivityAuditTests.cs b/Tests/ThreadPilot.Core.Tests/LogViewerActivityAuditTests.cs index dee81b8..00d1c83 100644 --- a/Tests/ThreadPilot.Core.Tests/LogViewerActivityAuditTests.cs +++ b/Tests/ThreadPilot.Core.Tests/LogViewerActivityAuditTests.cs @@ -1,61 +1,61 @@ -namespace ThreadPilot.Core.Tests -{ - using CommunityToolkit.Mvvm.Input; - using Microsoft.Extensions.Logging; - using Microsoft.Extensions.Logging.Abstractions; - using Moq; - using ThreadPilot.Models; - using ThreadPilot.Services; - using ThreadPilot.ViewModels; - - public sealed class LogViewerActivityAuditTests - { - [Fact] - public async Task InitializeAsync_LoadsVisibleThreadPilotActivityEntries() - { - var audit = new ActivityAuditService(NullLogger.Instance); - await audit.LogSuccessAsync("Power Plans", "Applied power plan Gaming", "Guid: game"); - var viewModel = CreateViewModel(audit); - - await viewModel.InitializeAsync(); - - var entry = Assert.Single(viewModel.LogEntries); - Assert.Equal("Power Plans", entry.Category); - Assert.Equal("Applied power plan Gaming", entry.Message); - Assert.Equal(LogLevel.Information, entry.Level); - Assert.Equal("Guid: game", entry.Details); - } - - [Fact] - public async Task ClearLogsCommand_ClearsOnlyVisibleActivityDisplayWithoutAddingNoise() - { - var audit = new ActivityAuditService(NullLogger.Instance); - await audit.LogSuccessAsync("Power Plans", "Applied power plan Gaming"); - var viewModel = CreateViewModel(audit); - await viewModel.InitializeAsync(); - - await ((IAsyncRelayCommand)viewModel.ClearLogsCommand).ExecuteAsync(null); - - Assert.Empty(viewModel.LogEntries); - Assert.Single(await audit.GetEntriesAsync()); - } - - private static LogViewerViewModel CreateViewModel(IActivityAuditService audit) - { - var logging = new Mock(MockBehavior.Loose); - logging - .Setup(service => service.GetLogStatisticsAsync()) - .ReturnsAsync(new LogFileStatistics()); - var settings = new Mock(MockBehavior.Loose); - settings - .SetupGet(service => service.Settings) - .Returns(new ApplicationSettingsModel()); - - return new LogViewerViewModel( - audit, - logging.Object, - settings.Object, - NullLogger.Instance); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using CommunityToolkit.Mvvm.Input; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public sealed class LogViewerActivityAuditTests + { + [Fact] + public async Task InitializeAsync_LoadsVisibleThreadPilotActivityEntries() + { + var audit = new ActivityAuditService(NullLogger.Instance); + await audit.LogSuccessAsync("Power Plans", "Applied power plan Gaming", "Guid: game"); + var viewModel = CreateViewModel(audit); + + await viewModel.InitializeAsync(); + + var entry = Assert.Single(viewModel.LogEntries); + Assert.Equal("Power Plans", entry.Category); + Assert.Equal("Applied power plan Gaming", entry.Message); + Assert.Equal(LogLevel.Information, entry.Level); + Assert.Equal("Guid: game", entry.Details); + } + + [Fact] + public async Task ClearLogsCommand_ClearsOnlyVisibleActivityDisplayWithoutAddingNoise() + { + var audit = new ActivityAuditService(NullLogger.Instance); + await audit.LogSuccessAsync("Power Plans", "Applied power plan Gaming"); + var viewModel = CreateViewModel(audit); + await viewModel.InitializeAsync(); + + await ((IAsyncRelayCommand)viewModel.ClearLogsCommand).ExecuteAsync(null); + + Assert.Empty(viewModel.LogEntries); + Assert.Single(await audit.GetEntriesAsync()); + } + + private static LogViewerViewModel CreateViewModel(IActivityAuditService audit) + { + var logging = new Mock(MockBehavior.Loose); + logging + .Setup(service => service.GetLogStatisticsAsync()) + .ReturnsAsync(new LogFileStatistics()); + var settings = new Mock(MockBehavior.Loose); + settings + .SetupGet(service => service.Settings) + .Returns(new ApplicationSettingsModel()); + + return new LogViewerViewModel( + audit, + logging.Object, + settings.Object, + NullLogger.Instance); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/MasksViewModelTests.cs b/Tests/ThreadPilot.Core.Tests/MasksViewModelTests.cs index 2cedcf0..5f0ea58 100644 --- a/Tests/ThreadPilot.Core.Tests/MasksViewModelTests.cs +++ b/Tests/ThreadPilot.Core.Tests/MasksViewModelTests.cs @@ -1,161 +1,161 @@ -namespace ThreadPilot.Core.Tests -{ - using System.Reflection; - using System.Text.RegularExpressions; - using System.Xml.Linq; - using Moq; - using ThreadPilot.Services; - using ThreadPilot.ViewModels; - - public sealed class MasksViewModelTests - { - [Fact] - public void MasksView_SubtitleClarifiesPerProcessUse() - { - var document = LoadMasksViewXaml(); - var serialized = document.ToString(SaveOptions.DisableFormatting); - var locale = LoadEnglishLocale(); - - Assert.Contains("MasksView_Subtitle", serialized, StringComparison.Ordinal); - Assert.Contains("per-process use", locale, StringComparison.Ordinal); - } - - [Fact] - public void MasksView_ContainsEditingOnlyClarification() - { - var document = LoadMasksViewXaml(); - var serialized = document.ToString(SaveOptions.DisableFormatting); - - Assert.Contains("does not change CPU affinity", serialized, StringComparison.Ordinal); - Assert.Contains("until you apply it to a process", serialized, StringComparison.Ordinal); - } - - [Fact] - public void MasksView_DefaultPresetTooltipWarnsNoAutoApply() - { - var document = LoadMasksViewXaml(); - var serialized = document.ToString(SaveOptions.DisableFormatting); - var locale = LoadEnglishLocale(); - - Assert.Contains("MasksView_DefaultPresetTip", serialized, StringComparison.Ordinal); - Assert.Contains("does not apply CPU affinity automatically", locale, StringComparison.Ordinal); - Assert.Contains("Pre-selected when ThreadPilot", locale, StringComparison.Ordinal); - } - - [Fact] - public void MasksView_NoGlobalAffinityControls() - { - var document = LoadMasksViewXaml(); - var serialized = document.ToString(SaveOptions.DisableFormatting); - - Assert.DoesNotContain("apply globally", serialized, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("global affinity", serialized, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("disable SMT", serialized, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("HyperThreading", serialized, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void MasksView_ToggleCpuTextClarifiesNoRunningProcessImpact() - { - var document = LoadMasksViewXaml(); - var serialized = document.ToString(SaveOptions.DisableFormatting); - var locale = LoadEnglishLocale(); - - Assert.Contains("MasksView_SelectCpusTip", serialized, StringComparison.Ordinal); - Assert.Contains("do not affect running processes", locale, StringComparison.Ordinal); - } - - [Fact] - public void MasksView_DeleteWarningRefersToProcessesAndRules_NotGlobal() - { - var document = LoadMasksViewXaml(); - var serialized = document.ToString(SaveOptions.DisableFormatting); - - Assert.DoesNotContain("all processes", serialized, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("system-wide", serialized, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("globally", serialized, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void MasksViewModel_ExposesOnlyCrudCommands() - { - var commandNames = typeof(MasksViewModel) - .GetProperties(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) - .Where(p => p.PropertyType.Name.Contains("ommand", StringComparison.OrdinalIgnoreCase)) - .Select(p => p.Name) - .ToList(); - - Assert.Contains("CreateMaskCommand", commandNames); - Assert.Contains("DeleteMaskCommand", commandNames); - Assert.Contains("DuplicateMaskCommand", commandNames); - } - - [Fact] - public void MasksViewModel_HasNoAffinityApplyDependencies() - { - var constructorDependencies = typeof(MasksViewModel) - .GetConstructors() - .SelectMany(c => c.GetParameters()) - .Select(p => p.ParameterType.FullName ?? p.ParameterType.Name) - .ToList(); - - Assert.DoesNotContain("AffinityApplyService", constructorDependencies, StringComparer.OrdinalIgnoreCase); - Assert.DoesNotContain("ProcessAffinityApplyCoordinator", constructorDependencies, StringComparer.OrdinalIgnoreCase); - Assert.DoesNotContain("IProcessService", constructorDependencies, StringComparer.OrdinalIgnoreCase); - } - - [Fact] - public void MasksViewModel_HasNoAffinityApplyMethods() - { - var methods = typeof(MasksViewModel) - .GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) - .Select(m => m.Name) - .ToList(); - - Assert.DoesNotContain(methods, m => Regex.IsMatch(m, "Apply.*Affinity", RegexOptions.IgnoreCase)); - Assert.DoesNotContain(methods, m => Regex.IsMatch(m, "Set.*Affinity", RegexOptions.IgnoreCase)); - Assert.DoesNotContain(methods, m => Regex.IsMatch(m, "Apply.*Cpu.*Selection", RegexOptions.IgnoreCase)); - } - - [Fact] - public void MasksView_AllCoresProtectedDefaultInText() - { - var document = LoadMasksViewXaml(); - var serialized = document.ToString(SaveOptions.DisableFormatting); - var locale = LoadEnglishLocale(); - - Assert.Contains("MasksView_SelectCpusTip", serialized, StringComparison.Ordinal); - Assert.Contains("All Cores is the protected default preset", locale, StringComparison.Ordinal); - } - - private static XDocument LoadMasksViewXaml() - { - var repoRoot = GetRepositoryRoot(); - var path = Path.Combine(repoRoot, "Views", "MasksView.xaml"); - return XDocument.Load(path, LoadOptions.PreserveWhitespace); - } - - private static string LoadEnglishLocale() - { - var repoRoot = GetRepositoryRoot(); - return File.ReadAllText(Path.Combine(repoRoot, "Locales", "en-US.xaml")); - } - - private static string GetRepositoryRoot() - { - var currentDir = AppContext.BaseDirectory; - var dir = new DirectoryInfo(currentDir); - while (dir != null && !File.Exists(Path.Combine(dir.FullName, "ThreadPilot_1.sln"))) - { - dir = dir.Parent; - } - - if (dir == null) - { - throw new InvalidOperationException("Could not find repository root from " + currentDir); - } - - return dir.FullName; - } - } -} +namespace ThreadPilot.Core.Tests +{ + using System.Reflection; + using System.Text.RegularExpressions; + using System.Xml.Linq; + using Moq; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public sealed class MasksViewModelTests + { + [Fact] + public void MasksView_SubtitleClarifiesPerProcessUse() + { + var document = LoadMasksViewXaml(); + var serialized = document.ToString(SaveOptions.DisableFormatting); + var locale = LoadEnglishLocale(); + + Assert.Contains("MasksView_Subtitle", serialized, StringComparison.Ordinal); + Assert.Contains("per-process use", locale, StringComparison.Ordinal); + } + + [Fact] + public void MasksView_ContainsEditingOnlyClarification() + { + var document = LoadMasksViewXaml(); + var serialized = document.ToString(SaveOptions.DisableFormatting); + + Assert.Contains("does not change CPU affinity", serialized, StringComparison.Ordinal); + Assert.Contains("until you apply it to a process", serialized, StringComparison.Ordinal); + } + + [Fact] + public void MasksView_DefaultPresetTooltipWarnsNoAutoApply() + { + var document = LoadMasksViewXaml(); + var serialized = document.ToString(SaveOptions.DisableFormatting); + var locale = LoadEnglishLocale(); + + Assert.Contains("MasksView_DefaultPresetTip", serialized, StringComparison.Ordinal); + Assert.Contains("does not apply CPU affinity automatically", locale, StringComparison.Ordinal); + Assert.Contains("Pre-selected when ThreadPilot", locale, StringComparison.Ordinal); + } + + [Fact] + public void MasksView_NoGlobalAffinityControls() + { + var document = LoadMasksViewXaml(); + var serialized = document.ToString(SaveOptions.DisableFormatting); + + Assert.DoesNotContain("apply globally", serialized, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("global affinity", serialized, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("disable SMT", serialized, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("HyperThreading", serialized, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void MasksView_ToggleCpuTextClarifiesNoRunningProcessImpact() + { + var document = LoadMasksViewXaml(); + var serialized = document.ToString(SaveOptions.DisableFormatting); + var locale = LoadEnglishLocale(); + + Assert.Contains("MasksView_SelectCpusTip", serialized, StringComparison.Ordinal); + Assert.Contains("do not affect running processes", locale, StringComparison.Ordinal); + } + + [Fact] + public void MasksView_DeleteWarningRefersToProcessesAndRules_NotGlobal() + { + var document = LoadMasksViewXaml(); + var serialized = document.ToString(SaveOptions.DisableFormatting); + + Assert.DoesNotContain("all processes", serialized, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("system-wide", serialized, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("globally", serialized, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void MasksViewModel_ExposesOnlyCrudCommands() + { + var commandNames = typeof(MasksViewModel) + .GetProperties(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) + .Where(p => p.PropertyType.Name.Contains("ommand", StringComparison.OrdinalIgnoreCase)) + .Select(p => p.Name) + .ToList(); + + Assert.Contains("CreateMaskCommand", commandNames); + Assert.Contains("DeleteMaskCommand", commandNames); + Assert.Contains("DuplicateMaskCommand", commandNames); + } + + [Fact] + public void MasksViewModel_HasNoAffinityApplyDependencies() + { + var constructorDependencies = typeof(MasksViewModel) + .GetConstructors() + .SelectMany(c => c.GetParameters()) + .Select(p => p.ParameterType.FullName ?? p.ParameterType.Name) + .ToList(); + + Assert.DoesNotContain("AffinityApplyService", constructorDependencies, StringComparer.OrdinalIgnoreCase); + Assert.DoesNotContain("ProcessAffinityApplyCoordinator", constructorDependencies, StringComparer.OrdinalIgnoreCase); + Assert.DoesNotContain("IProcessService", constructorDependencies, StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public void MasksViewModel_HasNoAffinityApplyMethods() + { + var methods = typeof(MasksViewModel) + .GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) + .Select(m => m.Name) + .ToList(); + + Assert.DoesNotContain(methods, m => Regex.IsMatch(m, "Apply.*Affinity", RegexOptions.IgnoreCase)); + Assert.DoesNotContain(methods, m => Regex.IsMatch(m, "Set.*Affinity", RegexOptions.IgnoreCase)); + Assert.DoesNotContain(methods, m => Regex.IsMatch(m, "Apply.*Cpu.*Selection", RegexOptions.IgnoreCase)); + } + + [Fact] + public void MasksView_AllCoresProtectedDefaultInText() + { + var document = LoadMasksViewXaml(); + var serialized = document.ToString(SaveOptions.DisableFormatting); + var locale = LoadEnglishLocale(); + + Assert.Contains("MasksView_SelectCpusTip", serialized, StringComparison.Ordinal); + Assert.Contains("All Cores is the protected default preset", locale, StringComparison.Ordinal); + } + + private static XDocument LoadMasksViewXaml() + { + var repoRoot = GetRepositoryRoot(); + var path = Path.Combine(repoRoot, "Views", "MasksView.xaml"); + return XDocument.Load(path, LoadOptions.PreserveWhitespace); + } + + private static string LoadEnglishLocale() + { + var repoRoot = GetRepositoryRoot(); + return File.ReadAllText(Path.Combine(repoRoot, "Locales", "en-US.xaml")); + } + + private static string GetRepositoryRoot() + { + var currentDir = AppContext.BaseDirectory; + var dir = new DirectoryInfo(currentDir); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "ThreadPilot_1.sln"))) + { + dir = dir.Parent; + } + + if (dir == null) + { + throw new InvalidOperationException("Could not find repository root from " + currentDir); + } + + return dir.FullName; + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/NotificationServiceLocalizationTests.cs b/Tests/ThreadPilot.Core.Tests/NotificationServiceLocalizationTests.cs index 0383448..9fd7989 100644 --- a/Tests/ThreadPilot.Core.Tests/NotificationServiceLocalizationTests.cs +++ b/Tests/ThreadPilot.Core.Tests/NotificationServiceLocalizationTests.cs @@ -1,161 +1,161 @@ -namespace ThreadPilot.Core.Tests -{ - using Microsoft.Extensions.Logging.Abstractions; - using Moq; - using ThreadPilot.Models; - using ThreadPilot.Services; - - public sealed class NotificationServiceLocalizationTests - { - [Fact] - public async Task ShowPowerPlanChangeNotificationAsync_UsesLocalizedTitleAndFormat() - { - var harness = new Harness(new Dictionary - { - ["Notification_PowerPlanChangedTitle"] = "Localized power title", - ["Notification_PowerPlanChangedFormat"] = "Changed {0} -> {1}", - }); - var service = harness.CreateService(); - - await service.ShowPowerPlanChangeNotificationAsync("Balanced", "Performance"); - - var notification = Assert.Single(service.NotificationHistory); - Assert.Equal("Localized power title", notification.Title); - Assert.Equal("Changed Balanced -> Performance", notification.Message); - harness.Tray.Verify( - tray => tray.ShowTrayNotification( - "Localized power title", - "Changed Balanced -> Performance", - NotificationType.PowerPlanChange, - It.IsAny()), - Times.Once); - } - - [Fact] - public async Task ShowPowerPlanChangeNotificationAsync_UsesLocalizedProcessFormat() - { - var harness = new Harness(new Dictionary - { - ["Notification_PowerPlanChangedTitle"] = "Power", - ["Notification_PowerPlanChangedProcessFormat"] = "{1}: {0}", - }); - var service = harness.CreateService(); - - await service.ShowPowerPlanChangeNotificationAsync("Balanced", "Performance", "game.exe"); - - var notification = Assert.Single(service.NotificationHistory); - Assert.Equal("Power", notification.Title); - Assert.Equal("game.exe: Performance", notification.Message); - } - - [Theory] - [InlineData(true, "Enabled localized", NotificationType.Success)] - [InlineData(false, "Disabled localized", NotificationType.Warning)] - public async Task ShowProcessMonitoringNotificationAsync_UsesLocalizedTitle(bool isEnabled, string expectedTitle, NotificationType expectedType) - { - var harness = new Harness(new Dictionary - { - ["Notification_ProcessMonitoringEnabled"] = "Enabled localized", - ["Notification_ProcessMonitoringDisabled"] = "Disabled localized", - }); - var service = harness.CreateService(); - - await service.ShowProcessMonitoringNotificationAsync("Monitoring changed", isEnabled); - - var notification = Assert.Single(service.NotificationHistory); - Assert.Equal(expectedTitle, notification.Title); - Assert.Equal("Monitoring changed", notification.Message); - Assert.Equal(expectedType, notification.Type); - } - - [Fact] - public async Task ShowCpuAffinityNotificationAsync_UsesLocalizedTitleAndFormat() - { - var harness = new Harness(new Dictionary - { - ["Notification_CpuAffinityAppliedTitle"] = "Affinity localized", - ["Notification_CpuAffinityAppliedFormat"] = "{0} uses {1}", - }); - var service = harness.CreateService(); - - await service.ShowCpuAffinityNotificationAsync("game.exe", "CPU 0, 1"); - - var notification = Assert.Single(service.NotificationHistory); - Assert.Equal("Affinity localized", notification.Title); - Assert.Equal("game.exe uses CPU 0, 1", notification.Message); - } - - [Fact] - public async Task ShowNotificationAsync_LocalizesKnownAndDynamicGameBoostStrings() - { - var harness = new Harness(new Dictionary - { - ["Notification_GameBoostActivatedTitle"] = "Boost title", - ["Notification_GameBoostActivatedFormat"] = "Boosted {0}", - }); - var service = harness.CreateService(); - - await service.ShowNotificationAsync( - "Game Boost Activated", - "Game Boost mode activated for game.exe", - NotificationType.Information); - - var notification = Assert.Single(service.NotificationHistory); - Assert.Equal("Boost title", notification.Title); - Assert.Equal("Boosted game.exe", notification.Message); - } - - [Fact] - public async Task ShowNotificationAsync_KeepsOriginalText_WhenLocalizationKeyIsMissing() - { - var harness = new Harness(new Dictionary()); - var service = harness.CreateService(); - - await service.ShowNotificationAsync( - "Affinity blocked", - "Unmapped notification message", - NotificationType.Warning); - - var notification = Assert.Single(service.NotificationHistory); - Assert.Equal("Affinity blocked", notification.Title); - Assert.Equal("Unmapped notification message", notification.Message); - } - - private sealed class Harness - { - private readonly IReadOnlyDictionary localizedStrings; - - public Mock Settings { get; } = new(MockBehavior.Loose); - - public Mock Tray { get; } = new(MockBehavior.Loose); - - public Mock Localization { get; } = new(MockBehavior.Loose); - - public Harness(IReadOnlyDictionary localizedStrings) - { - this.localizedStrings = localizedStrings; - this.Settings.SetupGet(service => service.Settings).Returns(new ApplicationSettingsModel - { - EnableToastNotifications = false, - }); - this.Localization - .Setup(service => service.GetString(It.IsAny())) - .Returns(this.GetLocalizedString); - } - - public NotificationService CreateService() - { - return new NotificationService( - NullLogger.Instance, - this.Settings.Object, - this.Tray.Object, - this.Localization.Object); - } - - private string GetLocalizedString(string key) - { - return this.localizedStrings.TryGetValue(key, out var value) ? value : key; - } - } - } -} +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class NotificationServiceLocalizationTests + { + [Fact] + public async Task ShowPowerPlanChangeNotificationAsync_UsesLocalizedTitleAndFormat() + { + var harness = new Harness(new Dictionary + { + ["Notification_PowerPlanChangedTitle"] = "Localized power title", + ["Notification_PowerPlanChangedFormat"] = "Changed {0} -> {1}", + }); + var service = harness.CreateService(); + + await service.ShowPowerPlanChangeNotificationAsync("Balanced", "Performance"); + + var notification = Assert.Single(service.NotificationHistory); + Assert.Equal("Localized power title", notification.Title); + Assert.Equal("Changed Balanced -> Performance", notification.Message); + harness.Tray.Verify( + tray => tray.ShowTrayNotification( + "Localized power title", + "Changed Balanced -> Performance", + NotificationType.PowerPlanChange, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShowPowerPlanChangeNotificationAsync_UsesLocalizedProcessFormat() + { + var harness = new Harness(new Dictionary + { + ["Notification_PowerPlanChangedTitle"] = "Power", + ["Notification_PowerPlanChangedProcessFormat"] = "{1}: {0}", + }); + var service = harness.CreateService(); + + await service.ShowPowerPlanChangeNotificationAsync("Balanced", "Performance", "game.exe"); + + var notification = Assert.Single(service.NotificationHistory); + Assert.Equal("Power", notification.Title); + Assert.Equal("game.exe: Performance", notification.Message); + } + + [Theory] + [InlineData(true, "Enabled localized", NotificationType.Success)] + [InlineData(false, "Disabled localized", NotificationType.Warning)] + public async Task ShowProcessMonitoringNotificationAsync_UsesLocalizedTitle(bool isEnabled, string expectedTitle, NotificationType expectedType) + { + var harness = new Harness(new Dictionary + { + ["Notification_ProcessMonitoringEnabled"] = "Enabled localized", + ["Notification_ProcessMonitoringDisabled"] = "Disabled localized", + }); + var service = harness.CreateService(); + + await service.ShowProcessMonitoringNotificationAsync("Monitoring changed", isEnabled); + + var notification = Assert.Single(service.NotificationHistory); + Assert.Equal(expectedTitle, notification.Title); + Assert.Equal("Monitoring changed", notification.Message); + Assert.Equal(expectedType, notification.Type); + } + + [Fact] + public async Task ShowCpuAffinityNotificationAsync_UsesLocalizedTitleAndFormat() + { + var harness = new Harness(new Dictionary + { + ["Notification_CpuAffinityAppliedTitle"] = "Affinity localized", + ["Notification_CpuAffinityAppliedFormat"] = "{0} uses {1}", + }); + var service = harness.CreateService(); + + await service.ShowCpuAffinityNotificationAsync("game.exe", "CPU 0, 1"); + + var notification = Assert.Single(service.NotificationHistory); + Assert.Equal("Affinity localized", notification.Title); + Assert.Equal("game.exe uses CPU 0, 1", notification.Message); + } + + [Fact] + public async Task ShowNotificationAsync_LocalizesKnownAndDynamicGameBoostStrings() + { + var harness = new Harness(new Dictionary + { + ["Notification_GameBoostActivatedTitle"] = "Boost title", + ["Notification_GameBoostActivatedFormat"] = "Boosted {0}", + }); + var service = harness.CreateService(); + + await service.ShowNotificationAsync( + "Game Boost Activated", + "Game Boost mode activated for game.exe", + NotificationType.Information); + + var notification = Assert.Single(service.NotificationHistory); + Assert.Equal("Boost title", notification.Title); + Assert.Equal("Boosted game.exe", notification.Message); + } + + [Fact] + public async Task ShowNotificationAsync_KeepsOriginalText_WhenLocalizationKeyIsMissing() + { + var harness = new Harness(new Dictionary()); + var service = harness.CreateService(); + + await service.ShowNotificationAsync( + "Affinity blocked", + "Unmapped notification message", + NotificationType.Warning); + + var notification = Assert.Single(service.NotificationHistory); + Assert.Equal("Affinity blocked", notification.Title); + Assert.Equal("Unmapped notification message", notification.Message); + } + + private sealed class Harness + { + private readonly IReadOnlyDictionary localizedStrings; + + public Mock Settings { get; } = new(MockBehavior.Loose); + + public Mock Tray { get; } = new(MockBehavior.Loose); + + public Mock Localization { get; } = new(MockBehavior.Loose); + + public Harness(IReadOnlyDictionary localizedStrings) + { + this.localizedStrings = localizedStrings; + this.Settings.SetupGet(service => service.Settings).Returns(new ApplicationSettingsModel + { + EnableToastNotifications = false, + }); + this.Localization + .Setup(service => service.GetString(It.IsAny())) + .Returns(this.GetLocalizedString); + } + + public NotificationService CreateService() + { + return new NotificationService( + NullLogger.Instance, + this.Settings.Object, + this.Tray.Object, + this.Localization.Object); + } + + private string GetLocalizedString(string key) + { + return this.localizedStrings.TryGetValue(key, out var value) ? value : key; + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs b/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs index d28b98f..f575782 100644 --- a/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs @@ -1,106 +1,106 @@ -namespace ThreadPilot.Core.Tests -{ - using System.Text.RegularExpressions; - - public sealed partial class PackagingMetadataTests - { - private const string ReleaseVersion = "1.4.0"; - private const string ReleaseAssemblyVersion = "1.4.0.0"; - - [Fact] - public void InnoInstallers_UseStableDisplayNameAndSeparateVersionMetadata() - { - var root = FindRepositoryRoot(); - var installerScripts = new[] - { - Path.Combine(root, "Installer", "setup.iss"), - Path.Combine(root, "Installer", "Installer.iss"), - }; - - foreach (var scriptPath in installerScripts) - { - var script = File.ReadAllText(scriptPath); - - Assert.Contains("AppName={#MyAppName}", script, StringComparison.Ordinal); - Assert.Contains("AppVersion={#MyAppVersion}", script, StringComparison.Ordinal); - Assert.Contains("AppVerName={#MyAppName}", script, StringComparison.Ordinal); - Assert.DoesNotContain("AppVerName={#MyAppName} {#MyAppVersion}", script, StringComparison.Ordinal); - Assert.Matches(MyAppVersionRegex(), script); - } - } - - [Fact] - public void PrimaryInstaller_RemovesThreadPilotOwnedDataOnlyDuringUninstall() - { - var root = FindRepositoryRoot(); - var script = File.ReadAllText(Path.Combine(root, "Installer", "setup.iss")); - - Assert.Contains("[UninstallDelete]", script, StringComparison.Ordinal); - Assert.Contains("Name: \"{userappdata}\\ThreadPilot\"", script, StringComparison.Ordinal); - Assert.Contains("ThreadPilot user data is preserved during install/update", script, StringComparison.Ordinal); - Assert.DoesNotContain("[InstallDelete]", script, StringComparison.Ordinal); - Assert.DoesNotContain("Name: \"{userappdata}\"", script, StringComparison.Ordinal); - } - - [Fact] - public void PrimaryInstaller_CleansOnlyRecognizedLegacyBetaUninstallRegistryEntries() - { - var root = FindRepositoryRoot(); - var script = File.ReadAllText(Path.Combine(root, "Installer", "setup.iss")); - - Assert.Contains("ThreadPilot 0.1.0-beta", script, StringComparison.Ordinal); - Assert.Contains("DeleteLegacyBetaUninstallEntry", script, StringComparison.Ordinal); - Assert.Contains("DisplayName", script, StringComparison.Ordinal); - Assert.Contains("InstallLocation", script, StringComparison.Ordinal); - Assert.Contains("{autopf}\\ThreadPilot", script, StringComparison.Ordinal); - Assert.DoesNotContain(@"DeleteKeyIncludingSubkeys(HKLM, 'Software\Microsoft\Windows\CurrentVersion\Uninstall')", script, StringComparison.Ordinal); - } - - [Fact] - public void VersionMetadata_IsBumpedToReleaseVersion() - { - var root = FindRepositoryRoot(); - - AssertFileContains(Path.Combine(root, "ThreadPilot.csproj"), $"{ReleaseVersion}"); - AssertFileContains(Path.Combine(root, "ThreadPilot.csproj"), $"{ReleaseAssemblyVersion}"); - AssertFileContains(Path.Combine(root, "ThreadPilot.csproj"), $"{ReleaseAssemblyVersion}"); - AssertFileContains(Path.Combine(root, "ThreadPilot.csproj"), $"{ReleaseVersion}"); - AssertFileContains(Path.Combine(root, "app.manifest"), $"version=\"{ReleaseAssemblyVersion}\""); - AssertFileContains(Path.Combine(root, "Installer", "ThreadPilot.wxs"), $"Version=\"{ReleaseAssemblyVersion}\""); - AssertFileContains(Path.Combine(root, "chocolatey", "threadpilot.nuspec"), $"{ReleaseVersion}"); - AssertFileContains(Path.Combine(root, "chocolatey", "threadpilot.nuspec"), $"releases/tag/v{ReleaseVersion}"); - AssertFileContains(Path.Combine(root, "sonar-project.properties"), $"sonar.projectVersion={ReleaseVersion}"); - AssertFileContains(Path.Combine(root, "build", "build-release.ps1"), $"[string]$Version = \"{ReleaseVersion}\""); - AssertFileContains(Path.Combine(root, "build", "build-installer.ps1"), $"[string]$Version = \"{ReleaseVersion}\""); - AssertFileContains(Path.Combine(root, "build", "package-release-zips.ps1"), $"[string]$Version = \"{ReleaseVersion}\""); - Assert.True(File.Exists(Path.Combine(root, "docs", "releases", $"v{ReleaseVersion}.md"))); - AssertFileContains(Path.Combine(root, "docs", "release", "RELEASE_NOTES.md"), $"v{ReleaseVersion}"); - } - - private static void AssertFileContains(string path, string expected) - { - var content = File.ReadAllText(path); - Assert.Contains(expected, content, StringComparison.Ordinal); - } - - private static string FindRepositoryRoot() - { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory != null) - { - if (File.Exists(Path.Combine(directory.FullName, "ThreadPilot.csproj")) && - Directory.Exists(Path.Combine(directory.FullName, "Installer"))) - { - return directory.FullName; - } - - directory = directory.Parent; - } - - throw new InvalidOperationException("Repository root could not be located."); - } - - [GeneratedRegex("#define MyAppVersion \"1\\.4\\.0\"", RegexOptions.CultureInvariant)] - private static partial Regex MyAppVersionRegex(); - } -} +namespace ThreadPilot.Core.Tests +{ + using System.Text.RegularExpressions; + + public sealed partial class PackagingMetadataTests + { + private const string ReleaseVersion = "1.4.0"; + private const string ReleaseAssemblyVersion = "1.4.0.0"; + + [Fact] + public void InnoInstallers_UseStableDisplayNameAndSeparateVersionMetadata() + { + var root = FindRepositoryRoot(); + var installerScripts = new[] + { + Path.Combine(root, "Installer", "setup.iss"), + Path.Combine(root, "Installer", "Installer.iss"), + }; + + foreach (var scriptPath in installerScripts) + { + var script = File.ReadAllText(scriptPath); + + Assert.Contains("AppName={#MyAppName}", script, StringComparison.Ordinal); + Assert.Contains("AppVersion={#MyAppVersion}", script, StringComparison.Ordinal); + Assert.Contains("AppVerName={#MyAppName}", script, StringComparison.Ordinal); + Assert.DoesNotContain("AppVerName={#MyAppName} {#MyAppVersion}", script, StringComparison.Ordinal); + Assert.Matches(MyAppVersionRegex(), script); + } + } + + [Fact] + public void PrimaryInstaller_RemovesThreadPilotOwnedDataOnlyDuringUninstall() + { + var root = FindRepositoryRoot(); + var script = File.ReadAllText(Path.Combine(root, "Installer", "setup.iss")); + + Assert.Contains("[UninstallDelete]", script, StringComparison.Ordinal); + Assert.Contains("Name: \"{userappdata}\\ThreadPilot\"", script, StringComparison.Ordinal); + Assert.Contains("ThreadPilot user data is preserved during install/update", script, StringComparison.Ordinal); + Assert.DoesNotContain("[InstallDelete]", script, StringComparison.Ordinal); + Assert.DoesNotContain("Name: \"{userappdata}\"", script, StringComparison.Ordinal); + } + + [Fact] + public void PrimaryInstaller_CleansOnlyRecognizedLegacyBetaUninstallRegistryEntries() + { + var root = FindRepositoryRoot(); + var script = File.ReadAllText(Path.Combine(root, "Installer", "setup.iss")); + + Assert.Contains("ThreadPilot 0.1.0-beta", script, StringComparison.Ordinal); + Assert.Contains("DeleteLegacyBetaUninstallEntry", script, StringComparison.Ordinal); + Assert.Contains("DisplayName", script, StringComparison.Ordinal); + Assert.Contains("InstallLocation", script, StringComparison.Ordinal); + Assert.Contains("{autopf}\\ThreadPilot", script, StringComparison.Ordinal); + Assert.DoesNotContain(@"DeleteKeyIncludingSubkeys(HKLM, 'Software\Microsoft\Windows\CurrentVersion\Uninstall')", script, StringComparison.Ordinal); + } + + [Fact] + public void VersionMetadata_IsBumpedToReleaseVersion() + { + var root = FindRepositoryRoot(); + + AssertFileContains(Path.Combine(root, "ThreadPilot.csproj"), $"{ReleaseVersion}"); + AssertFileContains(Path.Combine(root, "ThreadPilot.csproj"), $"{ReleaseAssemblyVersion}"); + AssertFileContains(Path.Combine(root, "ThreadPilot.csproj"), $"{ReleaseAssemblyVersion}"); + AssertFileContains(Path.Combine(root, "ThreadPilot.csproj"), $"{ReleaseVersion}"); + AssertFileContains(Path.Combine(root, "app.manifest"), $"version=\"{ReleaseAssemblyVersion}\""); + AssertFileContains(Path.Combine(root, "Installer", "ThreadPilot.wxs"), $"Version=\"{ReleaseAssemblyVersion}\""); + AssertFileContains(Path.Combine(root, "chocolatey", "threadpilot.nuspec"), $"{ReleaseVersion}"); + AssertFileContains(Path.Combine(root, "chocolatey", "threadpilot.nuspec"), $"releases/tag/v{ReleaseVersion}"); + AssertFileContains(Path.Combine(root, "sonar-project.properties"), $"sonar.projectVersion={ReleaseVersion}"); + AssertFileContains(Path.Combine(root, "build", "build-release.ps1"), $"[string]$Version = \"{ReleaseVersion}\""); + AssertFileContains(Path.Combine(root, "build", "build-installer.ps1"), $"[string]$Version = \"{ReleaseVersion}\""); + AssertFileContains(Path.Combine(root, "build", "package-release-zips.ps1"), $"[string]$Version = \"{ReleaseVersion}\""); + Assert.True(File.Exists(Path.Combine(root, "docs", "releases", $"v{ReleaseVersion}.md"))); + AssertFileContains(Path.Combine(root, "docs", "release", "RELEASE_NOTES.md"), $"v{ReleaseVersion}"); + } + + private static void AssertFileContains(string path, string expected) + { + var content = File.ReadAllText(path); + Assert.Contains(expected, content, StringComparison.Ordinal); + } + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "ThreadPilot.csproj")) && + Directory.Exists(Path.Combine(directory.FullName, "Installer"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Repository root could not be located."); + } + + [GeneratedRegex("#define MyAppVersion \"1\\.4\\.0\"", RegexOptions.CultureInvariant)] + private static partial Regex MyAppVersionRegex(); + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PassiveProcessErrorThrottleTests.cs b/Tests/ThreadPilot.Core.Tests/PassiveProcessErrorThrottleTests.cs index 26ce17f..b5b26ff 100644 --- a/Tests/ThreadPilot.Core.Tests/PassiveProcessErrorThrottleTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PassiveProcessErrorThrottleTests.cs @@ -1,59 +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)); - } - } -} +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/PerformanceViewModelDiagnosticsTests.cs b/Tests/ThreadPilot.Core.Tests/PerformanceViewModelDiagnosticsTests.cs index 4d43f20..777b7f0 100644 --- a/Tests/ThreadPilot.Core.Tests/PerformanceViewModelDiagnosticsTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PerformanceViewModelDiagnosticsTests.cs @@ -1,255 +1,255 @@ -namespace ThreadPilot.Core.Tests -{ - using Microsoft.Extensions.Logging.Abstractions; - using Moq; - using ThreadPilot.Models; - using ThreadPilot.Services; - using ThreadPilot.ViewModels; - - public sealed class PerformanceViewModelDiagnosticsTests - { - [Fact] - public async Task InitializeAsync_DoesNotStartLiveMonitoringOrScanProcesses() - { - var harness = new Harness(); - var viewModel = harness.CreateViewModel(); - - await viewModel.InitializeAsync(); - - harness.Performance.Verify(x => x.StartMonitoringAsync(), Times.Never); - harness.Performance.Verify(x => x.GetSystemMetricsAsync(It.IsAny()), Times.Never); - harness.Performance.Verify(x => x.GetTopCpuProcessesAsync(It.IsAny()), Times.Never); - harness.Performance.Verify(x => x.GetTopMemoryProcessesAsync(It.IsAny()), Times.Never); - harness.PowerPlan.Verify(x => x.GetActivePowerPlan(), Times.Never); - } - - [Fact] - public async Task ActivateDiagnosticsAsync_LoadsSnapshotWithoutStartingLiveMonitoring() - { - var harness = new Harness(); - var viewModel = harness.CreateViewModel(); - - await viewModel.ActivateDiagnosticsAsync(); - - harness.Performance.Verify(x => x.GetSystemMetricsAsync(false), Times.Once); - harness.Performance.Verify(x => x.GetHistoricalDataAsync(TimeSpan.FromHours(1)), Times.Once); - harness.Performance.Verify(x => x.GetTopCpuProcessesAsync(25), Times.Once); - harness.Performance.Verify(x => x.GetTopMemoryProcessesAsync(25), Times.Once); - harness.PowerPlan.Verify(x => x.GetActivePowerPlan(), Times.Once); - harness.Performance.Verify(x => x.StartMonitoringAsync(), Times.Never); - Assert.False(viewModel.IsMonitoring); - Assert.Empty(await harness.Audit.GetEntriesAsync()); - } - - [Fact] - public async Task SuspendBackgroundMonitoringAsync_StopsLiveMonitoringAndDoesNotAutoResume() - { - var harness = new Harness(); - var viewModel = harness.CreateViewModel(); - - await viewModel.StartMonitoringCommand.ExecuteAsync(null); - await viewModel.SuspendBackgroundMonitoringAsync(); - await viewModel.ResumeBackgroundMonitoringAsync(); - - harness.Performance.Verify(x => x.StartMonitoringAsync(), Times.Once); - harness.Performance.Verify(x => x.StopMonitoringAsync(), Times.Once); - Assert.False(viewModel.IsMonitoring); - Assert.Equal("Stopped", viewModel.MonitoringStateText); - } - - [Fact] - public async Task StartMonitoringCommand_LogsSuccess_WhenServiceStarts() - { - var harness = new Harness(); - var viewModel = harness.CreateViewModel(); - - await viewModel.StartMonitoringCommand.ExecuteAsync(null); - - harness.Logging.Verify( - logger => logger.LogUserActionAsync( - "OptimizationMonitoringStarted", - "Performance monitoring started", - null), - Times.Once); - var entry = Assert.Single( - await harness.Audit.GetEntriesAsync(), - entry => entry.Message == "Performance monitoring started"); - Assert.Equal("Optimization", entry.Category); - Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); - } - - [Fact] - public async Task StartMonitoringCommand_LogsFailure_WhenServiceFails() - { - var harness = new Harness(startMonitoringThrows: true); - var viewModel = harness.CreateViewModel(); - - await viewModel.StartMonitoringCommand.ExecuteAsync(null); - - Assert.True(viewModel.HasError); - harness.Logging.Verify( - logger => logger.LogUserActionAsync( - "OptimizationActionFailed", - It.Is(details => details.Contains("Failed to start performance monitoring")), - null), - Times.Once); - var entry = Assert.Single( - await harness.Audit.GetEntriesAsync(), - entry => entry.Message.Contains("Failed to start performance monitoring")); - Assert.Equal("Optimization", entry.Category); - Assert.Equal(ActivityAuditSeverity.Error, entry.Severity); - } - - [Fact] - public async Task StopMonitoringCommand_StopsServiceAndLogsSuccess() - { - var harness = new Harness(); - var viewModel = harness.CreateViewModel(); - - await viewModel.StopMonitoringCommand.ExecuteAsync(null); - - harness.Performance.Verify(service => service.StopMonitoringAsync(), Times.Once); - harness.Logging.Verify( - logger => logger.LogUserActionAsync( - "OptimizationMonitoringStopped", - "Performance monitoring stopped", - null), - Times.Once); - var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); - Assert.Equal("Optimization", entry.Category); - Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); - Assert.Equal("Performance monitoring stopped", viewModel.StatusMessage); - } - - [Fact] - public async Task RefreshMetricsCommand_WhenMetricsFails_LogsFailureSafely() - { - var harness = new Harness(metricsThrows: true); - var viewModel = harness.CreateViewModel(); - - await viewModel.RefreshMetricsCommand.ExecuteAsync(null); - - Assert.True(viewModel.HasError); - harness.Logging.Verify( - logger => logger.LogUserActionAsync( - "OptimizationActionFailed", - It.Is(details => details.Contains("Failed to refresh performance snapshot")), - null), - Times.Once); - var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); - Assert.Equal("Optimization", entry.Category); - Assert.Equal(ActivityAuditSeverity.Error, entry.Severity); - } - - [Fact] - public async Task ClearHistoricalDataCommand_ClearsServiceAndLogsSuccess() - { - var harness = new Harness(); - var viewModel = harness.CreateViewModel(); - - await viewModel.ClearHistoricalDataCommand.ExecuteAsync(null); - - harness.Performance.Verify(service => service.ClearHistoricalDataAsync(), Times.Once); - harness.Logging.Verify( - logger => logger.LogUserActionAsync( - "OptimizationHistoryCleared", - "Historical metrics cleared", - null), - Times.Once); - var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); - Assert.Equal("Optimization", entry.Category); - Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); - Assert.Equal("Historical data cleared", viewModel.StatusMessage); - } - - [Fact] - public void ShowAdvancedDiagnostics_DefaultsToHidden() - { - Assert.False(AppNavigationOptions.ShowAdvancedDiagnostics); - } - - private sealed class Harness - { - public Mock Performance { get; } = new(MockBehavior.Strict); - - public Mock Process { get; } = new(MockBehavior.Strict); - - public Mock Associations { get; } = new(MockBehavior.Strict); - - public Mock PowerPlan { get; } = new(MockBehavior.Strict); - - public Mock ProcessMonitorManager { get; } = new(MockBehavior.Strict); - - public Mock SystemTweaks { get; } = new(MockBehavior.Strict); - - public Mock Logging { get; } = new(MockBehavior.Loose); - - public ActivityAuditService Audit { get; } = new(NullLogger.Instance); - - public Harness(bool startMonitoringThrows = false, bool metricsThrows = false) - { - if (metricsThrows) - { - this.Performance - .Setup(x => x.GetSystemMetricsAsync(It.IsAny())) - .ThrowsAsync(new InvalidOperationException("metrics unavailable")); - } - else - { - this.Performance - .Setup(x => x.GetSystemMetricsAsync(It.IsAny())) - .ReturnsAsync(new SystemPerformanceMetrics()); - } - - this.Performance - .Setup(x => x.GetHistoricalDataAsync(It.IsAny())) - .ReturnsAsync(new List()); - this.Performance - .Setup(x => x.GetTopCpuProcessesAsync(It.IsAny())) - .ReturnsAsync(new List()); - this.Performance - .Setup(x => x.GetTopMemoryProcessesAsync(It.IsAny())) - .ReturnsAsync(new List()); - if (startMonitoringThrows) - { - this.Performance - .Setup(x => x.StartMonitoringAsync()) - .ThrowsAsync(new InvalidOperationException("monitoring unavailable")); - } - else - { - this.Performance - .Setup(x => x.StartMonitoringAsync()) - .Returns(Task.CompletedTask); - } - - this.Performance - .Setup(x => x.StopMonitoringAsync()) - .Returns(Task.CompletedTask); - this.Performance - .Setup(x => x.ClearHistoricalDataAsync()) - .Returns(Task.CompletedTask); - - this.Associations - .Setup(x => x.GetAssociationsAsync()) - .ReturnsAsync(Array.Empty()); - - this.PowerPlan - .Setup(x => x.GetActivePowerPlan()) - .ReturnsAsync(new PowerPlanModel { Guid = "balanced", Name = "Balanced" }); - } - - public PerformanceViewModel CreateViewModel() => - new( - this.Performance.Object, - this.Process.Object, - this.Associations.Object, - this.PowerPlan.Object, - this.ProcessMonitorManager.Object, - this.SystemTweaks.Object, - NullLogger.Instance, - this.Logging.Object, - this.Audit); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public sealed class PerformanceViewModelDiagnosticsTests + { + [Fact] + public async Task InitializeAsync_DoesNotStartLiveMonitoringOrScanProcesses() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + + await viewModel.InitializeAsync(); + + harness.Performance.Verify(x => x.StartMonitoringAsync(), Times.Never); + harness.Performance.Verify(x => x.GetSystemMetricsAsync(It.IsAny()), Times.Never); + harness.Performance.Verify(x => x.GetTopCpuProcessesAsync(It.IsAny()), Times.Never); + harness.Performance.Verify(x => x.GetTopMemoryProcessesAsync(It.IsAny()), Times.Never); + harness.PowerPlan.Verify(x => x.GetActivePowerPlan(), Times.Never); + } + + [Fact] + public async Task ActivateDiagnosticsAsync_LoadsSnapshotWithoutStartingLiveMonitoring() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + + await viewModel.ActivateDiagnosticsAsync(); + + harness.Performance.Verify(x => x.GetSystemMetricsAsync(false), Times.Once); + harness.Performance.Verify(x => x.GetHistoricalDataAsync(TimeSpan.FromHours(1)), Times.Once); + harness.Performance.Verify(x => x.GetTopCpuProcessesAsync(25), Times.Once); + harness.Performance.Verify(x => x.GetTopMemoryProcessesAsync(25), Times.Once); + harness.PowerPlan.Verify(x => x.GetActivePowerPlan(), Times.Once); + harness.Performance.Verify(x => x.StartMonitoringAsync(), Times.Never); + Assert.False(viewModel.IsMonitoring); + Assert.Empty(await harness.Audit.GetEntriesAsync()); + } + + [Fact] + public async Task SuspendBackgroundMonitoringAsync_StopsLiveMonitoringAndDoesNotAutoResume() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + + await viewModel.StartMonitoringCommand.ExecuteAsync(null); + await viewModel.SuspendBackgroundMonitoringAsync(); + await viewModel.ResumeBackgroundMonitoringAsync(); + + harness.Performance.Verify(x => x.StartMonitoringAsync(), Times.Once); + harness.Performance.Verify(x => x.StopMonitoringAsync(), Times.Once); + Assert.False(viewModel.IsMonitoring); + Assert.Equal("Stopped", viewModel.MonitoringStateText); + } + + [Fact] + public async Task StartMonitoringCommand_LogsSuccess_WhenServiceStarts() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + + await viewModel.StartMonitoringCommand.ExecuteAsync(null); + + harness.Logging.Verify( + logger => logger.LogUserActionAsync( + "OptimizationMonitoringStarted", + "Performance monitoring started", + null), + Times.Once); + var entry = Assert.Single( + await harness.Audit.GetEntriesAsync(), + entry => entry.Message == "Performance monitoring started"); + Assert.Equal("Optimization", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + } + + [Fact] + public async Task StartMonitoringCommand_LogsFailure_WhenServiceFails() + { + var harness = new Harness(startMonitoringThrows: true); + var viewModel = harness.CreateViewModel(); + + await viewModel.StartMonitoringCommand.ExecuteAsync(null); + + Assert.True(viewModel.HasError); + harness.Logging.Verify( + logger => logger.LogUserActionAsync( + "OptimizationActionFailed", + It.Is(details => details.Contains("Failed to start performance monitoring")), + null), + Times.Once); + var entry = Assert.Single( + await harness.Audit.GetEntriesAsync(), + entry => entry.Message.Contains("Failed to start performance monitoring")); + Assert.Equal("Optimization", entry.Category); + Assert.Equal(ActivityAuditSeverity.Error, entry.Severity); + } + + [Fact] + public async Task StopMonitoringCommand_StopsServiceAndLogsSuccess() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + + await viewModel.StopMonitoringCommand.ExecuteAsync(null); + + harness.Performance.Verify(service => service.StopMonitoringAsync(), Times.Once); + harness.Logging.Verify( + logger => logger.LogUserActionAsync( + "OptimizationMonitoringStopped", + "Performance monitoring stopped", + null), + Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Optimization", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + Assert.Equal("Performance monitoring stopped", viewModel.StatusMessage); + } + + [Fact] + public async Task RefreshMetricsCommand_WhenMetricsFails_LogsFailureSafely() + { + var harness = new Harness(metricsThrows: true); + var viewModel = harness.CreateViewModel(); + + await viewModel.RefreshMetricsCommand.ExecuteAsync(null); + + Assert.True(viewModel.HasError); + harness.Logging.Verify( + logger => logger.LogUserActionAsync( + "OptimizationActionFailed", + It.Is(details => details.Contains("Failed to refresh performance snapshot")), + null), + Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Optimization", entry.Category); + Assert.Equal(ActivityAuditSeverity.Error, entry.Severity); + } + + [Fact] + public async Task ClearHistoricalDataCommand_ClearsServiceAndLogsSuccess() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + + await viewModel.ClearHistoricalDataCommand.ExecuteAsync(null); + + harness.Performance.Verify(service => service.ClearHistoricalDataAsync(), Times.Once); + harness.Logging.Verify( + logger => logger.LogUserActionAsync( + "OptimizationHistoryCleared", + "Historical metrics cleared", + null), + Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Optimization", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + Assert.Equal("Historical data cleared", viewModel.StatusMessage); + } + + [Fact] + public void ShowAdvancedDiagnostics_DefaultsToHidden() + { + Assert.False(AppNavigationOptions.ShowAdvancedDiagnostics); + } + + private sealed class Harness + { + public Mock Performance { get; } = new(MockBehavior.Strict); + + public Mock Process { get; } = new(MockBehavior.Strict); + + public Mock Associations { get; } = new(MockBehavior.Strict); + + public Mock PowerPlan { get; } = new(MockBehavior.Strict); + + public Mock ProcessMonitorManager { get; } = new(MockBehavior.Strict); + + public Mock SystemTweaks { get; } = new(MockBehavior.Strict); + + public Mock Logging { get; } = new(MockBehavior.Loose); + + public ActivityAuditService Audit { get; } = new(NullLogger.Instance); + + public Harness(bool startMonitoringThrows = false, bool metricsThrows = false) + { + if (metricsThrows) + { + this.Performance + .Setup(x => x.GetSystemMetricsAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("metrics unavailable")); + } + else + { + this.Performance + .Setup(x => x.GetSystemMetricsAsync(It.IsAny())) + .ReturnsAsync(new SystemPerformanceMetrics()); + } + + this.Performance + .Setup(x => x.GetHistoricalDataAsync(It.IsAny())) + .ReturnsAsync(new List()); + this.Performance + .Setup(x => x.GetTopCpuProcessesAsync(It.IsAny())) + .ReturnsAsync(new List()); + this.Performance + .Setup(x => x.GetTopMemoryProcessesAsync(It.IsAny())) + .ReturnsAsync(new List()); + if (startMonitoringThrows) + { + this.Performance + .Setup(x => x.StartMonitoringAsync()) + .ThrowsAsync(new InvalidOperationException("monitoring unavailable")); + } + else + { + this.Performance + .Setup(x => x.StartMonitoringAsync()) + .Returns(Task.CompletedTask); + } + + this.Performance + .Setup(x => x.StopMonitoringAsync()) + .Returns(Task.CompletedTask); + this.Performance + .Setup(x => x.ClearHistoricalDataAsync()) + .Returns(Task.CompletedTask); + + this.Associations + .Setup(x => x.GetAssociationsAsync()) + .ReturnsAsync(Array.Empty()); + + this.PowerPlan + .Setup(x => x.GetActivePowerPlan()) + .ReturnsAsync(new PowerPlanModel { Guid = "balanced", Name = "Balanced" }); + } + + public PerformanceViewModel CreateViewModel() => + new( + this.Performance.Object, + this.Process.Object, + this.Associations.Object, + this.PowerPlan.Object, + this.ProcessMonitorManager.Object, + this.SystemTweaks.Object, + NullLogger.Instance, + this.Logging.Object, + this.Audit); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs index 5fb13e3..94529e1 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs @@ -1,103 +1,103 @@ -/* - * ThreadPilot - persistent process rule JSON store tests. - */ -namespace ThreadPilot.Core.Tests -{ - using System.Diagnostics; - using ThreadPilot.Models; - using ThreadPilot.Services; - - public sealed class PersistentProcessRuleJsonStoreTests - { - [Fact] - public async Task LoadAsync_WithMissingFile_ReturnsEmptyList() - { - var filePath = CreateTemporaryFilePath(); - var store = new PersistentProcessRuleJsonStore(() => filePath); - - var rules = await store.LoadAsync(); - - Assert.Empty(rules); - } - - [Fact] - public async Task SaveAndLoadAsync_RoundTripsCpuSelectionAndLegacyAffinityMask() - { - var filePath = CreateTemporaryFilePath(); - var store = new PersistentProcessRuleJsonStore(() => filePath); - var rule = new PersistentProcessRule - { - Id = "rule-a", - Name = "Game", - IsEnabled = true, - ProcessName = "game.exe", - CpuSelection = new CpuSelection - { - LogicalProcessors = [new ProcessorRef(0, 0, 0)], - GlobalLogicalProcessorIndexes = [0], - }, - LegacyAffinityMask = 3, - Priority = ProcessPriorityClass.AboveNormal, - MemoryPriority = ProcessMemoryPriority.BelowNormal, - ApplyAffinityOnStart = true, - ApplyPriorityOnStart = true, - ApplyMemoryPriorityOnStart = true, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - Description = ProcessOperationUserMessages.PersistentRulesDescription, - }; - - try - { - await store.SaveAsync([rule]); - - var loaded = await store.LoadAsync(); - - var loadedRule = Assert.Single(loaded); - Assert.Equal("rule-a", loadedRule.Id); - Assert.Equal(3, loadedRule.LegacyAffinityMask); - Assert.Equal(ProcessPriorityClass.AboveNormal, loadedRule.Priority); - Assert.Equal(ProcessMemoryPriority.BelowNormal, loadedRule.MemoryPriority); - Assert.True(loadedRule.ApplyMemoryPriorityOnStart); - Assert.NotNull(loadedRule.CpuSelection); - Assert.Equal(0, loadedRule.CpuSelection.GlobalLogicalProcessorIndexes.Single()); - } - finally - { - DeleteFile(filePath); - } - } - - [Fact] - public async Task LoadAsync_WithCorruptJson_ReturnsEmptyList() - { - var filePath = CreateTemporaryFilePath(); - Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); - await File.WriteAllTextAsync(filePath, "{ not json"); - var store = new PersistentProcessRuleJsonStore(() => filePath); - - try - { - var rules = await store.LoadAsync(); - - Assert.Empty(rules); - } - finally - { - DeleteFile(filePath); - } - } - - private static string CreateTemporaryFilePath() => - Path.Combine(Path.GetTempPath(), $"threadpilot-rules-{Guid.NewGuid():N}", "rules.json"); - - private static void DeleteFile(string filePath) - { - var directory = Path.GetDirectoryName(filePath); - if (directory != null && Directory.Exists(directory)) - { - Directory.Delete(directory, recursive: true); - } - } - } -} +/* + * ThreadPilot - persistent process rule JSON store tests. + */ +namespace ThreadPilot.Core.Tests +{ + using System.Diagnostics; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class PersistentProcessRuleJsonStoreTests + { + [Fact] + public async Task LoadAsync_WithMissingFile_ReturnsEmptyList() + { + var filePath = CreateTemporaryFilePath(); + var store = new PersistentProcessRuleJsonStore(() => filePath); + + var rules = await store.LoadAsync(); + + Assert.Empty(rules); + } + + [Fact] + public async Task SaveAndLoadAsync_RoundTripsCpuSelectionAndLegacyAffinityMask() + { + var filePath = CreateTemporaryFilePath(); + var store = new PersistentProcessRuleJsonStore(() => filePath); + var rule = new PersistentProcessRule + { + Id = "rule-a", + Name = "Game", + IsEnabled = true, + ProcessName = "game.exe", + CpuSelection = new CpuSelection + { + LogicalProcessors = [new ProcessorRef(0, 0, 0)], + GlobalLogicalProcessorIndexes = [0], + }, + LegacyAffinityMask = 3, + Priority = ProcessPriorityClass.AboveNormal, + MemoryPriority = ProcessMemoryPriority.BelowNormal, + ApplyAffinityOnStart = true, + ApplyPriorityOnStart = true, + ApplyMemoryPriorityOnStart = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + Description = ProcessOperationUserMessages.PersistentRulesDescription, + }; + + try + { + await store.SaveAsync([rule]); + + var loaded = await store.LoadAsync(); + + var loadedRule = Assert.Single(loaded); + Assert.Equal("rule-a", loadedRule.Id); + Assert.Equal(3, loadedRule.LegacyAffinityMask); + Assert.Equal(ProcessPriorityClass.AboveNormal, loadedRule.Priority); + Assert.Equal(ProcessMemoryPriority.BelowNormal, loadedRule.MemoryPriority); + Assert.True(loadedRule.ApplyMemoryPriorityOnStart); + Assert.NotNull(loadedRule.CpuSelection); + Assert.Equal(0, loadedRule.CpuSelection.GlobalLogicalProcessorIndexes.Single()); + } + finally + { + DeleteFile(filePath); + } + } + + [Fact] + public async Task LoadAsync_WithCorruptJson_ReturnsEmptyList() + { + var filePath = CreateTemporaryFilePath(); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + await File.WriteAllTextAsync(filePath, "{ not json"); + var store = new PersistentProcessRuleJsonStore(() => filePath); + + try + { + var rules = await store.LoadAsync(); + + Assert.Empty(rules); + } + finally + { + DeleteFile(filePath); + } + } + + private static string CreateTemporaryFilePath() => + Path.Combine(Path.GetTempPath(), $"threadpilot-rules-{Guid.NewGuid():N}", "rules.json"); + + private static void DeleteFile(string filePath) + { + var directory = Path.GetDirectoryName(filePath); + if (directory != null && Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleMatcherTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleMatcherTests.cs index dd76b77..40c689f 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleMatcherTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleMatcherTests.cs @@ -1,97 +1,97 @@ -/* - * ThreadPilot - persistent process rule matcher tests. - */ -namespace ThreadPilot.Core.Tests -{ - using ThreadPilot.Models; - using ThreadPilot.Services; - - public sealed class PersistentProcessRuleMatcherTests - { - private readonly PersistentProcessRuleMatcher matcher = new(); - - [Fact] - public void IsMatch_WithProcessName_MatchesCaseInsensitive() - { - var rule = CreateRule(processName: "GAME.EXE"); - var process = CreateProcess(name: "game.exe"); - - var result = this.matcher.IsMatch(rule, process); - - Assert.True(result); - } - - [Fact] - public void IsMatch_WithExecutablePath_MatchesCaseInsensitive() - { - var rule = CreateRule(executablePath: @"C:\Games\App\Game.exe"); - var process = CreateProcess(executablePath: @"c:\games\app\game.exe"); - - var result = this.matcher.IsMatch(rule, process); - - Assert.True(result); - } - - [Fact] - public void IsMatch_WithNameAndPath_UsesExecutablePathPriority() - { - var rule = CreateRule(processName: "game.exe", executablePath: @"C:\Games\App\Game.exe"); - var process = CreateProcess(name: "game.exe", executablePath: @"C:\Other\Game.exe"); - - var result = this.matcher.IsMatch(rule, process); - - Assert.False(result); - } - - [Fact] - public void IsMatch_WithDisabledRule_ReturnsFalse() - { - var rule = CreateRule(processName: "game.exe") with { IsEnabled = false }; - var process = CreateProcess(name: "game.exe"); - - var result = this.matcher.IsMatch(rule, process); - - Assert.False(result); - } - - [Fact] - public void IsMatch_WithProcessWithoutExecutablePath_CanMatchProcessName() - { - var rule = CreateRule(processName: "game.exe"); - var process = CreateProcess(name: "GAME.EXE", executablePath: string.Empty); - - var result = this.matcher.IsMatch(rule, process); - - Assert.True(result); - } - - [Fact] - public void IsMatch_WithNullPaths_DoesNotThrow() - { - var rule = CreateRule(processName: null, executablePath: null); - var process = CreateProcess(name: "game.exe", executablePath: null); - - var exception = Record.Exception(() => this.matcher.IsMatch(rule, process)); - - Assert.Null(exception); - } - - private static PersistentProcessRule CreateRule(string? processName = null, string? executablePath = null) => - new() - { - Id = Guid.NewGuid().ToString("N"), - Name = "Rule", - IsEnabled = true, - ProcessName = processName, - ExecutablePath = executablePath, - }; - - private static ProcessModel CreateProcess(string name = "game.exe", string? executablePath = @"C:\Games\Game.exe") => - new() - { - ProcessId = 42, - Name = name, - ExecutablePath = executablePath ?? string.Empty, - }; - } -} +/* + * ThreadPilot - persistent process rule matcher tests. + */ +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class PersistentProcessRuleMatcherTests + { + private readonly PersistentProcessRuleMatcher matcher = new(); + + [Fact] + public void IsMatch_WithProcessName_MatchesCaseInsensitive() + { + var rule = CreateRule(processName: "GAME.EXE"); + var process = CreateProcess(name: "game.exe"); + + var result = this.matcher.IsMatch(rule, process); + + Assert.True(result); + } + + [Fact] + public void IsMatch_WithExecutablePath_MatchesCaseInsensitive() + { + var rule = CreateRule(executablePath: @"C:\Games\App\Game.exe"); + var process = CreateProcess(executablePath: @"c:\games\app\game.exe"); + + var result = this.matcher.IsMatch(rule, process); + + Assert.True(result); + } + + [Fact] + public void IsMatch_WithNameAndPath_UsesExecutablePathPriority() + { + var rule = CreateRule(processName: "game.exe", executablePath: @"C:\Games\App\Game.exe"); + var process = CreateProcess(name: "game.exe", executablePath: @"C:\Other\Game.exe"); + + var result = this.matcher.IsMatch(rule, process); + + Assert.False(result); + } + + [Fact] + public void IsMatch_WithDisabledRule_ReturnsFalse() + { + var rule = CreateRule(processName: "game.exe") with { IsEnabled = false }; + var process = CreateProcess(name: "game.exe"); + + var result = this.matcher.IsMatch(rule, process); + + Assert.False(result); + } + + [Fact] + public void IsMatch_WithProcessWithoutExecutablePath_CanMatchProcessName() + { + var rule = CreateRule(processName: "game.exe"); + var process = CreateProcess(name: "GAME.EXE", executablePath: string.Empty); + + var result = this.matcher.IsMatch(rule, process); + + Assert.True(result); + } + + [Fact] + public void IsMatch_WithNullPaths_DoesNotThrow() + { + var rule = CreateRule(processName: null, executablePath: null); + var process = CreateProcess(name: "game.exe", executablePath: null); + + var exception = Record.Exception(() => this.matcher.IsMatch(rule, process)); + + Assert.Null(exception); + } + + private static PersistentProcessRule CreateRule(string? processName = null, string? executablePath = null) => + new() + { + Id = Guid.NewGuid().ToString("N"), + Name = "Rule", + IsEnabled = true, + ProcessName = processName, + ExecutablePath = executablePath, + }; + + private static ProcessModel CreateProcess(string name = "game.exe", string? executablePath = @"C:\Games\Game.exe") => + new() + { + ProcessId = 42, + Name = name, + ExecutablePath = executablePath ?? string.Empty, + }; + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs index a2e2913..494d46d 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs @@ -1,512 +1,512 @@ -/* - * ThreadPilot - persistent rule auto-apply coordinator tests. - */ -namespace ThreadPilot.Core.Tests -{ - using System.Diagnostics; - using Microsoft.Extensions.Logging.Abstractions; - using Moq; - using ThreadPilot.Models; - using ThreadPilot.Services; - - public sealed class PersistentRuleAutoApplyServiceTests - { - [Fact] - public async Task ApplyForProcessStartAsync_WhenMatchingEnabledRuleExists_CallsRulesEngine() - { - var process = CreateProcess(); - var rule = CreateRule(); - var engine = CreateEngine(rule, CreateSuccess(rule, process)); - var audit = new ActivityAuditService(NullLogger.Instance); - var service = CreateService([rule], engine.Object, audit: audit); - - var results = await service.ApplyForProcessStartAsync(process); - - Assert.Single(results); - var entry = Assert.Single(await audit.GetEntriesAsync()); - Assert.Equal("Rules", entry.Category); - Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); - Assert.Contains("Auto-applied saved rule", entry.Message); - engine.Verify( - x => x.ApplyMatchingRulesAsync( - process, - It.IsAny?>(), - It.IsAny()), - Times.Once); - } - - [Fact] - public async Task ApplyForProcessStartAsync_WhenRuleIsDisabled_DoesNotCallRulesEngine() - { - var process = CreateProcess(); - var rule = CreateRule() with { IsEnabled = false }; - var engine = CreateEngine(rule, CreateSuccess(rule, process)); - var service = CreateService([rule], engine.Object); - - var results = await service.ApplyForProcessStartAsync(process); - - Assert.Empty(results); - engine.Verify( - x => x.ApplyMatchingRulesAsync( - It.IsAny(), - It.IsAny?>(), - It.IsAny()), - Times.Never); - } - - [Fact] - public async Task ApplyForProcessStartAsync_WhenNoRuleMatches_DoesNotCallRulesEngine() - { - var process = CreateProcess("editor.exe"); - var rule = CreateRule(processName: "game.exe"); - var engine = CreateEngine(rule, CreateSuccess(rule, process)); - var service = CreateService([rule], engine.Object); - - var results = await service.ApplyForProcessStartAsync(process); - - Assert.Empty(results); - engine.Verify( - x => x.ApplyMatchingRulesAsync( - It.IsAny(), - It.IsAny?>(), - It.IsAny()), - Times.Never); - } - - [Fact] - public async Task ApplyForProcessStartAsync_DoesNotReapplySameRuleDuringCooldown() - { - var now = DateTimeOffset.UtcNow; - var process = CreateProcess(); - var rule = CreateRule(); - var engine = CreateEngine(rule, CreateSuccess(rule, process)); - var audit = new ActivityAuditService(NullLogger.Instance); - var service = CreateService([rule], engine.Object, nowProvider: () => now, audit: audit); - - await service.ApplyForProcessStartAsync(process); - await service.ApplyForProcessStartAsync(process); - - Assert.Single(await audit.GetEntriesAsync()); - engine.Verify( - x => x.ApplyMatchingRulesAsync( - process, - It.IsAny?>(), - It.IsAny()), - Times.Once); - } - - [Fact] - public async Task ApplyForProcessStartAsync_AfterCooldown_RetriesRule() - { - var now = DateTimeOffset.UtcNow; - var process = CreateProcess(); - var rule = CreateRule(); - var engine = CreateEngine(rule, CreateSuccess(rule, process)); - var service = CreateService([rule], engine.Object, nowProvider: () => now); - - await service.ApplyForProcessStartAsync(process); - now = now.AddSeconds(31); - await service.ApplyForProcessStartAsync(process); - - engine.Verify( - x => x.ApplyMatchingRulesAsync( - process, - It.IsAny?>(), - It.IsAny()), - Times.Exactly(2)); - } - - [Fact] - public async Task ApplyForProcessStartAsync_AfterProcessExit_DoesNotSuppressReusedPid() - { - var process = CreateProcess(); - var rule = CreateRule(); - var engine = CreateEngine(rule, CreateSuccess(rule, process)); - var service = CreateService([rule], engine.Object); - - await service.ApplyForProcessStartAsync(process); - service.MarkProcessExited(process.ProcessId); - await service.ApplyForProcessStartAsync(process); - - engine.Verify( - x => x.ApplyMatchingRulesAsync( - process, - It.IsAny?>(), - It.IsAny()), - Times.Exactly(2)); - } - - [Fact] - public async Task ApplyForProcessStartAsync_WithAccessDeniedFailure_ReturnsFailureWithoutThrowing() - { - var process = CreateProcess(); - var rule = CreateRule(); - var failure = CreateFailure(rule, process, ProcessOperationUserMessages.AccessDenied, isAccessDenied: true); - var engine = CreateEngine(rule, failure); - var audit = new ActivityAuditService(NullLogger.Instance); - var service = CreateService([rule], engine.Object, audit: audit); - - var result = Assert.Single(await service.ApplyForProcessStartAsync(process)); - - Assert.False(result.Success); - Assert.True(result.IsAccessDenied); - Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); - var entry = Assert.Single(await audit.GetEntriesAsync()); - Assert.Equal("Rules", entry.Category); - Assert.Equal(ActivityAuditSeverity.Warning, entry.Severity); - Assert.Contains("Failed to auto-apply saved rule", entry.Message); - } - - [Fact] - public async Task ApplyForProcessStartAsync_WithProcessExitedFailure_ReturnsFailureWithoutThrowing() - { - var process = CreateProcess(); - var rule = CreateRule(); - var failure = CreateFailure(rule, process, ProcessOperationUserMessages.ProcessExited, isProcessExited: true); - var engine = CreateEngine(rule, failure); - var service = CreateService([rule], engine.Object); - - var result = Assert.Single(await service.ApplyForProcessStartAsync(process)); - - Assert.False(result.Success); - Assert.True(result.IsProcessExited); - Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); - } - - [Fact] - public async Task ApplyForProcessStartAsync_WithProtectedProcessFailure_ReturnsSafeFailureWithoutThrowing() - { - var process = CreateProcess(); - var rule = CreateRule(); - var failure = CreateFailure( - rule, - process, - ProcessOperationUserMessages.AntiCheatProtectedLikely, - isAccessDenied: true, - isAntiCheatLikely: true); - var engine = CreateEngine(rule, failure); - var service = CreateService([rule], engine.Object); - - var result = Assert.Single(await service.ApplyForProcessStartAsync(process)); - - Assert.False(result.Success); - Assert.True(result.IsAntiCheatLikely); - Assert.DoesNotContain("bypass", result.UserMessage, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task ApplyForProcessStartAsync_WhenRulesEngineCancels_PropagatesCancellation() - { - var process = CreateProcess(); - var rule = CreateRule(); - var engine = CreateEngineThatCancels(); - var service = CreateService([rule], engine.Object); - - await Assert.ThrowsAsync(() => - service.ApplyForProcessStartAsync(process)); - } - - [Fact] - public async Task ApplyForProcessStartAsync_FeatureFlagDisabled_DoesNotCallRulesEngine() - { - var process = CreateProcess(); - var rule = CreateRule(); - var engine = CreateEngine(rule, CreateSuccess(rule, process)); - var service = CreateService( - [rule], - engine.Object, - settings: new ApplicationSettingsModel { ApplyPersistentRulesOnProcessStart = false }); - - var results = await service.ApplyForProcessStartAsync(process); - - Assert.Empty(results); - engine.Verify( - x => x.ApplyMatchingRulesAsync( - It.IsAny(), - It.IsAny?>(), - It.IsAny()), - Times.Never); - } - - [Theory] - [InlineData(0, "game.exe")] - [InlineData(42, "")] - [InlineData(42, " ")] - public async Task ApplyForProcessStartAsync_WithInvalidProcess_DoesNotCallRulesEngine(int processId, string processName) - { - var process = CreateProcess(processName); - process.ProcessId = processId; - var rule = CreateRule(); - var engine = CreateEngine(rule, CreateSuccess(rule, process)); - var service = CreateService([rule], engine.Object); - - var results = await service.ApplyForProcessStartAsync(process); - - Assert.Empty(results); - engine.Verify( - x => x.ApplyMatchingRulesAsync( - It.IsAny(), - It.IsAny?>(), - It.IsAny()), - Times.Never); - } - - [Fact] - public async Task ApplyForDiscoveredProcessesAsync_FeatureFlagDisabled_DoesNotCallRulesEngine() - { - var process = CreateProcess(); - var rule = CreateRule(); - var engine = CreateEngine(rule, CreateSuccess(rule, process)); - var service = CreateService( - [rule], - engine.Object, - settings: new ApplicationSettingsModel { ApplyPersistentRulesOnProcessStart = false }); - - var results = await service.ApplyForDiscoveredProcessesAsync([process]); - - Assert.Empty(results); - engine.Verify( - x => x.ApplyMatchingRulesAsync( - It.IsAny(), - It.IsAny?>(), - It.IsAny()), - Times.Never); - } - - [Fact] - public async Task ApplyForDiscoveredProcessesAsync_GroupsDuplicateProcessesByProcessId() - { - var process = CreateProcess(); - var duplicate = CreateProcess("game.exe"); - duplicate.ExecutablePath = @"C:\Games\GameCopy.exe"; - var rule = CreateRule(); - var engine = CreateEngine(rule, CreateSuccess(rule, process)); - var service = CreateService([rule], engine.Object); - - await service.ApplyForDiscoveredProcessesAsync([process, duplicate]); - - engine.Verify( - x => x.ApplyMatchingRulesAsync( - It.IsAny(), - It.IsAny?>(), - It.IsAny()), - Times.Once); - } - - [Fact] - public async Task ApplyForDiscoveredProcessesAsync_ClearsCooldownForProcessesNoLongerPresent() - { - var process = CreateProcess(); - var rule = CreateRule(); - var engine = CreateEngine(rule, CreateSuccess(rule, process)); - var service = CreateService([rule], engine.Object); - - await service.ApplyForDiscoveredProcessesAsync([process]); - await service.ApplyForDiscoveredProcessesAsync([]); - await service.ApplyForDiscoveredProcessesAsync([process]); - - engine.Verify( - x => x.ApplyMatchingRulesAsync( - process, - It.IsAny?>(), - It.IsAny()), - Times.Exactly(2)); - } - - [Fact] - public async Task ApplyForProcessStartAsync_WhenRuleUpdatedDuringCooldown_AllowsReapply() - { - var process = CreateProcess(); - var rule = CreateRule(); - var rules = new List { rule }; - var engine = CreateEngine(rule, CreateSuccess(rule, process)); - var service = CreateService(rules, engine.Object); - - await service.ApplyForProcessStartAsync(process); - rules[0] = rule with { UpdatedAt = rule.UpdatedAt.AddSeconds(1) }; - await service.ApplyForProcessStartAsync(process); - - engine.Verify( - x => x.ApplyMatchingRulesAsync( - process, - It.IsAny?>(), - It.IsAny()), - Times.Exactly(2)); - } - - [Fact] - public async Task ApplyForProcessStartAsync_WhenRulesEngineThrows_ReturnsControlledFailure() - { - var process = CreateProcess(); - var rule = CreateRule(); - var engine = CreateEngineThatThrows(new InvalidOperationException("native apply failed")); - var service = CreateService([rule], engine.Object); - - var result = Assert.Single(await service.ApplyForProcessStartAsync(process)); - - Assert.False(result.Success); - Assert.Equal(rule.Id, result.RuleId); - Assert.Equal(process.ProcessId, result.ProcessId); - Assert.Equal("ThreadPilot could not apply the saved rule.", result.UserMessage); - Assert.Equal("native apply failed", result.TechnicalMessage); - } - - [Fact] - public async Task MarkProcessExited_RemovesOnlyMatchingProcessAttempts() - { - var process = CreateProcess(); - var otherProcess = CreateProcess("game.exe"); - otherProcess.ProcessId = 84; - var rule = CreateRule(); - var engine = CreateEngine(rule, CreateSuccess(rule, process)); - var service = CreateService([rule], engine.Object); - - await service.ApplyForProcessStartAsync(process); - await service.ApplyForProcessStartAsync(otherProcess); - service.MarkProcessExited(process.ProcessId); - await service.ApplyForProcessStartAsync(process); - await service.ApplyForProcessStartAsync(otherProcess); - - engine.Verify( - x => x.ApplyMatchingRulesAsync( - process, - It.IsAny?>(), - It.IsAny()), - Times.Exactly(2)); - engine.Verify( - x => x.ApplyMatchingRulesAsync( - otherProcess, - It.IsAny?>(), - It.IsAny()), - Times.Once); - } - - private static PersistentRuleAutoApplyService CreateService( - IReadOnlyList rules, - IPersistentRulesEngine engine, - ApplicationSettingsModel? settings = null, - Func? nowProvider = null, - IActivityAuditService? audit = null) => - new( - new FakePersistentProcessRuleStore(rules), - new PersistentProcessRuleMatcher(), - engine, - CreateSettingsService(settings ?? new ApplicationSettingsModel()), - NullLogger.Instance, - nowProvider ?? (() => DateTimeOffset.UtcNow), - TimeSpan.FromSeconds(30), - audit); - - private static Mock CreateEngine( - PersistentProcessRule rule, - PersistentRuleApplyResult result) - { - var engine = new Mock(MockBehavior.Strict); - engine - .Setup(x => x.ApplyMatchingRulesAsync( - It.IsAny(), - It.IsAny?>(), - It.IsAny())) - .ReturnsAsync((ProcessModel _, Predicate? predicate, CancellationToken _) => - predicate == null || predicate(rule) - ? new[] { result } - : Array.Empty()); - return engine; - } - - private static Mock CreateEngineThatCancels() - { - var engine = new Mock(MockBehavior.Strict); - engine - .Setup(x => x.ApplyMatchingRulesAsync( - It.IsAny(), - It.IsAny?>(), - It.IsAny())) - .ThrowsAsync(new OperationCanceledException()); - return engine; - } - - private static Mock CreateEngineThatThrows(Exception exception) - { - var engine = new Mock(MockBehavior.Strict); - engine - .Setup(x => x.ApplyMatchingRulesAsync( - It.IsAny(), - It.IsAny?>(), - It.IsAny())) - .ThrowsAsync(exception); - return engine; - } - - private static IApplicationSettingsService CreateSettingsService(ApplicationSettingsModel settings) - { - var settingsService = new Mock(MockBehavior.Loose); - settingsService.SetupGet(x => x.Settings).Returns(settings); - return settingsService.Object; - } - - private static ProcessModel CreateProcess(string name = "game.exe") => - new() - { - ProcessId = 42, - Name = name, - ExecutablePath = @"C:\Games\Game.exe", - Priority = ProcessPriorityClass.Normal, - }; - - private static PersistentProcessRule CreateRule(string id = "rule", string processName = "game.exe") => - new() - { - Id = id, - Name = id, - IsEnabled = true, - ProcessName = processName, - LegacyAffinityMask = 3, - ApplyAffinityOnStart = true, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - }; - - private static PersistentRuleApplyResult CreateSuccess(PersistentProcessRule rule, ProcessModel process) => - new() - { - Success = true, - RuleId = rule.Id, - ProcessId = process.ProcessId, - ProcessName = process.Name, - AffinityApplied = true, - UserMessage = "Persistent rule applied.", - TechnicalMessage = "ok", - }; - - private static PersistentRuleApplyResult CreateFailure( - PersistentProcessRule rule, - ProcessModel process, - string userMessage, - bool isAccessDenied = false, - bool isAntiCheatLikely = false, - bool isProcessExited = false) => - new() - { - Success = false, - RuleId = rule.Id, - ProcessId = process.ProcessId, - ProcessName = process.Name, - UserMessage = userMessage, - TechnicalMessage = userMessage, - IsAccessDenied = isAccessDenied, - IsAntiCheatLikely = isAntiCheatLikely, - IsProcessExited = isProcessExited, - }; - - private sealed class FakePersistentProcessRuleStore(IReadOnlyList rules) - : IPersistentProcessRuleStore - { - public Task> LoadAsync() => - Task.FromResult(rules); - - public Task SaveAsync(IReadOnlyList rules) => - Task.CompletedTask; - } - } -} +/* + * ThreadPilot - persistent rule auto-apply coordinator tests. + */ +namespace ThreadPilot.Core.Tests +{ + using System.Diagnostics; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class PersistentRuleAutoApplyServiceTests + { + [Fact] + public async Task ApplyForProcessStartAsync_WhenMatchingEnabledRuleExists_CallsRulesEngine() + { + var process = CreateProcess(); + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var audit = new ActivityAuditService(NullLogger.Instance); + var service = CreateService([rule], engine.Object, audit: audit); + + var results = await service.ApplyForProcessStartAsync(process); + + Assert.Single(results); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Rules", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + Assert.Contains("Auto-applied saved rule", entry.Message); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + process, + It.IsAny?>(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ApplyForProcessStartAsync_WhenRuleIsDisabled_DoesNotCallRulesEngine() + { + var process = CreateProcess(); + var rule = CreateRule() with { IsEnabled = false }; + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService([rule], engine.Object); + + var results = await service.ApplyForProcessStartAsync(process); + + Assert.Empty(results); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ApplyForProcessStartAsync_WhenNoRuleMatches_DoesNotCallRulesEngine() + { + var process = CreateProcess("editor.exe"); + var rule = CreateRule(processName: "game.exe"); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService([rule], engine.Object); + + var results = await service.ApplyForProcessStartAsync(process); + + Assert.Empty(results); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ApplyForProcessStartAsync_DoesNotReapplySameRuleDuringCooldown() + { + var now = DateTimeOffset.UtcNow; + var process = CreateProcess(); + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var audit = new ActivityAuditService(NullLogger.Instance); + var service = CreateService([rule], engine.Object, nowProvider: () => now, audit: audit); + + await service.ApplyForProcessStartAsync(process); + await service.ApplyForProcessStartAsync(process); + + Assert.Single(await audit.GetEntriesAsync()); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + process, + It.IsAny?>(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ApplyForProcessStartAsync_AfterCooldown_RetriesRule() + { + var now = DateTimeOffset.UtcNow; + var process = CreateProcess(); + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService([rule], engine.Object, nowProvider: () => now); + + await service.ApplyForProcessStartAsync(process); + now = now.AddSeconds(31); + await service.ApplyForProcessStartAsync(process); + + engine.Verify( + x => x.ApplyMatchingRulesAsync( + process, + It.IsAny?>(), + It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task ApplyForProcessStartAsync_AfterProcessExit_DoesNotSuppressReusedPid() + { + var process = CreateProcess(); + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService([rule], engine.Object); + + await service.ApplyForProcessStartAsync(process); + service.MarkProcessExited(process.ProcessId); + await service.ApplyForProcessStartAsync(process); + + engine.Verify( + x => x.ApplyMatchingRulesAsync( + process, + It.IsAny?>(), + It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task ApplyForProcessStartAsync_WithAccessDeniedFailure_ReturnsFailureWithoutThrowing() + { + var process = CreateProcess(); + var rule = CreateRule(); + var failure = CreateFailure(rule, process, ProcessOperationUserMessages.AccessDenied, isAccessDenied: true); + var engine = CreateEngine(rule, failure); + var audit = new ActivityAuditService(NullLogger.Instance); + var service = CreateService([rule], engine.Object, audit: audit); + + var result = Assert.Single(await service.ApplyForProcessStartAsync(process)); + + Assert.False(result.Success); + Assert.True(result.IsAccessDenied); + Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Rules", entry.Category); + Assert.Equal(ActivityAuditSeverity.Warning, entry.Severity); + Assert.Contains("Failed to auto-apply saved rule", entry.Message); + } + + [Fact] + public async Task ApplyForProcessStartAsync_WithProcessExitedFailure_ReturnsFailureWithoutThrowing() + { + var process = CreateProcess(); + var rule = CreateRule(); + var failure = CreateFailure(rule, process, ProcessOperationUserMessages.ProcessExited, isProcessExited: true); + var engine = CreateEngine(rule, failure); + var service = CreateService([rule], engine.Object); + + var result = Assert.Single(await service.ApplyForProcessStartAsync(process)); + + Assert.False(result.Success); + Assert.True(result.IsProcessExited); + Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); + } + + [Fact] + public async Task ApplyForProcessStartAsync_WithProtectedProcessFailure_ReturnsSafeFailureWithoutThrowing() + { + var process = CreateProcess(); + var rule = CreateRule(); + var failure = CreateFailure( + rule, + process, + ProcessOperationUserMessages.AntiCheatProtectedLikely, + isAccessDenied: true, + isAntiCheatLikely: true); + var engine = CreateEngine(rule, failure); + var service = CreateService([rule], engine.Object); + + var result = Assert.Single(await service.ApplyForProcessStartAsync(process)); + + Assert.False(result.Success); + Assert.True(result.IsAntiCheatLikely); + Assert.DoesNotContain("bypass", result.UserMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ApplyForProcessStartAsync_WhenRulesEngineCancels_PropagatesCancellation() + { + var process = CreateProcess(); + var rule = CreateRule(); + var engine = CreateEngineThatCancels(); + var service = CreateService([rule], engine.Object); + + await Assert.ThrowsAsync(() => + service.ApplyForProcessStartAsync(process)); + } + + [Fact] + public async Task ApplyForProcessStartAsync_FeatureFlagDisabled_DoesNotCallRulesEngine() + { + var process = CreateProcess(); + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService( + [rule], + engine.Object, + settings: new ApplicationSettingsModel { ApplyPersistentRulesOnProcessStart = false }); + + var results = await service.ApplyForProcessStartAsync(process); + + Assert.Empty(results); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + + [Theory] + [InlineData(0, "game.exe")] + [InlineData(42, "")] + [InlineData(42, " ")] + public async Task ApplyForProcessStartAsync_WithInvalidProcess_DoesNotCallRulesEngine(int processId, string processName) + { + var process = CreateProcess(processName); + process.ProcessId = processId; + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService([rule], engine.Object); + + var results = await service.ApplyForProcessStartAsync(process); + + Assert.Empty(results); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ApplyForDiscoveredProcessesAsync_FeatureFlagDisabled_DoesNotCallRulesEngine() + { + var process = CreateProcess(); + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService( + [rule], + engine.Object, + settings: new ApplicationSettingsModel { ApplyPersistentRulesOnProcessStart = false }); + + var results = await service.ApplyForDiscoveredProcessesAsync([process]); + + Assert.Empty(results); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ApplyForDiscoveredProcessesAsync_GroupsDuplicateProcessesByProcessId() + { + var process = CreateProcess(); + var duplicate = CreateProcess("game.exe"); + duplicate.ExecutablePath = @"C:\Games\GameCopy.exe"; + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService([rule], engine.Object); + + await service.ApplyForDiscoveredProcessesAsync([process, duplicate]); + + engine.Verify( + x => x.ApplyMatchingRulesAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ApplyForDiscoveredProcessesAsync_ClearsCooldownForProcessesNoLongerPresent() + { + var process = CreateProcess(); + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService([rule], engine.Object); + + await service.ApplyForDiscoveredProcessesAsync([process]); + await service.ApplyForDiscoveredProcessesAsync([]); + await service.ApplyForDiscoveredProcessesAsync([process]); + + engine.Verify( + x => x.ApplyMatchingRulesAsync( + process, + It.IsAny?>(), + It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task ApplyForProcessStartAsync_WhenRuleUpdatedDuringCooldown_AllowsReapply() + { + var process = CreateProcess(); + var rule = CreateRule(); + var rules = new List { rule }; + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService(rules, engine.Object); + + await service.ApplyForProcessStartAsync(process); + rules[0] = rule with { UpdatedAt = rule.UpdatedAt.AddSeconds(1) }; + await service.ApplyForProcessStartAsync(process); + + engine.Verify( + x => x.ApplyMatchingRulesAsync( + process, + It.IsAny?>(), + It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task ApplyForProcessStartAsync_WhenRulesEngineThrows_ReturnsControlledFailure() + { + var process = CreateProcess(); + var rule = CreateRule(); + var engine = CreateEngineThatThrows(new InvalidOperationException("native apply failed")); + var service = CreateService([rule], engine.Object); + + var result = Assert.Single(await service.ApplyForProcessStartAsync(process)); + + Assert.False(result.Success); + Assert.Equal(rule.Id, result.RuleId); + Assert.Equal(process.ProcessId, result.ProcessId); + Assert.Equal("ThreadPilot could not apply the saved rule.", result.UserMessage); + Assert.Equal("native apply failed", result.TechnicalMessage); + } + + [Fact] + public async Task MarkProcessExited_RemovesOnlyMatchingProcessAttempts() + { + var process = CreateProcess(); + var otherProcess = CreateProcess("game.exe"); + otherProcess.ProcessId = 84; + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService([rule], engine.Object); + + await service.ApplyForProcessStartAsync(process); + await service.ApplyForProcessStartAsync(otherProcess); + service.MarkProcessExited(process.ProcessId); + await service.ApplyForProcessStartAsync(process); + await service.ApplyForProcessStartAsync(otherProcess); + + engine.Verify( + x => x.ApplyMatchingRulesAsync( + process, + It.IsAny?>(), + It.IsAny()), + Times.Exactly(2)); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + otherProcess, + It.IsAny?>(), + It.IsAny()), + Times.Once); + } + + private static PersistentRuleAutoApplyService CreateService( + IReadOnlyList rules, + IPersistentRulesEngine engine, + ApplicationSettingsModel? settings = null, + Func? nowProvider = null, + IActivityAuditService? audit = null) => + new( + new FakePersistentProcessRuleStore(rules), + new PersistentProcessRuleMatcher(), + engine, + CreateSettingsService(settings ?? new ApplicationSettingsModel()), + NullLogger.Instance, + nowProvider ?? (() => DateTimeOffset.UtcNow), + TimeSpan.FromSeconds(30), + audit); + + private static Mock CreateEngine( + PersistentProcessRule rule, + PersistentRuleApplyResult result) + { + var engine = new Mock(MockBehavior.Strict); + engine + .Setup(x => x.ApplyMatchingRulesAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync((ProcessModel _, Predicate? predicate, CancellationToken _) => + predicate == null || predicate(rule) + ? new[] { result } + : Array.Empty()); + return engine; + } + + private static Mock CreateEngineThatCancels() + { + var engine = new Mock(MockBehavior.Strict); + engine + .Setup(x => x.ApplyMatchingRulesAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + return engine; + } + + private static Mock CreateEngineThatThrows(Exception exception) + { + var engine = new Mock(MockBehavior.Strict); + engine + .Setup(x => x.ApplyMatchingRulesAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ThrowsAsync(exception); + return engine; + } + + private static IApplicationSettingsService CreateSettingsService(ApplicationSettingsModel settings) + { + var settingsService = new Mock(MockBehavior.Loose); + settingsService.SetupGet(x => x.Settings).Returns(settings); + return settingsService.Object; + } + + private static ProcessModel CreateProcess(string name = "game.exe") => + new() + { + ProcessId = 42, + Name = name, + ExecutablePath = @"C:\Games\Game.exe", + Priority = ProcessPriorityClass.Normal, + }; + + private static PersistentProcessRule CreateRule(string id = "rule", string processName = "game.exe") => + new() + { + Id = id, + Name = id, + IsEnabled = true, + ProcessName = processName, + LegacyAffinityMask = 3, + ApplyAffinityOnStart = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + private static PersistentRuleApplyResult CreateSuccess(PersistentProcessRule rule, ProcessModel process) => + new() + { + Success = true, + RuleId = rule.Id, + ProcessId = process.ProcessId, + ProcessName = process.Name, + AffinityApplied = true, + UserMessage = "Persistent rule applied.", + TechnicalMessage = "ok", + }; + + private static PersistentRuleApplyResult CreateFailure( + PersistentProcessRule rule, + ProcessModel process, + string userMessage, + bool isAccessDenied = false, + bool isAntiCheatLikely = false, + bool isProcessExited = false) => + new() + { + Success = false, + RuleId = rule.Id, + ProcessId = process.ProcessId, + ProcessName = process.Name, + UserMessage = userMessage, + TechnicalMessage = userMessage, + IsAccessDenied = isAccessDenied, + IsAntiCheatLikely = isAntiCheatLikely, + IsProcessExited = isProcessExited, + }; + + private sealed class FakePersistentProcessRuleStore(IReadOnlyList rules) + : IPersistentProcessRuleStore + { + public Task> LoadAsync() => + Task.FromResult(rules); + + public Task SaveAsync(IReadOnlyList rules) => + Task.CompletedTask; + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs index 71daf66..7d1103d 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs @@ -1,482 +1,482 @@ -/* - * ThreadPilot - persistent rules engine tests. - */ -namespace ThreadPilot.Core.Tests -{ - using System.Diagnostics; - using Moq; - using ThreadPilot.Models; - using ThreadPilot.Services; - - public sealed class PersistentRulesEngineTests - { - [Fact] - public async Task ApplyMatchingRulesAsync_WithCpuSelection_AppliesCpuSelection() - { - var selection = CreateCpuSelection(); - var rule = CreateRule(cpuSelection: selection, applyAffinity: true); - var affinity = CreateAffinityService(); - var processService = CreateProcessService(); - var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); - var process = CreateProcess(); - - var results = await engine.ApplyMatchingRulesAsync(process); - - Assert.Single(results); - Assert.True(results[0].Success); - Assert.True(results[0].AffinityApplied); - affinity.Verify(s => s.ApplyAsync(process, selection), Times.Once); - affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task ApplyMatchingRulesAsync_WithLegacyAffinityMask_AppliesLegacyAffinity() - { - var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true); - var affinity = CreateAffinityService(); - var processService = CreateProcessService(); - var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); - var process = CreateProcess(); - - var results = await engine.ApplyMatchingRulesAsync(process); - - Assert.Single(results); - Assert.True(results[0].Success); - Assert.True(results[0].AffinityApplied); - affinity.Verify(s => s.ApplyAsync(process, 3), Times.Once); - affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task ApplyMatchingRulesAsync_WithPriority_AppliesPriority() - { - var rule = CreateRule(priority: ProcessPriorityClass.High, applyPriority: true); - var affinity = CreateAffinityService(); - var processService = CreateProcessService(); - var memoryPriorityService = CreateMemoryPriorityService(); - var engine = CreateEngine([rule], affinity.Object, processService.Object, memoryPriorityService.Object); - var process = CreateProcess(); - - var results = await engine.ApplyMatchingRulesAsync(process); - - Assert.Single(results); - Assert.True(results[0].Success); - Assert.True(results[0].PriorityApplied); - processService.Verify(s => s.SetProcessPriority(process, ProcessPriorityClass.High), Times.Once); - } - - [Fact] - public async Task ApplyMatchingRulesAsync_WithMemoryPriority_AppliesMemoryPriority() - { - var rule = CreateRule(memoryPriority: ProcessMemoryPriority.Low, applyMemoryPriority: true); - var affinity = CreateAffinityService(); - var processService = CreateProcessService(); - var memoryPriorityService = CreateMemoryPriorityService(); - var engine = CreateEngine([rule], affinity.Object, processService.Object, memoryPriorityService.Object); - var process = CreateProcess(); - - var results = await engine.ApplyMatchingRulesAsync(process); - - Assert.Single(results); - Assert.True(results[0].Success); - Assert.True(results[0].MemoryPriorityApplied); - memoryPriorityService.Verify( - s => s.SetMemoryPriorityAsync(process, ProcessMemoryPriority.Low), - Times.Once); - } - - [Fact] - public async Task ApplyMatchingRulesAsync_WithRealtimePriority_ReturnsControlledFailure() - { - var rule = CreateRule(priority: ProcessPriorityClass.RealTime, applyPriority: true); - var affinity = CreateAffinityService(); - var processService = CreateProcessService(); - processService - .Setup(s => s.SetProcessPriority(It.IsAny(), ProcessPriorityClass.RealTime)) - .ThrowsAsync(new InvalidOperationException(ProcessOperationUserMessages.RealtimePriorityBlocked)); - var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); - - var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); - - var result = Assert.Single(results); - Assert.False(result.Success); - Assert.False(result.PriorityApplied); - Assert.Equal(ProcessOperationUserMessages.RealtimePriorityBlocked, result.UserMessage); - } - - [Fact] - public async Task ApplyMatchingRulesAsync_WithAccessDeniedAffinity_ReturnsAccessDeniedResult() - { - var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true); - var affinity = CreateAffinityService(AffinityApplyResult.Failed( - AffinityApplyErrorCodes.AccessDenied, - ProcessOperationUserMessages.AccessDenied, - "Access is denied.", - isAccessDenied: true)); - var engine = CreateEngine([rule], affinity.Object, CreateProcessService().Object, CreateMemoryPriorityService().Object); - - var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); - - var result = Assert.Single(results); - Assert.False(result.Success); - Assert.True(result.IsAccessDenied); - Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); - } - - [Fact] - public async Task ApplyMatchingRulesAsync_WithAntiCheatAffinity_ReturnsSafeProtectedResult() - { - var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true); - var affinity = CreateAffinityService(AffinityApplyResult.Failed( - AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely, - ProcessOperationUserMessages.AntiCheatProtectedLikely, - "Protected process.", - isAccessDenied: true, - isAntiCheatLikely: true)); - var engine = CreateEngine([rule], affinity.Object, CreateProcessService().Object, CreateMemoryPriorityService().Object); - - var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); - - var result = Assert.Single(results); - Assert.False(result.Success); - Assert.True(result.IsAntiCheatLikely); - Assert.DoesNotContain("bypass", result.UserMessage, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task ApplyMatchingRulesAsync_WithProcessExitedAffinity_ReturnsProcessExitedResult() - { - var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true); - var affinity = CreateAffinityService(AffinityApplyResult.Failed( - AffinityApplyErrorCodes.ProcessExited, - ProcessOperationUserMessages.ProcessExited, - "Process exited.", - failureReason: AffinityApplyFailureReason.ProcessTerminated)); - var engine = CreateEngine([rule], affinity.Object, CreateProcessService().Object, CreateMemoryPriorityService().Object); - - var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); - - var result = Assert.Single(results); - Assert.False(result.Success); - Assert.True(result.IsProcessExited); - Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); - } - - [Fact] - public async Task ApplyMatchingRulesAsync_WithDisabledRule_DoesNotApply() - { - var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true) with { IsEnabled = false }; - var affinity = CreateAffinityService(); - var processService = CreateProcessService(); - var memoryPriorityService = CreateMemoryPriorityService(); - var engine = CreateEngine([rule], affinity.Object, processService.Object, memoryPriorityService.Object); - - var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); - - Assert.Empty(results); - affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); - processService.Verify(s => s.SetProcessPriority(It.IsAny(), It.IsAny()), Times.Never); - memoryPriorityService.Verify( - s => s.SetMemoryPriorityAsync(It.IsAny(), It.IsAny()), - Times.Never); - } - - [Fact] - public async Task ApplyMatchingRulesAsync_WithAffinityEnabledButNoAffinityPayload_ReturnsFailure() - { - var rule = CreateRule(applyAffinity: true); - var affinity = CreateAffinityService(); - var processService = CreateProcessService(); - var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); - - var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); - - var result = Assert.Single(results); - Assert.False(result.Success); - Assert.False(result.AffinityApplied); - Assert.False(result.PriorityApplied); - Assert.False(result.MemoryPriorityApplied); - Assert.Equal("PersistentRuleMissingAffinity", result.ErrorCode); - Assert.Equal("This saved rule has no affinity selection to apply.", result.UserMessage); - affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); - affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); - processService.Verify(s => s.SetProcessPriority(It.IsAny(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task ApplyMatchingRulesAsync_WithPriorityEnabledButNoPriorityPayload_ReturnsFailure() - { - var rule = CreateRule(applyPriority: true); - var affinity = CreateAffinityService(); - var processService = CreateProcessService(); - var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); - - var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); - - var result = Assert.Single(results); - Assert.False(result.Success); - Assert.False(result.AffinityApplied); - Assert.False(result.PriorityApplied); - Assert.False(result.MemoryPriorityApplied); - Assert.Equal("PersistentRuleMissingPriority", result.ErrorCode); - Assert.Equal("This saved rule has no priority value to apply.", result.UserMessage); - affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); - affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); - processService.Verify(s => s.SetProcessPriority(It.IsAny(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task ApplyMatchingRulesAsync_WithMemoryPriorityEnabledButNoMemoryPriorityPayload_ReturnsFailure() - { - var rule = CreateRule(applyMemoryPriority: true); - var affinity = CreateAffinityService(); - var processService = CreateProcessService(); - var memoryPriorityService = CreateMemoryPriorityService(); - var engine = CreateEngine([rule], affinity.Object, processService.Object, memoryPriorityService.Object); - - var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); - - var result = Assert.Single(results); - Assert.False(result.Success); - Assert.False(result.AffinityApplied); - Assert.False(result.PriorityApplied); - Assert.False(result.MemoryPriorityApplied); - Assert.Equal("PersistentRuleMissingMemoryPriority", result.ErrorCode); - Assert.Equal("This saved rule has no memory priority value to apply.", result.UserMessage); - memoryPriorityService.Verify( - s => s.SetMemoryPriorityAsync(It.IsAny(), It.IsAny()), - Times.Never); - } - - [Fact] - public async Task ApplyMatchingRulesAsync_WithAffinityPriorityAndMemoryPriority_AppliesAllFlags() - { - var rule = CreateRule( - legacyAffinityMask: 3, - priority: ProcessPriorityClass.AboveNormal, - memoryPriority: ProcessMemoryPriority.BelowNormal, - applyAffinity: true, - applyPriority: true, - applyMemoryPriority: true); - var affinity = CreateAffinityService(); - var processService = CreateProcessService(); - var memoryPriorityService = CreateMemoryPriorityService(); - var engine = CreateEngine([rule], affinity.Object, processService.Object, memoryPriorityService.Object); - - var result = Assert.Single(await engine.ApplyMatchingRulesAsync(CreateProcess())); - - Assert.True(result.Success); - Assert.True(result.AffinityApplied); - Assert.True(result.PriorityApplied); - Assert.True(result.MemoryPriorityApplied); - } - - [Fact] - public async Task ApplyMatchingRulesAsync_WithMemoryPriorityAccessDenied_PropagatesAccessDeniedResult() - { - var rule = CreateRule(memoryPriority: ProcessMemoryPriority.Low, applyMemoryPriority: true); - var memoryPriorityService = CreateMemoryPriorityService(ProcessOperationResult.Failed( - AffinityApplyErrorCodes.AccessDenied, - ProcessOperationUserMessages.AccessDenied, - "Access is denied.", - isAccessDenied: true)); - var engine = CreateEngine( - [rule], - CreateAffinityService().Object, - CreateProcessService().Object, - memoryPriorityService.Object); - - var result = Assert.Single(await engine.ApplyMatchingRulesAsync(CreateProcess())); - - Assert.False(result.Success); - Assert.False(result.MemoryPriorityApplied); - Assert.True(result.IsAccessDenied); - Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode); - Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); - } - - [Fact] - public async Task ApplyMatchingRulesAsync_WithNoActions_ReturnsControlledFailure() - { - var rule = CreateRule(); - var affinity = CreateAffinityService(); - var processService = CreateProcessService(); - var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); - - var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); - - var result = Assert.Single(results); - Assert.False(result.Success); - Assert.False(result.AffinityApplied); - Assert.False(result.PriorityApplied); - Assert.False(result.MemoryPriorityApplied); - Assert.Equal("PersistentRuleNoActions", result.ErrorCode); - affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); - affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); - processService.Verify(s => s.SetProcessPriority(It.IsAny(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task ApplyMatchingRulesAsync_WithMultipleMatchingRules_ReturnsResultPerRuleWithoutRetry() - { - var rules = new[] - { - CreateRule(id: "rule-a", legacyAffinityMask: 1, applyAffinity: true), - CreateRule(id: "rule-b", priority: ProcessPriorityClass.AboveNormal, applyPriority: true), - }; - var affinity = CreateAffinityService(); - var processService = CreateProcessService(); - var engine = CreateEngine(rules, affinity.Object, processService.Object, CreateMemoryPriorityService().Object); - - var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); - - Assert.Equal(2, results.Count); - Assert.Contains(results, result => result.RuleId == "rule-a"); - Assert.Contains(results, result => result.RuleId == "rule-b"); - affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Once); - processService.Verify(s => s.SetProcessPriority(It.IsAny(), It.IsAny()), Times.Once); - } - - [Fact] - public async Task ApplyMatchingRulesAsync_WhenRuleFilterExcludesMatchingRule_DoesNotApplyIt() - { - var rules = new[] - { - CreateRule(id: "rule-a", legacyAffinityMask: 1, applyAffinity: true), - CreateRule(id: "rule-b", legacyAffinityMask: 2, applyAffinity: true), - }; - var affinity = CreateAffinityService(); - var processService = CreateProcessService(); - var engine = CreateEngine(rules, affinity.Object, processService.Object, CreateMemoryPriorityService().Object); - - var results = await engine.ApplyMatchingRulesAsync( - CreateProcess(), - rule => rule.Id != "rule-a"); - - var result = Assert.Single(results); - Assert.Equal("rule-b", result.RuleId); - affinity.Verify(s => s.ApplyAsync(It.IsAny(), 1L), Times.Never); - affinity.Verify(s => s.ApplyAsync(It.IsAny(), 2L), Times.Once); - } - - [Fact] - public async Task ApplyMatchingRulesAsync_WhenRuleFilterIncludesSelectedRule_AppliesOnlyThatRule() - { - var rules = new[] - { - CreateRule(id: "rule-a", priority: ProcessPriorityClass.AboveNormal, applyPriority: true), - CreateRule(id: "rule-b", priority: ProcessPriorityClass.High, applyPriority: true), - }; - var affinity = CreateAffinityService(); - var processService = CreateProcessService(); - var engine = CreateEngine(rules, affinity.Object, processService.Object, CreateMemoryPriorityService().Object); - - var results = await engine.ApplyMatchingRulesAsync( - CreateProcess(), - rule => rule.Id == "rule-b"); - - var result = Assert.Single(results); - Assert.Equal("rule-b", result.RuleId); - processService.Verify(s => s.SetProcessPriority(It.IsAny(), ProcessPriorityClass.AboveNormal), Times.Never); - processService.Verify(s => s.SetProcessPriority(It.IsAny(), ProcessPriorityClass.High), Times.Once); - } - - private static PersistentRulesEngine CreateEngine( - IReadOnlyList rules, - IAffinityApplyService affinityApplyService, - IProcessService processService, - IProcessMemoryPriorityService memoryPriorityService) => - new( - new FakePersistentProcessRuleStore(rules), - new PersistentProcessRuleMatcher(), - affinityApplyService, - processService, - memoryPriorityService, - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - private static Mock CreateAffinityService(AffinityApplyResult? result = null) - { - var mock = new Mock(MockBehavior.Strict); - var resolved = result ?? AffinityApplyResult.Succeeded(1, 1); - mock - .Setup(s => s.ApplyAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(resolved); - mock - .Setup(s => s.ApplyAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(resolved); - return mock; - } - - private static Mock CreateProcessService() - { - var mock = new Mock(MockBehavior.Strict); - mock - .Setup(s => s.SetProcessPriority(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - return mock; - } - - private static Mock CreateMemoryPriorityService(ProcessOperationResult? result = null) - { - var mock = new Mock(MockBehavior.Strict); - mock - .Setup(s => s.SetMemoryPriorityAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(result ?? ProcessOperationResult.Succeeded( - "Memory priority applied.", - "Memory priority applied in test.")); - return mock; - } - - private static PersistentProcessRule CreateRule( - string id = "rule", - CpuSelection? cpuSelection = null, - long? legacyAffinityMask = null, - ProcessPriorityClass? priority = null, - ProcessMemoryPriority? memoryPriority = null, - bool applyAffinity = false, - bool applyPriority = false, - bool applyMemoryPriority = false) => - new() - { - Id = id, - Name = id, - IsEnabled = true, - ProcessName = "game.exe", - CpuSelection = cpuSelection, - LegacyAffinityMask = legacyAffinityMask, - Priority = priority, - MemoryPriority = memoryPriority, - ApplyAffinityOnStart = applyAffinity, - ApplyPriorityOnStart = applyPriority, - ApplyMemoryPriorityOnStart = applyMemoryPriority, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - }; - - private static CpuSelection CreateCpuSelection() => - new() - { - LogicalProcessors = [new ProcessorRef(0, 0, 0)], - GlobalLogicalProcessorIndexes = [0], - }; - - private static ProcessModel CreateProcess() => - new() - { - ProcessId = 42, - Name = "game.exe", - ExecutablePath = @"C:\Games\Game.exe", - Priority = ProcessPriorityClass.Normal, - }; - - private sealed class FakePersistentProcessRuleStore(IReadOnlyList rules) - : IPersistentProcessRuleStore - { - public Task> LoadAsync() => - Task.FromResult(rules); - - public Task SaveAsync(IReadOnlyList rules) => - Task.CompletedTask; - } - } -} +/* + * ThreadPilot - persistent rules engine tests. + */ +namespace ThreadPilot.Core.Tests +{ + using System.Diagnostics; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class PersistentRulesEngineTests + { + [Fact] + public async Task ApplyMatchingRulesAsync_WithCpuSelection_AppliesCpuSelection() + { + var selection = CreateCpuSelection(); + var rule = CreateRule(cpuSelection: selection, applyAffinity: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); + var process = CreateProcess(); + + var results = await engine.ApplyMatchingRulesAsync(process); + + Assert.Single(results); + Assert.True(results[0].Success); + Assert.True(results[0].AffinityApplied); + affinity.Verify(s => s.ApplyAsync(process, selection), Times.Once); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithLegacyAffinityMask_AppliesLegacyAffinity() + { + var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); + var process = CreateProcess(); + + var results = await engine.ApplyMatchingRulesAsync(process); + + Assert.Single(results); + Assert.True(results[0].Success); + Assert.True(results[0].AffinityApplied); + affinity.Verify(s => s.ApplyAsync(process, 3), Times.Once); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithPriority_AppliesPriority() + { + var rule = CreateRule(priority: ProcessPriorityClass.High, applyPriority: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var memoryPriorityService = CreateMemoryPriorityService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object, memoryPriorityService.Object); + var process = CreateProcess(); + + var results = await engine.ApplyMatchingRulesAsync(process); + + Assert.Single(results); + Assert.True(results[0].Success); + Assert.True(results[0].PriorityApplied); + processService.Verify(s => s.SetProcessPriority(process, ProcessPriorityClass.High), Times.Once); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithMemoryPriority_AppliesMemoryPriority() + { + var rule = CreateRule(memoryPriority: ProcessMemoryPriority.Low, applyMemoryPriority: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var memoryPriorityService = CreateMemoryPriorityService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object, memoryPriorityService.Object); + var process = CreateProcess(); + + var results = await engine.ApplyMatchingRulesAsync(process); + + Assert.Single(results); + Assert.True(results[0].Success); + Assert.True(results[0].MemoryPriorityApplied); + memoryPriorityService.Verify( + s => s.SetMemoryPriorityAsync(process, ProcessMemoryPriority.Low), + Times.Once); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithRealtimePriority_ReturnsControlledFailure() + { + var rule = CreateRule(priority: ProcessPriorityClass.RealTime, applyPriority: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + processService + .Setup(s => s.SetProcessPriority(It.IsAny(), ProcessPriorityClass.RealTime)) + .ThrowsAsync(new InvalidOperationException(ProcessOperationUserMessages.RealtimePriorityBlocked)); + var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.False(result.PriorityApplied); + Assert.Equal(ProcessOperationUserMessages.RealtimePriorityBlocked, result.UserMessage); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithAccessDeniedAffinity_ReturnsAccessDeniedResult() + { + var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true); + var affinity = CreateAffinityService(AffinityApplyResult.Failed( + AffinityApplyErrorCodes.AccessDenied, + ProcessOperationUserMessages.AccessDenied, + "Access is denied.", + isAccessDenied: true)); + var engine = CreateEngine([rule], affinity.Object, CreateProcessService().Object, CreateMemoryPriorityService().Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.True(result.IsAccessDenied); + Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithAntiCheatAffinity_ReturnsSafeProtectedResult() + { + var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true); + var affinity = CreateAffinityService(AffinityApplyResult.Failed( + AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely, + ProcessOperationUserMessages.AntiCheatProtectedLikely, + "Protected process.", + isAccessDenied: true, + isAntiCheatLikely: true)); + var engine = CreateEngine([rule], affinity.Object, CreateProcessService().Object, CreateMemoryPriorityService().Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.True(result.IsAntiCheatLikely); + Assert.DoesNotContain("bypass", result.UserMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithProcessExitedAffinity_ReturnsProcessExitedResult() + { + var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true); + var affinity = CreateAffinityService(AffinityApplyResult.Failed( + AffinityApplyErrorCodes.ProcessExited, + ProcessOperationUserMessages.ProcessExited, + "Process exited.", + failureReason: AffinityApplyFailureReason.ProcessTerminated)); + var engine = CreateEngine([rule], affinity.Object, CreateProcessService().Object, CreateMemoryPriorityService().Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.True(result.IsProcessExited); + Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithDisabledRule_DoesNotApply() + { + var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true) with { IsEnabled = false }; + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var memoryPriorityService = CreateMemoryPriorityService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object, memoryPriorityService.Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + Assert.Empty(results); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + processService.Verify(s => s.SetProcessPriority(It.IsAny(), It.IsAny()), Times.Never); + memoryPriorityService.Verify( + s => s.SetMemoryPriorityAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithAffinityEnabledButNoAffinityPayload_ReturnsFailure() + { + var rule = CreateRule(applyAffinity: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.False(result.AffinityApplied); + Assert.False(result.PriorityApplied); + Assert.False(result.MemoryPriorityApplied); + Assert.Equal("PersistentRuleMissingAffinity", result.ErrorCode); + Assert.Equal("This saved rule has no affinity selection to apply.", result.UserMessage); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + processService.Verify(s => s.SetProcessPriority(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithPriorityEnabledButNoPriorityPayload_ReturnsFailure() + { + var rule = CreateRule(applyPriority: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.False(result.AffinityApplied); + Assert.False(result.PriorityApplied); + Assert.False(result.MemoryPriorityApplied); + Assert.Equal("PersistentRuleMissingPriority", result.ErrorCode); + Assert.Equal("This saved rule has no priority value to apply.", result.UserMessage); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + processService.Verify(s => s.SetProcessPriority(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithMemoryPriorityEnabledButNoMemoryPriorityPayload_ReturnsFailure() + { + var rule = CreateRule(applyMemoryPriority: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var memoryPriorityService = CreateMemoryPriorityService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object, memoryPriorityService.Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.False(result.AffinityApplied); + Assert.False(result.PriorityApplied); + Assert.False(result.MemoryPriorityApplied); + Assert.Equal("PersistentRuleMissingMemoryPriority", result.ErrorCode); + Assert.Equal("This saved rule has no memory priority value to apply.", result.UserMessage); + memoryPriorityService.Verify( + s => s.SetMemoryPriorityAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithAffinityPriorityAndMemoryPriority_AppliesAllFlags() + { + var rule = CreateRule( + legacyAffinityMask: 3, + priority: ProcessPriorityClass.AboveNormal, + memoryPriority: ProcessMemoryPriority.BelowNormal, + applyAffinity: true, + applyPriority: true, + applyMemoryPriority: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var memoryPriorityService = CreateMemoryPriorityService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object, memoryPriorityService.Object); + + var result = Assert.Single(await engine.ApplyMatchingRulesAsync(CreateProcess())); + + Assert.True(result.Success); + Assert.True(result.AffinityApplied); + Assert.True(result.PriorityApplied); + Assert.True(result.MemoryPriorityApplied); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithMemoryPriorityAccessDenied_PropagatesAccessDeniedResult() + { + var rule = CreateRule(memoryPriority: ProcessMemoryPriority.Low, applyMemoryPriority: true); + var memoryPriorityService = CreateMemoryPriorityService(ProcessOperationResult.Failed( + AffinityApplyErrorCodes.AccessDenied, + ProcessOperationUserMessages.AccessDenied, + "Access is denied.", + isAccessDenied: true)); + var engine = CreateEngine( + [rule], + CreateAffinityService().Object, + CreateProcessService().Object, + memoryPriorityService.Object); + + var result = Assert.Single(await engine.ApplyMatchingRulesAsync(CreateProcess())); + + Assert.False(result.Success); + Assert.False(result.MemoryPriorityApplied); + Assert.True(result.IsAccessDenied); + Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithNoActions_ReturnsControlledFailure() + { + var rule = CreateRule(); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.False(result.AffinityApplied); + Assert.False(result.PriorityApplied); + Assert.False(result.MemoryPriorityApplied); + Assert.Equal("PersistentRuleNoActions", result.ErrorCode); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + processService.Verify(s => s.SetProcessPriority(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithMultipleMatchingRules_ReturnsResultPerRuleWithoutRetry() + { + var rules = new[] + { + CreateRule(id: "rule-a", legacyAffinityMask: 1, applyAffinity: true), + CreateRule(id: "rule-b", priority: ProcessPriorityClass.AboveNormal, applyPriority: true), + }; + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var engine = CreateEngine(rules, affinity.Object, processService.Object, CreateMemoryPriorityService().Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + Assert.Equal(2, results.Count); + Assert.Contains(results, result => result.RuleId == "rule-a"); + Assert.Contains(results, result => result.RuleId == "rule-b"); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Once); + processService.Verify(s => s.SetProcessPriority(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WhenRuleFilterExcludesMatchingRule_DoesNotApplyIt() + { + var rules = new[] + { + CreateRule(id: "rule-a", legacyAffinityMask: 1, applyAffinity: true), + CreateRule(id: "rule-b", legacyAffinityMask: 2, applyAffinity: true), + }; + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var engine = CreateEngine(rules, affinity.Object, processService.Object, CreateMemoryPriorityService().Object); + + var results = await engine.ApplyMatchingRulesAsync( + CreateProcess(), + rule => rule.Id != "rule-a"); + + var result = Assert.Single(results); + Assert.Equal("rule-b", result.RuleId); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), 1L), Times.Never); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), 2L), Times.Once); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WhenRuleFilterIncludesSelectedRule_AppliesOnlyThatRule() + { + var rules = new[] + { + CreateRule(id: "rule-a", priority: ProcessPriorityClass.AboveNormal, applyPriority: true), + CreateRule(id: "rule-b", priority: ProcessPriorityClass.High, applyPriority: true), + }; + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var engine = CreateEngine(rules, affinity.Object, processService.Object, CreateMemoryPriorityService().Object); + + var results = await engine.ApplyMatchingRulesAsync( + CreateProcess(), + rule => rule.Id == "rule-b"); + + var result = Assert.Single(results); + Assert.Equal("rule-b", result.RuleId); + processService.Verify(s => s.SetProcessPriority(It.IsAny(), ProcessPriorityClass.AboveNormal), Times.Never); + processService.Verify(s => s.SetProcessPriority(It.IsAny(), ProcessPriorityClass.High), Times.Once); + } + + private static PersistentRulesEngine CreateEngine( + IReadOnlyList rules, + IAffinityApplyService affinityApplyService, + IProcessService processService, + IProcessMemoryPriorityService memoryPriorityService) => + new( + new FakePersistentProcessRuleStore(rules), + new PersistentProcessRuleMatcher(), + affinityApplyService, + processService, + memoryPriorityService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + private static Mock CreateAffinityService(AffinityApplyResult? result = null) + { + var mock = new Mock(MockBehavior.Strict); + var resolved = result ?? AffinityApplyResult.Succeeded(1, 1); + mock + .Setup(s => s.ApplyAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(resolved); + mock + .Setup(s => s.ApplyAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(resolved); + return mock; + } + + private static Mock CreateProcessService() + { + var mock = new Mock(MockBehavior.Strict); + mock + .Setup(s => s.SetProcessPriority(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + return mock; + } + + private static Mock CreateMemoryPriorityService(ProcessOperationResult? result = null) + { + var mock = new Mock(MockBehavior.Strict); + mock + .Setup(s => s.SetMemoryPriorityAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(result ?? ProcessOperationResult.Succeeded( + "Memory priority applied.", + "Memory priority applied in test.")); + return mock; + } + + private static PersistentProcessRule CreateRule( + string id = "rule", + CpuSelection? cpuSelection = null, + long? legacyAffinityMask = null, + ProcessPriorityClass? priority = null, + ProcessMemoryPriority? memoryPriority = null, + bool applyAffinity = false, + bool applyPriority = false, + bool applyMemoryPriority = false) => + new() + { + Id = id, + Name = id, + IsEnabled = true, + ProcessName = "game.exe", + CpuSelection = cpuSelection, + LegacyAffinityMask = legacyAffinityMask, + Priority = priority, + MemoryPriority = memoryPriority, + ApplyAffinityOnStart = applyAffinity, + ApplyPriorityOnStart = applyPriority, + ApplyMemoryPriorityOnStart = applyMemoryPriority, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + private static CpuSelection CreateCpuSelection() => + new() + { + LogicalProcessors = [new ProcessorRef(0, 0, 0)], + GlobalLogicalProcessorIndexes = [0], + }; + + private static ProcessModel CreateProcess() => + new() + { + ProcessId = 42, + Name = "game.exe", + ExecutablePath = @"C:\Games\Game.exe", + Priority = ProcessPriorityClass.Normal, + }; + + private sealed class FakePersistentProcessRuleStore(IReadOnlyList rules) + : IPersistentProcessRuleStore + { + public Task> LoadAsync() => + Task.FromResult(rules); + + public Task SaveAsync(IReadOnlyList rules) => + Task.CompletedTask; + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PowerPlanServiceSecurityTests.cs b/Tests/ThreadPilot.Core.Tests/PowerPlanServiceSecurityTests.cs index 7942396..770cad0 100644 --- a/Tests/ThreadPilot.Core.Tests/PowerPlanServiceSecurityTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PowerPlanServiceSecurityTests.cs @@ -1,74 +1,62 @@ -/* - * ThreadPilot - Core security unit tests. - */ -namespace ThreadPilot.Core.Tests -{ - using Microsoft.Extensions.Logging.Abstractions; - using Moq; - using ThreadPilot.Services; - - /// - /// Unit tests for security-focused validation behavior in . - /// - public sealed class PowerPlanServiceSecurityTests - { - /// - /// Ensures relative import paths are rejected. - /// - [Fact] - public async Task ImportCustomPowerPlan_ReturnsFalse_ForRelativePath() - { - var service = CreateService(); - - var result = await service.ImportCustomPowerPlan("..\\evil.pow"); - - Assert.False(result); - } - - /// - /// Ensures non-.pow files are rejected. - /// - [Fact] - public async Task ImportCustomPowerPlan_ReturnsFalse_ForInvalidExtension() - { - var service = CreateService(); - var filePath = Path.Combine(Path.GetTempPath(), "threadpilot-test.txt"); - await File.WriteAllTextAsync(filePath, "content"); - - try - { - var result = await service.ImportCustomPowerPlan(filePath); - Assert.False(result); - } - finally - { - if (File.Exists(filePath)) - { - File.Delete(filePath); - } - } - } - - /// - /// Ensures invalid GUID values are rejected before invoking powercfg. - /// - [Theory] - [InlineData("")] - [InlineData("invalid-guid")] - [InlineData("1234")] - public async Task SetActivePowerPlanByGuidAsync_ReturnsFalse_ForInvalidGuid(string guid) - { - var service = CreateService(); - - var result = await service.SetActivePowerPlanByGuidAsync(guid); - - Assert.False(result); - } - - private static PowerPlanService CreateService() - { - var enhancedLogger = new Mock(MockBehavior.Loose); - return new PowerPlanService(NullLogger.Instance, enhancedLogger.Object); - } - } -} +/* + * ThreadPilot - Core security unit tests. + */ +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Services; + + public sealed class PowerPlanServiceSecurityTests + { + [Fact] + public async Task ImportCustomPowerPlan_ReturnsFalse_ForRelativePath() + { + var service = CreateService(); + + var result = await service.ImportCustomPowerPlan("..\\evil.pow"); + + Assert.False(result); + } + + [Fact] + public async Task ImportCustomPowerPlan_ReturnsFalse_ForInvalidExtension() + { + var service = CreateService(); + var filePath = Path.Combine(Path.GetTempPath(), "threadpilot-test.txt"); + await File.WriteAllTextAsync(filePath, "content"); + + try + { + var result = await service.ImportCustomPowerPlan(filePath); + Assert.False(result); + } + finally + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + } + + [Theory] + [InlineData("")] + [InlineData("invalid-guid")] + [InlineData("1234")] + public async Task SetActivePowerPlanByGuidAsync_ReturnsFalse_ForInvalidGuid(string guid) + { + var service = CreateService(); + + var result = await service.SetActivePowerPlanByGuidAsync(guid); + + Assert.False(result); + } + + private static PowerPlanService CreateService() + { + var enhancedLogger = new Mock(MockBehavior.Loose); + return new PowerPlanService(NullLogger.Instance, enhancedLogger.Object); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs b/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs index 1c08dc2..67bc6d1 100644 --- a/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs @@ -1,199 +1,196 @@ -/* - * ThreadPilot - power plan service unit tests. - */ -namespace ThreadPilot.Core.Tests -{ - using Microsoft.Extensions.Logging.Abstractions; - using Moq; - using ThreadPilot.Services; - using ThreadPilot.Services.Abstractions; - - /// - /// Unit tests for deterministic behavior in . - /// - public sealed class PowerPlanServiceTests - { - [Fact] - public async Task GetActivePowerPlan_ParsesPowerCfgOutput() - { - const string guid = "381b4222-f694-41f0-9685-ff5bb260df2e"; - var runner = new RecordingProcessRunner - { - ResultFactory = _ => new ProcessRunResult( - 0, - $"Power Scheme GUID: {guid} (Balanced)", - string.Empty), - }; - var service = CreateService(runner); - - var result = await service.GetActivePowerPlan(); - - Assert.NotNull(result); - Assert.Equal(guid, result.Guid); - Assert.Equal("Balanced", result.Name); - Assert.True(result.IsActive); - } - - [Fact] - public async Task SetActivePowerPlanByGuidAsync_SkipsChange_WhenAlreadyActive() - { - const string guid = "381b4222-f694-41f0-9685-ff5bb260df2e"; - var runner = new RecordingProcessRunner - { - ResultFactory = _ => new ProcessRunResult( - 0, - $"Power Scheme GUID: {guid} (Balanced)", - string.Empty), - }; - var service = CreateService(runner); - - var result = await service.SetActivePowerPlanByGuidAsync(guid, preventDuplicateChanges: true); - - Assert.True(result); - - var invocation = Assert.Single(runner.Invocations); - Assert.Equal(Path.Combine(Environment.SystemDirectory, "powercfg.exe"), invocation.FileName); - Assert.Equal(new[] { "/getactivescheme" }, invocation.Arguments); - } - - [Fact] - public async Task DeletePowerPlanAsync_InvokesPowerCfgDelete_WhenPlanIsNotActive() - { - const string activeGuid = "381b4222-f694-41f0-9685-ff5bb260df2e"; - const string deleteGuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; - var runner = new RecordingProcessRunner - { - ResultFactory = invocation => - invocation.Arguments.SequenceEqual(new[] { "/getactivescheme" }) - ? new ProcessRunResult( - 0, - $"Power Scheme GUID: {activeGuid} (Balanced)", - string.Empty) - : new ProcessRunResult(0, string.Empty, string.Empty), - }; - var service = CreateService(runner); - - var result = await service.DeletePowerPlanAsync(deleteGuid); - - Assert.True(result); - Assert.Contains(runner.Invocations, invocation => - invocation.Arguments.SequenceEqual(new[] { "/delete", deleteGuid })); - } - - [Fact] - public async Task DeletePowerPlanAsync_DoesNotDeleteActivePlan() - { - const string activeGuid = "381b4222-f694-41f0-9685-ff5bb260df2e"; - var runner = new RecordingProcessRunner - { - ResultFactory = _ => new ProcessRunResult( - 0, - $"Power Scheme GUID: {activeGuid} (Balanced)", - string.Empty), - }; - var service = CreateService(runner); - - var result = await service.DeletePowerPlanAsync(activeGuid); - - Assert.False(result); - Assert.DoesNotContain(runner.Invocations, invocation => - invocation.Arguments.Contains("/delete", StringComparer.OrdinalIgnoreCase)); - } - - [Fact] - public async Task AddCustomPowerPlanFileAsync_RenamesOnCollision() - { - var tempRoot = Path.Combine(Path.GetTempPath(), $"threadpilot-powerplans-{Guid.NewGuid():N}"); - var managedDirectory = Path.Combine(tempRoot, "managed"); - var sourceDirectory = Path.Combine(tempRoot, "source"); - Directory.CreateDirectory(managedDirectory); - Directory.CreateDirectory(sourceDirectory); - - var sourcePath = Path.Combine(sourceDirectory, "gaming.pow"); - var existingPath = Path.Combine(managedDirectory, "gaming.pow"); - await File.WriteAllTextAsync(sourcePath, "source"); - await File.WriteAllTextAsync(existingPath, "existing"); - - try - { - var service = CreateService( - new RecordingProcessRunner(), - powerPlansPathProvider: () => managedDirectory); - - var result = await service.AddCustomPowerPlanFileAsync(sourcePath); - - Assert.True(result); - var renamedPath = Path.Combine(managedDirectory, "gaming_1.pow"); - Assert.True(File.Exists(renamedPath)); - Assert.Equal("source", await File.ReadAllTextAsync(renamedPath)); - } - finally - { - if (Directory.Exists(tempRoot)) - { - Directory.Delete(tempRoot, recursive: true); - } - } - } - - [Fact] - public async Task GetCustomPowerPlansAsync_DiscoversBundledPlansRecursively() - { - var tempRoot = Path.Combine(Path.GetTempPath(), $"threadpilot-powerplans-{Guid.NewGuid():N}"); - var nestedDirectory = Path.Combine(tempRoot, "nested"); - Directory.CreateDirectory(nestedDirectory); - - var rootPlanPath = Path.Combine(tempRoot, "root.pow"); - var nestedPlanPath = Path.Combine(nestedDirectory, "nested.pow"); - await File.WriteAllTextAsync(rootPlanPath, "root"); - await File.WriteAllTextAsync(nestedPlanPath, "nested"); - - try - { - var service = CreateService( - new RecordingProcessRunner(), - powerPlansPathProvider: () => tempRoot); - - var result = await service.GetCustomPowerPlansAsync(); - - Assert.Contains(result, plan => plan.Name == "root" && plan.FilePath == rootPlanPath); - Assert.Contains(result, plan => plan.Name == "nested" && plan.FilePath == nestedPlanPath); - } - finally - { - if (Directory.Exists(tempRoot)) - { - Directory.Delete(tempRoot, recursive: true); - } - } - } - - private static PowerPlanService CreateService( - IProcessRunner runner, - Func? powerPlansPathProvider = null) - { - var enhancedLogger = new Mock(MockBehavior.Loose); - return new PowerPlanService( - NullLogger.Instance, - enhancedLogger.Object, - runner, - powerPlansPathProvider); - } - - private sealed class RecordingProcessRunner : IProcessRunner - { - public List Invocations { get; } = new(); - - public Func? ResultFactory { get; init; } - - public Task RunAsync(string fileName, IReadOnlyList arguments, TimeSpan timeout) - { - var invocation = new ProcessInvocation(fileName, arguments.ToList(), timeout); - this.Invocations.Add(invocation); - return Task.FromResult(this.ResultFactory?.Invoke(invocation) ?? new ProcessRunResult(0, string.Empty, string.Empty)); - } - } - - private sealed record ProcessInvocation(string FileName, List Arguments, TimeSpan Timeout); - } -} +/* + * ThreadPilot - power plan service unit tests. + */ +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Services; + using ThreadPilot.Services.Abstractions; + + public sealed class PowerPlanServiceTests + { + [Fact] + public async Task GetActivePowerPlan_ParsesPowerCfgOutput() + { + const string guid = "381b4222-f694-41f0-9685-ff5bb260df2e"; + var runner = new RecordingProcessRunner + { + ResultFactory = _ => new ProcessRunResult( + 0, + $"Power Scheme GUID: {guid} (Balanced)", + string.Empty), + }; + var service = CreateService(runner); + + var result = await service.GetActivePowerPlan(); + + Assert.NotNull(result); + Assert.Equal(guid, result.Guid); + Assert.Equal("Balanced", result.Name); + Assert.True(result.IsActive); + } + + [Fact] + public async Task SetActivePowerPlanByGuidAsync_SkipsChange_WhenAlreadyActive() + { + const string guid = "381b4222-f694-41f0-9685-ff5bb260df2e"; + var runner = new RecordingProcessRunner + { + ResultFactory = _ => new ProcessRunResult( + 0, + $"Power Scheme GUID: {guid} (Balanced)", + string.Empty), + }; + var service = CreateService(runner); + + var result = await service.SetActivePowerPlanByGuidAsync(guid, preventDuplicateChanges: true); + + Assert.True(result); + + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(Path.Combine(Environment.SystemDirectory, "powercfg.exe"), invocation.FileName); + Assert.Equal(new[] { "/getactivescheme" }, invocation.Arguments); + } + + [Fact] + public async Task DeletePowerPlanAsync_InvokesPowerCfgDelete_WhenPlanIsNotActive() + { + const string activeGuid = "381b4222-f694-41f0-9685-ff5bb260df2e"; + const string deleteGuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + var runner = new RecordingProcessRunner + { + ResultFactory = invocation => + invocation.Arguments.SequenceEqual(new[] { "/getactivescheme" }) + ? new ProcessRunResult( + 0, + $"Power Scheme GUID: {activeGuid} (Balanced)", + string.Empty) + : new ProcessRunResult(0, string.Empty, string.Empty), + }; + var service = CreateService(runner); + + var result = await service.DeletePowerPlanAsync(deleteGuid); + + Assert.True(result); + Assert.Contains(runner.Invocations, invocation => + invocation.Arguments.SequenceEqual(new[] { "/delete", deleteGuid })); + } + + [Fact] + public async Task DeletePowerPlanAsync_DoesNotDeleteActivePlan() + { + const string activeGuid = "381b4222-f694-41f0-9685-ff5bb260df2e"; + var runner = new RecordingProcessRunner + { + ResultFactory = _ => new ProcessRunResult( + 0, + $"Power Scheme GUID: {activeGuid} (Balanced)", + string.Empty), + }; + var service = CreateService(runner); + + var result = await service.DeletePowerPlanAsync(activeGuid); + + Assert.False(result); + Assert.DoesNotContain(runner.Invocations, invocation => + invocation.Arguments.Contains("/delete", StringComparer.OrdinalIgnoreCase)); + } + + [Fact] + public async Task AddCustomPowerPlanFileAsync_RenamesOnCollision() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"threadpilot-powerplans-{Guid.NewGuid():N}"); + var managedDirectory = Path.Combine(tempRoot, "managed"); + var sourceDirectory = Path.Combine(tempRoot, "source"); + Directory.CreateDirectory(managedDirectory); + Directory.CreateDirectory(sourceDirectory); + + var sourcePath = Path.Combine(sourceDirectory, "gaming.pow"); + var existingPath = Path.Combine(managedDirectory, "gaming.pow"); + await File.WriteAllTextAsync(sourcePath, "source"); + await File.WriteAllTextAsync(existingPath, "existing"); + + try + { + var service = CreateService( + new RecordingProcessRunner(), + powerPlansPathProvider: () => managedDirectory); + + var result = await service.AddCustomPowerPlanFileAsync(sourcePath); + + Assert.True(result); + var renamedPath = Path.Combine(managedDirectory, "gaming_1.pow"); + Assert.True(File.Exists(renamedPath)); + Assert.Equal("source", await File.ReadAllTextAsync(renamedPath)); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task GetCustomPowerPlansAsync_DiscoversBundledPlansRecursively() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"threadpilot-powerplans-{Guid.NewGuid():N}"); + var nestedDirectory = Path.Combine(tempRoot, "nested"); + Directory.CreateDirectory(nestedDirectory); + + var rootPlanPath = Path.Combine(tempRoot, "root.pow"); + var nestedPlanPath = Path.Combine(nestedDirectory, "nested.pow"); + await File.WriteAllTextAsync(rootPlanPath, "root"); + await File.WriteAllTextAsync(nestedPlanPath, "nested"); + + try + { + var service = CreateService( + new RecordingProcessRunner(), + powerPlansPathProvider: () => tempRoot); + + var result = await service.GetCustomPowerPlansAsync(); + + Assert.Contains(result, plan => plan.Name == "root" && plan.FilePath == rootPlanPath); + Assert.Contains(result, plan => plan.Name == "nested" && plan.FilePath == nestedPlanPath); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + private static PowerPlanService CreateService( + IProcessRunner runner, + Func? powerPlansPathProvider = null) + { + var enhancedLogger = new Mock(MockBehavior.Loose); + return new PowerPlanService( + NullLogger.Instance, + enhancedLogger.Object, + runner, + powerPlansPathProvider); + } + + private sealed class RecordingProcessRunner : IProcessRunner + { + public List Invocations { get; } = new(); + + public Func? ResultFactory { get; init; } + + public Task RunAsync(string fileName, IReadOnlyList arguments, TimeSpan timeout) + { + var invocation = new ProcessInvocation(fileName, arguments.ToList(), timeout); + this.Invocations.Add(invocation); + return Task.FromResult(this.ResultFactory?.Invoke(invocation) ?? new ProcessRunResult(0, string.Empty, string.Empty)); + } + } + + private sealed record ProcessInvocation(string FileName, List Arguments, TimeSpan Timeout); + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PowerPlanTransitionGateTests.cs b/Tests/ThreadPilot.Core.Tests/PowerPlanTransitionGateTests.cs index 5c3071d..cda7fe6 100644 --- a/Tests/ThreadPilot.Core.Tests/PowerPlanTransitionGateTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PowerPlanTransitionGateTests.cs @@ -1,89 +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); - } - } -} +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/PowerPlanViewModelTests.cs b/Tests/ThreadPilot.Core.Tests/PowerPlanViewModelTests.cs index c637735..3fbd874 100644 --- a/Tests/ThreadPilot.Core.Tests/PowerPlanViewModelTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PowerPlanViewModelTests.cs @@ -1,152 +1,152 @@ -namespace ThreadPilot.Core.Tests -{ - using System.Collections.ObjectModel; - using Microsoft.Extensions.Logging.Abstractions; - using Moq; - using ThreadPilot.Models; - using ThreadPilot.Services; - using ThreadPilot.ViewModels; - - public sealed class PowerPlanViewModelTests - { - [Fact] - public async Task DeletePowerPlanCommand_CallsServiceRefreshesAndLogs_WhenPlanIsNotActive() - { - var harness = new Harness(); - var deletePlan = new PowerPlanModel { Guid = Harness.DeleteGuid, Name = "Gaming" }; - var viewModel = harness.CreateViewModel(); - - await viewModel.DeletePowerPlanCommand.ExecuteAsync(deletePlan); - - harness.PowerPlan.Verify(service => service.DeletePowerPlanAsync(Harness.DeleteGuid), Times.Once); - harness.PowerPlan.Verify(service => service.GetPowerPlansAsync(), Times.Once); - harness.Logging.Verify( - logger => logger.LogUserActionAsync( - "PowerPlanDeleted", - "Deleted power plan Gaming", - $"Guid: {Harness.DeleteGuid}"), - Times.Once); - var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); - Assert.Equal("Power Plans", entry.Category); - Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); - Assert.Equal("Deleted power plan Gaming", entry.Message); - Assert.Equal("Power plan deleted: Gaming.", viewModel.StatusMessage); - Assert.False(viewModel.HasError); - } - - [Fact] - public async Task DeletePowerPlanCommand_BlocksActivePlanBeforeCallingService() - { - var harness = new Harness(); - var activePlan = new PowerPlanModel { Guid = Harness.ActiveGuid, Name = "Balanced", IsActive = true }; - var viewModel = harness.CreateViewModel(); - - await viewModel.DeletePowerPlanCommand.ExecuteAsync(activePlan); - - harness.PowerPlan.Verify(service => service.DeletePowerPlanAsync(It.IsAny()), Times.Never); - Assert.Equal("Switch to another power plan before deleting the active plan.", viewModel.StatusMessage); - Assert.True(viewModel.HasError); - var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); - Assert.Equal("Power Plans", entry.Category); - Assert.Equal(ActivityAuditSeverity.Warning, entry.Severity); - Assert.Contains("Switch to another power plan", entry.Message); - } - - [Fact] - public async Task DeletePowerPlanCommand_ShowsFailureAndDoesNotCrash_WhenServiceFails() - { - var harness = new Harness(deleteSucceeds: false); - var deletePlan = new PowerPlanModel { Guid = Harness.DeleteGuid, Name = "Gaming" }; - var viewModel = harness.CreateViewModel(); - - await viewModel.DeletePowerPlanCommand.ExecuteAsync(deletePlan); - - Assert.Equal("Could not delete power plan Gaming. Windows may not allow this plan to be removed.", viewModel.StatusMessage); - Assert.True(viewModel.HasError); - } - - [Fact] - public async Task SetActivePlanCommand_ShowsSuccessStatusAndLogs() - { - var harness = new Harness(); - var viewModel = harness.CreateViewModel(); - viewModel.SelectedPowerPlan = new PowerPlanModel { Guid = Harness.DeleteGuid, Name = "Gaming" }; - - await viewModel.SetActivePlanCommand.ExecuteAsync(null); - - Assert.Equal("Power plan applied: Gaming.", viewModel.StatusMessage); - harness.Logging.Verify( - logger => logger.LogUserActionAsync( - "PowerPlanApplied", - "Applied power plan Gaming", - $"Guid: {Harness.DeleteGuid}"), - Times.Once); - var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); - Assert.Equal("Power Plans", entry.Category); - Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); - Assert.Equal("Applied power plan Gaming", entry.Message); - } - - [Fact] - public async Task RefreshPowerPlansCommand_ShowsCompletionStatusAndLogs() - { - var harness = new Harness(); - var viewModel = harness.CreateViewModel(); - - await viewModel.RefreshPowerPlansCommand.ExecuteAsync(null); - - Assert.Equal("Power plans refreshed.", viewModel.StatusMessage); - harness.Logging.Verify( - logger => logger.LogUserActionAsync( - "PowerPlansRefreshed", - "Refreshed power plan list", - null), - Times.Once); - var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); - Assert.Equal("Power Plans", entry.Category); - Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); - Assert.Equal("Refreshed power plan list", entry.Message); - } - - private sealed class Harness - { - public const string ActiveGuid = "381b4222-f694-41f0-9685-ff5bb260df2e"; - public const string DeleteGuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; - - public Mock PowerPlan { get; } = new(MockBehavior.Strict); - - public Mock Logging { get; } = new(MockBehavior.Loose); - - public ActivityAuditService Audit { get; } = new(NullLogger.Instance); - - public Harness(bool deleteSucceeds = true) - { - var active = new PowerPlanModel { Guid = ActiveGuid, Name = "Balanced", IsActive = true }; - var delete = new PowerPlanModel { Guid = DeleteGuid, Name = "Gaming" }; - - this.PowerPlan - .Setup(service => service.GetPowerPlansAsync()) - .ReturnsAsync(new ObservableCollection { active, delete }); - this.PowerPlan - .Setup(service => service.GetCustomPowerPlansAsync()) - .ReturnsAsync(new ObservableCollection()); - this.PowerPlan - .Setup(service => service.GetActivePowerPlan()) - .ReturnsAsync(active); - this.PowerPlan - .Setup(service => service.SetActivePowerPlan(It.IsAny())) - .ReturnsAsync(true); - this.PowerPlan - .Setup(service => service.DeletePowerPlanAsync(DeleteGuid)) - .ReturnsAsync(deleteSucceeds); - } - - public PowerPlanViewModel CreateViewModel() => - new( - NullLogger.Instance, - this.PowerPlan.Object, - this.Logging.Object, - this.Audit); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using System.Collections.ObjectModel; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public sealed class PowerPlanViewModelTests + { + [Fact] + public async Task DeletePowerPlanCommand_CallsServiceRefreshesAndLogs_WhenPlanIsNotActive() + { + var harness = new Harness(); + var deletePlan = new PowerPlanModel { Guid = Harness.DeleteGuid, Name = "Gaming" }; + var viewModel = harness.CreateViewModel(); + + await viewModel.DeletePowerPlanCommand.ExecuteAsync(deletePlan); + + harness.PowerPlan.Verify(service => service.DeletePowerPlanAsync(Harness.DeleteGuid), Times.Once); + harness.PowerPlan.Verify(service => service.GetPowerPlansAsync(), Times.Once); + harness.Logging.Verify( + logger => logger.LogUserActionAsync( + "PowerPlanDeleted", + "Deleted power plan Gaming", + $"Guid: {Harness.DeleteGuid}"), + Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Power Plans", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + Assert.Equal("Deleted power plan Gaming", entry.Message); + Assert.Equal("Power plan deleted: Gaming.", viewModel.StatusMessage); + Assert.False(viewModel.HasError); + } + + [Fact] + public async Task DeletePowerPlanCommand_BlocksActivePlanBeforeCallingService() + { + var harness = new Harness(); + var activePlan = new PowerPlanModel { Guid = Harness.ActiveGuid, Name = "Balanced", IsActive = true }; + var viewModel = harness.CreateViewModel(); + + await viewModel.DeletePowerPlanCommand.ExecuteAsync(activePlan); + + harness.PowerPlan.Verify(service => service.DeletePowerPlanAsync(It.IsAny()), Times.Never); + Assert.Equal("Switch to another power plan before deleting the active plan.", viewModel.StatusMessage); + Assert.True(viewModel.HasError); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Power Plans", entry.Category); + Assert.Equal(ActivityAuditSeverity.Warning, entry.Severity); + Assert.Contains("Switch to another power plan", entry.Message); + } + + [Fact] + public async Task DeletePowerPlanCommand_ShowsFailureAndDoesNotCrash_WhenServiceFails() + { + var harness = new Harness(deleteSucceeds: false); + var deletePlan = new PowerPlanModel { Guid = Harness.DeleteGuid, Name = "Gaming" }; + var viewModel = harness.CreateViewModel(); + + await viewModel.DeletePowerPlanCommand.ExecuteAsync(deletePlan); + + Assert.Equal("Could not delete power plan Gaming. Windows may not allow this plan to be removed.", viewModel.StatusMessage); + Assert.True(viewModel.HasError); + } + + [Fact] + public async Task SetActivePlanCommand_ShowsSuccessStatusAndLogs() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + viewModel.SelectedPowerPlan = new PowerPlanModel { Guid = Harness.DeleteGuid, Name = "Gaming" }; + + await viewModel.SetActivePlanCommand.ExecuteAsync(null); + + Assert.Equal("Power plan applied: Gaming.", viewModel.StatusMessage); + harness.Logging.Verify( + logger => logger.LogUserActionAsync( + "PowerPlanApplied", + "Applied power plan Gaming", + $"Guid: {Harness.DeleteGuid}"), + Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Power Plans", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + Assert.Equal("Applied power plan Gaming", entry.Message); + } + + [Fact] + public async Task RefreshPowerPlansCommand_ShowsCompletionStatusAndLogs() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + + await viewModel.RefreshPowerPlansCommand.ExecuteAsync(null); + + Assert.Equal("Power plans refreshed.", viewModel.StatusMessage); + harness.Logging.Verify( + logger => logger.LogUserActionAsync( + "PowerPlansRefreshed", + "Refreshed power plan list", + null), + Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Power Plans", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + Assert.Equal("Refreshed power plan list", entry.Message); + } + + private sealed class Harness + { + public const string ActiveGuid = "381b4222-f694-41f0-9685-ff5bb260df2e"; + public const string DeleteGuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + + public Mock PowerPlan { get; } = new(MockBehavior.Strict); + + public Mock Logging { get; } = new(MockBehavior.Loose); + + public ActivityAuditService Audit { get; } = new(NullLogger.Instance); + + public Harness(bool deleteSucceeds = true) + { + var active = new PowerPlanModel { Guid = ActiveGuid, Name = "Balanced", IsActive = true }; + var delete = new PowerPlanModel { Guid = DeleteGuid, Name = "Gaming" }; + + this.PowerPlan + .Setup(service => service.GetPowerPlansAsync()) + .ReturnsAsync(new ObservableCollection { active, delete }); + this.PowerPlan + .Setup(service => service.GetCustomPowerPlansAsync()) + .ReturnsAsync(new ObservableCollection()); + this.PowerPlan + .Setup(service => service.GetActivePowerPlan()) + .ReturnsAsync(active); + this.PowerPlan + .Setup(service => service.SetActivePowerPlan(It.IsAny())) + .ReturnsAsync(true); + this.PowerPlan + .Setup(service => service.DeletePowerPlanAsync(DeleteGuid)) + .ReturnsAsync(deleteSucceeds); + } + + public PowerPlanViewModel CreateViewModel() => + new( + NullLogger.Instance, + this.PowerPlan.Object, + this.Logging.Object, + this.Audit); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PowerPlanViewXamlTests.cs b/Tests/ThreadPilot.Core.Tests/PowerPlanViewXamlTests.cs index de45109..81fdddd 100644 --- a/Tests/ThreadPilot.Core.Tests/PowerPlanViewXamlTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PowerPlanViewXamlTests.cs @@ -1,58 +1,58 @@ -namespace ThreadPilot.Core.Tests -{ - using System.Xml.Linq; - - public sealed class PowerPlanViewXamlTests - { - private static readonly string PowerPlanViewPath = Path.Combine( - AppContext.BaseDirectory, - "..", - "..", - "..", - "..", - "..", - "Views", - "PowerPlanView.xaml"); - - [Fact] - public void PowerPlanItems_ExposeDeleteContextMenu() - { - var document = XDocument.Load(PowerPlanViewPath, LoadOptions.PreserveWhitespace); - var deleteCommandBinding = document - .Descendants() - .SelectMany(element => element.Attributes()) - .SingleOrDefault(attribute => attribute.Value.Contains("DeletePowerPlanCommand", StringComparison.Ordinal)); - - Assert.NotNull(deleteCommandBinding); - } - - [Fact] - public void HeaderInstructionText_WrapsToAvoidButtonOverlap() - { - var document = XDocument.Load(PowerPlanViewPath, LoadOptions.PreserveWhitespace); - var instructionTextBlocks = document - .Descendants() - .Where(element => element.Name.LocalName == "TextBlock") - .Where(element => element.Attributes().Any(attribute => - attribute.Value.Contains("PowerPlanView_SelectActiveTip", StringComparison.Ordinal) || - attribute.Value.Contains("PowerPlanView_LocalPlansTip", StringComparison.Ordinal))) - .ToList(); - - Assert.Equal(2, instructionTextBlocks.Count); - Assert.All(instructionTextBlocks, textBlock => - Assert.Contains(textBlock.Attributes(), attribute => - attribute.Name.LocalName == "TextWrapping" && attribute.Value == "Wrap")); - } - - [Fact] - public void ActivePowerPlanTemplate_ContainsActiveBadgeAndAccentBorder() - { - var document = XDocument.Load(PowerPlanViewPath, LoadOptions.PreserveWhitespace); - var serialized = document.ToString(SaveOptions.DisableFormatting); - - Assert.Contains("PowerPlanView_Active", serialized, StringComparison.Ordinal); - Assert.Contains("IsActive", serialized, StringComparison.Ordinal); - Assert.Contains("Accent", serialized, StringComparison.Ordinal); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using System.Xml.Linq; + + public sealed class PowerPlanViewXamlTests + { + private static readonly string PowerPlanViewPath = Path.Combine( + AppContext.BaseDirectory, + "..", + "..", + "..", + "..", + "..", + "Views", + "PowerPlanView.xaml"); + + [Fact] + public void PowerPlanItems_ExposeDeleteContextMenu() + { + var document = XDocument.Load(PowerPlanViewPath, LoadOptions.PreserveWhitespace); + var deleteCommandBinding = document + .Descendants() + .SelectMany(element => element.Attributes()) + .SingleOrDefault(attribute => attribute.Value.Contains("DeletePowerPlanCommand", StringComparison.Ordinal)); + + Assert.NotNull(deleteCommandBinding); + } + + [Fact] + public void HeaderInstructionText_WrapsToAvoidButtonOverlap() + { + var document = XDocument.Load(PowerPlanViewPath, LoadOptions.PreserveWhitespace); + var instructionTextBlocks = document + .Descendants() + .Where(element => element.Name.LocalName == "TextBlock") + .Where(element => element.Attributes().Any(attribute => + attribute.Value.Contains("PowerPlanView_SelectActiveTip", StringComparison.Ordinal) || + attribute.Value.Contains("PowerPlanView_LocalPlansTip", StringComparison.Ordinal))) + .ToList(); + + Assert.Equal(2, instructionTextBlocks.Count); + Assert.All(instructionTextBlocks, textBlock => + Assert.Contains(textBlock.Attributes(), attribute => + attribute.Name.LocalName == "TextWrapping" && attribute.Value == "Wrap")); + } + + [Fact] + public void ActivePowerPlanTemplate_ContainsActiveBadgeAndAccentBorder() + { + var document = XDocument.Load(PowerPlanViewPath, LoadOptions.PreserveWhitespace); + var serialized = document.ToString(SaveOptions.DisableFormatting); + + Assert.Contains("PowerPlanView_Active", serialized, StringComparison.Ordinal); + Assert.Contains("IsActive", serialized, StringComparison.Ordinal); + Assert.Contains("Accent", serialized, StringComparison.Ordinal); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessAffinityApplyCoordinatorTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessAffinityApplyCoordinatorTests.cs index 4c72b7e..1d92cb0 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessAffinityApplyCoordinatorTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessAffinityApplyCoordinatorTests.cs @@ -1,274 +1,274 @@ -namespace ThreadPilot.Core.Tests -{ - using Microsoft.Extensions.Logging.Abstractions; - using ThreadPilot.Models; - using ThreadPilot.Services; - - public sealed class ProcessAffinityApplyCoordinatorTests - { - [Fact] - public async Task ApplyCoreMaskAsync_WithCpuSelection_UsesCpuSelectionPath() - { - var process = CreateProcess(); - var selection = new CpuSelection - { - LogicalProcessors = [new ProcessorRef(0, 0, 0), new ProcessorRef(0, 2, 2)], - GlobalLogicalProcessorIndexes = [0, 2], - }; - var mask = CreateMask([true, false, true]); - mask.CpuSelection = selection; - var affinity = new RecordingAffinityApplyService(); - var coordinator = CreateCoordinator(affinity); - - var result = await coordinator.ApplyCoreMaskAsync(process, mask); - - Assert.True(result.Success); - Assert.Equal(1, affinity.CpuSelectionApplyCalls); - Assert.Equal(0, affinity.LegacyApplyCalls); - Assert.Same(selection, affinity.LastSelection); - } - - [Fact] - public async Task ApplyCoreMaskAsync_WithCpu64Selection_DoesNotUseLegacyMaskOrAliasCpu0() - { - var process = CreateProcess(); - var cpu64 = new ProcessorRef(1, 0, 64); - var mask = CreateMask(Enumerable.Range(0, 65).Select(index => index == 64).ToList()); - mask.CpuSelection = new CpuSelection - { - LogicalProcessors = [cpu64], - GlobalLogicalProcessorIndexes = [64], - }; - var affinity = new RecordingAffinityApplyService(); - var coordinator = CreateCoordinator(affinity); - - var result = await coordinator.ApplyCoreMaskAsync(process, mask); - - Assert.True(result.Success); - Assert.Equal(0, affinity.LegacyApplyCalls); - var applied = Assert.Single(affinity.LastSelection!.LogicalProcessors); - Assert.Equal(cpu64, applied); - Assert.DoesNotContain(affinity.LastSelection.LogicalProcessors, processor => processor.GlobalIndex == 0); - } - - [Fact] - public async Task ApplyCoreMaskAsync_WithMultiGroupSelection_DoesNotUseLegacyMask() - { - var process = CreateProcess(); - var selection = new CpuSelection - { - LogicalProcessors = [new ProcessorRef(0, 0, 0), new ProcessorRef(1, 0, 64)], - GlobalLogicalProcessorIndexes = [0, 64], - }; - var mask = CreateMask(Enumerable.Repeat(true, 65).ToList()); - mask.CpuSelection = selection; - var affinity = new RecordingAffinityApplyService(); - var coordinator = CreateCoordinator(affinity); - - var result = await coordinator.ApplyCoreMaskAsync(process, mask); - - Assert.True(result.Success); - Assert.Equal(1, affinity.CpuSelectionApplyCalls); - Assert.Equal(0, affinity.LegacyApplyCalls); - } - - [Fact] - public async Task ApplyCoreMaskAsync_WithoutCpuSelectionAndWithoutTopology_UsesLegacyForSingleGroupMask() - { - var process = CreateProcess(); - var affinity = new RecordingAffinityApplyService(); - var coordinator = CreateCoordinator(affinity, topologyProvider: null); - - var result = await coordinator.ApplyCoreMaskAsync(process, CreateMask([true, false, true])); - - Assert.True(result.Success); - Assert.Equal(1, affinity.LegacyApplyCalls); - Assert.Equal(0b101, affinity.LastLegacyMask); - Assert.Equal(0, affinity.CpuSelectionApplyCalls); - } - - [Fact] - public async Task ApplyCoreMaskAsync_WithoutCpuSelection_MigratesToCpuSelectionWhenTopologyIsAvailable() - { - var process = CreateProcess(); - var topology = CpuTopologySnapshot.Create( - [new ProcessorRef(0, 0, 0), new ProcessorRef(0, 1, 1), new ProcessorRef(0, 2, 2)]); - var affinity = new RecordingAffinityApplyService(); - var coordinator = CreateCoordinator(affinity, new FakeCpuTopologyProvider(topology)); - - var result = await coordinator.ApplyCoreMaskAsync(process, CreateMask([true, false, true])); - - Assert.True(result.Success); - Assert.Equal(1, affinity.CpuSelectionApplyCalls); - Assert.Equal(0, affinity.LegacyApplyCalls); - Assert.Equal([0, 2], affinity.LastSelection!.GlobalLogicalProcessorIndexes); - } - - [Fact] - public async Task ApplyCoreMaskAsync_WithCpu64BoolMaskAndTopology_UsesCpuSelectionPath() - { - var process = CreateProcess(); - var processors = Enumerable.Range(0, 65) - .Select(index => index < 64 - ? new ProcessorRef(0, (byte)index, index) - : new ProcessorRef(1, 0, index)) - .ToList(); - var topology = CpuTopologySnapshot.Create(processors); - var boolMask = Enumerable.Range(0, 65).Select(index => index == 64).ToList(); - var affinity = new RecordingAffinityApplyService(); - var coordinator = CreateCoordinator(affinity, new FakeCpuTopologyProvider(topology)); - - var result = await coordinator.ApplyCoreMaskAsync(process, CreateMask(boolMask)); - - Assert.True(result.Success); - Assert.Equal(1, affinity.CpuSelectionApplyCalls); - Assert.Equal(0, affinity.LegacyApplyCalls); - var applied = Assert.Single(affinity.LastSelection!.LogicalProcessors); - Assert.Equal(new ProcessorRef(1, 0, 64), applied); - Assert.DoesNotContain(affinity.LastSelection.LogicalProcessors, processor => processor.GlobalIndex == 0); - } - - [Fact] - public async Task ApplyCoreMaskAsync_WhenTopologyUnavailableAndMaskIsUnsafe_BlocksLegacyFallback() - { - var process = CreateProcess(); - var boolMask = Enumerable.Range(0, 65).Select(index => index == 64).ToList(); - var affinity = new RecordingAffinityApplyService(); - var coordinator = CreateCoordinator(affinity, topologyProvider: null); - - var result = await coordinator.ApplyCoreMaskAsync(process, CreateMask(boolMask)); - - Assert.False(result.Success); - Assert.Equal(AffinityApplyErrorCodes.LegacyFallbackUnsafe, result.ErrorCode); - Assert.Equal(ProcessOperationUserMessages.LegacyFallbackBlocked, result.UserMessage); - Assert.Equal(0, affinity.LegacyApplyCalls); - Assert.Equal(0, affinity.CpuSelectionApplyCalls); - } - - [Fact] - public async Task ApplyCoreMaskAsync_WhenCpuSelectionAccessDenied_ReturnsSafeAccessDeniedMessage() - { - var process = CreateProcess(); - var affinity = new RecordingAffinityApplyService - { - CpuSelectionResult = AffinityApplyResult.Failed( - AffinityApplyErrorCodes.AccessDenied, - ProcessOperationUserMessages.AccessDenied, - "Access is denied.", - isAccessDenied: true), - }; - var mask = CreateMask([true]); - mask.CpuSelection = new CpuSelection - { - LogicalProcessors = [new ProcessorRef(0, 0, 0)], - GlobalLogicalProcessorIndexes = [0], - }; - var coordinator = CreateCoordinator(affinity); - - var result = await coordinator.ApplyCoreMaskAsync(process, mask); - - Assert.False(result.Success); - Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode); - Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); - Assert.DoesNotContain("bypass", result.UserMessage, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task ApplyCoreMaskAsync_WhenCpuSelectionAntiCheatBlocked_ReturnsNoBypassMessage() - { - var process = CreateProcess(); - var affinity = new RecordingAffinityApplyService - { - CpuSelectionResult = AffinityApplyResult.Failed( - AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely, - ProcessOperationUserMessages.AntiCheatProtectedLikely, - "Protected process.", - isAccessDenied: true, - isAntiCheatLikely: true), - }; - var mask = CreateMask([true]); - mask.CpuSelection = new CpuSelection - { - LogicalProcessors = [new ProcessorRef(0, 0, 0)], - GlobalLogicalProcessorIndexes = [0], - }; - var coordinator = CreateCoordinator(affinity); - - var result = await coordinator.ApplyCoreMaskAsync(process, mask); - - Assert.False(result.Success); - Assert.Equal(AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely, result.ErrorCode); - Assert.Equal(ProcessOperationUserMessages.AntiCheatProtectedLikely, result.UserMessage); - Assert.Contains("will not try to bypass", result.UserMessage, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("disable anti-cheat", result.UserMessage, StringComparison.OrdinalIgnoreCase); - } - - private static ProcessAffinityApplyCoordinator CreateCoordinator( - RecordingAffinityApplyService affinity, - ICpuTopologyProvider? topologyProvider = null) => - new( - affinity, - topologyProvider, - new CpuSelectionMigrationService(), - NullLogger.Instance); - - private static ProcessModel CreateProcess() => - new() - { - ProcessId = 42, - Name = "game.exe", - ProcessorAffinity = 1, - }; - - private static CoreMask CreateMask(IReadOnlyList boolMask) - { - var mask = new CoreMask { Name = "Manual" }; - foreach (var bit in boolMask) - { - mask.BoolMask.Add(bit); - } - - return mask; - } - - private sealed class FakeCpuTopologyProvider(CpuTopologySnapshot snapshot) : ICpuTopologyProvider - { - public Task GetTopologySnapshotAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(snapshot); - } - } - - private sealed class RecordingAffinityApplyService : IAffinityApplyService - { - public int LegacyApplyCalls { get; private set; } - - public int CpuSelectionApplyCalls { get; private set; } - - public long? LastLegacyMask { get; private set; } - - public CpuSelection? LastSelection { get; private set; } - - public AffinityApplyResult LegacyResult { get; init; } = - AffinityApplyResult.SucceededWithLegacyFallback(1, 1); - - public AffinityApplyResult CpuSelectionResult { get; init; } = - AffinityApplyResult.SucceededWithCpuSets("CPU Sets applied."); - - public Task ApplyAsync(ProcessModel process, long requestedMask) - { - this.LegacyApplyCalls++; - this.LastLegacyMask = requestedMask; - return Task.FromResult(this.LegacyResult); - } - - public Task ApplyAsync(ProcessModel process, CpuSelection selection) - { - this.CpuSelectionApplyCalls++; - this.LastSelection = selection; - return Task.FromResult(this.CpuSelectionResult); - } - } - } -} +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class ProcessAffinityApplyCoordinatorTests + { + [Fact] + public async Task ApplyCoreMaskAsync_WithCpuSelection_UsesCpuSelectionPath() + { + var process = CreateProcess(); + var selection = new CpuSelection + { + LogicalProcessors = [new ProcessorRef(0, 0, 0), new ProcessorRef(0, 2, 2)], + GlobalLogicalProcessorIndexes = [0, 2], + }; + var mask = CreateMask([true, false, true]); + mask.CpuSelection = selection; + var affinity = new RecordingAffinityApplyService(); + var coordinator = CreateCoordinator(affinity); + + var result = await coordinator.ApplyCoreMaskAsync(process, mask); + + Assert.True(result.Success); + Assert.Equal(1, affinity.CpuSelectionApplyCalls); + Assert.Equal(0, affinity.LegacyApplyCalls); + Assert.Same(selection, affinity.LastSelection); + } + + [Fact] + public async Task ApplyCoreMaskAsync_WithCpu64Selection_DoesNotUseLegacyMaskOrAliasCpu0() + { + var process = CreateProcess(); + var cpu64 = new ProcessorRef(1, 0, 64); + var mask = CreateMask(Enumerable.Range(0, 65).Select(index => index == 64).ToList()); + mask.CpuSelection = new CpuSelection + { + LogicalProcessors = [cpu64], + GlobalLogicalProcessorIndexes = [64], + }; + var affinity = new RecordingAffinityApplyService(); + var coordinator = CreateCoordinator(affinity); + + var result = await coordinator.ApplyCoreMaskAsync(process, mask); + + Assert.True(result.Success); + Assert.Equal(0, affinity.LegacyApplyCalls); + var applied = Assert.Single(affinity.LastSelection!.LogicalProcessors); + Assert.Equal(cpu64, applied); + Assert.DoesNotContain(affinity.LastSelection.LogicalProcessors, processor => processor.GlobalIndex == 0); + } + + [Fact] + public async Task ApplyCoreMaskAsync_WithMultiGroupSelection_DoesNotUseLegacyMask() + { + var process = CreateProcess(); + var selection = new CpuSelection + { + LogicalProcessors = [new ProcessorRef(0, 0, 0), new ProcessorRef(1, 0, 64)], + GlobalLogicalProcessorIndexes = [0, 64], + }; + var mask = CreateMask(Enumerable.Repeat(true, 65).ToList()); + mask.CpuSelection = selection; + var affinity = new RecordingAffinityApplyService(); + var coordinator = CreateCoordinator(affinity); + + var result = await coordinator.ApplyCoreMaskAsync(process, mask); + + Assert.True(result.Success); + Assert.Equal(1, affinity.CpuSelectionApplyCalls); + Assert.Equal(0, affinity.LegacyApplyCalls); + } + + [Fact] + public async Task ApplyCoreMaskAsync_WithoutCpuSelectionAndWithoutTopology_UsesLegacyForSingleGroupMask() + { + var process = CreateProcess(); + var affinity = new RecordingAffinityApplyService(); + var coordinator = CreateCoordinator(affinity, topologyProvider: null); + + var result = await coordinator.ApplyCoreMaskAsync(process, CreateMask([true, false, true])); + + Assert.True(result.Success); + Assert.Equal(1, affinity.LegacyApplyCalls); + Assert.Equal(0b101, affinity.LastLegacyMask); + Assert.Equal(0, affinity.CpuSelectionApplyCalls); + } + + [Fact] + public async Task ApplyCoreMaskAsync_WithoutCpuSelection_MigratesToCpuSelectionWhenTopologyIsAvailable() + { + var process = CreateProcess(); + var topology = CpuTopologySnapshot.Create( + [new ProcessorRef(0, 0, 0), new ProcessorRef(0, 1, 1), new ProcessorRef(0, 2, 2)]); + var affinity = new RecordingAffinityApplyService(); + var coordinator = CreateCoordinator(affinity, new FakeCpuTopologyProvider(topology)); + + var result = await coordinator.ApplyCoreMaskAsync(process, CreateMask([true, false, true])); + + Assert.True(result.Success); + Assert.Equal(1, affinity.CpuSelectionApplyCalls); + Assert.Equal(0, affinity.LegacyApplyCalls); + Assert.Equal([0, 2], affinity.LastSelection!.GlobalLogicalProcessorIndexes); + } + + [Fact] + public async Task ApplyCoreMaskAsync_WithCpu64BoolMaskAndTopology_UsesCpuSelectionPath() + { + var process = CreateProcess(); + var processors = Enumerable.Range(0, 65) + .Select(index => index < 64 + ? new ProcessorRef(0, (byte)index, index) + : new ProcessorRef(1, 0, index)) + .ToList(); + var topology = CpuTopologySnapshot.Create(processors); + var boolMask = Enumerable.Range(0, 65).Select(index => index == 64).ToList(); + var affinity = new RecordingAffinityApplyService(); + var coordinator = CreateCoordinator(affinity, new FakeCpuTopologyProvider(topology)); + + var result = await coordinator.ApplyCoreMaskAsync(process, CreateMask(boolMask)); + + Assert.True(result.Success); + Assert.Equal(1, affinity.CpuSelectionApplyCalls); + Assert.Equal(0, affinity.LegacyApplyCalls); + var applied = Assert.Single(affinity.LastSelection!.LogicalProcessors); + Assert.Equal(new ProcessorRef(1, 0, 64), applied); + Assert.DoesNotContain(affinity.LastSelection.LogicalProcessors, processor => processor.GlobalIndex == 0); + } + + [Fact] + public async Task ApplyCoreMaskAsync_WhenTopologyUnavailableAndMaskIsUnsafe_BlocksLegacyFallback() + { + var process = CreateProcess(); + var boolMask = Enumerable.Range(0, 65).Select(index => index == 64).ToList(); + var affinity = new RecordingAffinityApplyService(); + var coordinator = CreateCoordinator(affinity, topologyProvider: null); + + var result = await coordinator.ApplyCoreMaskAsync(process, CreateMask(boolMask)); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.LegacyFallbackUnsafe, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.LegacyFallbackBlocked, result.UserMessage); + Assert.Equal(0, affinity.LegacyApplyCalls); + Assert.Equal(0, affinity.CpuSelectionApplyCalls); + } + + [Fact] + public async Task ApplyCoreMaskAsync_WhenCpuSelectionAccessDenied_ReturnsSafeAccessDeniedMessage() + { + var process = CreateProcess(); + var affinity = new RecordingAffinityApplyService + { + CpuSelectionResult = AffinityApplyResult.Failed( + AffinityApplyErrorCodes.AccessDenied, + ProcessOperationUserMessages.AccessDenied, + "Access is denied.", + isAccessDenied: true), + }; + var mask = CreateMask([true]); + mask.CpuSelection = new CpuSelection + { + LogicalProcessors = [new ProcessorRef(0, 0, 0)], + GlobalLogicalProcessorIndexes = [0], + }; + var coordinator = CreateCoordinator(affinity); + + var result = await coordinator.ApplyCoreMaskAsync(process, mask); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); + Assert.DoesNotContain("bypass", result.UserMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ApplyCoreMaskAsync_WhenCpuSelectionAntiCheatBlocked_ReturnsNoBypassMessage() + { + var process = CreateProcess(); + var affinity = new RecordingAffinityApplyService + { + CpuSelectionResult = AffinityApplyResult.Failed( + AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely, + ProcessOperationUserMessages.AntiCheatProtectedLikely, + "Protected process.", + isAccessDenied: true, + isAntiCheatLikely: true), + }; + var mask = CreateMask([true]); + mask.CpuSelection = new CpuSelection + { + LogicalProcessors = [new ProcessorRef(0, 0, 0)], + GlobalLogicalProcessorIndexes = [0], + }; + var coordinator = CreateCoordinator(affinity); + + var result = await coordinator.ApplyCoreMaskAsync(process, mask); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.AntiCheatProtectedLikely, result.UserMessage); + Assert.Contains("will not try to bypass", result.UserMessage, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("disable anti-cheat", result.UserMessage, StringComparison.OrdinalIgnoreCase); + } + + private static ProcessAffinityApplyCoordinator CreateCoordinator( + RecordingAffinityApplyService affinity, + ICpuTopologyProvider? topologyProvider = null) => + new( + affinity, + topologyProvider, + new CpuSelectionMigrationService(), + NullLogger.Instance); + + private static ProcessModel CreateProcess() => + new() + { + ProcessId = 42, + Name = "game.exe", + ProcessorAffinity = 1, + }; + + private static CoreMask CreateMask(IReadOnlyList boolMask) + { + var mask = new CoreMask { Name = "Manual" }; + foreach (var bit in boolMask) + { + mask.BoolMask.Add(bit); + } + + return mask; + } + + private sealed class FakeCpuTopologyProvider(CpuTopologySnapshot snapshot) : ICpuTopologyProvider + { + public Task GetTopologySnapshotAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(snapshot); + } + } + + private sealed class RecordingAffinityApplyService : IAffinityApplyService + { + public int LegacyApplyCalls { get; private set; } + + public int CpuSelectionApplyCalls { get; private set; } + + public long? LastLegacyMask { get; private set; } + + public CpuSelection? LastSelection { get; private set; } + + public AffinityApplyResult LegacyResult { get; init; } = + AffinityApplyResult.SucceededWithLegacyFallback(1, 1); + + public AffinityApplyResult CpuSelectionResult { get; init; } = + AffinityApplyResult.SucceededWithCpuSets("CPU Sets applied."); + + public Task ApplyAsync(ProcessModel process, long requestedMask) + { + this.LegacyApplyCalls++; + this.LastLegacyMask = requestedMask; + return Task.FromResult(this.LegacyResult); + } + + public Task ApplyAsync(ProcessModel process, CpuSelection selection) + { + this.CpuSelectionApplyCalls++; + this.LastSelection = selection; + return Task.FromResult(this.CpuSelectionResult); + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessClassifierTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessClassifierTests.cs index fe48f47..8145b20 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessClassifierTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessClassifierTests.cs @@ -1,149 +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); - } - } -} +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/ProcessCpuSetHandlerTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs index 891b6ea..752f981 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs @@ -1,339 +1,339 @@ -namespace ThreadPilot.Core.Tests -{ - using System; - using Microsoft.Win32.SafeHandles; - using ThreadPilot.Models; - using ThreadPilot.Platforms.Windows; - using ThreadPilot.Services; - - public sealed class ProcessCpuSetHandlerTests - { - [Fact] - public void CpuSetMapping_KeepsSameLogicalProcessorIndexInDifferentGroupsDistinct() - { - var group0Cpu0 = new ProcessorRef(0, 0, 0); - var group1Cpu0 = new ProcessorRef(1, 0, 64); - var mapping = CpuSetMapping.Create(new Dictionary - { - [group0Cpu0] = 100, - [group1Cpu0] = 200, - }); - - Assert.True(mapping.TryGetCpuSetId(group0Cpu0, out var group0CpuSetId)); - Assert.True(mapping.TryGetCpuSetId(group1Cpu0, out var group1CpuSetId)); - Assert.Equal(100U, group0CpuSetId); - Assert.Equal(200U, group1CpuSetId); - Assert.True(mapping.TryGetProcessorRef(100, out var group0Processor)); - Assert.True(mapping.TryGetProcessorRef(200, out var group1Processor)); - Assert.Equal(group0Cpu0, group0Processor); - Assert.Equal(group1Cpu0, group1Processor); - } - - [Fact] - public void CpuSetMapping_Cpu64DoesNotSelectCpu0() - { - var group0Cpu0 = new ProcessorRef(0, 0, 0); - var group1Cpu0 = new ProcessorRef(1, 0, 64); - var topology = CpuTopologySnapshot.Create( - [group0Cpu0, group1Cpu0], - cpuSetIds: new Dictionary - { - [group0Cpu0] = 100, - [group1Cpu0] = 200, - }); - var selection = CpuSelection.FromProcessors([group1Cpu0], topology); - var mapping = CpuSetMapping.Create(new Dictionary - { - [group0Cpu0] = 100, - [group1Cpu0] = 200, - }); - - var cpuSetIds = mapping.ResolveCpuSetIds(selection); - - Assert.Equal([200U], cpuSetIds); - } - - [Fact] - public void CpuSetMapping_ResolveSelection_UsesExplicitCpuSetIds() - { - var mapping = CpuSetMapping.Create(new Dictionary - { - [new ProcessorRef(0, 0, 0)] = 100, - }); - var selection = new CpuSelection - { - CpuSetIds = [300, 100, 300], - LogicalProcessors = [new ProcessorRef(0, 0, 0)], - }; - - var cpuSetIds = mapping.ResolveCpuSetIds(selection); - - Assert.Equal([100U, 300U], cpuSetIds); - } - - [Fact] - public void CpuSetMapping_ResolveSelection_MapsProcessorRefsWhenCpuSetIdsAreMissing() - { - var cpu1 = new ProcessorRef(0, 1, 1); - var mapping = CpuSetMapping.Create(new Dictionary - { - [new ProcessorRef(0, 0, 0)] = 100, - [cpu1] = 101, - }); - var selection = new CpuSelection - { - LogicalProcessors = [cpu1], - }; - - var cpuSetIds = mapping.ResolveCpuSetIds(selection); - - Assert.Equal([101U], cpuSetIds); - } - - [Fact] - public void CpuSetMapping_ResolveSelection_ReturnsEmptyWhenNoMappingExists() - { - var mapping = CpuSetMapping.Empty; - var selection = new CpuSelection - { - LogicalProcessors = [new ProcessorRef(0, 1, 1)], - }; - - var cpuSetIds = mapping.ResolveCpuSetIds(selection); - - Assert.Empty(cpuSetIds); - } - - [Fact] - public void ProcessCpuSetHandler_ApplyCpuSelection_WithClearSelection_ClearsCpuSets() - { - var nativeApi = new FakeProcessCpuSetNativeApi(); - using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); - - var result = handler.ApplyCpuSelection(new CpuSelection(), clearSelection: true); - - Assert.True(result); - Assert.Null(nativeApi.LastAppliedCpuSetIds); - Assert.Equal(0U, nativeApi.LastAppliedCpuSetCount); - } - - [Fact] - public void ProcessCpuSetHandler_ApplyCpuSelection_WithClearSelectionAndNullSelection_ClearsCpuSets() - { - var nativeApi = new FakeProcessCpuSetNativeApi(); - using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); - - var result = handler.ApplyCpuSelection(null!, clearSelection: true); - - Assert.True(result); - Assert.Null(nativeApi.LastAppliedCpuSetIds); - Assert.Equal(0U, nativeApi.LastAppliedCpuSetCount); - } - - [Fact] - public void ProcessCpuSetHandler_ApplyCpuSelection_WithNullSelectionAndClearFalse_ThrowsArgumentNullException() - { - var nativeApi = new FakeProcessCpuSetNativeApi(); - using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); - - Assert.Throws(() => - handler.ApplyCpuSelection(null!, clearSelection: false)); - } - - [Fact] - public void ProcessCpuSetHandler_ApplyCpuSelection_WithExplicitCpuSetIds_AppliesThoseIds() - { - var nativeApi = new FakeProcessCpuSetNativeApi(); - using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); - var selection = new CpuSelection - { - CpuSetIds = [400, 200, 400], - }; - - var result = handler.ApplyCpuSelection(selection); - - Assert.True(result); - Assert.Equal([200U, 400U], nativeApi.LastAppliedCpuSetIds!); - } - - [Fact] - public void ProcessCpuSetHandler_ApplyCpuSelection_WithoutCpuSetIds_ResolvesProcessorRefs() - { - var cpu64 = new ProcessorRef(1, 0, 64); - var nativeApi = new FakeProcessCpuSetNativeApi(); - using var handler = CreateHandler( - nativeApi, - CpuSetMapping.Create(new Dictionary - { - [new ProcessorRef(0, 0, 0)] = 100, - [cpu64] = 200, - })); - var selection = new CpuSelection - { - LogicalProcessors = [cpu64], - }; - - var result = handler.ApplyCpuSelection(selection); - - Assert.True(result); - Assert.Equal([200U], nativeApi.LastAppliedCpuSetIds!); - } - - [Fact] - public void ProcessCpuSetHandler_ApplyCpuSelection_WithoutResolvableCpuSets_ReturnsFalse() - { - var nativeApi = new FakeProcessCpuSetNativeApi(); - using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); - var selection = new CpuSelection - { - LogicalProcessors = [new ProcessorRef(1, 0, 64)], - }; - - var result = handler.ApplyCpuSelection(selection); - - Assert.False(result); - Assert.False(nativeApi.WasSetProcessDefaultCpuSetsCalled); - } - - [Fact] - public void ProcessCpuSetHandler_ApplyCpuSelectionDetailed_WithoutResolvableCpuSets_ReturnsInvalidTopology() - { - var nativeApi = new FakeProcessCpuSetNativeApi(); - using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); - var selection = new CpuSelection - { - LogicalProcessors = [new ProcessorRef(1, 0, 64)], - }; - - var result = handler.ApplyCpuSelectionDetailed(selection); - - Assert.False(result.Success); - Assert.Equal(AffinityApplyErrorCodes.InvalidTopology, result.ErrorCode); - Assert.Equal(ProcessOperationUserMessages.InvalidTopology, result.UserMessage); - Assert.False(nativeApi.WasSetProcessDefaultCpuSetsCalled); - } - - [Fact] - public void ProcessCpuSetHandler_ApplyCpuSelectionDetailed_WhenNativeAccessDenied_ReturnsAccessDenied() - { - var nativeApi = new FakeProcessCpuSetNativeApi - { - SetProcessDefaultCpuSetsResult = false, - LastWin32Error = 5, - }; - using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); - var selection = new CpuSelection - { - CpuSetIds = [400], - }; - - var result = handler.ApplyCpuSelectionDetailed(selection); - - Assert.False(result.Success); - Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode); - Assert.Equal(5, result.Win32ErrorCode); - Assert.True(result.IsAccessDenied); - Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); - } - - [Fact] - public void ProcessCpuSetHandler_ApplyCpuSetMask_LegacySingleGroupMappingIsPreserved() - { - var nativeApi = new FakeProcessCpuSetNativeApi(); - using var handler = CreateHandler( - nativeApi, - CpuSetMapping.Create(new Dictionary - { - [new ProcessorRef(0, 0, 0)] = 100, - [new ProcessorRef(0, 1, 1)] = 101, - [new ProcessorRef(1, 0, 64)] = 200, - })); - - var result = handler.ApplyCpuSetMask(0b11); - - Assert.True(result); - Assert.Equal([100U, 101U], nativeApi.LastAppliedCpuSetIds!); - } - - [Fact] - public void ProcessCpuSetHandler_ApplyCpuSetMask_LegacyCpu0BitDoesNotRepresentGroup1Cpu0() - { - var nativeApi = new FakeProcessCpuSetNativeApi(); - using var handler = CreateHandler( - nativeApi, - CpuSetMapping.Create(new Dictionary - { - [new ProcessorRef(0, 0, 0)] = 100, - [new ProcessorRef(1, 0, 64)] = 200, - })); - - var result = handler.ApplyCpuSetMask(0b1); - - Assert.True(result); - Assert.Equal([100U], nativeApi.LastAppliedCpuSetIds!); - } - - private static ProcessCpuSetHandler CreateHandler( - FakeProcessCpuSetNativeApi nativeApi, - CpuSetMapping mapping) - { - return new ProcessCpuSetHandler(1234, "test.exe", nativeApi, mapping); - } - - private sealed class FakeProcessCpuSetNativeApi : IProcessCpuSetNativeApi - { - public bool WasSetProcessDefaultCpuSetsCalled { get; private set; } - - public uint[]? LastAppliedCpuSetIds { get; private set; } - - public uint LastAppliedCpuSetCount { get; private set; } - - public int LastWin32Error { get; set; } - - public bool SetProcessDefaultCpuSetsResult { get; init; } = true; - - public SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId) - { - return new SafeProcessHandle(new IntPtr(1), ownsHandle: false); - } - - public bool SetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetIds, uint cpuSetIdCount) - { - this.WasSetProcessDefaultCpuSetsCalled = true; - this.LastAppliedCpuSetIds = cpuSetIds; - this.LastAppliedCpuSetCount = cpuSetIdCount; - return this.SetProcessDefaultCpuSetsResult; - } - - public bool GetProcessTimes( - SafeProcessHandle process, - out FILETIME creationTime, - out FILETIME exitTime, - out FILETIME kernelTime, - out FILETIME userTime) - { - creationTime = default; - exitTime = default; - kernelTime = default; - userTime = default; - return false; - } - - public bool GetSystemCpuSetInformation( - IntPtr information, - uint bufferLength, - ref uint returnedLength, - SafeProcessHandle process, - uint flags) - { - returnedLength = 0; - return false; - } - - public int GetLastWin32Error() - { - return this.LastWin32Error; - } - } - } -} +namespace ThreadPilot.Core.Tests +{ + using System; + using Microsoft.Win32.SafeHandles; + using ThreadPilot.Models; + using ThreadPilot.Platforms.Windows; + using ThreadPilot.Services; + + public sealed class ProcessCpuSetHandlerTests + { + [Fact] + public void CpuSetMapping_KeepsSameLogicalProcessorIndexInDifferentGroupsDistinct() + { + var group0Cpu0 = new ProcessorRef(0, 0, 0); + var group1Cpu0 = new ProcessorRef(1, 0, 64); + var mapping = CpuSetMapping.Create(new Dictionary + { + [group0Cpu0] = 100, + [group1Cpu0] = 200, + }); + + Assert.True(mapping.TryGetCpuSetId(group0Cpu0, out var group0CpuSetId)); + Assert.True(mapping.TryGetCpuSetId(group1Cpu0, out var group1CpuSetId)); + Assert.Equal(100U, group0CpuSetId); + Assert.Equal(200U, group1CpuSetId); + Assert.True(mapping.TryGetProcessorRef(100, out var group0Processor)); + Assert.True(mapping.TryGetProcessorRef(200, out var group1Processor)); + Assert.Equal(group0Cpu0, group0Processor); + Assert.Equal(group1Cpu0, group1Processor); + } + + [Fact] + public void CpuSetMapping_Cpu64DoesNotSelectCpu0() + { + var group0Cpu0 = new ProcessorRef(0, 0, 0); + var group1Cpu0 = new ProcessorRef(1, 0, 64); + var topology = CpuTopologySnapshot.Create( + [group0Cpu0, group1Cpu0], + cpuSetIds: new Dictionary + { + [group0Cpu0] = 100, + [group1Cpu0] = 200, + }); + var selection = CpuSelection.FromProcessors([group1Cpu0], topology); + var mapping = CpuSetMapping.Create(new Dictionary + { + [group0Cpu0] = 100, + [group1Cpu0] = 200, + }); + + var cpuSetIds = mapping.ResolveCpuSetIds(selection); + + Assert.Equal([200U], cpuSetIds); + } + + [Fact] + public void CpuSetMapping_ResolveSelection_UsesExplicitCpuSetIds() + { + var mapping = CpuSetMapping.Create(new Dictionary + { + [new ProcessorRef(0, 0, 0)] = 100, + }); + var selection = new CpuSelection + { + CpuSetIds = [300, 100, 300], + LogicalProcessors = [new ProcessorRef(0, 0, 0)], + }; + + var cpuSetIds = mapping.ResolveCpuSetIds(selection); + + Assert.Equal([100U, 300U], cpuSetIds); + } + + [Fact] + public void CpuSetMapping_ResolveSelection_MapsProcessorRefsWhenCpuSetIdsAreMissing() + { + var cpu1 = new ProcessorRef(0, 1, 1); + var mapping = CpuSetMapping.Create(new Dictionary + { + [new ProcessorRef(0, 0, 0)] = 100, + [cpu1] = 101, + }); + var selection = new CpuSelection + { + LogicalProcessors = [cpu1], + }; + + var cpuSetIds = mapping.ResolveCpuSetIds(selection); + + Assert.Equal([101U], cpuSetIds); + } + + [Fact] + public void CpuSetMapping_ResolveSelection_ReturnsEmptyWhenNoMappingExists() + { + var mapping = CpuSetMapping.Empty; + var selection = new CpuSelection + { + LogicalProcessors = [new ProcessorRef(0, 1, 1)], + }; + + var cpuSetIds = mapping.ResolveCpuSetIds(selection); + + Assert.Empty(cpuSetIds); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSelection_WithClearSelection_ClearsCpuSets() + { + var nativeApi = new FakeProcessCpuSetNativeApi(); + using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); + + var result = handler.ApplyCpuSelection(new CpuSelection(), clearSelection: true); + + Assert.True(result); + Assert.Null(nativeApi.LastAppliedCpuSetIds); + Assert.Equal(0U, nativeApi.LastAppliedCpuSetCount); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSelection_WithClearSelectionAndNullSelection_ClearsCpuSets() + { + var nativeApi = new FakeProcessCpuSetNativeApi(); + using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); + + var result = handler.ApplyCpuSelection(null!, clearSelection: true); + + Assert.True(result); + Assert.Null(nativeApi.LastAppliedCpuSetIds); + Assert.Equal(0U, nativeApi.LastAppliedCpuSetCount); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSelection_WithNullSelectionAndClearFalse_ThrowsArgumentNullException() + { + var nativeApi = new FakeProcessCpuSetNativeApi(); + using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); + + Assert.Throws(() => + handler.ApplyCpuSelection(null!, clearSelection: false)); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSelection_WithExplicitCpuSetIds_AppliesThoseIds() + { + var nativeApi = new FakeProcessCpuSetNativeApi(); + using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); + var selection = new CpuSelection + { + CpuSetIds = [400, 200, 400], + }; + + var result = handler.ApplyCpuSelection(selection); + + Assert.True(result); + Assert.Equal([200U, 400U], nativeApi.LastAppliedCpuSetIds!); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSelection_WithoutCpuSetIds_ResolvesProcessorRefs() + { + var cpu64 = new ProcessorRef(1, 0, 64); + var nativeApi = new FakeProcessCpuSetNativeApi(); + using var handler = CreateHandler( + nativeApi, + CpuSetMapping.Create(new Dictionary + { + [new ProcessorRef(0, 0, 0)] = 100, + [cpu64] = 200, + })); + var selection = new CpuSelection + { + LogicalProcessors = [cpu64], + }; + + var result = handler.ApplyCpuSelection(selection); + + Assert.True(result); + Assert.Equal([200U], nativeApi.LastAppliedCpuSetIds!); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSelection_WithoutResolvableCpuSets_ReturnsFalse() + { + var nativeApi = new FakeProcessCpuSetNativeApi(); + using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); + var selection = new CpuSelection + { + LogicalProcessors = [new ProcessorRef(1, 0, 64)], + }; + + var result = handler.ApplyCpuSelection(selection); + + Assert.False(result); + Assert.False(nativeApi.WasSetProcessDefaultCpuSetsCalled); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSelectionDetailed_WithoutResolvableCpuSets_ReturnsInvalidTopology() + { + var nativeApi = new FakeProcessCpuSetNativeApi(); + using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); + var selection = new CpuSelection + { + LogicalProcessors = [new ProcessorRef(1, 0, 64)], + }; + + var result = handler.ApplyCpuSelectionDetailed(selection); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.InvalidTopology, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.InvalidTopology, result.UserMessage); + Assert.False(nativeApi.WasSetProcessDefaultCpuSetsCalled); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSelectionDetailed_WhenNativeAccessDenied_ReturnsAccessDenied() + { + var nativeApi = new FakeProcessCpuSetNativeApi + { + SetProcessDefaultCpuSetsResult = false, + LastWin32Error = 5, + }; + using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); + var selection = new CpuSelection + { + CpuSetIds = [400], + }; + + var result = handler.ApplyCpuSelectionDetailed(selection); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode); + Assert.Equal(5, result.Win32ErrorCode); + Assert.True(result.IsAccessDenied); + Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSetMask_LegacySingleGroupMappingIsPreserved() + { + var nativeApi = new FakeProcessCpuSetNativeApi(); + using var handler = CreateHandler( + nativeApi, + CpuSetMapping.Create(new Dictionary + { + [new ProcessorRef(0, 0, 0)] = 100, + [new ProcessorRef(0, 1, 1)] = 101, + [new ProcessorRef(1, 0, 64)] = 200, + })); + + var result = handler.ApplyCpuSetMask(0b11); + + Assert.True(result); + Assert.Equal([100U, 101U], nativeApi.LastAppliedCpuSetIds!); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSetMask_LegacyCpu0BitDoesNotRepresentGroup1Cpu0() + { + var nativeApi = new FakeProcessCpuSetNativeApi(); + using var handler = CreateHandler( + nativeApi, + CpuSetMapping.Create(new Dictionary + { + [new ProcessorRef(0, 0, 0)] = 100, + [new ProcessorRef(1, 0, 64)] = 200, + })); + + var result = handler.ApplyCpuSetMask(0b1); + + Assert.True(result); + Assert.Equal([100U], nativeApi.LastAppliedCpuSetIds!); + } + + private static ProcessCpuSetHandler CreateHandler( + FakeProcessCpuSetNativeApi nativeApi, + CpuSetMapping mapping) + { + return new ProcessCpuSetHandler(1234, "test.exe", nativeApi, mapping); + } + + private sealed class FakeProcessCpuSetNativeApi : IProcessCpuSetNativeApi + { + public bool WasSetProcessDefaultCpuSetsCalled { get; private set; } + + public uint[]? LastAppliedCpuSetIds { get; private set; } + + public uint LastAppliedCpuSetCount { get; private set; } + + public int LastWin32Error { get; set; } + + public bool SetProcessDefaultCpuSetsResult { get; init; } = true; + + public SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId) + { + return new SafeProcessHandle(new IntPtr(1), ownsHandle: false); + } + + public bool SetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetIds, uint cpuSetIdCount) + { + this.WasSetProcessDefaultCpuSetsCalled = true; + this.LastAppliedCpuSetIds = cpuSetIds; + this.LastAppliedCpuSetCount = cpuSetIdCount; + return this.SetProcessDefaultCpuSetsResult; + } + + public bool GetProcessTimes( + SafeProcessHandle process, + out FILETIME creationTime, + out FILETIME exitTime, + out FILETIME kernelTime, + out FILETIME userTime) + { + creationTime = default; + exitTime = default; + kernelTime = default; + userTime = default; + return false; + } + + public bool GetSystemCpuSetInformation( + IntPtr information, + uint bufferLength, + ref uint returnedLength, + SafeProcessHandle process, + uint flags) + { + returnedLength = 0; + return false; + } + + public int GetLastWin32Error() + { + return this.LastWin32Error; + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessFilterServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessFilterServiceTests.cs index 6ff88f0..997c9f8 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessFilterServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessFilterServiceTests.cs @@ -1,32 +1,32 @@ -namespace ThreadPilot.Core.Tests -{ - using ThreadPilot.Models; - using ThreadPilot.Services; - - public sealed class ProcessFilterServiceTests - { - [Theory] - [InlineData("svchost")] - [InlineData("svchost.exe")] - [InlineData("csrss")] - [InlineData("csrss.exe")] - public void FilterAndSort_HidesSystemProcesses_WithOrWithoutExeSuffix(string processName) - { - var service = new ProcessFilterService(); - var processes = new[] - { - new ProcessModel { Name = processName, CpuUsage = 10 }, - new ProcessModel { Name = "UserApp", CpuUsage = 1 }, - }; - - var result = service.FilterAndSort(processes, new ProcessFilterCriteria - { - HideSystemProcesses = true, - SortMode = "Name", - }); - - var remaining = Assert.Single(result); - Assert.Equal("UserApp", remaining.Name); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class ProcessFilterServiceTests + { + [Theory] + [InlineData("svchost")] + [InlineData("svchost.exe")] + [InlineData("csrss")] + [InlineData("csrss.exe")] + public void FilterAndSort_HidesSystemProcesses_WithOrWithoutExeSuffix(string processName) + { + var service = new ProcessFilterService(); + var processes = new[] + { + new ProcessModel { Name = processName, CpuUsage = 10 }, + new ProcessModel { Name = "UserApp", CpuUsage = 1 }, + }; + + var result = service.FilterAndSort(processes, new ProcessFilterCriteria + { + HideSystemProcesses = true, + SortMode = "Name", + }); + + var remaining = Assert.Single(result); + Assert.Equal("UserApp", remaining.Name); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessListDeltaUpdaterTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessListDeltaUpdaterTests.cs index 2f203db..17c37f1 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessListDeltaUpdaterTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessListDeltaUpdaterTests.cs @@ -1,134 +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); - } - } -} +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/ProcessMemoryPriorityServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs index 4988659..f1012fa 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs @@ -1,248 +1,248 @@ -/* - * ThreadPilot - process memory priority service tests. - */ -namespace ThreadPilot.Core.Tests -{ - using System.ComponentModel; - using System.Runtime.InteropServices; - using Microsoft.Win32.SafeHandles; - using ThreadPilot.Models; - using ThreadPilot.Platforms.Windows; - using ThreadPilot.Services; - - public sealed class ProcessMemoryPriorityServiceTests - { - [Fact] - public async Task SetMemoryPriorityAsync_WithValidProcess_CallsNativeApi() - { - var nativeApi = new FakeProcessMemoryPriorityNativeApi(); - var service = CreateService(nativeApi); - var process = CreateProcess(); - - var result = await service.SetMemoryPriorityAsync(process, ProcessMemoryPriority.Low); - - Assert.True(result.Success); - Assert.Equal(ProcessMemoryPriority.Low, nativeApi.LastSetPriority); - Assert.Equal(ProcessAccessFlags.PROCESS_SET_INFORMATION, nativeApi.LastOpenAccess); - Assert.Equal("Memory priority applied.", result.UserMessage); - } - - [Fact] - public async Task GetMemoryPriorityAsync_WithValidProcess_ReadsNativeApi() - { - var nativeApi = new FakeProcessMemoryPriorityNativeApi - { - PriorityToReturn = ProcessMemoryPriority.BelowNormal, - }; - var service = CreateService(nativeApi); - - var priority = await service.GetMemoryPriorityAsync(CreateProcess()); - - Assert.Equal(ProcessMemoryPriority.BelowNormal, priority); - Assert.Equal(ProcessAccessFlags.PROCESS_QUERY_LIMITED_INFORMATION, nativeApi.LastOpenAccess); - } - - [Theory] - [InlineData(1, ProcessMemoryPriority.VeryLow)] - [InlineData(2, ProcessMemoryPriority.Low)] - [InlineData(3, ProcessMemoryPriority.Medium)] - [InlineData(4, ProcessMemoryPriority.BelowNormal)] - [InlineData(5, ProcessMemoryPriority.Normal)] - public void ProcessMemoryPriority_UsesDocumentedWindowsLevels(int windowsLevel, ProcessMemoryPriority priority) - { - Assert.Equal(windowsLevel, (int)priority); - } - - [Fact] - public async Task SetMemoryPriorityAsync_WithNullProcess_ReturnsControlledFailure() - { - var service = CreateService(new FakeProcessMemoryPriorityNativeApi()); - - var result = await service.SetMemoryPriorityAsync(null!, ProcessMemoryPriority.Normal); - - Assert.False(result.Success); - Assert.Equal("InvalidProcess", result.ErrorCode); - Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); - Assert.NotEqual(ProcessMemoryPriorityService.UnsupportedUserMessage, result.UserMessage); - Assert.False(result.IsAccessDenied); - Assert.False(result.IsProcessExited); - } - - [Fact] - public async Task SetMemoryPriorityAsync_WithInvalidProcess_DoesNotReturnUnsupportedWindowsMessage() - { - var service = CreateService(new FakeProcessMemoryPriorityNativeApi()); - - var result = await service.SetMemoryPriorityAsync(new ProcessModel { ProcessId = 0 }, ProcessMemoryPriority.Normal); - - Assert.False(result.Success); - Assert.Equal("InvalidProcess", result.ErrorCode); - Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); - Assert.NotEqual(ProcessMemoryPriorityService.UnsupportedUserMessage, result.UserMessage); - } - - [Fact] - public async Task SetMemoryPriorityAsync_WithInvalidPriority_ReturnsInvalidPriorityMessage() - { - var service = CreateService(new FakeProcessMemoryPriorityNativeApi()); - - var result = await service.SetMemoryPriorityAsync(CreateProcess(), (ProcessMemoryPriority)99); - - Assert.False(result.Success); - Assert.Equal("InvalidMemoryPriority", result.ErrorCode); - Assert.Equal("This memory priority value is not supported.", result.UserMessage); - Assert.NotEqual(ProcessMemoryPriorityService.UnsupportedUserMessage, result.UserMessage); - } - - [Fact] - public async Task SetMemoryPriorityAsync_WhenProcessExited_ReturnsProcessExitedFailure() - { - var service = CreateService(new FakeProcessMemoryPriorityNativeApi - { - OpenException = new InvalidOperationException("The process has exited."), - }); - - var result = await service.SetMemoryPriorityAsync(CreateProcess(), ProcessMemoryPriority.Normal); - - Assert.False(result.Success); - Assert.True(result.IsProcessExited); - Assert.Equal(AffinityApplyErrorCodes.ProcessExited, result.ErrorCode); - Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); - } - - [Fact] - public async Task SetMemoryPriorityAsync_WhenAccessDenied_ReturnsSafeAccessDeniedFailure() - { - var service = CreateService(new FakeProcessMemoryPriorityNativeApi - { - SetException = new Win32Exception(5, "Access is denied."), - }); - - var result = await service.SetMemoryPriorityAsync(CreateProcess(), ProcessMemoryPriority.Normal); - - Assert.False(result.Success); - Assert.True(result.IsAccessDenied); - Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode); - Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); - } - - [Fact] - public async Task SetMemoryPriorityAsync_WhenProtectedByAntiCheat_ReturnsMessageWithoutBypassPromise() - { - var service = CreateService(new FakeProcessMemoryPriorityNativeApi - { - SetException = new UnauthorizedAccessException("Protected anti-cheat process."), - }); - - var result = await service.SetMemoryPriorityAsync(CreateProcess(), ProcessMemoryPriority.Normal); - - Assert.False(result.Success); - Assert.True(result.IsAccessDenied); - Assert.True(result.IsAntiCheatLikely); - Assert.Equal(AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely, result.ErrorCode); - Assert.Equal(ProcessOperationUserMessages.AntiCheatProtectedLikely, result.UserMessage); - Assert.Equal( - "The process appears protected by anti-cheat or process protection. ThreadPilot will not try to bypass it.", - ProcessOperationUserMessages.AntiCheatProtectedLikely); - Assert.DoesNotContain("disable anti-cheat", result.UserMessage, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("administrator", result.UserMessage, StringComparison.OrdinalIgnoreCase); - Assert.Contains("cannot bypass anti-cheat", ProcessOperationUserMessages.AdminClarification); - } - - [Fact] - public async Task SetMemoryPriorityAsync_WhenUnsupported_ReturnsControlledFailure() - { - var service = CreateService(new FakeProcessMemoryPriorityNativeApi { IsSupported = false }); - - var result = await service.SetMemoryPriorityAsync(CreateProcess(), ProcessMemoryPriority.Normal); - - Assert.False(result.Success); - Assert.Equal("Unsupported", result.ErrorCode); - Assert.Equal(ProcessMemoryPriorityService.UnsupportedUserMessage, result.UserMessage); - } - - [Fact] - public async Task SetMemoryPriorityAsync_WhenNativeCallFails_ReturnsControlledFailure() - { - var service = CreateService(new FakeProcessMemoryPriorityNativeApi - { - SetResult = false, - LastError = 31, - }); - - var result = await service.SetMemoryPriorityAsync(CreateProcess(), ProcessMemoryPriority.Normal); - - Assert.False(result.Success); - Assert.Equal(AffinityApplyErrorCodes.NativeApplyFailed, result.ErrorCode); - Assert.Contains("SetProcessInformation(ProcessMemoryPriority) failed", result.TechnicalMessage); - } - - private static ProcessMemoryPriorityService CreateService(IProcessMemoryPriorityNativeApi nativeApi) => - new(nativeApi, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - private static ProcessModel CreateProcess() => - new() - { - ProcessId = 42, - Name = "game.exe", - ExecutablePath = @"C:\Games\Game.exe", - }; - - private sealed class FakeProcessMemoryPriorityNativeApi : IProcessMemoryPriorityNativeApi - { - public bool IsSupported { get; init; } = true; - - public ProcessAccessFlags LastOpenAccess { get; private set; } - - public ProcessMemoryPriority? LastSetPriority { get; private set; } - - public ProcessMemoryPriority PriorityToReturn { get; init; } = ProcessMemoryPriority.Normal; - - public Exception? OpenException { get; init; } - - public Exception? SetException { get; init; } - - public bool SetResult { get; init; } = true; - - public int LastError { get; init; } - - public SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId) - { - this.LastOpenAccess = access; - if (this.OpenException != null) - { - throw this.OpenException; - } - - return new SafeProcessHandle(new IntPtr(1), ownsHandle: false); - } - - public bool GetProcessInformation( - SafeProcessHandle process, - ProcessInformationClass processInformationClass, - ref MemoryPriorityInformation processInformation, - uint processInformationSize) - { - processInformation.MemoryPriority = (uint)this.PriorityToReturn; - return true; - } - - public bool SetProcessInformation( - SafeProcessHandle process, - ProcessInformationClass processInformationClass, - ref MemoryPriorityInformation processInformation, - uint processInformationSize) - { - if (this.SetException != null) - { - throw this.SetException; - } - - this.LastSetPriority = (ProcessMemoryPriority)processInformation.MemoryPriority; - return this.SetResult; - } - - public int GetLastWin32Error() => this.LastError; - } - } -} +/* + * ThreadPilot - process memory priority service tests. + */ +namespace ThreadPilot.Core.Tests +{ + using System.ComponentModel; + using System.Runtime.InteropServices; + using Microsoft.Win32.SafeHandles; + using ThreadPilot.Models; + using ThreadPilot.Platforms.Windows; + using ThreadPilot.Services; + + public sealed class ProcessMemoryPriorityServiceTests + { + [Fact] + public async Task SetMemoryPriorityAsync_WithValidProcess_CallsNativeApi() + { + var nativeApi = new FakeProcessMemoryPriorityNativeApi(); + var service = CreateService(nativeApi); + var process = CreateProcess(); + + var result = await service.SetMemoryPriorityAsync(process, ProcessMemoryPriority.Low); + + Assert.True(result.Success); + Assert.Equal(ProcessMemoryPriority.Low, nativeApi.LastSetPriority); + Assert.Equal(ProcessAccessFlags.PROCESS_SET_INFORMATION, nativeApi.LastOpenAccess); + Assert.Equal("Memory priority applied.", result.UserMessage); + } + + [Fact] + public async Task GetMemoryPriorityAsync_WithValidProcess_ReadsNativeApi() + { + var nativeApi = new FakeProcessMemoryPriorityNativeApi + { + PriorityToReturn = ProcessMemoryPriority.BelowNormal, + }; + var service = CreateService(nativeApi); + + var priority = await service.GetMemoryPriorityAsync(CreateProcess()); + + Assert.Equal(ProcessMemoryPriority.BelowNormal, priority); + Assert.Equal(ProcessAccessFlags.PROCESS_QUERY_LIMITED_INFORMATION, nativeApi.LastOpenAccess); + } + + [Theory] + [InlineData(1, ProcessMemoryPriority.VeryLow)] + [InlineData(2, ProcessMemoryPriority.Low)] + [InlineData(3, ProcessMemoryPriority.Medium)] + [InlineData(4, ProcessMemoryPriority.BelowNormal)] + [InlineData(5, ProcessMemoryPriority.Normal)] + public void ProcessMemoryPriority_UsesDocumentedWindowsLevels(int windowsLevel, ProcessMemoryPriority priority) + { + Assert.Equal(windowsLevel, (int)priority); + } + + [Fact] + public async Task SetMemoryPriorityAsync_WithNullProcess_ReturnsControlledFailure() + { + var service = CreateService(new FakeProcessMemoryPriorityNativeApi()); + + var result = await service.SetMemoryPriorityAsync(null!, ProcessMemoryPriority.Normal); + + Assert.False(result.Success); + Assert.Equal("InvalidProcess", result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); + Assert.NotEqual(ProcessMemoryPriorityService.UnsupportedUserMessage, result.UserMessage); + Assert.False(result.IsAccessDenied); + Assert.False(result.IsProcessExited); + } + + [Fact] + public async Task SetMemoryPriorityAsync_WithInvalidProcess_DoesNotReturnUnsupportedWindowsMessage() + { + var service = CreateService(new FakeProcessMemoryPriorityNativeApi()); + + var result = await service.SetMemoryPriorityAsync(new ProcessModel { ProcessId = 0 }, ProcessMemoryPriority.Normal); + + Assert.False(result.Success); + Assert.Equal("InvalidProcess", result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); + Assert.NotEqual(ProcessMemoryPriorityService.UnsupportedUserMessage, result.UserMessage); + } + + [Fact] + public async Task SetMemoryPriorityAsync_WithInvalidPriority_ReturnsInvalidPriorityMessage() + { + var service = CreateService(new FakeProcessMemoryPriorityNativeApi()); + + var result = await service.SetMemoryPriorityAsync(CreateProcess(), (ProcessMemoryPriority)99); + + Assert.False(result.Success); + Assert.Equal("InvalidMemoryPriority", result.ErrorCode); + Assert.Equal("This memory priority value is not supported.", result.UserMessage); + Assert.NotEqual(ProcessMemoryPriorityService.UnsupportedUserMessage, result.UserMessage); + } + + [Fact] + public async Task SetMemoryPriorityAsync_WhenProcessExited_ReturnsProcessExitedFailure() + { + var service = CreateService(new FakeProcessMemoryPriorityNativeApi + { + OpenException = new InvalidOperationException("The process has exited."), + }); + + var result = await service.SetMemoryPriorityAsync(CreateProcess(), ProcessMemoryPriority.Normal); + + Assert.False(result.Success); + Assert.True(result.IsProcessExited); + Assert.Equal(AffinityApplyErrorCodes.ProcessExited, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); + } + + [Fact] + public async Task SetMemoryPriorityAsync_WhenAccessDenied_ReturnsSafeAccessDeniedFailure() + { + var service = CreateService(new FakeProcessMemoryPriorityNativeApi + { + SetException = new Win32Exception(5, "Access is denied."), + }); + + var result = await service.SetMemoryPriorityAsync(CreateProcess(), ProcessMemoryPriority.Normal); + + Assert.False(result.Success); + Assert.True(result.IsAccessDenied); + Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); + } + + [Fact] + public async Task SetMemoryPriorityAsync_WhenProtectedByAntiCheat_ReturnsMessageWithoutBypassPromise() + { + var service = CreateService(new FakeProcessMemoryPriorityNativeApi + { + SetException = new UnauthorizedAccessException("Protected anti-cheat process."), + }); + + var result = await service.SetMemoryPriorityAsync(CreateProcess(), ProcessMemoryPriority.Normal); + + Assert.False(result.Success); + Assert.True(result.IsAccessDenied); + Assert.True(result.IsAntiCheatLikely); + Assert.Equal(AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.AntiCheatProtectedLikely, result.UserMessage); + Assert.Equal( + "The process appears protected by anti-cheat or process protection. ThreadPilot will not try to bypass it.", + ProcessOperationUserMessages.AntiCheatProtectedLikely); + Assert.DoesNotContain("disable anti-cheat", result.UserMessage, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("administrator", result.UserMessage, StringComparison.OrdinalIgnoreCase); + Assert.Contains("cannot bypass anti-cheat", ProcessOperationUserMessages.AdminClarification); + } + + [Fact] + public async Task SetMemoryPriorityAsync_WhenUnsupported_ReturnsControlledFailure() + { + var service = CreateService(new FakeProcessMemoryPriorityNativeApi { IsSupported = false }); + + var result = await service.SetMemoryPriorityAsync(CreateProcess(), ProcessMemoryPriority.Normal); + + Assert.False(result.Success); + Assert.Equal("Unsupported", result.ErrorCode); + Assert.Equal(ProcessMemoryPriorityService.UnsupportedUserMessage, result.UserMessage); + } + + [Fact] + public async Task SetMemoryPriorityAsync_WhenNativeCallFails_ReturnsControlledFailure() + { + var service = CreateService(new FakeProcessMemoryPriorityNativeApi + { + SetResult = false, + LastError = 31, + }); + + var result = await service.SetMemoryPriorityAsync(CreateProcess(), ProcessMemoryPriority.Normal); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.NativeApplyFailed, result.ErrorCode); + Assert.Contains("SetProcessInformation(ProcessMemoryPriority) failed", result.TechnicalMessage); + } + + private static ProcessMemoryPriorityService CreateService(IProcessMemoryPriorityNativeApi nativeApi) => + new(nativeApi, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + private static ProcessModel CreateProcess() => + new() + { + ProcessId = 42, + Name = "game.exe", + ExecutablePath = @"C:\Games\Game.exe", + }; + + private sealed class FakeProcessMemoryPriorityNativeApi : IProcessMemoryPriorityNativeApi + { + public bool IsSupported { get; init; } = true; + + public ProcessAccessFlags LastOpenAccess { get; private set; } + + public ProcessMemoryPriority? LastSetPriority { get; private set; } + + public ProcessMemoryPriority PriorityToReturn { get; init; } = ProcessMemoryPriority.Normal; + + public Exception? OpenException { get; init; } + + public Exception? SetException { get; init; } + + public bool SetResult { get; init; } = true; + + public int LastError { get; init; } + + public SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId) + { + this.LastOpenAccess = access; + if (this.OpenException != null) + { + throw this.OpenException; + } + + return new SafeProcessHandle(new IntPtr(1), ownsHandle: false); + } + + public bool GetProcessInformation( + SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref MemoryPriorityInformation processInformation, + uint processInformationSize) + { + processInformation.MemoryPriority = (uint)this.PriorityToReturn; + return true; + } + + public bool SetProcessInformation( + SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref MemoryPriorityInformation processInformation, + uint processInformationSize) + { + if (this.SetException != null) + { + throw this.SetException; + } + + this.LastSetPriority = (ProcessMemoryPriority)processInformation.MemoryPriority; + return this.SetResult; + } + + public int GetLastWin32Error() => this.LastError; + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs index 7db3f9d..ea164df 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs @@ -1,905 +1,902 @@ -/* - * ThreadPilot - process monitor manager unit tests. - */ -namespace ThreadPilot.Core.Tests -{ - using System.Collections.ObjectModel; - using System.Threading; - using Microsoft.Extensions.Logging; - using Microsoft.Extensions.Logging.Abstractions; - using Moq; - using ThreadPilot.Models; - using ThreadPilot.Services; - - /// - /// Unit tests for orchestration logic in . - /// - public sealed class ProcessMonitorManagerServiceTests - { - [Fact] - public async Task StartAsync_LoadsConfiguration_AndStartsMonitoring() - { - var processMonitor = new FakeProcessMonitorService(); - var configuration = new ProcessMonitorConfiguration(); - 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(); - - Assert.True(manager.IsRunning); - Assert.Equal("Running", manager.Status); - Assert.Equal(1, processMonitor.StartCalls); - associationService.Verify(x => x.LoadConfigurationAsync(), Times.Once); - } - - [Fact] - public async Task StartAsync_SelectsHighestPriorityAssociation() - { - var processMonitor = new FakeProcessMonitorService - { - RunningProcesses = - { - new ProcessModel { ProcessId = 1, Name = "game-low" }, - new ProcessModel { ProcessId = 2, Name = "game-high" }, - }, - }; - - var configuration = new ProcessMonitorConfiguration - { - Associations = - { - new ProcessPowerPlanAssociation("game-low", "plan-low", "Low") { Priority = 1 }, - new ProcessPowerPlanAssociation("game-high", "plan-high", "High") { Priority = 10 }, - }, - PowerPlanChangeDelayMs = 0, - }; - - 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(); - - powerPlanService.Verify( - x => x.SetActivePowerPlanByGuidAsync("plan-high", true), - Times.Once); - notificationService.Verify( - x => x.ShowPowerPlanChangeNotificationAsync("Balanced", "plan-high-name", "game-high"), - Times.Once); - } - - [Fact] - public async Task ProcessStarted_WithDelay_TriggersSingleReevaluation() - { - var processMonitor = new FakeProcessMonitorService(); - var configuration = new ProcessMonitorConfiguration - { - PowerPlanChangeDelayMs = 25, - 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(150); - - powerPlanService.Verify( - x => x.SetActivePowerPlanByGuidAsync("plan-game", true), - 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() - { - var processMonitor = new FakeProcessMonitorService - { - RunningProcesses = - { - new ProcessModel { ProcessId = 21, Name = "game" }, - }, - }; - - var configuration = new ProcessMonitorConfiguration - { - DefaultPowerPlanGuid = "plan-default", - DefaultPowerPlanName = "Balanced", - PowerPlanChangeDelayMs = 0, - Associations = - { - new ProcessPowerPlanAssociation("game", "plan-game", "Game") { Priority = 1 }, - }, - }; - - 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(); - await manager.StopAsync(); - - powerPlanService.Verify( - x => x.SetActivePowerPlanByGuidAsync("plan-default", true), - Times.AtLeastOnce); - processService.Verify(x => x.UntrackProcess(21), Times.Once); - 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 ProcessStarted_AppliesPersistentRulesThroughCoordinator() - { - var process = new ProcessModel { ProcessId = 41, Name = "game.exe" }; - var processMonitor = new FakeProcessMonitorService(); - var configuration = new ProcessMonitorConfiguration(); - var associationService = CreateAssociationService(configuration); - var powerPlanService = CreatePowerPlanService(); - var notificationService = CreateNotificationService(); - var processService = CreateProcessService(); - var coreMaskService = CreateCoreMaskService(); - var affinityApplyService = CreateAffinityApplyService(); - var autoApplyService = CreateAutoApplyService(); - var manager = CreateService( - processMonitor, - associationService, - powerPlanService, - notificationService, - processService, - coreMaskService, - affinityApplyService, - autoApplyService); - - await manager.StartAsync(); - processMonitor.RaiseProcessStarted(process); - await Task.Delay(100); - - autoApplyService.Verify( - x => x.ApplyForDiscoveredProcessesAsync( - It.IsAny>(), - It.IsAny()), - Times.Once); - autoApplyService.Verify( - x => x.ApplyForProcessStartAsync(process, It.IsAny()), - Times.Once); - } - - [Fact] - public async Task EvaluateCurrentProcessesAsync_WhenPersistentRuleSnapshotApplyCancels_DoesNotLogWarning() - { - var processMonitor = new FakeProcessMonitorService - { - RunningProcesses = - { - new ProcessModel { ProcessId = 42, Name = "game.exe" }, - }, - }; - var configuration = new ProcessMonitorConfiguration(); - var associationService = CreateAssociationService(configuration); - var powerPlanService = CreatePowerPlanService(); - var notificationService = CreateNotificationService(); - var processService = CreateProcessService(); - var coreMaskService = CreateCoreMaskService(); - var affinityApplyService = CreateAffinityApplyService(); - var autoApplyService = CreateAutoApplyService(); - autoApplyService - .Setup(x => x.ApplyForDiscoveredProcessesAsync( - It.IsAny>(), - It.IsAny())) - .ThrowsAsync(new OperationCanceledException()); - var logger = new CapturingLogger(); - var manager = CreateService( - processMonitor, - associationService, - powerPlanService, - notificationService, - processService, - coreMaskService, - affinityApplyService, - autoApplyService, - logger); - - await manager.StartAsync(); - await manager.EvaluateCurrentProcessesAsync(); - - Assert.Empty(logger.WarningMessages); - } - - [Fact] - public async Task ProcessStarted_WhenPersistentRuleAutoApplyCancels_DoesNotLogWarning() - { - var process = new ProcessModel { ProcessId = 43, Name = "game.exe" }; - var processMonitor = new FakeProcessMonitorService(); - var configuration = new ProcessMonitorConfiguration(); - var associationService = CreateAssociationService(configuration); - var powerPlanService = CreatePowerPlanService(); - var notificationService = CreateNotificationService(); - var processService = CreateProcessService(); - var coreMaskService = CreateCoreMaskService(); - var affinityApplyService = CreateAffinityApplyService(); - var autoApplyService = CreateAutoApplyService(); - autoApplyService - .Setup(x => x.ApplyForProcessStartAsync( - process, - It.IsAny())) - .ThrowsAsync(new OperationCanceledException()); - var logger = new CapturingLogger(); - var manager = CreateService( - processMonitor, - associationService, - powerPlanService, - notificationService, - processService, - coreMaskService, - affinityApplyService, - autoApplyService, - logger); - - await manager.StartAsync(); - processMonitor.RaiseProcessStarted(process); - await Task.Delay(100); - - Assert.Empty(logger.WarningMessages); - } - - [Fact] - public async Task EvaluateCurrentProcessesAsync_WhenPersistentRuleAutoApplyThrows_LogsWarningWithoutBreakingRefresh() - { - var processMonitor = new FakeProcessMonitorService - { - RunningProcesses = - { - new ProcessModel { ProcessId = 44, Name = "game.exe" }, - }, - }; - var configuration = new ProcessMonitorConfiguration(); - var associationService = CreateAssociationService(configuration); - var powerPlanService = CreatePowerPlanService(); - var notificationService = CreateNotificationService(); - var processService = CreateProcessService(); - var coreMaskService = CreateCoreMaskService(); - var affinityApplyService = CreateAffinityApplyService(); - var autoApplyService = CreateAutoApplyService(); - autoApplyService - .Setup(x => x.ApplyForDiscoveredProcessesAsync( - It.IsAny>(), - It.IsAny())) - .ThrowsAsync(new InvalidOperationException("auto apply failed")); - var logger = new CapturingLogger(); - var manager = CreateService( - processMonitor, - associationService, - powerPlanService, - notificationService, - processService, - coreMaskService, - affinityApplyService, - autoApplyService, - logger); - - await manager.StartAsync(); - await manager.EvaluateCurrentProcessesAsync(); - - Assert.Contains( - logger.WarningMessages, - message => message.Contains("snapshot refresh", StringComparison.OrdinalIgnoreCase)); - Assert.True(manager.IsRunning); - } - - [Fact] - public async Task ProcessStarted_WhenPersistentRuleAutoApplyThrows_LogsWarningWithoutBreakingStartHandling() - { - var process = new ProcessModel { ProcessId = 45, Name = "game.exe" }; - var processMonitor = new FakeProcessMonitorService(); - var configuration = new ProcessMonitorConfiguration(); - var associationService = CreateAssociationService(configuration); - var powerPlanService = CreatePowerPlanService(); - var notificationService = CreateNotificationService(); - var processService = CreateProcessService(); - var coreMaskService = CreateCoreMaskService(); - var affinityApplyService = CreateAffinityApplyService(); - var autoApplyService = CreateAutoApplyService(); - autoApplyService - .Setup(x => x.ApplyForProcessStartAsync( - process, - It.IsAny())) - .ThrowsAsync(new InvalidOperationException("auto apply failed")); - var logger = new CapturingLogger(); - var manager = CreateService( - processMonitor, - associationService, - powerPlanService, - notificationService, - processService, - coreMaskService, - affinityApplyService, - autoApplyService, - logger); - - await manager.StartAsync(); - processMonitor.RaiseProcessStarted(process); - await Task.Delay(100); - - Assert.Contains( - logger.WarningMessages, - message => message.Contains("Persistent rule auto-apply failed", StringComparison.OrdinalIgnoreCase)); - Assert.True(manager.IsRunning); - } - - [Fact] - public async Task ProcessStarted_WhenPersistentRuleAutoApplySucceeds_LogsEnhancedMonitoringEvent() - { - var process = new ProcessModel { ProcessId = 46, Name = "game.exe" }; - var processMonitor = new FakeProcessMonitorService(); - var configuration = new ProcessMonitorConfiguration(); - var associationService = CreateAssociationService(configuration); - var powerPlanService = CreatePowerPlanService(); - var notificationService = CreateNotificationService(); - var processService = CreateProcessService(); - var coreMaskService = CreateCoreMaskService(); - var affinityApplyService = CreateAffinityApplyService(); - var autoApplyService = CreateAutoApplyService(); - autoApplyService - .Setup(x => x.ApplyForProcessStartAsync( - process, - It.IsAny())) - .ReturnsAsync(new[] - { - new PersistentRuleAutoApplyResult - { - Success = true, - RuleId = "rule-game", - ProcessId = process.ProcessId, - ProcessName = process.Name, - UserMessage = "Persistent rule applied.", - }, - }); - var enhancedLogger = CreateEnhancedLogger(); - var manager = CreateService( - processMonitor, - associationService, - powerPlanService, - notificationService, - processService, - coreMaskService, - affinityApplyService, - autoApplyService, - enhancedLogger: enhancedLogger); - - await manager.StartAsync(); - processMonitor.RaiseProcessStarted(process); - await Task.Delay(100); - - enhancedLogger.Verify( - x => x.LogProcessMonitoringEventAsync( - LogEventTypes.ProcessMonitoring.AssociationTriggered, - process.Name, - process.ProcessId, - It.Is(message => message.Contains("Persistent rule 'rule-game' applied automatically", StringComparison.Ordinal))), - Times.Once); - } - - [Fact] - public async Task ProcessStarted_WhenPersistentRuleAutoApplyReturnsFailure_DoesNotNotifyOrThrow() - { - var process = new ProcessModel { ProcessId = 47, Name = "game.exe" }; - var processMonitor = new FakeProcessMonitorService(); - var configuration = new ProcessMonitorConfiguration(); - var associationService = CreateAssociationService(configuration); - var powerPlanService = CreatePowerPlanService(); - var notificationService = CreateNotificationService(); - var processService = CreateProcessService(); - var coreMaskService = CreateCoreMaskService(); - var affinityApplyService = CreateAffinityApplyService(); - var autoApplyService = CreateAutoApplyService(); - autoApplyService - .Setup(x => x.ApplyForProcessStartAsync( - process, - It.IsAny())) - .ReturnsAsync(new[] - { - new PersistentRuleAutoApplyResult - { - Success = false, - RuleId = "rule-game", - ProcessId = process.ProcessId, - ProcessName = process.Name, - UserMessage = ProcessOperationUserMessages.AccessDenied, - IsAccessDenied = true, - }, - }); - var manager = CreateService( - processMonitor, - associationService, - powerPlanService, - notificationService, - processService, - coreMaskService, - affinityApplyService, - autoApplyService); - - await manager.StartAsync(); - processMonitor.RaiseProcessStarted(process); - await Task.Delay(100); - - Assert.True(manager.IsRunning); - notificationService.Verify( - x => x.ShowNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); - notificationService.Verify( - x => x.ShowErrorNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); - } - - [Fact] - public async Task Dispose_CompletesOnBlockingSynchronizationContext() - { - var processMonitor = new FakeProcessMonitorService - { - StopMonitoringAsyncImpl = async () => - { - await Task.Yield(); - }, - }; - - var configuration = new ProcessMonitorConfiguration(); - 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(); - - Exception? disposeException = null; - using var completed = new ManualResetEventSlim(false); - var disposeThread = new Thread(() => - { - SynchronizationContext.SetSynchronizationContext(new BlockingSynchronizationContext()); - - try - { - manager.Dispose(); - } - catch (Exception ex) - { - disposeException = ex; - } - finally - { - completed.Set(); - } - }); - - disposeThread.Start(); - - Assert.True(completed.Wait(TimeSpan.FromSeconds(1)), "Dispose did not complete promptly."); - Assert.Null(disposeException); - Assert.Equal(1, processMonitor.StopCalls); - Assert.Equal(1, processMonitor.DisposeCalls); - } - - private static ProcessMonitorManagerService CreateService( - FakeProcessMonitorService processMonitorService, - Mock associationService, - Mock powerPlanService, - Mock notificationService, - Mock processService, - Mock coreMaskService, - Mock affinityApplyService, - Mock? autoApplyService = null, - ILogger? logger = null, - Mock? enhancedLogger = null) - { - var resolvedEnhancedLogger = enhancedLogger ?? CreateEnhancedLogger(); - - var settingsService = new Mock(MockBehavior.Loose); - settingsService.SetupGet(x => x.Settings).Returns(new ApplicationSettingsModel()); - - return new ProcessMonitorManagerService( - processMonitorService, - associationService.Object, - powerPlanService.Object, - notificationService.Object, - settingsService.Object, - processService.Object, - coreMaskService.Object, - affinityApplyService.Object, - (autoApplyService ?? CreateAutoApplyService()).Object, - new PowerPlanTransitionGate(TimeSpan.FromSeconds(2), () => DateTimeOffset.UtcNow), - logger ?? NullLogger.Instance, - resolvedEnhancedLogger.Object); - } - - private static Mock CreateEnhancedLogger() - { - var enhancedLogger = new Mock(MockBehavior.Loose); - enhancedLogger - .Setup(x => x.LogSystemEventAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - enhancedLogger - .Setup(x => x.LogProcessMonitoringEventAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - enhancedLogger - .Setup(x => x.LogErrorAsync(It.IsAny(), It.IsAny(), It.IsAny?>())) - .Returns(Task.CompletedTask); - return enhancedLogger; - } - - private static Mock CreateAssociationService(ProcessMonitorConfiguration configuration) - { - var associationService = new Mock(MockBehavior.Strict); - associationService.SetupGet(x => x.Configuration).Returns(configuration); - associationService.Setup(x => x.LoadConfigurationAsync()).ReturnsAsync(true); - return associationService; - } - - private static Mock CreatePowerPlanService() - { - var powerPlanService = new Mock(MockBehavior.Strict); - powerPlanService.Setup(x => x.GetActivePowerPlan()).ReturnsAsync(new PowerPlanModel { Guid = "balanced", Name = "Balanced" }); - powerPlanService.Setup(x => x.SetActivePowerPlanByGuidAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); - powerPlanService.Setup(x => x.GetPowerPlanByGuidAsync(It.IsAny())) - .ReturnsAsync((string guid) => new PowerPlanModel { Guid = guid, Name = $"{guid}-name" }); - return powerPlanService; - } - - private static Mock CreateNotificationService() - { - var notificationService = new Mock(MockBehavior.Strict); - notificationService.SetupGet(x => x.NotificationHistory).Returns(Array.Empty()); - notificationService.Setup(x => x.ShowPowerPlanChangeNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - notificationService.Setup(x => x.ShowErrorNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - notificationService.Setup(x => x.ShowNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - return notificationService; - } - - private static Mock CreateProcessService() - { - var processService = new Mock(MockBehavior.Strict); - processService.Setup(x => x.UntrackProcess(It.IsAny())); - return processService; - } - - private static Mock CreateCoreMaskService() - { - var coreMaskService = new Mock(MockBehavior.Strict); - coreMaskService.SetupGet(x => x.AvailableMasks).Returns(new ObservableCollection()); - coreMaskService.Setup(x => x.UnregisterMaskApplication(It.IsAny())); - 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 static Mock CreateAutoApplyService() - { - var autoApplyService = new Mock(MockBehavior.Strict); - autoApplyService - .Setup(x => x.ApplyForDiscoveredProcessesAsync( - It.IsAny>(), - It.IsAny())) - .ReturnsAsync(Array.Empty()); - autoApplyService - .Setup(x => x.ApplyForProcessStartAsync( - It.IsAny(), - It.IsAny())) - .ReturnsAsync(Array.Empty()); - autoApplyService.Setup(x => x.MarkProcessExited(It.IsAny())); - return autoApplyService; - } - - private sealed class FakeProcessMonitorService : IProcessMonitorService - { - public event EventHandler? ProcessStarted; - - public event EventHandler? ProcessStopped - { - add { } - remove { } - } - - public event EventHandler? MonitoringStatusChanged - { - add { } - remove { } - } - - public List RunningProcesses { get; } = new(); - - public int StartCalls { get; private set; } - - public int StopCalls { get; private set; } - - public int DisposeCalls { get; private set; } - - public bool IsMonitoring { get; private set; } - - public bool IsWmiAvailable => false; - - public bool IsFallbackPollingActive => false; - - public Func? StopMonitoringAsyncImpl { get; init; } - - public Task StartMonitoringAsync() - { - this.StartCalls++; - this.IsMonitoring = true; - return Task.CompletedTask; - } - - public Task StopMonitoringAsync() - { - this.StopCalls++; - this.IsMonitoring = false; - return this.StopMonitoringAsyncImpl?.Invoke() ?? Task.CompletedTask; - } - - public Task> GetRunningProcessesAsync() => - Task.FromResult>(this.RunningProcesses.ToList()); - - public Task IsProcessRunningAsync(string executableName) => - Task.FromResult(this.RunningProcesses.Any(x => string.Equals(x.Name, executableName, StringComparison.OrdinalIgnoreCase))); - - public void UpdateSettings() - { - } - - public void RaiseProcessStarted(ProcessModel process) => - this.ProcessStarted?.Invoke(this, new ProcessEventArgs(process)); - - public void Dispose() - { - this.DisposeCalls++; - } - } - - private sealed class BlockingSynchronizationContext : SynchronizationContext - { - public override void Post(SendOrPostCallback d, object? state) - { - // Intentionally do not pump posted work to emulate a blocked UI thread. - } - } - - private sealed class CapturingLogger : ILogger - { - public List WarningMessages { get; } = new(); - - public IDisposable? BeginScope(TState state) - where TState : notnull => - NullScope.Instance; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - if (logLevel == LogLevel.Warning) - { - this.WarningMessages.Add(formatter(state, exception)); - } - } - - private sealed class NullScope : IDisposable - { - public static readonly NullScope Instance = new(); - - public void Dispose() - { - } - } - } - } -} +/* + * ThreadPilot - process monitor manager unit tests. + */ +namespace ThreadPilot.Core.Tests +{ + using System.Collections.ObjectModel; + using System.Threading; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class ProcessMonitorManagerServiceTests + { + [Fact] + public async Task StartAsync_LoadsConfiguration_AndStartsMonitoring() + { + var processMonitor = new FakeProcessMonitorService(); + var configuration = new ProcessMonitorConfiguration(); + 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(); + + Assert.True(manager.IsRunning); + Assert.Equal("Running", manager.Status); + Assert.Equal(1, processMonitor.StartCalls); + associationService.Verify(x => x.LoadConfigurationAsync(), Times.Once); + } + + [Fact] + public async Task StartAsync_SelectsHighestPriorityAssociation() + { + var processMonitor = new FakeProcessMonitorService + { + RunningProcesses = + { + new ProcessModel { ProcessId = 1, Name = "game-low" }, + new ProcessModel { ProcessId = 2, Name = "game-high" }, + }, + }; + + var configuration = new ProcessMonitorConfiguration + { + Associations = + { + new ProcessPowerPlanAssociation("game-low", "plan-low", "Low") { Priority = 1 }, + new ProcessPowerPlanAssociation("game-high", "plan-high", "High") { Priority = 10 }, + }, + PowerPlanChangeDelayMs = 0, + }; + + 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(); + + powerPlanService.Verify( + x => x.SetActivePowerPlanByGuidAsync("plan-high", true), + Times.Once); + notificationService.Verify( + x => x.ShowPowerPlanChangeNotificationAsync("Balanced", "plan-high-name", "game-high"), + Times.Once); + } + + [Fact] + public async Task ProcessStarted_WithDelay_TriggersSingleReevaluation() + { + var processMonitor = new FakeProcessMonitorService(); + var configuration = new ProcessMonitorConfiguration + { + PowerPlanChangeDelayMs = 25, + 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(150); + + powerPlanService.Verify( + x => x.SetActivePowerPlanByGuidAsync("plan-game", true), + 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() + { + var processMonitor = new FakeProcessMonitorService + { + RunningProcesses = + { + new ProcessModel { ProcessId = 21, Name = "game" }, + }, + }; + + var configuration = new ProcessMonitorConfiguration + { + DefaultPowerPlanGuid = "plan-default", + DefaultPowerPlanName = "Balanced", + PowerPlanChangeDelayMs = 0, + Associations = + { + new ProcessPowerPlanAssociation("game", "plan-game", "Game") { Priority = 1 }, + }, + }; + + 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(); + await manager.StopAsync(); + + powerPlanService.Verify( + x => x.SetActivePowerPlanByGuidAsync("plan-default", true), + Times.AtLeastOnce); + processService.Verify(x => x.UntrackProcess(21), Times.Once); + 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 ProcessStarted_AppliesPersistentRulesThroughCoordinator() + { + var process = new ProcessModel { ProcessId = 41, Name = "game.exe" }; + var processMonitor = new FakeProcessMonitorService(); + var configuration = new ProcessMonitorConfiguration(); + var associationService = CreateAssociationService(configuration); + var powerPlanService = CreatePowerPlanService(); + var notificationService = CreateNotificationService(); + var processService = CreateProcessService(); + var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); + var autoApplyService = CreateAutoApplyService(); + var manager = CreateService( + processMonitor, + associationService, + powerPlanService, + notificationService, + processService, + coreMaskService, + affinityApplyService, + autoApplyService); + + await manager.StartAsync(); + processMonitor.RaiseProcessStarted(process); + await Task.Delay(100); + + autoApplyService.Verify( + x => x.ApplyForDiscoveredProcessesAsync( + It.IsAny>(), + It.IsAny()), + Times.Once); + autoApplyService.Verify( + x => x.ApplyForProcessStartAsync(process, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task EvaluateCurrentProcessesAsync_WhenPersistentRuleSnapshotApplyCancels_DoesNotLogWarning() + { + var processMonitor = new FakeProcessMonitorService + { + RunningProcesses = + { + new ProcessModel { ProcessId = 42, Name = "game.exe" }, + }, + }; + var configuration = new ProcessMonitorConfiguration(); + var associationService = CreateAssociationService(configuration); + var powerPlanService = CreatePowerPlanService(); + var notificationService = CreateNotificationService(); + var processService = CreateProcessService(); + var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); + var autoApplyService = CreateAutoApplyService(); + autoApplyService + .Setup(x => x.ApplyForDiscoveredProcessesAsync( + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + var logger = new CapturingLogger(); + var manager = CreateService( + processMonitor, + associationService, + powerPlanService, + notificationService, + processService, + coreMaskService, + affinityApplyService, + autoApplyService, + logger); + + await manager.StartAsync(); + await manager.EvaluateCurrentProcessesAsync(); + + Assert.Empty(logger.WarningMessages); + } + + [Fact] + public async Task ProcessStarted_WhenPersistentRuleAutoApplyCancels_DoesNotLogWarning() + { + var process = new ProcessModel { ProcessId = 43, Name = "game.exe" }; + var processMonitor = new FakeProcessMonitorService(); + var configuration = new ProcessMonitorConfiguration(); + var associationService = CreateAssociationService(configuration); + var powerPlanService = CreatePowerPlanService(); + var notificationService = CreateNotificationService(); + var processService = CreateProcessService(); + var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); + var autoApplyService = CreateAutoApplyService(); + autoApplyService + .Setup(x => x.ApplyForProcessStartAsync( + process, + It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + var logger = new CapturingLogger(); + var manager = CreateService( + processMonitor, + associationService, + powerPlanService, + notificationService, + processService, + coreMaskService, + affinityApplyService, + autoApplyService, + logger); + + await manager.StartAsync(); + processMonitor.RaiseProcessStarted(process); + await Task.Delay(100); + + Assert.Empty(logger.WarningMessages); + } + + [Fact] + public async Task EvaluateCurrentProcessesAsync_WhenPersistentRuleAutoApplyThrows_LogsWarningWithoutBreakingRefresh() + { + var processMonitor = new FakeProcessMonitorService + { + RunningProcesses = + { + new ProcessModel { ProcessId = 44, Name = "game.exe" }, + }, + }; + var configuration = new ProcessMonitorConfiguration(); + var associationService = CreateAssociationService(configuration); + var powerPlanService = CreatePowerPlanService(); + var notificationService = CreateNotificationService(); + var processService = CreateProcessService(); + var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); + var autoApplyService = CreateAutoApplyService(); + autoApplyService + .Setup(x => x.ApplyForDiscoveredProcessesAsync( + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("auto apply failed")); + var logger = new CapturingLogger(); + var manager = CreateService( + processMonitor, + associationService, + powerPlanService, + notificationService, + processService, + coreMaskService, + affinityApplyService, + autoApplyService, + logger); + + await manager.StartAsync(); + await manager.EvaluateCurrentProcessesAsync(); + + Assert.Contains( + logger.WarningMessages, + message => message.Contains("snapshot refresh", StringComparison.OrdinalIgnoreCase)); + Assert.True(manager.IsRunning); + } + + [Fact] + public async Task ProcessStarted_WhenPersistentRuleAutoApplyThrows_LogsWarningWithoutBreakingStartHandling() + { + var process = new ProcessModel { ProcessId = 45, Name = "game.exe" }; + var processMonitor = new FakeProcessMonitorService(); + var configuration = new ProcessMonitorConfiguration(); + var associationService = CreateAssociationService(configuration); + var powerPlanService = CreatePowerPlanService(); + var notificationService = CreateNotificationService(); + var processService = CreateProcessService(); + var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); + var autoApplyService = CreateAutoApplyService(); + autoApplyService + .Setup(x => x.ApplyForProcessStartAsync( + process, + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("auto apply failed")); + var logger = new CapturingLogger(); + var manager = CreateService( + processMonitor, + associationService, + powerPlanService, + notificationService, + processService, + coreMaskService, + affinityApplyService, + autoApplyService, + logger); + + await manager.StartAsync(); + processMonitor.RaiseProcessStarted(process); + await Task.Delay(100); + + Assert.Contains( + logger.WarningMessages, + message => message.Contains("Persistent rule auto-apply failed", StringComparison.OrdinalIgnoreCase)); + Assert.True(manager.IsRunning); + } + + [Fact] + public async Task ProcessStarted_WhenPersistentRuleAutoApplySucceeds_LogsEnhancedMonitoringEvent() + { + var process = new ProcessModel { ProcessId = 46, Name = "game.exe" }; + var processMonitor = new FakeProcessMonitorService(); + var configuration = new ProcessMonitorConfiguration(); + var associationService = CreateAssociationService(configuration); + var powerPlanService = CreatePowerPlanService(); + var notificationService = CreateNotificationService(); + var processService = CreateProcessService(); + var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); + var autoApplyService = CreateAutoApplyService(); + autoApplyService + .Setup(x => x.ApplyForProcessStartAsync( + process, + It.IsAny())) + .ReturnsAsync(new[] + { + new PersistentRuleAutoApplyResult + { + Success = true, + RuleId = "rule-game", + ProcessId = process.ProcessId, + ProcessName = process.Name, + UserMessage = "Persistent rule applied.", + }, + }); + var enhancedLogger = CreateEnhancedLogger(); + var manager = CreateService( + processMonitor, + associationService, + powerPlanService, + notificationService, + processService, + coreMaskService, + affinityApplyService, + autoApplyService, + enhancedLogger: enhancedLogger); + + await manager.StartAsync(); + processMonitor.RaiseProcessStarted(process); + await Task.Delay(100); + + enhancedLogger.Verify( + x => x.LogProcessMonitoringEventAsync( + LogEventTypes.ProcessMonitoring.AssociationTriggered, + process.Name, + process.ProcessId, + It.Is(message => message.Contains("Persistent rule 'rule-game' applied automatically", StringComparison.Ordinal))), + Times.Once); + } + + [Fact] + public async Task ProcessStarted_WhenPersistentRuleAutoApplyReturnsFailure_DoesNotNotifyOrThrow() + { + var process = new ProcessModel { ProcessId = 47, Name = "game.exe" }; + var processMonitor = new FakeProcessMonitorService(); + var configuration = new ProcessMonitorConfiguration(); + var associationService = CreateAssociationService(configuration); + var powerPlanService = CreatePowerPlanService(); + var notificationService = CreateNotificationService(); + var processService = CreateProcessService(); + var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); + var autoApplyService = CreateAutoApplyService(); + autoApplyService + .Setup(x => x.ApplyForProcessStartAsync( + process, + It.IsAny())) + .ReturnsAsync(new[] + { + new PersistentRuleAutoApplyResult + { + Success = false, + RuleId = "rule-game", + ProcessId = process.ProcessId, + ProcessName = process.Name, + UserMessage = ProcessOperationUserMessages.AccessDenied, + IsAccessDenied = true, + }, + }); + var manager = CreateService( + processMonitor, + associationService, + powerPlanService, + notificationService, + processService, + coreMaskService, + affinityApplyService, + autoApplyService); + + await manager.StartAsync(); + processMonitor.RaiseProcessStarted(process); + await Task.Delay(100); + + Assert.True(manager.IsRunning); + notificationService.Verify( + x => x.ShowNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + notificationService.Verify( + x => x.ShowErrorNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Dispose_CompletesOnBlockingSynchronizationContext() + { + var processMonitor = new FakeProcessMonitorService + { + StopMonitoringAsyncImpl = async () => + { + await Task.Yield(); + }, + }; + + var configuration = new ProcessMonitorConfiguration(); + 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(); + + Exception? disposeException = null; + using var completed = new ManualResetEventSlim(false); + var disposeThread = new Thread(() => + { + SynchronizationContext.SetSynchronizationContext(new BlockingSynchronizationContext()); + + try + { + manager.Dispose(); + } + catch (Exception ex) + { + disposeException = ex; + } + finally + { + completed.Set(); + } + }); + + disposeThread.Start(); + + Assert.True(completed.Wait(TimeSpan.FromSeconds(1)), "Dispose did not complete promptly."); + Assert.Null(disposeException); + Assert.Equal(1, processMonitor.StopCalls); + Assert.Equal(1, processMonitor.DisposeCalls); + } + + private static ProcessMonitorManagerService CreateService( + FakeProcessMonitorService processMonitorService, + Mock associationService, + Mock powerPlanService, + Mock notificationService, + Mock processService, + Mock coreMaskService, + Mock affinityApplyService, + Mock? autoApplyService = null, + ILogger? logger = null, + Mock? enhancedLogger = null) + { + var resolvedEnhancedLogger = enhancedLogger ?? CreateEnhancedLogger(); + + var settingsService = new Mock(MockBehavior.Loose); + settingsService.SetupGet(x => x.Settings).Returns(new ApplicationSettingsModel()); + + return new ProcessMonitorManagerService( + processMonitorService, + associationService.Object, + powerPlanService.Object, + notificationService.Object, + settingsService.Object, + processService.Object, + coreMaskService.Object, + affinityApplyService.Object, + (autoApplyService ?? CreateAutoApplyService()).Object, + new PowerPlanTransitionGate(TimeSpan.FromSeconds(2), () => DateTimeOffset.UtcNow), + logger ?? NullLogger.Instance, + resolvedEnhancedLogger.Object); + } + + private static Mock CreateEnhancedLogger() + { + var enhancedLogger = new Mock(MockBehavior.Loose); + enhancedLogger + .Setup(x => x.LogSystemEventAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + enhancedLogger + .Setup(x => x.LogProcessMonitoringEventAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + enhancedLogger + .Setup(x => x.LogErrorAsync(It.IsAny(), It.IsAny(), It.IsAny?>())) + .Returns(Task.CompletedTask); + return enhancedLogger; + } + + private static Mock CreateAssociationService(ProcessMonitorConfiguration configuration) + { + var associationService = new Mock(MockBehavior.Strict); + associationService.SetupGet(x => x.Configuration).Returns(configuration); + associationService.Setup(x => x.LoadConfigurationAsync()).ReturnsAsync(true); + return associationService; + } + + private static Mock CreatePowerPlanService() + { + var powerPlanService = new Mock(MockBehavior.Strict); + powerPlanService.Setup(x => x.GetActivePowerPlan()).ReturnsAsync(new PowerPlanModel { Guid = "balanced", Name = "Balanced" }); + powerPlanService.Setup(x => x.SetActivePowerPlanByGuidAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + powerPlanService.Setup(x => x.GetPowerPlanByGuidAsync(It.IsAny())) + .ReturnsAsync((string guid) => new PowerPlanModel { Guid = guid, Name = $"{guid}-name" }); + return powerPlanService; + } + + private static Mock CreateNotificationService() + { + var notificationService = new Mock(MockBehavior.Strict); + notificationService.SetupGet(x => x.NotificationHistory).Returns(Array.Empty()); + notificationService.Setup(x => x.ShowPowerPlanChangeNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + notificationService.Setup(x => x.ShowErrorNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + notificationService.Setup(x => x.ShowNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + return notificationService; + } + + private static Mock CreateProcessService() + { + var processService = new Mock(MockBehavior.Strict); + processService.Setup(x => x.UntrackProcess(It.IsAny())); + return processService; + } + + private static Mock CreateCoreMaskService() + { + var coreMaskService = new Mock(MockBehavior.Strict); + coreMaskService.SetupGet(x => x.AvailableMasks).Returns(new ObservableCollection()); + coreMaskService.Setup(x => x.UnregisterMaskApplication(It.IsAny())); + 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 static Mock CreateAutoApplyService() + { + var autoApplyService = new Mock(MockBehavior.Strict); + autoApplyService + .Setup(x => x.ApplyForDiscoveredProcessesAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(Array.Empty()); + autoApplyService + .Setup(x => x.ApplyForProcessStartAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(Array.Empty()); + autoApplyService.Setup(x => x.MarkProcessExited(It.IsAny())); + return autoApplyService; + } + + private sealed class FakeProcessMonitorService : IProcessMonitorService + { + public event EventHandler? ProcessStarted; + + public event EventHandler? ProcessStopped + { + add { } + remove { } + } + + public event EventHandler? MonitoringStatusChanged + { + add { } + remove { } + } + + public List RunningProcesses { get; } = new(); + + public int StartCalls { get; private set; } + + public int StopCalls { get; private set; } + + public int DisposeCalls { get; private set; } + + public bool IsMonitoring { get; private set; } + + public bool IsWmiAvailable => false; + + public bool IsFallbackPollingActive => false; + + public Func? StopMonitoringAsyncImpl { get; init; } + + public Task StartMonitoringAsync() + { + this.StartCalls++; + this.IsMonitoring = true; + return Task.CompletedTask; + } + + public Task StopMonitoringAsync() + { + this.StopCalls++; + this.IsMonitoring = false; + return this.StopMonitoringAsyncImpl?.Invoke() ?? Task.CompletedTask; + } + + public Task> GetRunningProcessesAsync() => + Task.FromResult>(this.RunningProcesses.ToList()); + + public Task IsProcessRunningAsync(string executableName) => + Task.FromResult(this.RunningProcesses.Any(x => string.Equals(x.Name, executableName, StringComparison.OrdinalIgnoreCase))); + + public void UpdateSettings() + { + } + + public void RaiseProcessStarted(ProcessModel process) => + this.ProcessStarted?.Invoke(this, new ProcessEventArgs(process)); + + public void Dispose() + { + this.DisposeCalls++; + } + } + + private sealed class BlockingSynchronizationContext : SynchronizationContext + { + public override void Post(SendOrPostCallback d, object? state) + { + // Intentionally do not pump posted work to emulate a blocked UI thread. + } + } + + private sealed class CapturingLogger : ILogger + { + public List WarningMessages { get; } = new(); + + public IDisposable? BeginScope(TState state) + where TState : notnull => + NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (logLevel == LogLevel.Warning) + { + this.WarningMessages.Add(formatter(state, exception)); + } + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs index 0e26333..47eef10 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs @@ -1,375 +1,375 @@ -/* - * ThreadPilot - persistent process rule creation tests. - */ -namespace ThreadPilot.Core.Tests -{ - using System.Diagnostics; - using Microsoft.Extensions.Logging.Abstractions; - using ThreadPilot.Models; - using ThreadPilot.Services; - - public sealed class ProcessRuleCreationServiceTests - { - [Fact] - public async Task SaveRuleAsync_UsesExecutablePathWhenAvailable() - { - var store = new CapturingRuleStore(); - var service = CreateService(store); - var process = CreateProcess(path: @"C:\Games\Game.exe"); - - var result = await service.SaveRuleAsync( - process, - new ProcessRuleCreationPayload { Priority = ProcessPriorityClass.AboveNormal }); - - Assert.True(result.Success); - var rule = Assert.Single(store.SavedRules); - Assert.Equal(@"C:\Games\Game.exe", rule.ExecutablePath); - Assert.Equal("Game.exe", rule.ProcessName); - Assert.True(rule.IsEnabled); - Assert.Equal("Game.exe rule", rule.Name); - Assert.Equal("Created from Process tab action.", rule.Description); - Assert.True(result.Created); - Assert.False(result.Updated); - Assert.Equal("Saved rule for Game.exe.", result.UserMessage); - } - - [Fact] - public async Task SaveRuleAsync_FallsBackToProcessNameWhenPathUnavailable() - { - var store = new CapturingRuleStore(); - var service = CreateService(store); - - await service.SaveRuleAsync( - CreateProcess(path: string.Empty), - new ProcessRuleCreationPayload { Priority = ProcessPriorityClass.Normal }); - - var rule = Assert.Single(store.SavedRules); - Assert.Null(rule.ExecutablePath); - Assert.Equal("Game.exe", rule.ProcessName); - } - - [Fact] - public async Task SaveRuleAsync_UpdatesExistingPathMatchWithoutDuplicating() - { - var createdAt = DateTime.UtcNow.AddDays(-2); - var existing = new PersistentProcessRule - { - Id = "existing-rule", - Name = "Old", - IsEnabled = true, - ProcessName = "Game.exe", - ExecutablePath = @"C:\Games\Game.exe", - Priority = ProcessPriorityClass.Normal, - ApplyPriorityOnStart = true, - CreatedAt = createdAt, - UpdatedAt = createdAt, - }; - var store = new CapturingRuleStore([existing]); - var service = CreateService(store); - - var result = await service.SaveRuleAsync( - CreateProcess(path: @"C:\Games\Game.exe"), - new ProcessRuleCreationPayload { Priority = ProcessPriorityClass.High }); - - var rule = Assert.Single(store.SavedRules); - Assert.True(result.Updated); - Assert.False(result.Created); - Assert.Equal("Updated saved rule for Game.exe.", result.UserMessage); - Assert.Equal("existing-rule", rule.Id); - Assert.Equal(createdAt, rule.CreatedAt); - Assert.Equal(ProcessPriorityClass.High, rule.Priority); - Assert.True(rule.UpdatedAt > createdAt); - } - - [Fact] - public async Task SaveRuleAsync_UpdatesExistingPathlessNameMatchWhenNewPathIsAvailable() - { - var existing = new PersistentProcessRule - { - Id = "pathless-rule", - Name = "Game.exe rule", - IsEnabled = true, - ProcessName = "Game.exe", - CreatedAt = DateTime.UtcNow.AddDays(-1), - UpdatedAt = DateTime.UtcNow.AddDays(-1), - }; - var store = new CapturingRuleStore([existing]); - var service = CreateService(store); - - await service.SaveRuleAsync( - CreateProcess(path: @"C:\Games\Game.exe"), - new ProcessRuleCreationPayload { Priority = ProcessPriorityClass.AboveNormal }); - - var rule = Assert.Single(store.SavedRules); - Assert.Equal("pathless-rule", rule.Id); - Assert.Equal(@"C:\Games\Game.exe", rule.ExecutablePath); - Assert.Equal(ProcessPriorityClass.AboveNormal, rule.Priority); - } - - [Fact] - public async Task SaveRuleAsync_SavesCpuSelectionWhenProvided() - { - var store = new CapturingRuleStore(); - var service = CreateService(store); - var selection = CreateCpuSelection(); - - await service.SaveRuleAsync( - CreateProcess(), - new ProcessRuleCreationPayload { CpuSelection = selection }); - - var rule = Assert.Single(store.SavedRules); - Assert.Same(selection, rule.CpuSelection); - Assert.Null(rule.LegacyAffinityMask); - Assert.True(rule.ApplyAffinityOnStart); - } - - [Fact] - public async Task SaveCurrentSettingsAsRuleAsync_PrefersCpuSelectionWhenTopologyIsAvailable() - { - var store = new CapturingRuleStore(); - var topologyProvider = new FakeTopologyProvider(CpuTopologySnapshot.Create( - [ - new ProcessorRef(0, 0, 0), - new ProcessorRef(0, 1, 1), - ])); - var service = CreateService(store, topologyProvider); - - await service.SaveCurrentSettingsAsRuleAsync( - CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0), - currentCoreSelection: [true, false], - currentMemoryPriority: null); - - var rule = Assert.Single(store.SavedRules); - Assert.NotNull(rule.CpuSelection); - Assert.Null(rule.LegacyAffinityMask); - Assert.True(rule.ApplyAffinityOnStart); - Assert.Equal(0, rule.CpuSelection.GlobalLogicalProcessorIndexes.Single()); - } - - [Fact] - public async Task SaveCurrentSettingsAsRuleAsync_SavesLegacyMaskWhenSelectionIsSafelyRepresentable() - { - var store = new CapturingRuleStore(); - var service = CreateService(store, topologyProvider: null); - - var result = await service.SaveCurrentSettingsAsRuleAsync( - CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0x3), - currentCoreSelection: [true, true, false], - currentMemoryPriority: null); - - Assert.True(result.Success); - var rule = Assert.Single(store.SavedRules); - Assert.Equal(0x3, rule.LegacyAffinityMask); - Assert.Null(rule.CpuSelection); - Assert.True(rule.ApplyAffinityOnStart); - Assert.Null(rule.Priority); - Assert.False(rule.ApplyPriorityOnStart); - } - - [Fact] - public async Task SaveCurrentSettingsAsRuleAsync_BlocksUnsafeLegacyAffinity() - { - var store = new CapturingRuleStore(); - var service = CreateService(store, topologyProvider: null); - var unsafeSelection = Enumerable.Repeat(true, 65).ToArray(); - - var result = await service.SaveCurrentSettingsAsRuleAsync( - CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0), - unsafeSelection, - currentMemoryPriority: null); - - Assert.False(result.Success); - Assert.Equal( - "The current affinity selection cannot be saved safely on this CPU topology.", - result.UserMessage); - Assert.Empty(store.SavedRules); - } - - [Fact] - public async Task SaveCurrentSettingsAsRuleAsync_BlocksRealtimePriority() - { - var store = new CapturingRuleStore(); - var service = CreateService(store); - - var result = await service.SaveCurrentSettingsAsRuleAsync( - CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0), - currentCoreSelection: null, - currentMemoryPriority: null); - - Assert.False(result.Success); - Assert.Equal("There are no current settings to save as a rule.", result.UserMessage); - Assert.Empty(store.SavedRules); - } - - [Fact] - public async Task SaveCurrentSettingsAsRuleAsync_WithNormalPriorityAndNoOtherPayload_ReturnsNoActionFailure() - { - var store = new CapturingRuleStore(); - var service = CreateService(store); - - var result = await service.SaveCurrentSettingsAsRuleAsync( - CreateProcess(priority: ProcessPriorityClass.Normal, affinity: 0), - currentCoreSelection: null, - currentMemoryPriority: null); - - Assert.False(result.Success); - Assert.Equal("NoActionableRulePayload", result.ErrorCode); - Assert.Equal("There are no current settings to save as a rule.", result.UserMessage); - Assert.Empty(store.SavedRules); - } - - [Fact] - public async Task SaveCurrentSettingsAsRuleAsync_WithNormalPriorityAndAffinity_SavesAffinityButDoesNotEnablePriority() - { - var store = new CapturingRuleStore(); - var service = CreateService(store); - - var result = await service.SaveCurrentSettingsAsRuleAsync( - CreateProcess(priority: ProcessPriorityClass.Normal, affinity: 0x5), - currentCoreSelection: null, - currentMemoryPriority: null); - - Assert.True(result.Success); - var rule = Assert.Single(store.SavedRules); - Assert.Equal(0x5, rule.LegacyAffinityMask); - Assert.True(rule.ApplyAffinityOnStart); - Assert.Null(rule.Priority); - Assert.False(rule.ApplyPriorityOnStart); - } - - [Fact] - public async Task SaveCurrentSettingsAsRuleAsync_WithAboveNormalPriority_SavesPriority() - { - var store = new CapturingRuleStore(); - var service = CreateService(store); - - var result = await service.SaveCurrentSettingsAsRuleAsync( - CreateProcess(priority: ProcessPriorityClass.AboveNormal, affinity: 0), - currentCoreSelection: null, - currentMemoryPriority: null); - - Assert.True(result.Success); - var rule = Assert.Single(store.SavedRules); - Assert.Equal(ProcessPriorityClass.AboveNormal, rule.Priority); - Assert.True(rule.ApplyPriorityOnStart); - } - - [Fact] - public async Task SaveCurrentSettingsAsRuleAsync_WithHighPriority_SavesPriority() - { - var store = new CapturingRuleStore(); - var service = CreateService(store); - - var result = await service.SaveCurrentSettingsAsRuleAsync( - CreateProcess(priority: ProcessPriorityClass.High, affinity: 0), - currentCoreSelection: null, - currentMemoryPriority: null); - - Assert.True(result.Success); - var rule = Assert.Single(store.SavedRules); - Assert.Equal(ProcessPriorityClass.High, rule.Priority); - Assert.True(rule.ApplyPriorityOnStart); - } - - [Fact] - public async Task SaveCurrentSettingsAsRuleAsync_WithRealtimePriority_OmitsPriorityWithoutSavingIt() - { - var store = new CapturingRuleStore(); - var service = CreateService(store); - - var result = await service.SaveCurrentSettingsAsRuleAsync( - CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0x3), - currentCoreSelection: null, - currentMemoryPriority: null); - - Assert.True(result.Success); - var rule = Assert.Single(store.SavedRules); - Assert.Equal(0x3, rule.LegacyAffinityMask); - Assert.Null(rule.Priority); - Assert.False(rule.ApplyPriorityOnStart); - } - - [Fact] - public async Task SaveCurrentSettingsAsRuleAsync_SavesMemoryPriorityWhenAvailable() - { - var store = new CapturingRuleStore(); - var service = CreateService(store); - - await service.SaveCurrentSettingsAsRuleAsync( - CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0), - currentCoreSelection: null, - currentMemoryPriority: ProcessMemoryPriority.BelowNormal); - - var rule = Assert.Single(store.SavedRules); - Assert.Equal(ProcessMemoryPriority.BelowNormal, rule.MemoryPriority); - Assert.True(rule.ApplyMemoryPriorityOnStart); - } - - [Fact] - public async Task SaveCurrentSettingsAsRuleAsync_ReturnsControlledFailureWhenNoActionablePayloadExists() - { - var store = new CapturingRuleStore(); - var service = CreateService(store); - - var result = await service.SaveCurrentSettingsAsRuleAsync( - CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0), - currentCoreSelection: [], - currentMemoryPriority: null); - - Assert.False(result.Success); - Assert.Equal("There are no current settings to save as a rule.", result.UserMessage); - Assert.Empty(store.SavedRules); - } - - private static ProcessRuleCreationService CreateService( - CapturingRuleStore store, - ICpuTopologyProvider? topologyProvider = null) => - new( - store, - topologyProvider, - new CpuSelectionMigrationService(), - NullLogger.Instance); - - private static CpuSelection CreateCpuSelection() => - new() - { - LogicalProcessors = [new ProcessorRef(0, 0, 0)], - GlobalLogicalProcessorIndexes = [0], - }; - - private static ProcessModel CreateProcess( - string name = "Game.exe", - string path = @"C:\Games\Game.exe", - ProcessPriorityClass priority = ProcessPriorityClass.Normal, - long affinity = 0xF) => - new() - { - ProcessId = 42, - Name = name, - ExecutablePath = path, - Priority = priority, - ProcessorAffinity = affinity, - }; - - private sealed class CapturingRuleStore(IReadOnlyList? initialRules = null) - : IPersistentProcessRuleStore - { - public IReadOnlyList SavedRules { get; private set; } = []; - - public Task> LoadAsync() => - Task.FromResult(initialRules ?? this.SavedRules); - - public Task SaveAsync(IReadOnlyList rules) - { - this.SavedRules = rules.ToList(); - return Task.CompletedTask; - } - } - - private sealed class FakeTopologyProvider(CpuTopologySnapshot topology) : ICpuTopologyProvider - { - public Task GetTopologySnapshotAsync(CancellationToken cancellationToken = default) => - Task.FromResult(topology); - } - } -} +/* + * ThreadPilot - persistent process rule creation tests. + */ +namespace ThreadPilot.Core.Tests +{ + using System.Diagnostics; + using Microsoft.Extensions.Logging.Abstractions; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class ProcessRuleCreationServiceTests + { + [Fact] + public async Task SaveRuleAsync_UsesExecutablePathWhenAvailable() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + var process = CreateProcess(path: @"C:\Games\Game.exe"); + + var result = await service.SaveRuleAsync( + process, + new ProcessRuleCreationPayload { Priority = ProcessPriorityClass.AboveNormal }); + + Assert.True(result.Success); + var rule = Assert.Single(store.SavedRules); + Assert.Equal(@"C:\Games\Game.exe", rule.ExecutablePath); + Assert.Equal("Game.exe", rule.ProcessName); + Assert.True(rule.IsEnabled); + Assert.Equal("Game.exe rule", rule.Name); + Assert.Equal("Created from Process tab action.", rule.Description); + Assert.True(result.Created); + Assert.False(result.Updated); + Assert.Equal("Saved rule for Game.exe.", result.UserMessage); + } + + [Fact] + public async Task SaveRuleAsync_FallsBackToProcessNameWhenPathUnavailable() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + + await service.SaveRuleAsync( + CreateProcess(path: string.Empty), + new ProcessRuleCreationPayload { Priority = ProcessPriorityClass.Normal }); + + var rule = Assert.Single(store.SavedRules); + Assert.Null(rule.ExecutablePath); + Assert.Equal("Game.exe", rule.ProcessName); + } + + [Fact] + public async Task SaveRuleAsync_UpdatesExistingPathMatchWithoutDuplicating() + { + var createdAt = DateTime.UtcNow.AddDays(-2); + var existing = new PersistentProcessRule + { + Id = "existing-rule", + Name = "Old", + IsEnabled = true, + ProcessName = "Game.exe", + ExecutablePath = @"C:\Games\Game.exe", + Priority = ProcessPriorityClass.Normal, + ApplyPriorityOnStart = true, + CreatedAt = createdAt, + UpdatedAt = createdAt, + }; + var store = new CapturingRuleStore([existing]); + var service = CreateService(store); + + var result = await service.SaveRuleAsync( + CreateProcess(path: @"C:\Games\Game.exe"), + new ProcessRuleCreationPayload { Priority = ProcessPriorityClass.High }); + + var rule = Assert.Single(store.SavedRules); + Assert.True(result.Updated); + Assert.False(result.Created); + Assert.Equal("Updated saved rule for Game.exe.", result.UserMessage); + Assert.Equal("existing-rule", rule.Id); + Assert.Equal(createdAt, rule.CreatedAt); + Assert.Equal(ProcessPriorityClass.High, rule.Priority); + Assert.True(rule.UpdatedAt > createdAt); + } + + [Fact] + public async Task SaveRuleAsync_UpdatesExistingPathlessNameMatchWhenNewPathIsAvailable() + { + var existing = new PersistentProcessRule + { + Id = "pathless-rule", + Name = "Game.exe rule", + IsEnabled = true, + ProcessName = "Game.exe", + CreatedAt = DateTime.UtcNow.AddDays(-1), + UpdatedAt = DateTime.UtcNow.AddDays(-1), + }; + var store = new CapturingRuleStore([existing]); + var service = CreateService(store); + + await service.SaveRuleAsync( + CreateProcess(path: @"C:\Games\Game.exe"), + new ProcessRuleCreationPayload { Priority = ProcessPriorityClass.AboveNormal }); + + var rule = Assert.Single(store.SavedRules); + Assert.Equal("pathless-rule", rule.Id); + Assert.Equal(@"C:\Games\Game.exe", rule.ExecutablePath); + Assert.Equal(ProcessPriorityClass.AboveNormal, rule.Priority); + } + + [Fact] + public async Task SaveRuleAsync_SavesCpuSelectionWhenProvided() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + var selection = CreateCpuSelection(); + + await service.SaveRuleAsync( + CreateProcess(), + new ProcessRuleCreationPayload { CpuSelection = selection }); + + var rule = Assert.Single(store.SavedRules); + Assert.Same(selection, rule.CpuSelection); + Assert.Null(rule.LegacyAffinityMask); + Assert.True(rule.ApplyAffinityOnStart); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_PrefersCpuSelectionWhenTopologyIsAvailable() + { + var store = new CapturingRuleStore(); + var topologyProvider = new FakeTopologyProvider(CpuTopologySnapshot.Create( + [ + new ProcessorRef(0, 0, 0), + new ProcessorRef(0, 1, 1), + ])); + var service = CreateService(store, topologyProvider); + + await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0), + currentCoreSelection: [true, false], + currentMemoryPriority: null); + + var rule = Assert.Single(store.SavedRules); + Assert.NotNull(rule.CpuSelection); + Assert.Null(rule.LegacyAffinityMask); + Assert.True(rule.ApplyAffinityOnStart); + Assert.Equal(0, rule.CpuSelection.GlobalLogicalProcessorIndexes.Single()); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_SavesLegacyMaskWhenSelectionIsSafelyRepresentable() + { + var store = new CapturingRuleStore(); + var service = CreateService(store, topologyProvider: null); + + var result = await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0x3), + currentCoreSelection: [true, true, false], + currentMemoryPriority: null); + + Assert.True(result.Success); + var rule = Assert.Single(store.SavedRules); + Assert.Equal(0x3, rule.LegacyAffinityMask); + Assert.Null(rule.CpuSelection); + Assert.True(rule.ApplyAffinityOnStart); + Assert.Null(rule.Priority); + Assert.False(rule.ApplyPriorityOnStart); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_BlocksUnsafeLegacyAffinity() + { + var store = new CapturingRuleStore(); + var service = CreateService(store, topologyProvider: null); + var unsafeSelection = Enumerable.Repeat(true, 65).ToArray(); + + var result = await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0), + unsafeSelection, + currentMemoryPriority: null); + + Assert.False(result.Success); + Assert.Equal( + "The current affinity selection cannot be saved safely on this CPU topology.", + result.UserMessage); + Assert.Empty(store.SavedRules); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_BlocksRealtimePriority() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + + var result = await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0), + currentCoreSelection: null, + currentMemoryPriority: null); + + Assert.False(result.Success); + Assert.Equal("There are no current settings to save as a rule.", result.UserMessage); + Assert.Empty(store.SavedRules); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_WithNormalPriorityAndNoOtherPayload_ReturnsNoActionFailure() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + + var result = await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.Normal, affinity: 0), + currentCoreSelection: null, + currentMemoryPriority: null); + + Assert.False(result.Success); + Assert.Equal("NoActionableRulePayload", result.ErrorCode); + Assert.Equal("There are no current settings to save as a rule.", result.UserMessage); + Assert.Empty(store.SavedRules); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_WithNormalPriorityAndAffinity_SavesAffinityButDoesNotEnablePriority() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + + var result = await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.Normal, affinity: 0x5), + currentCoreSelection: null, + currentMemoryPriority: null); + + Assert.True(result.Success); + var rule = Assert.Single(store.SavedRules); + Assert.Equal(0x5, rule.LegacyAffinityMask); + Assert.True(rule.ApplyAffinityOnStart); + Assert.Null(rule.Priority); + Assert.False(rule.ApplyPriorityOnStart); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_WithAboveNormalPriority_SavesPriority() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + + var result = await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.AboveNormal, affinity: 0), + currentCoreSelection: null, + currentMemoryPriority: null); + + Assert.True(result.Success); + var rule = Assert.Single(store.SavedRules); + Assert.Equal(ProcessPriorityClass.AboveNormal, rule.Priority); + Assert.True(rule.ApplyPriorityOnStart); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_WithHighPriority_SavesPriority() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + + var result = await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.High, affinity: 0), + currentCoreSelection: null, + currentMemoryPriority: null); + + Assert.True(result.Success); + var rule = Assert.Single(store.SavedRules); + Assert.Equal(ProcessPriorityClass.High, rule.Priority); + Assert.True(rule.ApplyPriorityOnStart); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_WithRealtimePriority_OmitsPriorityWithoutSavingIt() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + + var result = await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0x3), + currentCoreSelection: null, + currentMemoryPriority: null); + + Assert.True(result.Success); + var rule = Assert.Single(store.SavedRules); + Assert.Equal(0x3, rule.LegacyAffinityMask); + Assert.Null(rule.Priority); + Assert.False(rule.ApplyPriorityOnStart); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_SavesMemoryPriorityWhenAvailable() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + + await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0), + currentCoreSelection: null, + currentMemoryPriority: ProcessMemoryPriority.BelowNormal); + + var rule = Assert.Single(store.SavedRules); + Assert.Equal(ProcessMemoryPriority.BelowNormal, rule.MemoryPriority); + Assert.True(rule.ApplyMemoryPriorityOnStart); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_ReturnsControlledFailureWhenNoActionablePayloadExists() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + + var result = await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0), + currentCoreSelection: [], + currentMemoryPriority: null); + + Assert.False(result.Success); + Assert.Equal("There are no current settings to save as a rule.", result.UserMessage); + Assert.Empty(store.SavedRules); + } + + private static ProcessRuleCreationService CreateService( + CapturingRuleStore store, + ICpuTopologyProvider? topologyProvider = null) => + new( + store, + topologyProvider, + new CpuSelectionMigrationService(), + NullLogger.Instance); + + private static CpuSelection CreateCpuSelection() => + new() + { + LogicalProcessors = [new ProcessorRef(0, 0, 0)], + GlobalLogicalProcessorIndexes = [0], + }; + + private static ProcessModel CreateProcess( + string name = "Game.exe", + string path = @"C:\Games\Game.exe", + ProcessPriorityClass priority = ProcessPriorityClass.Normal, + long affinity = 0xF) => + new() + { + ProcessId = 42, + Name = name, + ExecutablePath = path, + Priority = priority, + ProcessorAffinity = affinity, + }; + + private sealed class CapturingRuleStore(IReadOnlyList? initialRules = null) + : IPersistentProcessRuleStore + { + public IReadOnlyList SavedRules { get; private set; } = []; + + public Task> LoadAsync() => + Task.FromResult(initialRules ?? this.SavedRules); + + public Task SaveAsync(IReadOnlyList rules) + { + this.SavedRules = rules.ToList(); + return Task.CompletedTask; + } + } + + private sealed class FakeTopologyProvider(CpuTopologySnapshot topology) : ICpuTopologyProvider + { + public Task GetTopologySnapshotAsync(CancellationToken cancellationToken = default) => + Task.FromResult(topology); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessServiceSecurityTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessServiceSecurityTests.cs index 7416210..2b0682b 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessServiceSecurityTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessServiceSecurityTests.cs @@ -1,82 +1,73 @@ -/* - * ThreadPilot - process service security guard tests. - */ -namespace ThreadPilot.Core.Tests -{ - using Moq; - using ThreadPilot.Models; - using ThreadPilot.Services; - - /// - /// Unit tests for security guard behavior in . - /// - public sealed class ProcessServiceSecurityTests - { - /// - /// Ensures protected process priority updates are blocked before mutation. - /// - [Fact] - public async Task SetProcessPriority_ThrowsUnauthorized_ForProtectedProcess() - { - var security = new Mock(MockBehavior.Strict); - security - .Setup(s => s.ValidateProcessOperation("lsass", "SetProcessPriority")) - .Returns(false); - security - .Setup(s => s.AuditElevatedAction("SetProcessPriority", "lsass", false)) - .Returns(Task.CompletedTask); - - var service = new ProcessService(null, security.Object); - var process = new ProcessModel { Name = "lsass", ProcessId = 500 }; - - await Assert.ThrowsAsync(async () => - await service.SetProcessPriority(process, System.Diagnostics.ProcessPriorityClass.Normal)); - - security.VerifyAll(); - } - - /// - /// Ensures protected process affinity updates are blocked before mutation. - /// - [Fact] - public async Task SetProcessorAffinity_ThrowsUnauthorized_ForProtectedProcess() - { - var security = new Mock(MockBehavior.Strict); - security - .Setup(s => s.ValidateProcessOperation("System", "SetProcessAffinity")) - .Returns(false); - security - .Setup(s => s.AuditElevatedAction("SetProcessAffinity", "System", false)) - .Returns(Task.CompletedTask); - - var service = new ProcessService(null, security.Object); - var process = new ProcessModel { Name = "System", ProcessId = 4 }; - - await Assert.ThrowsAsync(async () => - await service.SetProcessorAffinity(process, 0x03)); - - security.VerifyAll(); - } - - [Fact] - public async Task SetProcessorAffinity_WithInvalidCpuSelection_AuditsFailure() - { - var security = new Mock(MockBehavior.Strict); - security - .Setup(s => s.ValidateProcessOperation("Game", "SetProcessAffinity")) - .Returns(true); - security - .Setup(s => s.AuditElevatedAction("SetProcessAffinity", "Game", false)) - .Returns(Task.CompletedTask); - - var service = new ProcessService(null, security.Object); - var process = new ProcessModel { Name = "Game", ProcessId = int.MaxValue }; - - var result = await service.SetProcessorAffinity(process, new CpuSelection()); - - Assert.False(result.Success); - Assert.Equal(AffinityApplyErrorCodes.InvalidSelection, result.ErrorCode); - security.VerifyAll(); - } - } -} +/* + * ThreadPilot - process service security guard tests. + */ +namespace ThreadPilot.Core.Tests +{ + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class ProcessServiceSecurityTests + { + [Fact] + public async Task SetProcessPriority_ThrowsUnauthorized_ForProtectedProcess() + { + var security = new Mock(MockBehavior.Strict); + security + .Setup(s => s.ValidateProcessOperation("lsass", "SetProcessPriority")) + .Returns(false); + security + .Setup(s => s.AuditElevatedAction("SetProcessPriority", "lsass", false)) + .Returns(Task.CompletedTask); + + var service = new ProcessService(null, security.Object); + var process = new ProcessModel { Name = "lsass", ProcessId = 500 }; + + await Assert.ThrowsAsync(async () => + await service.SetProcessPriority(process, System.Diagnostics.ProcessPriorityClass.Normal)); + + security.VerifyAll(); + } + + [Fact] + public async Task SetProcessorAffinity_ThrowsUnauthorized_ForProtectedProcess() + { + var security = new Mock(MockBehavior.Strict); + security + .Setup(s => s.ValidateProcessOperation("System", "SetProcessAffinity")) + .Returns(false); + security + .Setup(s => s.AuditElevatedAction("SetProcessAffinity", "System", false)) + .Returns(Task.CompletedTask); + + var service = new ProcessService(null, security.Object); + var process = new ProcessModel { Name = "System", ProcessId = 4 }; + + await Assert.ThrowsAsync(async () => + await service.SetProcessorAffinity(process, 0x03)); + + security.VerifyAll(); + } + + [Fact] + public async Task SetProcessorAffinity_WithInvalidCpuSelection_AuditsFailure() + { + var security = new Mock(MockBehavior.Strict); + security + .Setup(s => s.ValidateProcessOperation("Game", "SetProcessAffinity")) + .Returns(true); + security + .Setup(s => s.AuditElevatedAction("SetProcessAffinity", "Game", false)) + .Returns(Task.CompletedTask); + + var service = new ProcessService(null, security.Object); + var process = new ProcessModel { Name = "Game", ProcessId = int.MaxValue }; + + var result = await service.SetProcessorAffinity(process, new CpuSelection()); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.InvalidSelection, result.ErrorCode); + security.VerifyAll(); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs index 8bc5783..d257bda 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs @@ -1,534 +1,531 @@ -/* - * ThreadPilot - process service unit tests. - */ -namespace ThreadPilot.Core.Tests -{ - using System.Collections.Concurrent; - using System.ComponentModel; - using System.Diagnostics; - using System.Text.Json; - using Microsoft.Extensions.Logging; - using Moq; - using ThreadPilot.Models; - using ThreadPilot.Services; - - /// - /// Unit tests for deterministic behavior in . - /// - public sealed class ProcessServiceTests - { - [Fact] - public async Task SaveProcessProfile_WritesExpectedJson() - { - var profilesDirectory = CreateTemporaryDirectory(); - var profileName = $"profile-{Guid.NewGuid():N}"; - var process = new ProcessModel - { - Name = "game.exe", - Priority = ProcessPriorityClass.High, - ProcessorAffinity = 3, - }; - - try - { - var service = CreateService(profilesDirectory); - - var result = await service.SaveProcessProfile(profileName, process); - - Assert.True(result); - - var filePath = Path.Combine(profilesDirectory, $"{profileName}.json"); - Assert.True(File.Exists(filePath)); - - using var document = JsonDocument.Parse(await File.ReadAllTextAsync(filePath)); - Assert.Equal("game.exe", document.RootElement.GetProperty("ProcessName").GetString()); - Assert.Equal((int)ProcessPriorityClass.High, document.RootElement.GetProperty("Priority").GetInt32()); - Assert.Equal(3, document.RootElement.GetProperty("ProcessorAffinity").GetInt64()); - } - finally - { - DeleteDirectory(profilesDirectory); - } - } - - [Fact] - public async Task SaveProcessProfile_WithTopologyProvider_WritesCpuSelectionSchema() - { - var profilesDirectory = CreateTemporaryDirectory(); - var profileName = $"profile-{Guid.NewGuid():N}"; - var topology = CpuTopologySnapshot.Create( - [ - new ProcessorRef(0, 0, 0), - new ProcessorRef(0, 1, 1), - new ProcessorRef(0, 2, 2), - ]); - var process = new ProcessModel - { - Name = "game.exe", - Priority = ProcessPriorityClass.High, - ProcessorAffinity = 0b101, - }; - - try - { - var service = CreateService(profilesDirectory, new FakeCpuTopologyProvider(topology)); - - var result = await service.SaveProcessProfile(profileName, process); - - Assert.True(result); - - var filePath = Path.Combine(profilesDirectory, $"{profileName}.json"); - var profile = JsonSerializer.Deserialize( - await File.ReadAllTextAsync(filePath), - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - - Assert.NotNull(profile); - Assert.Equal(CpuAffinityProfileSchemaVersions.CpuSelection, profile.ProfileSchemaVersion); - Assert.Equal(0b101, profile.ProcessorAffinity); - Assert.NotNull(profile.CpuSelection); - Assert.Equal([0, 2], profile.CpuSelection!.GlobalLogicalProcessorIndexes); - } - finally - { - DeleteDirectory(profilesDirectory); - } - } - - [Fact] - public async Task LoadProcessProfile_ReturnsFalse_WhenFileIsMissing() - { - var profilesDirectory = CreateTemporaryDirectory(); - - try - { - var service = CreateService(profilesDirectory); - - var result = await service.LoadProcessProfile("missing-profile", new ProcessModel()); - - Assert.False(result); - } - finally - { - DeleteDirectory(profilesDirectory); - } - } - - [Fact] - public async Task LoadProcessProfile_WithCpuSelectionApplyFailure_ReturnsFalse() - { - var profilesDirectory = CreateTemporaryDirectory(); - var profileName = $"profile-{Guid.NewGuid():N}"; - var topology = CreateTopology(); - var profile = new ProcessProfileSnapshot - { - ProcessName = "game.exe", - Priority = ProcessPriorityClass.Normal, - ProcessorAffinity = 1, - ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection, - CpuSelection = CpuSelection.FromProcessors([topology.LogicalProcessors[0]], topology), - }; - var profileApplier = new FakeLoadProcessProfileApplier( - cpuSelectionResult: AffinityApplyResult.Failed( - AffinityApplyErrorCodes.NativeApplyFailed, - "Affinity was not applied.", - "simulated apply failure")); - var service = CreateService( - profilesDirectory, - new FakeCpuTopologyProvider(topology), - profileApplier); - - try - { - await WriteProfileAsync(profilesDirectory, profileName, profile); - - var result = await service.LoadProcessProfile(profileName, CreateProcess()); - - Assert.False(result); - Assert.Equal(1, profileApplier.CpuSelectionApplyCalls); - Assert.Equal(0, profileApplier.LegacyAffinityApplyCalls); - } - finally - { - DeleteDirectory(profilesDirectory); - } - } - - [Fact] - public async Task LoadProcessProfile_WithCpuSelectionApplySuccess_ReturnsTrue() - { - var profilesDirectory = CreateTemporaryDirectory(); - var profileName = $"profile-{Guid.NewGuid():N}"; - var topology = CreateTopology(); - var profile = new ProcessProfileSnapshot - { - ProcessName = "game.exe", - Priority = ProcessPriorityClass.Normal, - ProcessorAffinity = 1, - ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection, - CpuSelection = CpuSelection.FromProcessors([topology.LogicalProcessors[0]], topology), - }; - var profileApplier = new FakeLoadProcessProfileApplier( - cpuSelectionResult: AffinityApplyResult.SucceededWithCpuSets("simulated apply success")); - var service = CreateService( - profilesDirectory, - new FakeCpuTopologyProvider(topology), - profileApplier); - - try - { - await WriteProfileAsync(profilesDirectory, profileName, profile); - - var result = await service.LoadProcessProfile(profileName, CreateProcess()); - - Assert.True(result); - Assert.Equal(1, profileApplier.CpuSelectionApplyCalls); - Assert.Equal(0, profileApplier.LegacyAffinityApplyCalls); - } - finally - { - DeleteDirectory(profilesDirectory); - } - } - - [Fact] - public async Task LoadProcessProfile_WithoutTopologyProvider_UsesLegacyAffinityPath() - { - var profilesDirectory = CreateTemporaryDirectory(); - var profileName = $"profile-{Guid.NewGuid():N}"; - var profile = new ProcessProfileSnapshot - { - ProcessName = "game.exe", - Priority = ProcessPriorityClass.Normal, - ProcessorAffinity = 0b11, - }; - var profileApplier = new FakeLoadProcessProfileApplier(); - var service = CreateService(profilesDirectory, topologyProvider: null, profileApplier); - - try - { - await WriteProfileAsync(profilesDirectory, profileName, profile); - - var result = await service.LoadProcessProfile(profileName, CreateProcess()); - - Assert.True(result); - Assert.Equal(1, profileApplier.LegacyAffinityApplyCalls); - Assert.Equal(0b11, profileApplier.LastLegacyAffinityMask); - Assert.Equal(0, profileApplier.CpuSelectionApplyCalls); - } - finally - { - DeleteDirectory(profilesDirectory); - } - } - - [Fact] - public async Task LoadProcessProfile_WithRealtimePriority_ReturnsFalseWithoutApplyingPriorityOrAffinity() - { - var profilesDirectory = CreateTemporaryDirectory(); - var profileName = $"profile-{Guid.NewGuid():N}"; - var profile = new ProcessProfileSnapshot - { - ProcessName = "game.exe", - Priority = ProcessPriorityClass.RealTime, - ProcessorAffinity = 0b11, - }; - var profileApplier = new FakeLoadProcessProfileApplier(); - var service = CreateService(profilesDirectory, topologyProvider: null, profileApplier); - - try - { - await WriteProfileAsync(profilesDirectory, profileName, profile); - - var result = await service.LoadProcessProfile(profileName, CreateProcess()); - - Assert.False(result); - Assert.Equal(0, profileApplier.PriorityApplyCalls); - Assert.Equal(0, profileApplier.LegacyAffinityApplyCalls); - Assert.Equal(0, profileApplier.CpuSelectionApplyCalls); - } - finally - { - DeleteDirectory(profilesDirectory); - } - } - - [Fact] - public void PriorityGuardrails_HighPriorityReturnsUserFacingWarning() - { - var warning = ProcessPriorityGuardrails.GetWarning(ProcessPriorityClass.High); - - Assert.Equal(ProcessOperationUserMessages.HighPriorityWarning, warning); - } - - [Fact] - public async Task SetProcessPriority_WithRealtime_AuditsFailureAndThrowsBlockedMessage() - { - var logger = new Mock>(); - var security = new Mock(MockBehavior.Strict); - security - .Setup(s => s.AuditElevatedAction("SetProcessPriority", "game.exe", false)) - .Returns(Task.CompletedTask); - - var service = CreateService(CreateTemporaryDirectory(), logger: logger.Object, securityService: security.Object); - var process = CreateProcess(); - - try - { - var ex = await Assert.ThrowsAsync( - () => service.SetProcessPriority(process, ProcessPriorityClass.RealTime)); - - Assert.Equal(ProcessOperationUserMessages.RealtimePriorityBlocked, ex.Message); - Assert.Equal(ProcessPriorityClass.Normal, process.Priority); - security.Verify( - s => s.AuditElevatedAction("SetProcessPriority", "game.exe", false), - Times.Once); - VerifyWarningLogged(logger, ProcessOperationUserMessages.RealtimePriorityBlocked); - } - finally - { - DeleteDirectory(GetProfilesDirectory(service)); - } - } - - [Fact] - public async Task SetRegistryPriorityAsync_WithRealtime_ReturnsFalse() - { - var service = CreateService(CreateTemporaryDirectory()); - - try - { - var result = await service.SetRegistryPriorityAsync(CreateProcess(), enable: true, ProcessPriorityClass.RealTime); - - Assert.False(result); - Assert.Contains("does not bypass", ProcessOperationUserMessages.PersistentLaunchTimePriorityNotice, StringComparison.OrdinalIgnoreCase); - } - finally - { - DeleteDirectory(GetProfilesDirectory(service)); - } - } - - [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() - { - var service = CreateService(CreateTemporaryDirectory()); - - try - { - service.TrackPriorityChange(42, ProcessPriorityClass.Normal); - service.TrackPriorityChange(42, ProcessPriorityClass.High); - - var trackedPriorities = GetPrivateDictionary(service, "originalPriorities"); - Assert.True(trackedPriorities.TryGetValue(42, out var priority)); - Assert.Equal(ProcessPriorityClass.Normal, priority); - } - finally - { - DeleteDirectory(GetProfilesDirectory(service)); - } - } - - [Fact] - public void UntrackProcess_ClearsTrackedState() - { - var service = CreateService(CreateTemporaryDirectory()); - - try - { - service.TrackAppliedMask(77, "mask-a"); - service.TrackPriorityChange(77, ProcessPriorityClass.BelowNormal); - - service.UntrackProcess(77); - - var trackedMasks = GetPrivateDictionary(service, "appliedMasks"); - var trackedPriorities = GetPrivateDictionary(service, "originalPriorities"); - Assert.False(trackedMasks.ContainsKey(77)); - Assert.False(trackedPriorities.ContainsKey(77)); - } - finally - { - DeleteDirectory(GetProfilesDirectory(service)); - } - } - - private static ProcessService CreateService( - string profilesDirectory, - ICpuTopologyProvider? topologyProvider = null, - FakeLoadProcessProfileApplier? profileApplier = null, - ILogger? logger = null, - ISecurityService? securityService = null) - { - if (profileApplier == null) - { - return new(logger, securityService, () => profilesDirectory, cpuTopologyProvider: topologyProvider); - } - - return new ProcessService( - logger, - securityService, - () => profilesDirectory, - foregroundProcessService: null, - processClassifier: null, - passiveProcessErrorThrottle: null, - cpuTopologyProvider: topologyProvider, - cpuSelectionMigrationService: null, - loadProcessProfilePrioritySetter: profileApplier.SetPriorityAsync, - loadProcessProfileCpuSelectionSetter: profileApplier.SetCpuSelectionAsync, - loadProcessProfileLegacyAffinitySetter: profileApplier.SetLegacyAffinityAsync); - } - - private static ProcessModel CreateProcess() => - new() - { - ProcessId = 1234, - Name = "game.exe", - Priority = ProcessPriorityClass.Normal, - ProcessorAffinity = 0, - }; - - private static CpuTopologySnapshot CreateTopology() => - CpuTopologySnapshot.Create( - [ - new ProcessorRef(0, 0, 0), - new ProcessorRef(0, 1, 1), - ]); - - private static Task WriteProfileAsync( - string profilesDirectory, - string profileName, - ProcessProfileSnapshot profile) - { - var filePath = Path.Combine(profilesDirectory, $"{profileName}.json"); - var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); - return File.WriteAllTextAsync(filePath, json); - } - - private static string CreateTemporaryDirectory() - { - var path = Path.Combine(Path.GetTempPath(), $"threadpilot-process-service-{Guid.NewGuid():N}"); - Directory.CreateDirectory(path); - return path; - } - - private static void DeleteDirectory(string path) - { - if (Directory.Exists(path)) - { - Directory.Delete(path, recursive: true); - } - } - - private static string GetProfilesDirectory(ProcessService service) - { - var property = typeof(ProcessService).GetProperty("ProfilesDirectory", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); - return (string)(property?.GetValue(service) ?? throw new InvalidOperationException("ProfilesDirectory property not found.")); - } - - private static ConcurrentDictionary GetPrivateDictionary(ProcessService service, string fieldName) - where TKey : notnull - { - var field = typeof(ProcessService).GetField(fieldName, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); - return (ConcurrentDictionary)(field?.GetValue(service) ?? throw new InvalidOperationException($"Field '{fieldName}' not found.")); - } - - private static void VerifyWarningLogged(Mock> logger, string message) - { - logger.Verify( - l => l.Log( - LogLevel.Warning, - It.IsAny(), - It.Is((state, _) => state.ToString() != null && state.ToString()!.Contains(message, StringComparison.Ordinal)), - It.IsAny(), - It.IsAny>()), - Times.Once); - } - - private sealed class FakeCpuTopologyProvider(CpuTopologySnapshot snapshot) : ICpuTopologyProvider - { - public Task GetTopologySnapshotAsync( - CancellationToken cancellationToken = default) => - Task.FromResult(snapshot); - } - - private sealed class FakeLoadProcessProfileApplier - { - private readonly AffinityApplyResult cpuSelectionResult; - - public FakeLoadProcessProfileApplier(AffinityApplyResult? cpuSelectionResult = null) - { - this.cpuSelectionResult = cpuSelectionResult ?? AffinityApplyResult.Succeeded(0, 0); - } - - public int PriorityApplyCalls { get; private set; } - - public int CpuSelectionApplyCalls { get; private set; } - - public int LegacyAffinityApplyCalls { get; private set; } - - public long LastLegacyAffinityMask { get; private set; } - - public Task SetPriorityAsync(ProcessModel process, ProcessPriorityClass priority) - { - this.PriorityApplyCalls++; - process.Priority = priority; - return Task.CompletedTask; - } - - public Task SetCpuSelectionAsync(ProcessModel process, CpuSelection selection) - { - this.CpuSelectionApplyCalls++; - return Task.FromResult(this.cpuSelectionResult); - } - - public Task SetLegacyAffinityAsync(ProcessModel process, long affinityMask) - { - this.LegacyAffinityApplyCalls++; - this.LastLegacyAffinityMask = affinityMask; - process.ProcessorAffinity = affinityMask; - return Task.CompletedTask; - } - } - } -} +/* + * ThreadPilot - process service unit tests. + */ +namespace ThreadPilot.Core.Tests +{ + using System.Collections.Concurrent; + using System.ComponentModel; + using System.Diagnostics; + using System.Text.Json; + using Microsoft.Extensions.Logging; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class ProcessServiceTests + { + [Fact] + public async Task SaveProcessProfile_WritesExpectedJson() + { + var profilesDirectory = CreateTemporaryDirectory(); + var profileName = $"profile-{Guid.NewGuid():N}"; + var process = new ProcessModel + { + Name = "game.exe", + Priority = ProcessPriorityClass.High, + ProcessorAffinity = 3, + }; + + try + { + var service = CreateService(profilesDirectory); + + var result = await service.SaveProcessProfile(profileName, process); + + Assert.True(result); + + var filePath = Path.Combine(profilesDirectory, $"{profileName}.json"); + Assert.True(File.Exists(filePath)); + + using var document = JsonDocument.Parse(await File.ReadAllTextAsync(filePath)); + Assert.Equal("game.exe", document.RootElement.GetProperty("ProcessName").GetString()); + Assert.Equal((int)ProcessPriorityClass.High, document.RootElement.GetProperty("Priority").GetInt32()); + Assert.Equal(3, document.RootElement.GetProperty("ProcessorAffinity").GetInt64()); + } + finally + { + DeleteDirectory(profilesDirectory); + } + } + + [Fact] + public async Task SaveProcessProfile_WithTopologyProvider_WritesCpuSelectionSchema() + { + var profilesDirectory = CreateTemporaryDirectory(); + var profileName = $"profile-{Guid.NewGuid():N}"; + var topology = CpuTopologySnapshot.Create( + [ + new ProcessorRef(0, 0, 0), + new ProcessorRef(0, 1, 1), + new ProcessorRef(0, 2, 2), + ]); + var process = new ProcessModel + { + Name = "game.exe", + Priority = ProcessPriorityClass.High, + ProcessorAffinity = 0b101, + }; + + try + { + var service = CreateService(profilesDirectory, new FakeCpuTopologyProvider(topology)); + + var result = await service.SaveProcessProfile(profileName, process); + + Assert.True(result); + + var filePath = Path.Combine(profilesDirectory, $"{profileName}.json"); + var profile = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(filePath), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + Assert.NotNull(profile); + Assert.Equal(CpuAffinityProfileSchemaVersions.CpuSelection, profile.ProfileSchemaVersion); + Assert.Equal(0b101, profile.ProcessorAffinity); + Assert.NotNull(profile.CpuSelection); + Assert.Equal([0, 2], profile.CpuSelection!.GlobalLogicalProcessorIndexes); + } + finally + { + DeleteDirectory(profilesDirectory); + } + } + + [Fact] + public async Task LoadProcessProfile_ReturnsFalse_WhenFileIsMissing() + { + var profilesDirectory = CreateTemporaryDirectory(); + + try + { + var service = CreateService(profilesDirectory); + + var result = await service.LoadProcessProfile("missing-profile", new ProcessModel()); + + Assert.False(result); + } + finally + { + DeleteDirectory(profilesDirectory); + } + } + + [Fact] + public async Task LoadProcessProfile_WithCpuSelectionApplyFailure_ReturnsFalse() + { + var profilesDirectory = CreateTemporaryDirectory(); + var profileName = $"profile-{Guid.NewGuid():N}"; + var topology = CreateTopology(); + var profile = new ProcessProfileSnapshot + { + ProcessName = "game.exe", + Priority = ProcessPriorityClass.Normal, + ProcessorAffinity = 1, + ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection, + CpuSelection = CpuSelection.FromProcessors([topology.LogicalProcessors[0]], topology), + }; + var profileApplier = new FakeLoadProcessProfileApplier( + cpuSelectionResult: AffinityApplyResult.Failed( + AffinityApplyErrorCodes.NativeApplyFailed, + "Affinity was not applied.", + "simulated apply failure")); + var service = CreateService( + profilesDirectory, + new FakeCpuTopologyProvider(topology), + profileApplier); + + try + { + await WriteProfileAsync(profilesDirectory, profileName, profile); + + var result = await service.LoadProcessProfile(profileName, CreateProcess()); + + Assert.False(result); + Assert.Equal(1, profileApplier.CpuSelectionApplyCalls); + Assert.Equal(0, profileApplier.LegacyAffinityApplyCalls); + } + finally + { + DeleteDirectory(profilesDirectory); + } + } + + [Fact] + public async Task LoadProcessProfile_WithCpuSelectionApplySuccess_ReturnsTrue() + { + var profilesDirectory = CreateTemporaryDirectory(); + var profileName = $"profile-{Guid.NewGuid():N}"; + var topology = CreateTopology(); + var profile = new ProcessProfileSnapshot + { + ProcessName = "game.exe", + Priority = ProcessPriorityClass.Normal, + ProcessorAffinity = 1, + ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection, + CpuSelection = CpuSelection.FromProcessors([topology.LogicalProcessors[0]], topology), + }; + var profileApplier = new FakeLoadProcessProfileApplier( + cpuSelectionResult: AffinityApplyResult.SucceededWithCpuSets("simulated apply success")); + var service = CreateService( + profilesDirectory, + new FakeCpuTopologyProvider(topology), + profileApplier); + + try + { + await WriteProfileAsync(profilesDirectory, profileName, profile); + + var result = await service.LoadProcessProfile(profileName, CreateProcess()); + + Assert.True(result); + Assert.Equal(1, profileApplier.CpuSelectionApplyCalls); + Assert.Equal(0, profileApplier.LegacyAffinityApplyCalls); + } + finally + { + DeleteDirectory(profilesDirectory); + } + } + + [Fact] + public async Task LoadProcessProfile_WithoutTopologyProvider_UsesLegacyAffinityPath() + { + var profilesDirectory = CreateTemporaryDirectory(); + var profileName = $"profile-{Guid.NewGuid():N}"; + var profile = new ProcessProfileSnapshot + { + ProcessName = "game.exe", + Priority = ProcessPriorityClass.Normal, + ProcessorAffinity = 0b11, + }; + var profileApplier = new FakeLoadProcessProfileApplier(); + var service = CreateService(profilesDirectory, topologyProvider: null, profileApplier); + + try + { + await WriteProfileAsync(profilesDirectory, profileName, profile); + + var result = await service.LoadProcessProfile(profileName, CreateProcess()); + + Assert.True(result); + Assert.Equal(1, profileApplier.LegacyAffinityApplyCalls); + Assert.Equal(0b11, profileApplier.LastLegacyAffinityMask); + Assert.Equal(0, profileApplier.CpuSelectionApplyCalls); + } + finally + { + DeleteDirectory(profilesDirectory); + } + } + + [Fact] + public async Task LoadProcessProfile_WithRealtimePriority_ReturnsFalseWithoutApplyingPriorityOrAffinity() + { + var profilesDirectory = CreateTemporaryDirectory(); + var profileName = $"profile-{Guid.NewGuid():N}"; + var profile = new ProcessProfileSnapshot + { + ProcessName = "game.exe", + Priority = ProcessPriorityClass.RealTime, + ProcessorAffinity = 0b11, + }; + var profileApplier = new FakeLoadProcessProfileApplier(); + var service = CreateService(profilesDirectory, topologyProvider: null, profileApplier); + + try + { + await WriteProfileAsync(profilesDirectory, profileName, profile); + + var result = await service.LoadProcessProfile(profileName, CreateProcess()); + + Assert.False(result); + Assert.Equal(0, profileApplier.PriorityApplyCalls); + Assert.Equal(0, profileApplier.LegacyAffinityApplyCalls); + Assert.Equal(0, profileApplier.CpuSelectionApplyCalls); + } + finally + { + DeleteDirectory(profilesDirectory); + } + } + + [Fact] + public void PriorityGuardrails_HighPriorityReturnsUserFacingWarning() + { + var warning = ProcessPriorityGuardrails.GetWarning(ProcessPriorityClass.High); + + Assert.Equal(ProcessOperationUserMessages.HighPriorityWarning, warning); + } + + [Fact] + public async Task SetProcessPriority_WithRealtime_AuditsFailureAndThrowsBlockedMessage() + { + var logger = new Mock>(); + var security = new Mock(MockBehavior.Strict); + security + .Setup(s => s.AuditElevatedAction("SetProcessPriority", "game.exe", false)) + .Returns(Task.CompletedTask); + + var service = CreateService(CreateTemporaryDirectory(), logger: logger.Object, securityService: security.Object); + var process = CreateProcess(); + + try + { + var ex = await Assert.ThrowsAsync( + () => service.SetProcessPriority(process, ProcessPriorityClass.RealTime)); + + Assert.Equal(ProcessOperationUserMessages.RealtimePriorityBlocked, ex.Message); + Assert.Equal(ProcessPriorityClass.Normal, process.Priority); + security.Verify( + s => s.AuditElevatedAction("SetProcessPriority", "game.exe", false), + Times.Once); + VerifyWarningLogged(logger, ProcessOperationUserMessages.RealtimePriorityBlocked); + } + finally + { + DeleteDirectory(GetProfilesDirectory(service)); + } + } + + [Fact] + public async Task SetRegistryPriorityAsync_WithRealtime_ReturnsFalse() + { + var service = CreateService(CreateTemporaryDirectory()); + + try + { + var result = await service.SetRegistryPriorityAsync(CreateProcess(), enable: true, ProcessPriorityClass.RealTime); + + Assert.False(result); + Assert.Contains("does not bypass", ProcessOperationUserMessages.PersistentLaunchTimePriorityNotice, StringComparison.OrdinalIgnoreCase); + } + finally + { + DeleteDirectory(GetProfilesDirectory(service)); + } + } + + [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() + { + var service = CreateService(CreateTemporaryDirectory()); + + try + { + service.TrackPriorityChange(42, ProcessPriorityClass.Normal); + service.TrackPriorityChange(42, ProcessPriorityClass.High); + + var trackedPriorities = GetPrivateDictionary(service, "originalPriorities"); + Assert.True(trackedPriorities.TryGetValue(42, out var priority)); + Assert.Equal(ProcessPriorityClass.Normal, priority); + } + finally + { + DeleteDirectory(GetProfilesDirectory(service)); + } + } + + [Fact] + public void UntrackProcess_ClearsTrackedState() + { + var service = CreateService(CreateTemporaryDirectory()); + + try + { + service.TrackAppliedMask(77, "mask-a"); + service.TrackPriorityChange(77, ProcessPriorityClass.BelowNormal); + + service.UntrackProcess(77); + + var trackedMasks = GetPrivateDictionary(service, "appliedMasks"); + var trackedPriorities = GetPrivateDictionary(service, "originalPriorities"); + Assert.False(trackedMasks.ContainsKey(77)); + Assert.False(trackedPriorities.ContainsKey(77)); + } + finally + { + DeleteDirectory(GetProfilesDirectory(service)); + } + } + + private static ProcessService CreateService( + string profilesDirectory, + ICpuTopologyProvider? topologyProvider = null, + FakeLoadProcessProfileApplier? profileApplier = null, + ILogger? logger = null, + ISecurityService? securityService = null) + { + if (profileApplier == null) + { + return new(logger, securityService, () => profilesDirectory, cpuTopologyProvider: topologyProvider); + } + + return new ProcessService( + logger, + securityService, + () => profilesDirectory, + foregroundProcessService: null, + processClassifier: null, + passiveProcessErrorThrottle: null, + cpuTopologyProvider: topologyProvider, + cpuSelectionMigrationService: null, + loadProcessProfilePrioritySetter: profileApplier.SetPriorityAsync, + loadProcessProfileCpuSelectionSetter: profileApplier.SetCpuSelectionAsync, + loadProcessProfileLegacyAffinitySetter: profileApplier.SetLegacyAffinityAsync); + } + + private static ProcessModel CreateProcess() => + new() + { + ProcessId = 1234, + Name = "game.exe", + Priority = ProcessPriorityClass.Normal, + ProcessorAffinity = 0, + }; + + private static CpuTopologySnapshot CreateTopology() => + CpuTopologySnapshot.Create( + [ + new ProcessorRef(0, 0, 0), + new ProcessorRef(0, 1, 1), + ]); + + private static Task WriteProfileAsync( + string profilesDirectory, + string profileName, + ProcessProfileSnapshot profile) + { + var filePath = Path.Combine(profilesDirectory, $"{profileName}.json"); + var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); + return File.WriteAllTextAsync(filePath, json); + } + + private static string CreateTemporaryDirectory() + { + var path = Path.Combine(Path.GetTempPath(), $"threadpilot-process-service-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + + private static void DeleteDirectory(string path) + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + + private static string GetProfilesDirectory(ProcessService service) + { + var property = typeof(ProcessService).GetProperty("ProfilesDirectory", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + return (string)(property?.GetValue(service) ?? throw new InvalidOperationException("ProfilesDirectory property not found.")); + } + + private static ConcurrentDictionary GetPrivateDictionary(ProcessService service, string fieldName) + where TKey : notnull + { + var field = typeof(ProcessService).GetField(fieldName, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + return (ConcurrentDictionary)(field?.GetValue(service) ?? throw new InvalidOperationException($"Field '{fieldName}' not found.")); + } + + private static void VerifyWarningLogged(Mock> logger, string message) + { + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((state, _) => state.ToString() != null && state.ToString()!.Contains(message, StringComparison.Ordinal)), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + private sealed class FakeCpuTopologyProvider(CpuTopologySnapshot snapshot) : ICpuTopologyProvider + { + public Task GetTopologySnapshotAsync( + CancellationToken cancellationToken = default) => + Task.FromResult(snapshot); + } + + private sealed class FakeLoadProcessProfileApplier + { + private readonly AffinityApplyResult cpuSelectionResult; + + public FakeLoadProcessProfileApplier(AffinityApplyResult? cpuSelectionResult = null) + { + this.cpuSelectionResult = cpuSelectionResult ?? AffinityApplyResult.Succeeded(0, 0); + } + + public int PriorityApplyCalls { get; private set; } + + public int CpuSelectionApplyCalls { get; private set; } + + public int LegacyAffinityApplyCalls { get; private set; } + + public long LastLegacyAffinityMask { get; private set; } + + public Task SetPriorityAsync(ProcessModel process, ProcessPriorityClass priority) + { + this.PriorityApplyCalls++; + process.Priority = priority; + return Task.CompletedTask; + } + + public Task SetCpuSelectionAsync(ProcessModel process, CpuSelection selection) + { + this.CpuSelectionApplyCalls++; + return Task.FromResult(this.cpuSelectionResult); + } + + public Task SetLegacyAffinityAsync(ProcessModel process, long affinityMask) + { + this.LegacyAffinityApplyCalls++; + this.LastLegacyAffinityMask = affinityMask; + process.ProcessorAffinity = affinityMask; + return Task.CompletedTask; + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewModelAffinityTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelAffinityTests.cs index a8c4a54..1d8bb25 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewModelAffinityTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelAffinityTests.cs @@ -1,137 +1,137 @@ -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); - } - - [Fact] - public void ConstructorFallbackCoordinator_ReceivesTopologyProviderWhenProvided() - { - var processService = new Mock(MockBehavior.Loose); - var gameModeService = new Mock(MockBehavior.Loose); - var topologyProvider = new Mock(MockBehavior.Strict); - - var viewModel = CreateViewModel( - processService.Object, - gameModeService.Object, - cpuTopologyProvider: topologyProvider.Object); - - var coordinator = typeof(ProcessViewModel) - .GetField( - "processAffinityApplyCoordinator", - System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! - .GetValue(viewModel); - var provider = typeof(ProcessAffinityApplyCoordinator) - .GetField( - "cpuTopologyProvider", - System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! - .GetValue(coordinator); - - Assert.Same(topologyProvider.Object, provider); - } - - private static ProcessViewModel CreateViewModel(IProcessService processService, IGameModeService gameModeService) - => CreateViewModel(processService, gameModeService, cpuTopologyProvider: null); - - private static ProcessViewModel CreateViewModel( - IProcessService processService, - IGameModeService gameModeService, - ICpuTopologyProvider? cpuTopologyProvider) - { - 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, - cpuTopologyProvider: cpuTopologyProvider); - } - - 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" }, - ], - }; - } - } -} +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); + } + + [Fact] + public void ConstructorFallbackCoordinator_ReceivesTopologyProviderWhenProvided() + { + var processService = new Mock(MockBehavior.Loose); + var gameModeService = new Mock(MockBehavior.Loose); + var topologyProvider = new Mock(MockBehavior.Strict); + + var viewModel = CreateViewModel( + processService.Object, + gameModeService.Object, + cpuTopologyProvider: topologyProvider.Object); + + var coordinator = typeof(ProcessViewModel) + .GetField( + "processAffinityApplyCoordinator", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(viewModel); + var provider = typeof(ProcessAffinityApplyCoordinator) + .GetField( + "cpuTopologyProvider", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(coordinator); + + Assert.Same(topologyProvider.Object, provider); + } + + private static ProcessViewModel CreateViewModel(IProcessService processService, IGameModeService gameModeService) + => CreateViewModel(processService, gameModeService, cpuTopologyProvider: null); + + private static ProcessViewModel CreateViewModel( + IProcessService processService, + IGameModeService gameModeService, + ICpuTopologyProvider? cpuTopologyProvider) + { + 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, + cpuTopologyProvider: cpuTopologyProvider); + } + + 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/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs index 788e78b..29ae4e5 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs @@ -1,848 +1,848 @@ -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 enhancedLoggingService = new Mock(MockBehavior.Loose); - var audit = new ActivityAuditService(NullLogger.Instance); - var viewModel = CreateViewModel( - processService.Object, - enhancedLoggingService: enhancedLoggingService.Object, - activityAuditService: audit); - var process = CreateProcess(priority: ProcessPriorityClass.Normal); - - await viewModel.SetContextHighPriorityCommand.ExecuteAsync(process); - - processService.Verify( - service => service.SetProcessPriority(process, ProcessPriorityClass.High), - Times.Once); - enhancedLoggingService.Verify( - service => service.LogUserActionAsync( - "ProcessPriorityChanged", - It.Is(details => details.Contains("Game.exe") && details.Contains("High")), - It.Is(context => context.Contains("PID: 42"))), - Times.Once); - Assert.Equal(ProcessOperationUserMessages.HighPriorityWarning, viewModel.StatusMessage); - Assert.False(viewModel.HasError); - var entry = Assert.Single(await audit.GetEntriesAsync()); - Assert.Equal("Priority", entry.Category); - Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); - Assert.Contains("High", entry.Message); - } - - [Fact] - public async Task ApplyContextAffinityCommand_UsesProvidedRowProcess() - { - var processService = CreateProcessService(); - var coordinator = CreateAffinityCoordinator(); - var enhancedLoggingService = new Mock(MockBehavior.Loose); - var audit = new ActivityAuditService(NullLogger.Instance); - var viewModel = CreateViewModel( - processService.Object, - processAffinityApplyCoordinator: coordinator.Object, - enhancedLoggingService: enhancedLoggingService.Object, - activityAuditService: audit); - 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); - enhancedLoggingService.Verify( - service => service.LogUserActionAsync( - "ProcessAffinityApplied", - It.IsAny(), - It.Is(context => context.Contains("Process: Game.exe") && context.Contains("PID: 100"))), - Times.Once); - Assert.Same(rowProcess, viewModel.SelectedProcess); - var entry = Assert.Single(await audit.GetEntriesAsync()); - Assert.Equal("Affinity", entry.Category); - Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); - } - - [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 SetPriorityCommand_WhenRealtimeRequested_LogsVisibleBlockedEntry() - { - var processService = CreateProcessService(); - var audit = new ActivityAuditService(NullLogger.Instance); - var viewModel = CreateViewModel(processService.Object, activityAuditService: audit); - viewModel.SelectedProcess = CreateProcess(); - - await viewModel.SetPriorityCommand.ExecuteAsync(ProcessPriorityClass.RealTime); - - processService.Verify( - service => service.SetProcessPriority(It.IsAny(), ProcessPriorityClass.RealTime), - Times.Never); - var entry = Assert.Single(await audit.GetEntriesAsync()); - Assert.Equal("Priority", entry.Category); - Assert.Equal(ActivityAuditSeverity.Warning, entry.Severity); - Assert.Equal(ProcessOperationUserMessages.RealtimePriorityBlocked, entry.Message); - } - - [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 enhancedLoggingService = new Mock(MockBehavior.Loose); - var audit = new ActivityAuditService(NullLogger.Instance); - var process = CreateProcess(); - var viewModel = CreateViewModel( - CreateProcessService().Object, - memoryPriorityService: memoryPriorityService.Object, - enhancedLoggingService: enhancedLoggingService.Object, - activityAuditService: audit); - - await viewModel.SetContextMemoryPriorityLowCommand.ExecuteAsync(process); - - memoryPriorityService.Verify( - service => service.SetMemoryPriorityAsync(process, ProcessMemoryPriority.Low), - Times.Once); - var entry = Assert.Single(await audit.GetEntriesAsync()); - Assert.Equal("Memory Priority", entry.Category); - Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); - enhancedLoggingService.Verify( - service => service.LogUserActionAsync( - "ProcessMemoryPriorityChanged", - It.Is(details => details.Contains("Game.exe") && details.Contains("Low")), - It.Is(context => context.Contains("PID: 42"))), - 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 audit = new ActivityAuditService(NullLogger.Instance); - var viewModel = CreateViewModel( - CreateProcessService().Object, - memoryPriorityService: memoryPriorityService.Object, - activityAuditService: audit); - - await viewModel.SetContextMemoryPriorityNormalCommand.ExecuteAsync(process); - - Assert.Equal(ProcessOperationUserMessages.AccessDenied, viewModel.StatusMessage); - Assert.True(viewModel.HasError); - var entry = Assert.Single(await audit.GetEntriesAsync()); - Assert.Equal("Memory Priority", entry.Category); - Assert.Equal(ActivityAuditSeverity.Warning, entry.Severity); - } - - [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 audit = new ActivityAuditService(NullLogger.Instance); - var viewModel = CreateViewModel(processService.Object, activityAuditService: audit); - - await viewModel.ClearContextCpuSetsCommand.ExecuteAsync(process); - - processService.Verify(service => service.ClearProcessCpuSetAsync(process), Times.Once); - var entry = Assert.Single(await audit.GetEntriesAsync()); - Assert.Equal("Affinity", entry.Category); - Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); - } - - [Fact] - public async Task RefreshContextProcessInfoCommand_RefreshesSelectedProcessInfo() - { - var processService = CreateProcessService(); - var process = CreateProcess(); - var audit = new ActivityAuditService(NullLogger.Instance); - var viewModel = CreateViewModel(processService.Object, activityAuditService: audit); - - await viewModel.RefreshContextProcessInfoCommand.ExecuteAsync(process); - - processService.Verify(service => service.RefreshProcessInfo(process), Times.Once); - Assert.Equal("Process info refreshed for Game.exe.", viewModel.StatusMessage); - var entry = Assert.Single(await audit.GetEntriesAsync()); - Assert.Equal("Process", entry.Category); - Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); - } - - [Fact] - public async Task RefreshProcessesCommand_DoesNotCreateActivityAuditEntry() - { - var processService = CreateProcessService(); - var audit = new ActivityAuditService(NullLogger.Instance); - var viewModel = CreateViewModel(processService.Object, activityAuditService: audit); - - await viewModel.RefreshProcessesCommand.ExecuteAsync(null); - - Assert.Empty(await audit.GetEntriesAsync()); - } - - [Fact] - public async Task LockProcessList_WhenEnabled_SkipsRefreshAndKeepsSelection() - { - var processService = CreateProcessService(); - var audit = new ActivityAuditService(NullLogger.Instance); - var viewModel = CreateViewModel(processService.Object, activityAuditService: audit); - var selected = CreateProcess(processId: 42); - viewModel.Processes = new ObservableCollection { selected }; - viewModel.FilteredProcesses = new ObservableCollection { selected }; - viewModel.SelectedProcess = selected; - - viewModel.IsProcessListLocked = true; - await viewModel.RefreshProcessesCommand.ExecuteAsync(null); - - processService.Verify(service => service.GetProcessesAsync(), Times.Never); - Assert.Same(selected, viewModel.SelectedProcess); - var entry = Assert.Single(await audit.GetEntriesAsync()); - Assert.Equal("Process", entry.Category); - Assert.Equal("Lock process list enabled.", entry.Message); - } - - [Fact] - public async Task RefreshProcessesCommand_WhenProcessViewInactive_SkipsProcessRead() - { - var processService = CreateProcessService(); - var viewModel = CreateViewModel(processService.Object); - - viewModel.SetProcessViewActive(false); - await viewModel.RefreshProcessesCommand.ExecuteAsync(null); - - processService.Verify(service => service.GetProcessesAsync(), Times.Never); - processService.Verify(service => service.GetActiveApplicationsAsync(), Times.Never); - } - - [Fact] - public async Task RefreshProcessesCommand_WhenRefreshPaused_SkipsProcessRead() - { - var processService = CreateProcessService(); - var viewModel = CreateViewModel(processService.Object); - - viewModel.PauseRefresh(); - await viewModel.RefreshProcessesCommand.ExecuteAsync(null); - - processService.Verify(service => service.GetProcessesAsync(), Times.Never); - processService.Verify(service => service.GetActiveApplicationsAsync(), Times.Never); - } - - [Fact] - public async Task LoadProcessesCommand_WhenProcessViewInactive_DoesNotPreloadVirtualizedBatch() - { - var processService = CreateProcessService(); - var virtualizedProcessService = CreateVirtualizedProcessService(totalProcessCount: 100); - var viewModel = CreateViewModel( - processService.Object, - virtualizedProcessService: virtualizedProcessService.Object); - - viewModel.SetProcessViewActive(false); - await viewModel.LoadProcessesCommand.ExecuteAsync(null); - - virtualizedProcessService.Verify( - service => service.PreloadNextBatchAsync(It.IsAny(), It.IsAny()), - Times.Never); - } - - [Fact] - public async Task LoadProcessesCommand_WhenProcessListLocked_DoesNotPreloadVirtualizedBatch() - { - var processService = CreateProcessService(); - var virtualizedProcessService = CreateVirtualizedProcessService(totalProcessCount: 100); - var viewModel = CreateViewModel( - processService.Object, - virtualizedProcessService: virtualizedProcessService.Object); - - viewModel.IsProcessListLocked = true; - await viewModel.LoadProcessesCommand.ExecuteAsync(null); - - virtualizedProcessService.Verify( - service => service.PreloadNextBatchAsync(It.IsAny(), It.IsAny()), - Times.Never); - } - - [Fact] - public async Task LockProcessList_WhenDisabled_RefreshesOnceWithoutPersistentRuleSettingChange() - { - var processService = CreateProcessService(); - var audit = new ActivityAuditService(NullLogger.Instance); - var viewModel = CreateViewModel(processService.Object, activityAuditService: audit); - - viewModel.IsProcessListLocked = true; - viewModel.IsProcessListLocked = false; - - processService.Verify(service => service.GetProcessesAsync(), Times.Once); - var entries = await audit.GetEntriesAsync(); - Assert.Contains(entries, entry => entry.Message == "Lock process list enabled."); - Assert.Contains(entries, entry => entry.Message == "Lock process list disabled."); - Assert.DoesNotContain(entries, entry => entry.Message.Contains("refreshed", StringComparison.OrdinalIgnoreCase)); - Assert.DoesNotContain(entries, entry => entry.Message.Contains("Apply saved rules", StringComparison.OrdinalIgnoreCase)); - } - - [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); - } - - [Fact] - public async Task SaveCurrentSettingsAsRuleCommand_CreatesRuleForSelectedProcess() - { - var ruleStore = new CapturingRuleStore(); - var enhancedLoggingService = new Mock(MockBehavior.Loose); - var viewModel = CreateViewModel( - CreateProcessService().Object, - persistentRuleStore: ruleStore, - processRuleCreationService: CreateRuleCreationService(ruleStore), - enhancedLoggingService: enhancedLoggingService.Object); - var process = CreateProcess(); - viewModel.SelectedProcess = process; - viewModel.CpuCores = - [ - new CpuCoreModel { LogicalCoreId = 0, IsSelected = true }, - new CpuCoreModel { LogicalCoreId = 1, IsSelected = true }, - ]; - - await viewModel.SaveCurrentSettingsAsRuleCommand.ExecuteAsync(null); - - var rule = Assert.Single(ruleStore.SavedRules); - Assert.Equal(process.Name, rule.ProcessName); - Assert.Equal(process.ExecutablePath, rule.ExecutablePath); - Assert.Equal("Saved rule for Game.exe.", viewModel.StatusMessage); - enhancedLoggingService.Verify( - service => service.LogUserActionAsync( - "PersistentRuleSaved", - "Saved rule for Game.exe.", - It.Is(context => context.Contains("Process: Game.exe") && context.Contains("PID: 42"))), - Times.Once); - } - - [Fact] - public async Task SaveCurrentSettingsAsRuleCommand_UpdatesExistingMatchingRule() - { - var existing = new PersistentProcessRule - { - Id = "rule-1", - Name = "Old", - IsEnabled = true, - ProcessName = "Game.exe", - ExecutablePath = @"C:\Games\Game.exe", - CreatedAt = DateTime.UtcNow.AddDays(-1), - UpdatedAt = DateTime.UtcNow.AddDays(-1), - }; - var ruleStore = new CapturingRuleStore([existing]); - var enhancedLoggingService = new Mock(MockBehavior.Loose); - var viewModel = CreateViewModel( - CreateProcessService().Object, - persistentRuleStore: ruleStore, - processRuleCreationService: CreateRuleCreationService(ruleStore), - enhancedLoggingService: enhancedLoggingService.Object); - - await viewModel.SaveCurrentSettingsAsRuleCommand.ExecuteAsync(CreateProcess(priority: ProcessPriorityClass.High)); - - var rule = Assert.Single(ruleStore.SavedRules); - Assert.Equal("rule-1", rule.Id); - Assert.Equal(ProcessPriorityClass.High, rule.Priority); - Assert.Equal("Updated saved rule for Game.exe.", viewModel.StatusMessage); - enhancedLoggingService.Verify( - service => service.LogUserActionAsync( - "PersistentRuleSaved", - "Updated saved rule for Game.exe.", - It.Is(context => context.Contains("Process: Game.exe") && context.Contains("PID: 42"))), - Times.Once); - } - - [Fact] - public async Task SaveCurrentSettingsAsRuleCommand_WithNormalPriorityAndNoAffinityOrMemoryPriority_ShowsNoActionMessage() - { - var ruleStore = new CapturingRuleStore(); - var viewModel = CreateViewModel( - CreateProcessService().Object, - persistentRuleStore: ruleStore, - processRuleCreationService: CreateRuleCreationService(ruleStore)); - var process = CreateProcess(priority: ProcessPriorityClass.Normal, affinity: 0); - - await viewModel.SaveCurrentSettingsAsRuleCommand.ExecuteAsync(process); - - Assert.Empty(ruleStore.SavedRules); - Assert.Equal("There are no current settings to save as a rule.", viewModel.StatusMessage); - Assert.True(viewModel.HasError); - } - - [Fact] - public async Task SaveCurrentSettingsAsRuleCommand_WithAffinityAndNormalPriority_DoesNotSaveApplyPriorityOnStart() - { - var ruleStore = new CapturingRuleStore(); - var viewModel = CreateViewModel( - CreateProcessService().Object, - persistentRuleStore: ruleStore, - processRuleCreationService: CreateRuleCreationService(ruleStore)); - var process = CreateProcess(priority: ProcessPriorityClass.Normal, affinity: 0x5); - - await viewModel.SaveCurrentSettingsAsRuleCommand.ExecuteAsync(process); - - var rule = Assert.Single(ruleStore.SavedRules); - Assert.Equal(0x5, rule.LegacyAffinityMask); - Assert.Null(rule.Priority); - Assert.False(rule.ApplyPriorityOnStart); - } - - [Fact] - public async Task ApplyAffinityAndSaveAsRuleCommand_AppliesAffinityBeforeSavingRule() - { - var ruleStore = new CapturingRuleStore(); - var coordinator = CreateAffinityCoordinator(); - var viewModel = CreateViewModel( - CreateProcessService().Object, - processAffinityApplyCoordinator: coordinator.Object, - persistentRuleStore: ruleStore, - processRuleCreationService: CreateRuleCreationService(ruleStore)); - viewModel.CpuCores = - [ - new CpuCoreModel { LogicalCoreId = 0, IsSelected = true }, - new CpuCoreModel { LogicalCoreId = 1, IsSelected = false }, - ]; - var process = CreateProcess(); - - await viewModel.ApplyAffinityAndSaveAsRuleCommand.ExecuteAsync(process); - - coordinator.Verify( - service => service.ApplyCoreSelectionAsync( - process, - It.Is>(mask => mask.Count == 2 && mask[0] && !mask[1]), - "Manual Process tab context menu CPU selection", - default), - Times.Once); - var rule = Assert.Single(ruleStore.SavedRules); - Assert.Equal(1, rule.LegacyAffinityMask); - Assert.True(rule.ApplyAffinityOnStart); - } - - [Fact] - public async Task ApplyAffinityAndSaveAsRuleCommand_WhenAffinityApplyFails_DoesNotSaveRule() - { - var ruleStore = new CapturingRuleStore(); - var coordinator = new Mock(MockBehavior.Strict); - coordinator - .Setup(service => service.ApplyCoreSelectionAsync( - It.IsAny(), - It.IsAny>(), - It.IsAny(), - default)) - .ReturnsAsync(AffinityApplyResult.Failed( - AffinityApplyErrorCodes.AccessDenied, - ProcessOperationUserMessages.AccessDenied, - "Access denied.", - isAccessDenied: true)); - var audit = new ActivityAuditService(NullLogger.Instance); - var viewModel = CreateViewModel( - CreateProcessService().Object, - processAffinityApplyCoordinator: coordinator.Object, - persistentRuleStore: ruleStore, - processRuleCreationService: CreateRuleCreationService(ruleStore), - activityAuditService: audit); - viewModel.CpuCores = - [ - new CpuCoreModel { LogicalCoreId = 0, IsSelected = true }, - ]; - - await viewModel.ApplyAffinityAndSaveAsRuleCommand.ExecuteAsync(CreateProcess()); - - Assert.Empty(ruleStore.SavedRules); - Assert.Equal(ProcessOperationUserMessages.AccessDenied, viewModel.StatusMessage); - Assert.True(viewModel.HasError); - var entry = Assert.Single(await audit.GetEntriesAsync()); - Assert.Equal("Affinity", entry.Category); - Assert.Equal(ActivityAuditSeverity.Warning, entry.Severity); - } - - [Fact] - public async Task ApplyAffinityAndSaveAsRuleCommand_UsesRowProcessInsteadOfStaleSelectedProcess() - { - var ruleStore = new CapturingRuleStore(); - var coordinator = CreateAffinityCoordinator(); - var viewModel = CreateViewModel( - CreateProcessService().Object, - processAffinityApplyCoordinator: coordinator.Object, - persistentRuleStore: ruleStore, - processRuleCreationService: CreateRuleCreationService(ruleStore)); - viewModel.CpuCores = - [ - new CpuCoreModel { LogicalCoreId = 0, IsSelected = true }, - ]; - var staleSelected = CreateProcess(name: "Old.exe", path: @"C:\Old\Old.exe"); - var rowProcess = CreateProcess(name: "Row.exe", path: @"C:\Row\Row.exe"); - viewModel.SelectedProcess = staleSelected; - - await viewModel.ApplyAffinityAndSaveAsRuleCommand.ExecuteAsync(rowProcess); - - coordinator.Verify( - service => service.ApplyCoreSelectionAsync( - rowProcess, - It.IsAny>(), - It.IsAny(), - default), - Times.Once); - var rule = Assert.Single(ruleStore.SavedRules); - Assert.Equal("Row.exe", rule.ProcessName); - Assert.Equal(@"C:\Row\Row.exe", rule.ExecutablePath); - } - - [Fact] - public async Task SaveCurrentSettingsAsRuleCommand_UpdatesSelectedProcessSummary() - { - var ruleStore = new CapturingRuleStore(); - var viewModel = CreateViewModel( - CreateProcessService().Object, - persistentRuleStore: ruleStore, - processRuleCreationService: CreateRuleCreationService(ruleStore)); - var process = CreateProcess(); - - await viewModel.SaveCurrentSettingsAsRuleCommand.ExecuteAsync(process); - - Assert.True(viewModel.SelectedProcessSummary.HasThreadPilotRule); - Assert.Equal("Saved rule exists: Game.exe rule", viewModel.SelectedProcessSummary.RuleStatusText); - } - - 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 CreateVirtualizedProcessService(int totalProcessCount) - { - var virtualizedProcessService = new Mock(MockBehavior.Loose); - virtualizedProcessService.SetupProperty( - service => service.Configuration, - new VirtualizedProcessConfig()); - virtualizedProcessService - .Setup(service => service.InitializeAsync()) - .Returns(Task.CompletedTask); - virtualizedProcessService - .Setup(service => service.GetTotalProcessCountAsync(It.IsAny())) - .ReturnsAsync(totalProcessCount); - virtualizedProcessService - .Setup(service => service.LoadProcessBatchAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProcessBatchResult - { - Processes = [CreateProcess()], - BatchIndex = 0, - TotalBatches = 2, - TotalProcessCount = totalProcessCount, - HasMoreBatches = true, - }); - return virtualizedProcessService; - } - - 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, - IProcessRuleCreationService? processRuleCreationService = null, - Action? clipboardSetter = null, - Action? executableLocationOpener = null, - IEnhancedLoggingService? enhancedLoggingService = null, - IActivityAuditService? activityAuditService = null, - IVirtualizedProcessService? virtualizedProcessService = null) - { - if (virtualizedProcessService == null) - { - var virtualizedProcessServiceMock = new Mock(MockBehavior.Loose); - virtualizedProcessServiceMock.SetupProperty( - service => service.Configuration, - new VirtualizedProcessConfig()); - virtualizedProcessService = virtualizedProcessServiceMock.Object; - } - - 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, - cpuTopologyService.Object, - powerPlanService.Object, - notificationService.Object, - systemTrayService.Object, - coreMaskService.Object, - associationService.Object, - gameModeService.Object, - processAffinityApplyCoordinator: processAffinityApplyCoordinator, - enhancedLoggingService: enhancedLoggingService, - activityAuditService: activityAuditService, - memoryPriorityService: memoryPriorityService, - persistentRuleStore: persistentRuleStore, - persistentRuleMatcher: new PersistentProcessRuleMatcher(), - processRuleCreationService: processRuleCreationService, - 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, - long affinity = 0xF) - => new() - { - ProcessId = processId, - Name = name, - ExecutablePath = path, - CpuUsage = 1.5, - MemoryUsage = 128 * 1024 * 1024, - Priority = priority, - ProcessorAffinity = affinity, - Classification = ProcessClassification.ForegroundApp, - }; - - private static ProcessRuleCreationService CreateRuleCreationService(IPersistentProcessRuleStore ruleStore) => - new( - ruleStore, - topologyProvider: null, - new CpuSelectionMigrationService(), - NullLogger.Instance); - - private sealed class CapturingRuleStore(IReadOnlyList? initialRules = null) - : IPersistentProcessRuleStore - { - public IReadOnlyList SavedRules { get; private set; } = initialRules ?? []; - - public Task> LoadAsync() => - Task.FromResult(this.SavedRules); - - public Task SaveAsync(IReadOnlyList rules) - { - this.SavedRules = rules.ToList(); - return Task.CompletedTask; - } - } - } -} +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 enhancedLoggingService = new Mock(MockBehavior.Loose); + var audit = new ActivityAuditService(NullLogger.Instance); + var viewModel = CreateViewModel( + processService.Object, + enhancedLoggingService: enhancedLoggingService.Object, + activityAuditService: audit); + var process = CreateProcess(priority: ProcessPriorityClass.Normal); + + await viewModel.SetContextHighPriorityCommand.ExecuteAsync(process); + + processService.Verify( + service => service.SetProcessPriority(process, ProcessPriorityClass.High), + Times.Once); + enhancedLoggingService.Verify( + service => service.LogUserActionAsync( + "ProcessPriorityChanged", + It.Is(details => details.Contains("Game.exe") && details.Contains("High")), + It.Is(context => context.Contains("PID: 42"))), + Times.Once); + Assert.Equal(ProcessOperationUserMessages.HighPriorityWarning, viewModel.StatusMessage); + Assert.False(viewModel.HasError); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Priority", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + Assert.Contains("High", entry.Message); + } + + [Fact] + public async Task ApplyContextAffinityCommand_UsesProvidedRowProcess() + { + var processService = CreateProcessService(); + var coordinator = CreateAffinityCoordinator(); + var enhancedLoggingService = new Mock(MockBehavior.Loose); + var audit = new ActivityAuditService(NullLogger.Instance); + var viewModel = CreateViewModel( + processService.Object, + processAffinityApplyCoordinator: coordinator.Object, + enhancedLoggingService: enhancedLoggingService.Object, + activityAuditService: audit); + 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); + enhancedLoggingService.Verify( + service => service.LogUserActionAsync( + "ProcessAffinityApplied", + It.IsAny(), + It.Is(context => context.Contains("Process: Game.exe") && context.Contains("PID: 100"))), + Times.Once); + Assert.Same(rowProcess, viewModel.SelectedProcess); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Affinity", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + } + + [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 SetPriorityCommand_WhenRealtimeRequested_LogsVisibleBlockedEntry() + { + var processService = CreateProcessService(); + var audit = new ActivityAuditService(NullLogger.Instance); + var viewModel = CreateViewModel(processService.Object, activityAuditService: audit); + viewModel.SelectedProcess = CreateProcess(); + + await viewModel.SetPriorityCommand.ExecuteAsync(ProcessPriorityClass.RealTime); + + processService.Verify( + service => service.SetProcessPriority(It.IsAny(), ProcessPriorityClass.RealTime), + Times.Never); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Priority", entry.Category); + Assert.Equal(ActivityAuditSeverity.Warning, entry.Severity); + Assert.Equal(ProcessOperationUserMessages.RealtimePriorityBlocked, entry.Message); + } + + [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 enhancedLoggingService = new Mock(MockBehavior.Loose); + var audit = new ActivityAuditService(NullLogger.Instance); + var process = CreateProcess(); + var viewModel = CreateViewModel( + CreateProcessService().Object, + memoryPriorityService: memoryPriorityService.Object, + enhancedLoggingService: enhancedLoggingService.Object, + activityAuditService: audit); + + await viewModel.SetContextMemoryPriorityLowCommand.ExecuteAsync(process); + + memoryPriorityService.Verify( + service => service.SetMemoryPriorityAsync(process, ProcessMemoryPriority.Low), + Times.Once); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Memory Priority", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + enhancedLoggingService.Verify( + service => service.LogUserActionAsync( + "ProcessMemoryPriorityChanged", + It.Is(details => details.Contains("Game.exe") && details.Contains("Low")), + It.Is(context => context.Contains("PID: 42"))), + 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 audit = new ActivityAuditService(NullLogger.Instance); + var viewModel = CreateViewModel( + CreateProcessService().Object, + memoryPriorityService: memoryPriorityService.Object, + activityAuditService: audit); + + await viewModel.SetContextMemoryPriorityNormalCommand.ExecuteAsync(process); + + Assert.Equal(ProcessOperationUserMessages.AccessDenied, viewModel.StatusMessage); + Assert.True(viewModel.HasError); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Memory Priority", entry.Category); + Assert.Equal(ActivityAuditSeverity.Warning, entry.Severity); + } + + [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 audit = new ActivityAuditService(NullLogger.Instance); + var viewModel = CreateViewModel(processService.Object, activityAuditService: audit); + + await viewModel.ClearContextCpuSetsCommand.ExecuteAsync(process); + + processService.Verify(service => service.ClearProcessCpuSetAsync(process), Times.Once); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Affinity", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + } + + [Fact] + public async Task RefreshContextProcessInfoCommand_RefreshesSelectedProcessInfo() + { + var processService = CreateProcessService(); + var process = CreateProcess(); + var audit = new ActivityAuditService(NullLogger.Instance); + var viewModel = CreateViewModel(processService.Object, activityAuditService: audit); + + await viewModel.RefreshContextProcessInfoCommand.ExecuteAsync(process); + + processService.Verify(service => service.RefreshProcessInfo(process), Times.Once); + Assert.Equal("Process info refreshed for Game.exe.", viewModel.StatusMessage); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Process", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + } + + [Fact] + public async Task RefreshProcessesCommand_DoesNotCreateActivityAuditEntry() + { + var processService = CreateProcessService(); + var audit = new ActivityAuditService(NullLogger.Instance); + var viewModel = CreateViewModel(processService.Object, activityAuditService: audit); + + await viewModel.RefreshProcessesCommand.ExecuteAsync(null); + + Assert.Empty(await audit.GetEntriesAsync()); + } + + [Fact] + public async Task LockProcessList_WhenEnabled_SkipsRefreshAndKeepsSelection() + { + var processService = CreateProcessService(); + var audit = new ActivityAuditService(NullLogger.Instance); + var viewModel = CreateViewModel(processService.Object, activityAuditService: audit); + var selected = CreateProcess(processId: 42); + viewModel.Processes = new ObservableCollection { selected }; + viewModel.FilteredProcesses = new ObservableCollection { selected }; + viewModel.SelectedProcess = selected; + + viewModel.IsProcessListLocked = true; + await viewModel.RefreshProcessesCommand.ExecuteAsync(null); + + processService.Verify(service => service.GetProcessesAsync(), Times.Never); + Assert.Same(selected, viewModel.SelectedProcess); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Process", entry.Category); + Assert.Equal("Lock process list enabled.", entry.Message); + } + + [Fact] + public async Task RefreshProcessesCommand_WhenProcessViewInactive_SkipsProcessRead() + { + var processService = CreateProcessService(); + var viewModel = CreateViewModel(processService.Object); + + viewModel.SetProcessViewActive(false); + await viewModel.RefreshProcessesCommand.ExecuteAsync(null); + + processService.Verify(service => service.GetProcessesAsync(), Times.Never); + processService.Verify(service => service.GetActiveApplicationsAsync(), Times.Never); + } + + [Fact] + public async Task RefreshProcessesCommand_WhenRefreshPaused_SkipsProcessRead() + { + var processService = CreateProcessService(); + var viewModel = CreateViewModel(processService.Object); + + viewModel.PauseRefresh(); + await viewModel.RefreshProcessesCommand.ExecuteAsync(null); + + processService.Verify(service => service.GetProcessesAsync(), Times.Never); + processService.Verify(service => service.GetActiveApplicationsAsync(), Times.Never); + } + + [Fact] + public async Task LoadProcessesCommand_WhenProcessViewInactive_DoesNotPreloadVirtualizedBatch() + { + var processService = CreateProcessService(); + var virtualizedProcessService = CreateVirtualizedProcessService(totalProcessCount: 100); + var viewModel = CreateViewModel( + processService.Object, + virtualizedProcessService: virtualizedProcessService.Object); + + viewModel.SetProcessViewActive(false); + await viewModel.LoadProcessesCommand.ExecuteAsync(null); + + virtualizedProcessService.Verify( + service => service.PreloadNextBatchAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task LoadProcessesCommand_WhenProcessListLocked_DoesNotPreloadVirtualizedBatch() + { + var processService = CreateProcessService(); + var virtualizedProcessService = CreateVirtualizedProcessService(totalProcessCount: 100); + var viewModel = CreateViewModel( + processService.Object, + virtualizedProcessService: virtualizedProcessService.Object); + + viewModel.IsProcessListLocked = true; + await viewModel.LoadProcessesCommand.ExecuteAsync(null); + + virtualizedProcessService.Verify( + service => service.PreloadNextBatchAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task LockProcessList_WhenDisabled_RefreshesOnceWithoutPersistentRuleSettingChange() + { + var processService = CreateProcessService(); + var audit = new ActivityAuditService(NullLogger.Instance); + var viewModel = CreateViewModel(processService.Object, activityAuditService: audit); + + viewModel.IsProcessListLocked = true; + viewModel.IsProcessListLocked = false; + + processService.Verify(service => service.GetProcessesAsync(), Times.Once); + var entries = await audit.GetEntriesAsync(); + Assert.Contains(entries, entry => entry.Message == "Lock process list enabled."); + Assert.Contains(entries, entry => entry.Message == "Lock process list disabled."); + Assert.DoesNotContain(entries, entry => entry.Message.Contains("refreshed", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(entries, entry => entry.Message.Contains("Apply saved rules", StringComparison.OrdinalIgnoreCase)); + } + + [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); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleCommand_CreatesRuleForSelectedProcess() + { + var ruleStore = new CapturingRuleStore(); + var enhancedLoggingService = new Mock(MockBehavior.Loose); + var viewModel = CreateViewModel( + CreateProcessService().Object, + persistentRuleStore: ruleStore, + processRuleCreationService: CreateRuleCreationService(ruleStore), + enhancedLoggingService: enhancedLoggingService.Object); + var process = CreateProcess(); + viewModel.SelectedProcess = process; + viewModel.CpuCores = + [ + new CpuCoreModel { LogicalCoreId = 0, IsSelected = true }, + new CpuCoreModel { LogicalCoreId = 1, IsSelected = true }, + ]; + + await viewModel.SaveCurrentSettingsAsRuleCommand.ExecuteAsync(null); + + var rule = Assert.Single(ruleStore.SavedRules); + Assert.Equal(process.Name, rule.ProcessName); + Assert.Equal(process.ExecutablePath, rule.ExecutablePath); + Assert.Equal("Saved rule for Game.exe.", viewModel.StatusMessage); + enhancedLoggingService.Verify( + service => service.LogUserActionAsync( + "PersistentRuleSaved", + "Saved rule for Game.exe.", + It.Is(context => context.Contains("Process: Game.exe") && context.Contains("PID: 42"))), + Times.Once); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleCommand_UpdatesExistingMatchingRule() + { + var existing = new PersistentProcessRule + { + Id = "rule-1", + Name = "Old", + IsEnabled = true, + ProcessName = "Game.exe", + ExecutablePath = @"C:\Games\Game.exe", + CreatedAt = DateTime.UtcNow.AddDays(-1), + UpdatedAt = DateTime.UtcNow.AddDays(-1), + }; + var ruleStore = new CapturingRuleStore([existing]); + var enhancedLoggingService = new Mock(MockBehavior.Loose); + var viewModel = CreateViewModel( + CreateProcessService().Object, + persistentRuleStore: ruleStore, + processRuleCreationService: CreateRuleCreationService(ruleStore), + enhancedLoggingService: enhancedLoggingService.Object); + + await viewModel.SaveCurrentSettingsAsRuleCommand.ExecuteAsync(CreateProcess(priority: ProcessPriorityClass.High)); + + var rule = Assert.Single(ruleStore.SavedRules); + Assert.Equal("rule-1", rule.Id); + Assert.Equal(ProcessPriorityClass.High, rule.Priority); + Assert.Equal("Updated saved rule for Game.exe.", viewModel.StatusMessage); + enhancedLoggingService.Verify( + service => service.LogUserActionAsync( + "PersistentRuleSaved", + "Updated saved rule for Game.exe.", + It.Is(context => context.Contains("Process: Game.exe") && context.Contains("PID: 42"))), + Times.Once); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleCommand_WithNormalPriorityAndNoAffinityOrMemoryPriority_ShowsNoActionMessage() + { + var ruleStore = new CapturingRuleStore(); + var viewModel = CreateViewModel( + CreateProcessService().Object, + persistentRuleStore: ruleStore, + processRuleCreationService: CreateRuleCreationService(ruleStore)); + var process = CreateProcess(priority: ProcessPriorityClass.Normal, affinity: 0); + + await viewModel.SaveCurrentSettingsAsRuleCommand.ExecuteAsync(process); + + Assert.Empty(ruleStore.SavedRules); + Assert.Equal("There are no current settings to save as a rule.", viewModel.StatusMessage); + Assert.True(viewModel.HasError); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleCommand_WithAffinityAndNormalPriority_DoesNotSaveApplyPriorityOnStart() + { + var ruleStore = new CapturingRuleStore(); + var viewModel = CreateViewModel( + CreateProcessService().Object, + persistentRuleStore: ruleStore, + processRuleCreationService: CreateRuleCreationService(ruleStore)); + var process = CreateProcess(priority: ProcessPriorityClass.Normal, affinity: 0x5); + + await viewModel.SaveCurrentSettingsAsRuleCommand.ExecuteAsync(process); + + var rule = Assert.Single(ruleStore.SavedRules); + Assert.Equal(0x5, rule.LegacyAffinityMask); + Assert.Null(rule.Priority); + Assert.False(rule.ApplyPriorityOnStart); + } + + [Fact] + public async Task ApplyAffinityAndSaveAsRuleCommand_AppliesAffinityBeforeSavingRule() + { + var ruleStore = new CapturingRuleStore(); + var coordinator = CreateAffinityCoordinator(); + var viewModel = CreateViewModel( + CreateProcessService().Object, + processAffinityApplyCoordinator: coordinator.Object, + persistentRuleStore: ruleStore, + processRuleCreationService: CreateRuleCreationService(ruleStore)); + viewModel.CpuCores = + [ + new CpuCoreModel { LogicalCoreId = 0, IsSelected = true }, + new CpuCoreModel { LogicalCoreId = 1, IsSelected = false }, + ]; + var process = CreateProcess(); + + await viewModel.ApplyAffinityAndSaveAsRuleCommand.ExecuteAsync(process); + + coordinator.Verify( + service => service.ApplyCoreSelectionAsync( + process, + It.Is>(mask => mask.Count == 2 && mask[0] && !mask[1]), + "Manual Process tab context menu CPU selection", + default), + Times.Once); + var rule = Assert.Single(ruleStore.SavedRules); + Assert.Equal(1, rule.LegacyAffinityMask); + Assert.True(rule.ApplyAffinityOnStart); + } + + [Fact] + public async Task ApplyAffinityAndSaveAsRuleCommand_WhenAffinityApplyFails_DoesNotSaveRule() + { + var ruleStore = new CapturingRuleStore(); + var coordinator = new Mock(MockBehavior.Strict); + coordinator + .Setup(service => service.ApplyCoreSelectionAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + default)) + .ReturnsAsync(AffinityApplyResult.Failed( + AffinityApplyErrorCodes.AccessDenied, + ProcessOperationUserMessages.AccessDenied, + "Access denied.", + isAccessDenied: true)); + var audit = new ActivityAuditService(NullLogger.Instance); + var viewModel = CreateViewModel( + CreateProcessService().Object, + processAffinityApplyCoordinator: coordinator.Object, + persistentRuleStore: ruleStore, + processRuleCreationService: CreateRuleCreationService(ruleStore), + activityAuditService: audit); + viewModel.CpuCores = + [ + new CpuCoreModel { LogicalCoreId = 0, IsSelected = true }, + ]; + + await viewModel.ApplyAffinityAndSaveAsRuleCommand.ExecuteAsync(CreateProcess()); + + Assert.Empty(ruleStore.SavedRules); + Assert.Equal(ProcessOperationUserMessages.AccessDenied, viewModel.StatusMessage); + Assert.True(viewModel.HasError); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Affinity", entry.Category); + Assert.Equal(ActivityAuditSeverity.Warning, entry.Severity); + } + + [Fact] + public async Task ApplyAffinityAndSaveAsRuleCommand_UsesRowProcessInsteadOfStaleSelectedProcess() + { + var ruleStore = new CapturingRuleStore(); + var coordinator = CreateAffinityCoordinator(); + var viewModel = CreateViewModel( + CreateProcessService().Object, + processAffinityApplyCoordinator: coordinator.Object, + persistentRuleStore: ruleStore, + processRuleCreationService: CreateRuleCreationService(ruleStore)); + viewModel.CpuCores = + [ + new CpuCoreModel { LogicalCoreId = 0, IsSelected = true }, + ]; + var staleSelected = CreateProcess(name: "Old.exe", path: @"C:\Old\Old.exe"); + var rowProcess = CreateProcess(name: "Row.exe", path: @"C:\Row\Row.exe"); + viewModel.SelectedProcess = staleSelected; + + await viewModel.ApplyAffinityAndSaveAsRuleCommand.ExecuteAsync(rowProcess); + + coordinator.Verify( + service => service.ApplyCoreSelectionAsync( + rowProcess, + It.IsAny>(), + It.IsAny(), + default), + Times.Once); + var rule = Assert.Single(ruleStore.SavedRules); + Assert.Equal("Row.exe", rule.ProcessName); + Assert.Equal(@"C:\Row\Row.exe", rule.ExecutablePath); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleCommand_UpdatesSelectedProcessSummary() + { + var ruleStore = new CapturingRuleStore(); + var viewModel = CreateViewModel( + CreateProcessService().Object, + persistentRuleStore: ruleStore, + processRuleCreationService: CreateRuleCreationService(ruleStore)); + var process = CreateProcess(); + + await viewModel.SaveCurrentSettingsAsRuleCommand.ExecuteAsync(process); + + Assert.True(viewModel.SelectedProcessSummary.HasThreadPilotRule); + Assert.Equal("Saved rule exists: Game.exe rule", viewModel.SelectedProcessSummary.RuleStatusText); + } + + 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 CreateVirtualizedProcessService(int totalProcessCount) + { + var virtualizedProcessService = new Mock(MockBehavior.Loose); + virtualizedProcessService.SetupProperty( + service => service.Configuration, + new VirtualizedProcessConfig()); + virtualizedProcessService + .Setup(service => service.InitializeAsync()) + .Returns(Task.CompletedTask); + virtualizedProcessService + .Setup(service => service.GetTotalProcessCountAsync(It.IsAny())) + .ReturnsAsync(totalProcessCount); + virtualizedProcessService + .Setup(service => service.LoadProcessBatchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProcessBatchResult + { + Processes = [CreateProcess()], + BatchIndex = 0, + TotalBatches = 2, + TotalProcessCount = totalProcessCount, + HasMoreBatches = true, + }); + return virtualizedProcessService; + } + + 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, + IProcessRuleCreationService? processRuleCreationService = null, + Action? clipboardSetter = null, + Action? executableLocationOpener = null, + IEnhancedLoggingService? enhancedLoggingService = null, + IActivityAuditService? activityAuditService = null, + IVirtualizedProcessService? virtualizedProcessService = null) + { + if (virtualizedProcessService == null) + { + var virtualizedProcessServiceMock = new Mock(MockBehavior.Loose); + virtualizedProcessServiceMock.SetupProperty( + service => service.Configuration, + new VirtualizedProcessConfig()); + virtualizedProcessService = virtualizedProcessServiceMock.Object; + } + + 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, + cpuTopologyService.Object, + powerPlanService.Object, + notificationService.Object, + systemTrayService.Object, + coreMaskService.Object, + associationService.Object, + gameModeService.Object, + processAffinityApplyCoordinator: processAffinityApplyCoordinator, + enhancedLoggingService: enhancedLoggingService, + activityAuditService: activityAuditService, + memoryPriorityService: memoryPriorityService, + persistentRuleStore: persistentRuleStore, + persistentRuleMatcher: new PersistentProcessRuleMatcher(), + processRuleCreationService: processRuleCreationService, + 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, + long affinity = 0xF) + => new() + { + ProcessId = processId, + Name = name, + ExecutablePath = path, + CpuUsage = 1.5, + MemoryUsage = 128 * 1024 * 1024, + Priority = priority, + ProcessorAffinity = affinity, + Classification = ProcessClassification.ForegroundApp, + }; + + private static ProcessRuleCreationService CreateRuleCreationService(IPersistentProcessRuleStore ruleStore) => + new( + ruleStore, + topologyProvider: null, + new CpuSelectionMigrationService(), + NullLogger.Instance); + + private sealed class CapturingRuleStore(IReadOnlyList? initialRules = null) + : IPersistentProcessRuleStore + { + public IReadOnlyList SavedRules { get; private set; } = initialRules ?? []; + + public Task> LoadAsync() => + Task.FromResult(this.SavedRules); + + public Task SaveAsync(IReadOnlyList rules) + { + this.SavedRules = rules.ToList(); + return Task.CompletedTask; + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs index 9348f80..39861df 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs @@ -1,289 +1,289 @@ -namespace ThreadPilot.Core.Tests -{ - using System.Xml.Linq; - - public sealed class ProcessViewXamlBindingTests - { - private static readonly string ProcessViewPath = Path.Combine( - AppContext.BaseDirectory, - "..", - "..", - "..", - "..", - "..", - "Views", - "ProcessView.xaml"); - - [Fact] - public void LastOperationMessageBinding_IsDisplayOnly() - { - var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); - var lastOperationBindings = document - .Descendants() - .SelectMany(element => element.Attributes().Select(attribute => new - { - Element = element.Name.LocalName, - Attribute = attribute.Name.LocalName, - Value = attribute.Value, - })) - .Where(attribute => attribute.Value.Contains("SelectedProcessSummary.LastOperationMessage", StringComparison.Ordinal)) - .ToList(); - - var binding = Assert.Single(lastOperationBindings); - Assert.Equal("Text", binding.Attribute); - Assert.Contains("Mode=OneWay", binding.Value, StringComparison.Ordinal); - } - - [Fact] - public void SelectedProcessSummaryBindings_AreNotUsedByEditableControls() - { - var editableControls = new HashSet(StringComparer.Ordinal) - { - "CheckBox", - "ComboBox", - "DatePicker", - "PasswordBox", - "Slider", - "TextBox", - "ToggleButton", - }; - var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); - - var editableSummaryBindings = document - .Descendants() - .Where(element => editableControls.Contains(element.Name.LocalName)) - .SelectMany(element => element.Attributes().Select(attribute => new - { - Element = element.Name.LocalName, - Attribute = attribute.Name.LocalName, - Value = attribute.Value, - })) - .Where(attribute => attribute.Value.Contains("SelectedProcessSummary.", StringComparison.Ordinal)) - .ToList(); - - Assert.Empty(editableSummaryBindings); - } - - [Fact] - public void ProcessGridRowStyle_HighlightsSelectedRowsWithAccentTheme() - { - var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); - var serialized = document.ToString(SaveOptions.DisableFormatting); - - Assert.Contains("IsSelected", serialized, StringComparison.Ordinal); - Assert.Contains("Accent", serialized, StringComparison.Ordinal); - Assert.Contains("BorderThickness", serialized, StringComparison.Ordinal); - } - - [Fact] - public void ProcessGridContextMenu_MenuItemsUseStableDetachedMenuStyle() - { - var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); - var serialized = document.ToString(SaveOptions.DisableFormatting); - - Assert.Contains("()", updateCheckSection, StringComparison.Ordinal); - Assert.Contains("CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup))", updateCheckSection, StringComparison.Ordinal); - Assert.Contains("Startup update check ignored failure", updateCheckSection, StringComparison.Ordinal); - Assert.DoesNotContain("System.Windows.MessageBox.Show", updateCheckSection, StringComparison.Ordinal); - } - - [Fact] - public void LegacyActionSidePanel_IsNotPersistentPrimaryUi() - { - var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); - var serialized = document.ToString(SaveOptions.DisableFormatting); - - Assert.Contains("Grid.Column=\"2\" Visibility=\"Collapsed\"", serialized, StringComparison.Ordinal); - Assert.Contains("ProcessView_AdvancedAffinityPicker", serialized, StringComparison.Ordinal); - } - - private static string GetRepositoryRoot() - { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory != null && !File.Exists(Path.Combine(directory.FullName, "ThreadPilot.csproj"))) - { - directory = directory.Parent; - } - - if (directory == null) - { - throw new InvalidOperationException("Repository root was not found."); - } - - return directory.FullName; - } - } -} +namespace ThreadPilot.Core.Tests +{ + using System.Xml.Linq; + + public sealed class ProcessViewXamlBindingTests + { + private static readonly string ProcessViewPath = Path.Combine( + AppContext.BaseDirectory, + "..", + "..", + "..", + "..", + "..", + "Views", + "ProcessView.xaml"); + + [Fact] + public void LastOperationMessageBinding_IsDisplayOnly() + { + var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); + var lastOperationBindings = document + .Descendants() + .SelectMany(element => element.Attributes().Select(attribute => new + { + Element = element.Name.LocalName, + Attribute = attribute.Name.LocalName, + Value = attribute.Value, + })) + .Where(attribute => attribute.Value.Contains("SelectedProcessSummary.LastOperationMessage", StringComparison.Ordinal)) + .ToList(); + + var binding = Assert.Single(lastOperationBindings); + Assert.Equal("Text", binding.Attribute); + Assert.Contains("Mode=OneWay", binding.Value, StringComparison.Ordinal); + } + + [Fact] + public void SelectedProcessSummaryBindings_AreNotUsedByEditableControls() + { + var editableControls = new HashSet(StringComparer.Ordinal) + { + "CheckBox", + "ComboBox", + "DatePicker", + "PasswordBox", + "Slider", + "TextBox", + "ToggleButton", + }; + var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); + + var editableSummaryBindings = document + .Descendants() + .Where(element => editableControls.Contains(element.Name.LocalName)) + .SelectMany(element => element.Attributes().Select(attribute => new + { + Element = element.Name.LocalName, + Attribute = attribute.Name.LocalName, + Value = attribute.Value, + })) + .Where(attribute => attribute.Value.Contains("SelectedProcessSummary.", StringComparison.Ordinal)) + .ToList(); + + Assert.Empty(editableSummaryBindings); + } + + [Fact] + public void ProcessGridRowStyle_HighlightsSelectedRowsWithAccentTheme() + { + var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); + var serialized = document.ToString(SaveOptions.DisableFormatting); + + Assert.Contains("IsSelected", serialized, StringComparison.Ordinal); + Assert.Contains("Accent", serialized, StringComparison.Ordinal); + Assert.Contains("BorderThickness", serialized, StringComparison.Ordinal); + } + + [Fact] + public void ProcessGridContextMenu_MenuItemsUseStableDetachedMenuStyle() + { + var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); + var serialized = document.ToString(SaveOptions.DisableFormatting); + + Assert.Contains("()", updateCheckSection, StringComparison.Ordinal); + Assert.Contains("CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup))", updateCheckSection, StringComparison.Ordinal); + Assert.Contains("Startup update check ignored failure", updateCheckSection, StringComparison.Ordinal); + Assert.DoesNotContain("System.Windows.MessageBox.Show", updateCheckSection, StringComparison.Ordinal); + } + + [Fact] + public void LegacyActionSidePanel_IsNotPersistentPrimaryUi() + { + var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); + var serialized = document.ToString(SaveOptions.DisableFormatting); + + Assert.Contains("Grid.Column=\"2\" Visibility=\"Collapsed\"", serialized, StringComparison.Ordinal); + Assert.Contains("ProcessView_AdvancedAffinityPicker", serialized, StringComparison.Ordinal); + } + + private static string GetRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "ThreadPilot.csproj"))) + { + directory = directory.Parent; + } + + if (directory == null) + { + throw new InvalidOperationException("Repository root was not found."); + } + + return directory.FullName; + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/RetryPolicyServiceTests.cs b/Tests/ThreadPilot.Core.Tests/RetryPolicyServiceTests.cs deleted file mode 100644 index 0b629a6..0000000 --- a/Tests/ThreadPilot.Core.Tests/RetryPolicyServiceTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -/* - * ThreadPilot - retry policy service unit tests. - */ -namespace ThreadPilot.Core.Tests -{ - using Microsoft.Extensions.Logging.Abstractions; - using ThreadPilot.Services; - - /// - /// Unit tests for behavior. - /// - public sealed class RetryPolicyServiceTests - { - /// - /// Ensures the retry loop retries transient failures and eventually returns success. - /// - [Fact] - public async Task ExecuteAsync_RetriesTransientErrors_ThenSucceeds() - { - var service = new RetryPolicyService(NullLogger.Instance); - var attempts = 0; - - var policy = new RetryPolicy - { - MaxAttempts = 3, - InitialDelay = TimeSpan.Zero, - MaxDelay = TimeSpan.Zero, - BackoffMultiplier = 1, - ShouldRetry = _ => true, - }; - - var result = await service.ExecuteAsync( - () => - { - attempts++; - if (attempts < 3) - { - return Task.FromException(new InvalidOperationException("transient")); - } - - return Task.FromResult("ok"); - }, - policy); - - Assert.Equal("ok", result); - Assert.Equal(3, attempts); - } - - /// - /// Ensures non-retriable failures are surfaced immediately. - /// - [Fact] - public async Task ExecuteAsync_DoesNotRetry_WhenPredicateRejectsException() - { - var service = new RetryPolicyService(NullLogger.Instance); - var attempts = 0; - - var policy = new RetryPolicy - { - MaxAttempts = 5, - InitialDelay = TimeSpan.Zero, - MaxDelay = TimeSpan.Zero, - BackoffMultiplier = 1, - ShouldRetry = _ => false, - }; - - await Assert.ThrowsAsync(async () => - { - await service.ExecuteAsync( - () => - { - attempts++; - return Task.FromException(new UnauthorizedAccessException("denied")); - }, - policy); - }); - - Assert.Equal(1, attempts); - } - } -} diff --git a/Tests/ThreadPilot.Core.Tests/SecurityServiceTests.cs b/Tests/ThreadPilot.Core.Tests/SecurityServiceTests.cs index e1f1d4a..1b38f61 100644 --- a/Tests/ThreadPilot.Core.Tests/SecurityServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SecurityServiceTests.cs @@ -1,77 +1,62 @@ -/* - * ThreadPilot - security service unit tests. - */ -namespace ThreadPilot.Core.Tests -{ - using Microsoft.Extensions.Logging.Abstractions; - using Moq; - using ThreadPilot.Services; - - /// - /// Unit tests for validation behavior. - /// - public sealed class SecurityServiceTests - { - /// - /// Ensures protected processes cannot be modified. - /// - [Theory] - [InlineData("lsass")] - [InlineData("lsass.exe")] - [InlineData("csrss")] - [InlineData("wininit.exe")] - public void ValidateProcessOperation_ReturnsFalse_ForProtectedProcesses(string processName) - { - var service = CreateService(); - - var allowed = service.ValidateProcessOperation(processName, "SetProcessPriority"); - - Assert.False(allowed); - } - - /// - /// Ensures known-safe process operations remain allowed. - /// - [Fact] - public void ValidateProcessOperation_ReturnsTrue_ForAllowedOperationOnRegularProcess() - { - var service = CreateService(); - - var allowed = service.ValidateProcessOperation("notepad", "SetProcessAffinity"); - - Assert.True(allowed); - } - - /// - /// Ensures invalid process operations are rejected. - /// - [Fact] - public void ValidateProcessOperation_ReturnsFalse_ForInvalidOperation() - { - var service = CreateService(); - - var allowed = service.ValidateProcessOperation("notepad", "TerminateProcess"); - - Assert.False(allowed); - } - - /// - /// Ensures elevated operation validation tolerates log-control characters. - /// - [Fact] - public void ValidateElevatedOperation_ReturnsTrue_ForKnownOperation_WithControlCharacters() - { - var service = CreateService(); - - var allowed = service.ValidateElevatedOperation("SetProcessPriority\r\n"); - - Assert.True(allowed); - } - - private static SecurityService CreateService() - { - var enhancedLogger = new Mock(MockBehavior.Loose); - return new SecurityService(NullLogger.Instance, enhancedLogger.Object); - } - } -} +/* + * ThreadPilot - security service unit tests. + */ +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Services; + + public sealed class SecurityServiceTests + { + [Theory] + [InlineData("lsass")] + [InlineData("lsass.exe")] + [InlineData("csrss")] + [InlineData("wininit.exe")] + public void ValidateProcessOperation_ReturnsFalse_ForProtectedProcesses(string processName) + { + var service = CreateService(); + + var allowed = service.ValidateProcessOperation(processName, "SetProcessPriority"); + + Assert.False(allowed); + } + + [Fact] + public void ValidateProcessOperation_ReturnsTrue_ForAllowedOperationOnRegularProcess() + { + var service = CreateService(); + + var allowed = service.ValidateProcessOperation("notepad", "SetProcessAffinity"); + + Assert.True(allowed); + } + + [Fact] + public void ValidateProcessOperation_ReturnsFalse_ForInvalidOperation() + { + var service = CreateService(); + + var allowed = service.ValidateProcessOperation("notepad", "TerminateProcess"); + + Assert.False(allowed); + } + + [Fact] + public void ValidateElevatedOperation_ReturnsTrue_ForKnownOperation_WithControlCharacters() + { + var service = CreateService(); + + var allowed = service.ValidateElevatedOperation("SetProcessPriority\r\n"); + + Assert.True(allowed); + } + + private static SecurityService CreateService() + { + var enhancedLogger = new Mock(MockBehavior.Loose); + return new SecurityService(NullLogger.Instance, enhancedLogger.Object); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/SelectedProcessSummaryViewModelTests.cs b/Tests/ThreadPilot.Core.Tests/SelectedProcessSummaryViewModelTests.cs index 88d1203..7aca5a5 100644 --- a/Tests/ThreadPilot.Core.Tests/SelectedProcessSummaryViewModelTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SelectedProcessSummaryViewModelTests.cs @@ -1,348 +1,348 @@ -namespace ThreadPilot.Core.Tests -{ - using System.Diagnostics; - using System.Reflection; - using Moq; - using ThreadPilot.Models; - using ThreadPilot.Services; - using ThreadPilot.ViewModels; - - public sealed class SelectedProcessSummaryViewModelTests - { - [Fact] - public async Task UpdateAsync_WithNoSelectedProcess_ClearsSummary() - { - var viewModel = new SelectedProcessSummaryViewModel(); - - await viewModel.UpdateAsync(null); - - Assert.False(viewModel.HasSelection); - Assert.Equal("No process selected", viewModel.CurrentProcessStatusText); - Assert.Equal("Memory priority unavailable", viewModel.MemoryPriorityText); - Assert.Equal("No saved rule", viewModel.RuleStatusText); - } - - [Fact] - public async Task UpdateAsync_WithSelectedProcess_PopulatesCheapProcessFields() - { - var viewModel = new SelectedProcessSummaryViewModel(); - - await viewModel.UpdateAsync(CreateProcess("Game.exe", 1234, ProcessPriorityClass.High, 0x3, 512 * 1024 * 1024)); - - Assert.True(viewModel.HasSelection); - Assert.Equal(1234, viewModel.ProcessId); - Assert.Equal("Game.exe", viewModel.ProcessName); - Assert.Equal(@"C:\Games\Game.exe", viewModel.ExecutablePath); - Assert.Equal("Selected process: Game.exe (PID 1234)", viewModel.ProcessTitle); - Assert.Equal("CPU priority: High", viewModel.CpuPriorityText); - Assert.Equal("Memory: 512 MB", viewModel.MemoryUsageText); - Assert.Equal("Affinity: legacy mask 0x3", viewModel.AffinityText); - } - - [Fact] - public async Task UpdateAsync_WhenSelectionChanges_ReplacesSummary() - { - var viewModel = new SelectedProcessSummaryViewModel(); - - await viewModel.UpdateAsync(CreateProcess("First.exe", 1, ProcessPriorityClass.Normal, 0x1, 1)); - await viewModel.UpdateAsync(CreateProcess("Second.exe", 2, ProcessPriorityClass.BelowNormal, 0x2, 2)); - - Assert.Equal(2, viewModel.ProcessId); - Assert.Equal("Second.exe", viewModel.ProcessName); - Assert.Equal("CPU priority: BelowNormal", viewModel.CpuPriorityText); - Assert.Equal("Affinity: legacy mask 0x2", viewModel.AffinityText); - } - - [Fact] - public async Task UpdateAsync_WhenMemoryPriorityReadSucceeds_PopulatesMemoryPriority() - { - var memoryPriority = new Mock(MockBehavior.Strict); - memoryPriority - .Setup(service => service.GetMemoryPriorityAsync(It.IsAny())) - .ReturnsAsync(ProcessMemoryPriority.BelowNormal); - var viewModel = new SelectedProcessSummaryViewModel(memoryPriority.Object); - - await viewModel.UpdateAsync(CreateProcess()); - - Assert.Equal(ProcessMemoryPriority.BelowNormal, viewModel.MemoryPriority); - Assert.Equal("Memory priority: BelowNormal", viewModel.MemoryPriorityText); - } - - [Fact] - public async Task UpdateAsync_WhenMemoryPriorityUnavailable_ShowsUnavailableWithoutThrowing() - { - var memoryPriority = new Mock(MockBehavior.Strict); - memoryPriority - .Setup(service => service.GetMemoryPriorityAsync(It.IsAny())) - .ThrowsAsync(new UnauthorizedAccessException("Access denied")); - var viewModel = new SelectedProcessSummaryViewModel(memoryPriority.Object); - - await viewModel.UpdateAsync(CreateProcess()); - - Assert.Null(viewModel.MemoryPriority); - Assert.Equal("Memory priority unavailable", viewModel.MemoryPriorityText); - } - - [Fact] - public async Task UpdateAsync_WhenSelectionChangesBeforeSlowMemoryPriorityCompletes_KeepsLatestSelection() - { - var memoryPriority = new ControlledMemoryPriorityService(); - var viewModel = new SelectedProcessSummaryViewModel(memoryPriority); - var oldProcess = CreateProcess("Old.exe", 100, ProcessPriorityClass.Normal, 0x1, 10); - var latestProcess = CreateProcess("Latest.exe", 200, ProcessPriorityClass.High, 0x2, 20); - - var oldUpdate = viewModel.UpdateAsync(oldProcess); - await memoryPriority.WaitForReadAsync(oldProcess.ProcessId); - - memoryPriority.SetImmediatePriority(latestProcess.ProcessId, ProcessMemoryPriority.Normal); - await viewModel.UpdateAsync(latestProcess); - - memoryPriority.CompleteRead(oldProcess.ProcessId, ProcessMemoryPriority.VeryLow); - await oldUpdate; - - Assert.Equal(latestProcess.ProcessId, viewModel.ProcessId); - Assert.Equal(latestProcess.Name, viewModel.ProcessName); - Assert.Equal(ProcessMemoryPriority.Normal, viewModel.MemoryPriority); - Assert.Equal("Memory priority: Normal", viewModel.MemoryPriorityText); - } - - [Fact] - public async Task UpdateAsync_WhenSlowRuleLookupCompletesAfterSelectionChange_KeepsLatestRuleStatus() - { - var store = new ControlledPersistentProcessRuleStore(); - var viewModel = new SelectedProcessSummaryViewModel( - persistentRuleStore: store, - persistentRuleMatcher: new PersistentProcessRuleMatcher()); - var oldProcess = CreateProcess("Old.exe", 100); - var latestProcess = CreateProcess("Latest.exe", 200); - - var oldUpdate = viewModel.UpdateAsync(oldProcess); - await store.WaitForLoadAsync(1); - - store.EnqueueImmediateRules(new[] - { - new PersistentProcessRule - { - Name = "Latest rule", - ProcessName = latestProcess.Name, - IsEnabled = true, - }, - }); - await viewModel.UpdateAsync(latestProcess); - - store.CompleteLoad( - 1, - new[] - { - new PersistentProcessRule - { - Name = "Old rule", - ProcessName = oldProcess.Name, - IsEnabled = true, - }, - }); - await oldUpdate; - - Assert.Equal(latestProcess.ProcessId, viewModel.ProcessId); - Assert.Equal(latestProcess.Name, viewModel.ProcessName); - Assert.True(viewModel.HasThreadPilotRule); - Assert.Equal("Saved rule exists: Latest rule", viewModel.RuleStatusText); - } - - [Fact] - public void SelectedProcessSummary_HasNoPerformanceMonitoringDependency() - { - var type = typeof(SelectedProcessSummaryViewModel); - - var constructorParameters = type - .GetConstructors() - .SelectMany(ctor => ctor.GetParameters()) - .Select(parameter => parameter.ParameterType); - var fieldTypes = type - .GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) - .Select(field => field.FieldType); - - Assert.DoesNotContain(typeof(IPerformanceMonitoringService), constructorParameters); - Assert.DoesNotContain(typeof(IPerformanceMonitoringService), fieldTypes); - } - - [Fact] - public void SelectedProcessSummary_DoesNotOwnTimers() - { - var fieldTypes = typeof(SelectedProcessSummaryViewModel) - .GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) - .Select(field => field.FieldType); - - Assert.DoesNotContain(typeof(System.Timers.Timer), fieldTypes); - Assert.DoesNotContain(typeof(System.Threading.Timer), fieldTypes); - } - - [Fact] - public async Task UpdateAsync_WhenPersistentRuleMatches_ShowsSavedRule() - { - var store = new Mock(MockBehavior.Strict); - store - .Setup(ruleStore => ruleStore.LoadAsync()) - .ReturnsAsync(new[] - { - new PersistentProcessRule - { - Name = "Game rule", - ProcessName = "Game.exe", - IsEnabled = true, - }, - }); - var viewModel = new SelectedProcessSummaryViewModel( - persistentRuleStore: store.Object, - persistentRuleMatcher: new PersistentProcessRuleMatcher()); - - await viewModel.UpdateAsync(CreateProcess("Game.exe")); - - Assert.True(viewModel.HasThreadPilotRule); - Assert.Equal("Saved rule exists: Game rule", viewModel.RuleStatusText); - } - - [Fact] - public async Task UpdateAsync_WhenNoPersistentRuleMatches_ShowsNoSavedRule() - { - var store = new Mock(MockBehavior.Strict); - store - .Setup(ruleStore => ruleStore.LoadAsync()) - .ReturnsAsync(new[] - { - new PersistentProcessRule - { - Name = "Other rule", - ProcessName = "Other.exe", - IsEnabled = true, - }, - }); - var viewModel = new SelectedProcessSummaryViewModel( - persistentRuleStore: store.Object, - persistentRuleMatcher: new PersistentProcessRuleMatcher()); - - await viewModel.UpdateAsync(CreateProcess("Game.exe")); - - Assert.False(viewModel.HasThreadPilotRule); - Assert.Equal("No saved rule", viewModel.RuleStatusText); - } - - private static ProcessModel CreateProcess( - string name = "Game.exe", - int processId = 42, - ProcessPriorityClass priority = ProcessPriorityClass.Normal, - long affinity = 0xF, - long memoryUsage = 64 * 1024 * 1024) - => new() - { - ProcessId = processId, - Name = name, - ExecutablePath = @"C:\Games\Game.exe", - CpuUsage = 12.5, - MemoryUsage = memoryUsage, - Priority = priority, - ProcessorAffinity = affinity, - Classification = ProcessClassification.ForegroundApp, - }; - - private sealed class ControlledMemoryPriorityService : IProcessMemoryPriorityService - { - private readonly Dictionary> pendingReads = new(); - private readonly Dictionary readSignals = new(); - private readonly Dictionary immediatePriorities = new(); - - public Task GetMemoryPriorityAsync(ProcessModel process) - { - if (this.immediatePriorities.TryGetValue(process.ProcessId, out var priority)) - { - return Task.FromResult(priority); - } - - var pending = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - var signal = this.GetOrCreateReadSignal(process.ProcessId); - this.pendingReads[process.ProcessId] = pending; - signal.TrySetResult(); - return pending.Task; - } - - public Task SetMemoryPriorityAsync(ProcessModel process, ProcessMemoryPriority priority) - => throw new NotSupportedException(); - - public void SetImmediatePriority(int processId, ProcessMemoryPriority? priority) - { - this.immediatePriorities[processId] = priority; - } - - public Task WaitForReadAsync(int processId) => this.GetOrCreateReadSignal(processId).Task; - - public void CompleteRead(int processId, ProcessMemoryPriority? priority) - { - this.pendingReads[processId].SetResult(priority); - } - - private TaskCompletionSource GetOrCreateReadSignal(int processId) - { - if (!this.readSignals.TryGetValue(processId, out var signal)) - { - signal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - this.readSignals[processId] = signal; - } - - return signal; - } - } - - private sealed class ControlledPersistentProcessRuleStore : IPersistentProcessRuleStore - { - private readonly Dictionary>> pendingLoads = new(); - private readonly Dictionary loadSignals = new(); - private readonly Queue> immediateRules = new(); - private int loadCount; - - public Task> LoadAsync() - { - this.loadCount++; - var loadNumber = this.loadCount; - - if (this.immediateRules.Count > 0) - { - this.GetOrCreateLoadSignal(loadNumber).TrySetResult(); - return Task.FromResult(this.immediateRules.Dequeue()); - } - - var pending = new TaskCompletionSource>( - TaskCreationOptions.RunContinuationsAsynchronously); - this.pendingLoads[loadNumber] = pending; - this.GetOrCreateLoadSignal(loadNumber).TrySetResult(); - return pending.Task; - } - - public Task SaveAsync(IReadOnlyList rules) - => throw new NotSupportedException(); - - public void EnqueueImmediateRules(IReadOnlyList rules) - { - this.immediateRules.Enqueue(rules); - } - - public Task WaitForLoadAsync(int loadNumber) => this.GetOrCreateLoadSignal(loadNumber).Task; - - public void CompleteLoad(int loadNumber, IReadOnlyList rules) - { - this.pendingLoads[loadNumber].SetResult(rules); - } - - private TaskCompletionSource GetOrCreateLoadSignal(int loadNumber) - { - if (!this.loadSignals.TryGetValue(loadNumber, out var signal)) - { - signal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - this.loadSignals[loadNumber] = signal; - } - - return signal; - } - } - } -} +namespace ThreadPilot.Core.Tests +{ + using System.Diagnostics; + using System.Reflection; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public sealed class SelectedProcessSummaryViewModelTests + { + [Fact] + public async Task UpdateAsync_WithNoSelectedProcess_ClearsSummary() + { + var viewModel = new SelectedProcessSummaryViewModel(); + + await viewModel.UpdateAsync(null); + + Assert.False(viewModel.HasSelection); + Assert.Equal("No process selected", viewModel.CurrentProcessStatusText); + Assert.Equal("Memory priority unavailable", viewModel.MemoryPriorityText); + Assert.Equal("No saved rule", viewModel.RuleStatusText); + } + + [Fact] + public async Task UpdateAsync_WithSelectedProcess_PopulatesCheapProcessFields() + { + var viewModel = new SelectedProcessSummaryViewModel(); + + await viewModel.UpdateAsync(CreateProcess("Game.exe", 1234, ProcessPriorityClass.High, 0x3, 512 * 1024 * 1024)); + + Assert.True(viewModel.HasSelection); + Assert.Equal(1234, viewModel.ProcessId); + Assert.Equal("Game.exe", viewModel.ProcessName); + Assert.Equal(@"C:\Games\Game.exe", viewModel.ExecutablePath); + Assert.Equal("Selected process: Game.exe (PID 1234)", viewModel.ProcessTitle); + Assert.Equal("CPU priority: High", viewModel.CpuPriorityText); + Assert.Equal("Memory: 512 MB", viewModel.MemoryUsageText); + Assert.Equal("Affinity: legacy mask 0x3", viewModel.AffinityText); + } + + [Fact] + public async Task UpdateAsync_WhenSelectionChanges_ReplacesSummary() + { + var viewModel = new SelectedProcessSummaryViewModel(); + + await viewModel.UpdateAsync(CreateProcess("First.exe", 1, ProcessPriorityClass.Normal, 0x1, 1)); + await viewModel.UpdateAsync(CreateProcess("Second.exe", 2, ProcessPriorityClass.BelowNormal, 0x2, 2)); + + Assert.Equal(2, viewModel.ProcessId); + Assert.Equal("Second.exe", viewModel.ProcessName); + Assert.Equal("CPU priority: BelowNormal", viewModel.CpuPriorityText); + Assert.Equal("Affinity: legacy mask 0x2", viewModel.AffinityText); + } + + [Fact] + public async Task UpdateAsync_WhenMemoryPriorityReadSucceeds_PopulatesMemoryPriority() + { + var memoryPriority = new Mock(MockBehavior.Strict); + memoryPriority + .Setup(service => service.GetMemoryPriorityAsync(It.IsAny())) + .ReturnsAsync(ProcessMemoryPriority.BelowNormal); + var viewModel = new SelectedProcessSummaryViewModel(memoryPriority.Object); + + await viewModel.UpdateAsync(CreateProcess()); + + Assert.Equal(ProcessMemoryPriority.BelowNormal, viewModel.MemoryPriority); + Assert.Equal("Memory priority: BelowNormal", viewModel.MemoryPriorityText); + } + + [Fact] + public async Task UpdateAsync_WhenMemoryPriorityUnavailable_ShowsUnavailableWithoutThrowing() + { + var memoryPriority = new Mock(MockBehavior.Strict); + memoryPriority + .Setup(service => service.GetMemoryPriorityAsync(It.IsAny())) + .ThrowsAsync(new UnauthorizedAccessException("Access denied")); + var viewModel = new SelectedProcessSummaryViewModel(memoryPriority.Object); + + await viewModel.UpdateAsync(CreateProcess()); + + Assert.Null(viewModel.MemoryPriority); + Assert.Equal("Memory priority unavailable", viewModel.MemoryPriorityText); + } + + [Fact] + public async Task UpdateAsync_WhenSelectionChangesBeforeSlowMemoryPriorityCompletes_KeepsLatestSelection() + { + var memoryPriority = new ControlledMemoryPriorityService(); + var viewModel = new SelectedProcessSummaryViewModel(memoryPriority); + var oldProcess = CreateProcess("Old.exe", 100, ProcessPriorityClass.Normal, 0x1, 10); + var latestProcess = CreateProcess("Latest.exe", 200, ProcessPriorityClass.High, 0x2, 20); + + var oldUpdate = viewModel.UpdateAsync(oldProcess); + await memoryPriority.WaitForReadAsync(oldProcess.ProcessId); + + memoryPriority.SetImmediatePriority(latestProcess.ProcessId, ProcessMemoryPriority.Normal); + await viewModel.UpdateAsync(latestProcess); + + memoryPriority.CompleteRead(oldProcess.ProcessId, ProcessMemoryPriority.VeryLow); + await oldUpdate; + + Assert.Equal(latestProcess.ProcessId, viewModel.ProcessId); + Assert.Equal(latestProcess.Name, viewModel.ProcessName); + Assert.Equal(ProcessMemoryPriority.Normal, viewModel.MemoryPriority); + Assert.Equal("Memory priority: Normal", viewModel.MemoryPriorityText); + } + + [Fact] + public async Task UpdateAsync_WhenSlowRuleLookupCompletesAfterSelectionChange_KeepsLatestRuleStatus() + { + var store = new ControlledPersistentProcessRuleStore(); + var viewModel = new SelectedProcessSummaryViewModel( + persistentRuleStore: store, + persistentRuleMatcher: new PersistentProcessRuleMatcher()); + var oldProcess = CreateProcess("Old.exe", 100); + var latestProcess = CreateProcess("Latest.exe", 200); + + var oldUpdate = viewModel.UpdateAsync(oldProcess); + await store.WaitForLoadAsync(1); + + store.EnqueueImmediateRules(new[] + { + new PersistentProcessRule + { + Name = "Latest rule", + ProcessName = latestProcess.Name, + IsEnabled = true, + }, + }); + await viewModel.UpdateAsync(latestProcess); + + store.CompleteLoad( + 1, + new[] + { + new PersistentProcessRule + { + Name = "Old rule", + ProcessName = oldProcess.Name, + IsEnabled = true, + }, + }); + await oldUpdate; + + Assert.Equal(latestProcess.ProcessId, viewModel.ProcessId); + Assert.Equal(latestProcess.Name, viewModel.ProcessName); + Assert.True(viewModel.HasThreadPilotRule); + Assert.Equal("Saved rule exists: Latest rule", viewModel.RuleStatusText); + } + + [Fact] + public void SelectedProcessSummary_HasNoPerformanceMonitoringDependency() + { + var type = typeof(SelectedProcessSummaryViewModel); + + var constructorParameters = type + .GetConstructors() + .SelectMany(ctor => ctor.GetParameters()) + .Select(parameter => parameter.ParameterType); + var fieldTypes = type + .GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) + .Select(field => field.FieldType); + + Assert.DoesNotContain(typeof(IPerformanceMonitoringService), constructorParameters); + Assert.DoesNotContain(typeof(IPerformanceMonitoringService), fieldTypes); + } + + [Fact] + public void SelectedProcessSummary_DoesNotOwnTimers() + { + var fieldTypes = typeof(SelectedProcessSummaryViewModel) + .GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) + .Select(field => field.FieldType); + + Assert.DoesNotContain(typeof(System.Timers.Timer), fieldTypes); + Assert.DoesNotContain(typeof(System.Threading.Timer), fieldTypes); + } + + [Fact] + public async Task UpdateAsync_WhenPersistentRuleMatches_ShowsSavedRule() + { + var store = new Mock(MockBehavior.Strict); + store + .Setup(ruleStore => ruleStore.LoadAsync()) + .ReturnsAsync(new[] + { + new PersistentProcessRule + { + Name = "Game rule", + ProcessName = "Game.exe", + IsEnabled = true, + }, + }); + var viewModel = new SelectedProcessSummaryViewModel( + persistentRuleStore: store.Object, + persistentRuleMatcher: new PersistentProcessRuleMatcher()); + + await viewModel.UpdateAsync(CreateProcess("Game.exe")); + + Assert.True(viewModel.HasThreadPilotRule); + Assert.Equal("Saved rule exists: Game rule", viewModel.RuleStatusText); + } + + [Fact] + public async Task UpdateAsync_WhenNoPersistentRuleMatches_ShowsNoSavedRule() + { + var store = new Mock(MockBehavior.Strict); + store + .Setup(ruleStore => ruleStore.LoadAsync()) + .ReturnsAsync(new[] + { + new PersistentProcessRule + { + Name = "Other rule", + ProcessName = "Other.exe", + IsEnabled = true, + }, + }); + var viewModel = new SelectedProcessSummaryViewModel( + persistentRuleStore: store.Object, + persistentRuleMatcher: new PersistentProcessRuleMatcher()); + + await viewModel.UpdateAsync(CreateProcess("Game.exe")); + + Assert.False(viewModel.HasThreadPilotRule); + Assert.Equal("No saved rule", viewModel.RuleStatusText); + } + + private static ProcessModel CreateProcess( + string name = "Game.exe", + int processId = 42, + ProcessPriorityClass priority = ProcessPriorityClass.Normal, + long affinity = 0xF, + long memoryUsage = 64 * 1024 * 1024) + => new() + { + ProcessId = processId, + Name = name, + ExecutablePath = @"C:\Games\Game.exe", + CpuUsage = 12.5, + MemoryUsage = memoryUsage, + Priority = priority, + ProcessorAffinity = affinity, + Classification = ProcessClassification.ForegroundApp, + }; + + private sealed class ControlledMemoryPriorityService : IProcessMemoryPriorityService + { + private readonly Dictionary> pendingReads = new(); + private readonly Dictionary readSignals = new(); + private readonly Dictionary immediatePriorities = new(); + + public Task GetMemoryPriorityAsync(ProcessModel process) + { + if (this.immediatePriorities.TryGetValue(process.ProcessId, out var priority)) + { + return Task.FromResult(priority); + } + + var pending = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var signal = this.GetOrCreateReadSignal(process.ProcessId); + this.pendingReads[process.ProcessId] = pending; + signal.TrySetResult(); + return pending.Task; + } + + public Task SetMemoryPriorityAsync(ProcessModel process, ProcessMemoryPriority priority) + => throw new NotSupportedException(); + + public void SetImmediatePriority(int processId, ProcessMemoryPriority? priority) + { + this.immediatePriorities[processId] = priority; + } + + public Task WaitForReadAsync(int processId) => this.GetOrCreateReadSignal(processId).Task; + + public void CompleteRead(int processId, ProcessMemoryPriority? priority) + { + this.pendingReads[processId].SetResult(priority); + } + + private TaskCompletionSource GetOrCreateReadSignal(int processId) + { + if (!this.readSignals.TryGetValue(processId, out var signal)) + { + signal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + this.readSignals[processId] = signal; + } + + return signal; + } + } + + private sealed class ControlledPersistentProcessRuleStore : IPersistentProcessRuleStore + { + private readonly Dictionary>> pendingLoads = new(); + private readonly Dictionary loadSignals = new(); + private readonly Queue> immediateRules = new(); + private int loadCount; + + public Task> LoadAsync() + { + this.loadCount++; + var loadNumber = this.loadCount; + + if (this.immediateRules.Count > 0) + { + this.GetOrCreateLoadSignal(loadNumber).TrySetResult(); + return Task.FromResult(this.immediateRules.Dequeue()); + } + + var pending = new TaskCompletionSource>( + TaskCreationOptions.RunContinuationsAsynchronously); + this.pendingLoads[loadNumber] = pending; + this.GetOrCreateLoadSignal(loadNumber).TrySetResult(); + return pending.Task; + } + + public Task SaveAsync(IReadOnlyList rules) + => throw new NotSupportedException(); + + public void EnqueueImmediateRules(IReadOnlyList rules) + { + this.immediateRules.Enqueue(rules); + } + + public Task WaitForLoadAsync(int loadNumber) => this.GetOrCreateLoadSignal(loadNumber).Task; + + public void CompleteLoad(int loadNumber, IReadOnlyList rules) + { + this.pendingLoads[loadNumber].SetResult(rules); + } + + private TaskCompletionSource GetOrCreateLoadSignal(int loadNumber) + { + if (!this.loadSignals.TryGetValue(loadNumber, out var signal)) + { + signal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + this.loadSignals[loadNumber] = signal; + } + + return signal; + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/SelfResourcePolicyTests.cs b/Tests/ThreadPilot.Core.Tests/SelfResourcePolicyTests.cs index 06191c3..e17c4f3 100644 --- a/Tests/ThreadPilot.Core.Tests/SelfResourcePolicyTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SelfResourcePolicyTests.cs @@ -1,53 +1,53 @@ -namespace ThreadPilot.Core.Tests -{ - using ThreadPilot.Services; - - public sealed class SelfResourcePolicyTests - { - [Theory] - [InlineData(1)] - [InlineData(2)] - public void TryCreateLowImpactAffinityMask_DisablesAffinityOnSmallSystems(int logicalProcessorCount) - { - Assert.False(SelfResourcePolicy.TryCreateLowImpactAffinityMask(logicalProcessorCount, out _)); - } - - [Theory] - [InlineData(3, 0b100)] - [InlineData(4, 0b1100)] - [InlineData(8, 0b1100_0000)] - public void TryCreateLowImpactAffinityMask_UsesLastLogicalProcessors( - int logicalProcessorCount, - long expectedMask) - { - Assert.True(SelfResourcePolicy.TryCreateLowImpactAffinityMask(logicalProcessorCount, out var mask)); - Assert.Equal(expectedMask, mask); - } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - [InlineData(64)] - [InlineData(128)] - public void TryCreateLowImpactAffinityMask_RejectsInvalidProcessorCounts(int logicalProcessorCount) - { - Assert.False(SelfResourcePolicy.TryCreateLowImpactAffinityMask(logicalProcessorCount, out _)); - } - - [Theory] - [InlineData(false, true, true, false)] - [InlineData(true, false, true, false)] - [InlineData(true, true, false, false)] - [InlineData(true, true, true, true)] - public void ShouldLimitAffinity_RequiresHiddenLowImpactModeAndAffinitySetting( - bool isHidden, - bool enableSelfLowImpactMode, - bool enableSelfAffinityLimit, - bool expected) - { - Assert.Equal( - expected, - SelfResourcePolicy.ShouldLimitAffinity(isHidden, enableSelfLowImpactMode, enableSelfAffinityLimit)); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Services; + + public sealed class SelfResourcePolicyTests + { + [Theory] + [InlineData(1)] + [InlineData(2)] + public void TryCreateLowImpactAffinityMask_DisablesAffinityOnSmallSystems(int logicalProcessorCount) + { + Assert.False(SelfResourcePolicy.TryCreateLowImpactAffinityMask(logicalProcessorCount, out _)); + } + + [Theory] + [InlineData(3, 0b100)] + [InlineData(4, 0b1100)] + [InlineData(8, 0b1100_0000)] + public void TryCreateLowImpactAffinityMask_UsesLastLogicalProcessors( + int logicalProcessorCount, + long expectedMask) + { + Assert.True(SelfResourcePolicy.TryCreateLowImpactAffinityMask(logicalProcessorCount, out var mask)); + Assert.Equal(expectedMask, mask); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(64)] + [InlineData(128)] + public void TryCreateLowImpactAffinityMask_RejectsInvalidProcessorCounts(int logicalProcessorCount) + { + Assert.False(SelfResourcePolicy.TryCreateLowImpactAffinityMask(logicalProcessorCount, out _)); + } + + [Theory] + [InlineData(false, true, true, false)] + [InlineData(true, false, true, false)] + [InlineData(true, true, false, false)] + [InlineData(true, true, true, true)] + public void ShouldLimitAffinity_RequiresHiddenLowImpactModeAndAffinitySetting( + bool isHidden, + bool enableSelfLowImpactMode, + bool enableSelfAffinityLimit, + bool expected) + { + Assert.Equal( + expected, + SelfResourcePolicy.ShouldLimitAffinity(isHidden, enableSelfLowImpactMode, enableSelfAffinityLimit)); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ServiceConfigurationTests.cs b/Tests/ThreadPilot.Core.Tests/ServiceConfigurationTests.cs index 1012cb7..2597c10 100644 --- a/Tests/ThreadPilot.Core.Tests/ServiceConfigurationTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ServiceConfigurationTests.cs @@ -1,55 +1,55 @@ -/* - * ThreadPilot - dependency injection registration tests. - */ -namespace ThreadPilot.Core.Tests -{ - using Microsoft.Extensions.DependencyInjection; - using ThreadPilot.Models; - using ThreadPilot.Services; - - public sealed class ServiceConfigurationTests - { - [Fact] - public void ConfigureApplicationServices_RegistersPersistentRuleAutoApplyService() - { - using var provider = CreateProvider(); - - var service = provider.GetRequiredService(); - - Assert.IsType(service); - } - - [Fact] - public void ConfigureApplicationServices_RegistersProcessRuleCreationService() - { - using var provider = CreateProvider(); - - var service = provider.GetRequiredService(); - - Assert.IsType(service); - } - - [Fact] - public void ConfigureApplicationServices_RegistersActivityAuditService() - { - using var provider = CreateProvider(); - - var service = provider.GetRequiredService(); - - Assert.IsType(service); - } - - [Fact] - public void ApplicationSettings_DefaultsToPersistentRulesAutoApplyEnabled() - { - var settings = new ApplicationSettingsModel(); - - Assert.True(settings.ApplyPersistentRulesOnProcessStart); - } - - private static ServiceProvider CreateProvider() => - new ServiceCollection() - .ConfigureApplicationServices() - .BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); - } -} +/* + * ThreadPilot - dependency injection registration tests. + */ +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.DependencyInjection; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class ServiceConfigurationTests + { + [Fact] + public void ConfigureApplicationServices_RegistersPersistentRuleAutoApplyService() + { + using var provider = CreateProvider(); + + var service = provider.GetRequiredService(); + + Assert.IsType(service); + } + + [Fact] + public void ConfigureApplicationServices_RegistersProcessRuleCreationService() + { + using var provider = CreateProvider(); + + var service = provider.GetRequiredService(); + + Assert.IsType(service); + } + + [Fact] + public void ConfigureApplicationServices_RegistersActivityAuditService() + { + using var provider = CreateProvider(); + + var service = provider.GetRequiredService(); + + Assert.IsType(service); + } + + [Fact] + public void ApplicationSettings_DefaultsToPersistentRulesAutoApplyEnabled() + { + var settings = new ApplicationSettingsModel(); + + Assert.True(settings.ApplyPersistentRulesOnProcessStart); + } + + private static ServiceProvider CreateProvider() => + new ServiceCollection() + .ConfigureApplicationServices() + .BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + } +} diff --git a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs index 4afd6e0..c4b178d 100644 --- a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs @@ -1,256 +1,256 @@ -namespace ThreadPilot.Core.Tests -{ - using System.Collections.ObjectModel; - using CommunityToolkit.Mvvm.Input; - using Microsoft.Extensions.Logging.Abstractions; - using Moq; - using ThreadPilot.Models; - using ThreadPilot.Services; - using ThreadPilot.ViewModels; - - public sealed class SettingsViewModelThemeTests - { - [Fact] - public async Task ChangingTheme_AppliesThemeAndLogsVisibleActivityEntry() - { - var harness = new Harness(); - var viewModel = harness.CreateViewModel(); - - viewModel.Settings.UseDarkTheme = true; - - harness.Theme.Verify(service => service.ApplyTheme(true), Times.Once); - harness.Tray.Verify(service => service.ApplyTheme(true), Times.Once); - harness.Logging.Verify( - service => service.LogUserActionAsync( - "ThemeChanged", - "Theme changed to Dark", - null), - Times.Once); - var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); - Assert.Equal("Settings", entry.Category); - Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); - Assert.Equal("Theme changed to Dark", entry.Message); - Assert.Equal("Theme changed to Dark.", viewModel.StatusMessage); - } - - [Fact] - public void ChangingTheme_ToSameValue_DoesNotApplyOrLogAgain() - { - var harness = new Harness(initialDarkTheme: true); - var viewModel = harness.CreateViewModel(); - - viewModel.Settings.UseDarkTheme = true; - - harness.Theme.Verify(service => service.ApplyTheme(It.IsAny()), Times.Never); - harness.Tray.Verify(service => service.ApplyTheme(It.IsAny()), Times.Never); - harness.Logging.Verify( - service => service.LogUserActionAsync(It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); - } - - [Fact] - public async Task ChangingApplyPersistentRulesOnProcessStart_UpdatesSettingAndLogsVisibleActivityEntry() - { - var harness = new Harness(); - var viewModel = harness.CreateViewModel(); - - viewModel.Settings.ApplyPersistentRulesOnProcessStart = false; - - Assert.False(viewModel.Settings.ApplyPersistentRulesOnProcessStart); - harness.Logging.Verify( - service => service.LogUserActionAsync( - "SettingsChanged", - "[Settings] Apply saved rules at process start disabled.", - null), - Times.Once); - var disabledEntry = Assert.Single(await harness.Audit.GetEntriesAsync()); - Assert.Equal("Settings", disabledEntry.Category); - Assert.Equal("[Settings] Apply saved rules at process start disabled.", disabledEntry.Message); - - viewModel.Settings.ApplyPersistentRulesOnProcessStart = true; - - Assert.True(viewModel.Settings.ApplyPersistentRulesOnProcessStart); - harness.Logging.Verify( - service => service.LogUserActionAsync( - "SettingsChanged", - "[Settings] Apply saved rules at process start enabled.", - null), - Times.Once); - var entries = await harness.Audit.GetEntriesAsync(); - Assert.Contains(entries, entry => entry.Message == "[Settings] Apply saved rules at process start enabled."); - } - - [Fact] - public async Task ChangingLanguage_AppliesLanguageAndLogsVisibleActivityEntry() - { - var harness = new Harness(); - var viewModel = harness.CreateViewModel(); - - viewModel.Settings.Language = "zh-CN"; - - harness.Localization.Verify(service => service.ApplyLanguage("zh-CN"), Times.Once); - harness.Logging.Verify( - service => service.LogUserActionAsync( - "LanguageChanged", - "Language changed to Simplified Chinese", - null), - Times.Once); - var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); - Assert.Equal("Language changed to Simplified Chinese", entry.Message); - Assert.Equal("Language changed to Simplified Chinese.", viewModel.StatusMessage); - } - - [Fact] - public void ChangingLanguage_UsesLocalizedStatusMessage() - { - var harness = new Harness(); - harness.Localization - .Setup(service => service.GetString(It.IsAny())) - .Returns(key => key switch - { - "Settings_LanguageSimplifiedChinese" => "็ฎ€ไฝ“ไธญๆ–‡", - "Settings_StatusLanguageChangedFormat" => "่ฏญ่จ€ๅทฒๅˆ‡ๆขไธบ{0}ใ€‚", - _ => key, - }); - var viewModel = harness.CreateViewModel(); - - viewModel.Settings.Language = "zh-CN"; - - Assert.Equal("่ฏญ่จ€ๅทฒๅˆ‡ๆขไธบ็ฎ€ไฝ“ไธญๆ–‡ใ€‚", viewModel.StatusMessage); - } - - [Fact] - public async Task SaveSettingsCommand_PersistsSelectedLanguage() - { - var harness = new Harness(); - ApplicationSettingsModel? savedSettings = null; - harness.SettingsService - .Setup(service => service.UpdateSettingsAsync(It.IsAny())) - .Callback(settings => savedSettings = (ApplicationSettingsModel)settings.Clone()) - .Returns(Task.CompletedTask); - var viewModel = harness.CreateViewModel(); - viewModel.Settings.Language = "zh-CN"; - - await ((IAsyncRelayCommand)viewModel.SaveSettingsCommand).ExecuteAsync(null); - - Assert.NotNull(savedSettings); - Assert.Equal("zh-CN", savedSettings.Language); - Assert.False(viewModel.HasUnsavedChanges); - } - - [Fact] - public void SettingsView_ExposesPersistentRuleAutoApplyToggle() - { - var settingsViewPath = Path.Combine( - AppContext.BaseDirectory, - "..", - "..", - "..", - "..", - "..", - "Views", - "SettingsView.xaml"); - var serialized = File.ReadAllText(settingsViewPath); - - Assert.Contains("Text=\"{DynamicResource SettingsView_RulesAutomation}\" Style=\"{StaticResource SectionHeaderStyle}\"", serialized, StringComparison.Ordinal); - Assert.Contains("Text=\"{DynamicResource SettingsView_ApplyOnStart}\"", serialized, StringComparison.Ordinal); - Assert.Contains("TextWrapping=\"Wrap\"", serialized, StringComparison.Ordinal); - Assert.Contains("IsChecked=\"{Binding Settings.ApplyPersistentRulesOnProcessStart}\"", serialized, StringComparison.Ordinal); - Assert.Contains("Text=\"{DynamicResource SettingsView_ApplyOnStartDescription}\"", serialized, StringComparison.Ordinal); - } - - [Fact] - public void SettingsView_ExposesOptionalChineseLanguageSelection() - { - var settingsViewPath = Path.Combine( - AppContext.BaseDirectory, - "..", - "..", - "..", - "..", - "..", - "Views", - "SettingsView.xaml"); - var serialized = File.ReadAllText(settingsViewPath); - - Assert.Contains("SelectedValue=\"{Binding Settings.Language}\"", serialized, StringComparison.Ordinal); - Assert.Contains("Tag=\"en-US\"", serialized, StringComparison.Ordinal); - Assert.Contains("Tag=\"zh-CN\"", serialized, StringComparison.Ordinal); - } - - private sealed class Harness - { - private readonly ApplicationSettingsModel settings; - - public Mock SettingsService { get; } = new(MockBehavior.Loose); - - public Mock Notifications { get; } = new(MockBehavior.Loose); - - public Mock Autostart { get; } = new(MockBehavior.Loose); - - public Mock PowerPlans { get; } = new(MockBehavior.Loose); - - public Mock Associations { get; } = new(MockBehavior.Loose); - - public Mock ProcessMonitorManager { get; } = new(MockBehavior.Loose); - - public Mock Theme { get; } = new(MockBehavior.Loose); - - public Mock Tray { get; } = new(MockBehavior.Loose); - - public Mock Localization { get; } = new(MockBehavior.Loose); - - public Mock Updates { get; } = new(MockBehavior.Loose); - - public Mock VersionProvider { get; } = new(MockBehavior.Loose); - - public Mock Logging { get; } = new(MockBehavior.Loose); - - public ActivityAuditService Audit { get; } = new(NullLogger.Instance); - - public Harness(bool initialDarkTheme = false) - { - this.settings = new ApplicationSettingsModel - { - UseDarkTheme = initialDarkTheme, - HasUserThemePreference = initialDarkTheme, - }; - this.SettingsService.SetupGet(service => service.Settings).Returns(this.settings); - this.Autostart - .Setup(service => service.CheckAutostartStatusAsync()) - .ReturnsAsync(true); - this.PowerPlans - .Setup(service => service.GetPowerPlansAsync()) - .ReturnsAsync(new ObservableCollection()); - this.PowerPlans - .Setup(service => service.GetCustomPowerPlansAsync()) - .ReturnsAsync(new ObservableCollection()); - this.PowerPlans - .Setup(service => service.GetActivePowerPlan()) - .ReturnsAsync((PowerPlanModel?)null); - this.Associations - .Setup(service => service.GetDefaultPowerPlanAsync()) - .ReturnsAsync((string.Empty, string.Empty)); - this.VersionProvider.SetupGet(service => service.DisplayVersion).Returns("v1.3.1"); - this.VersionProvider.SetupGet(service => service.CurrentVersion).Returns(new SemanticVersion(1, 3, 1)); - } - - public SettingsViewModel CreateViewModel() => - new( - NullLogger.Instance, - this.SettingsService.Object, - this.Notifications.Object, - this.Autostart.Object, - this.PowerPlans.Object, - this.Associations.Object, - this.ProcessMonitorManager.Object, - this.Theme.Object, - this.Tray.Object, - this.Updates.Object, - this.VersionProvider.Object, - this.Localization.Object, - this.Logging.Object, - this.Audit); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using System.Collections.ObjectModel; + using CommunityToolkit.Mvvm.Input; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public sealed class SettingsViewModelThemeTests + { + [Fact] + public async Task ChangingTheme_AppliesThemeAndLogsVisibleActivityEntry() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + + viewModel.Settings.UseDarkTheme = true; + + harness.Theme.Verify(service => service.ApplyTheme(true), Times.Once); + harness.Tray.Verify(service => service.ApplyTheme(true), Times.Once); + harness.Logging.Verify( + service => service.LogUserActionAsync( + "ThemeChanged", + "Theme changed to Dark", + null), + Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Settings", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + Assert.Equal("Theme changed to Dark", entry.Message); + Assert.Equal("Theme changed to Dark.", viewModel.StatusMessage); + } + + [Fact] + public void ChangingTheme_ToSameValue_DoesNotApplyOrLogAgain() + { + var harness = new Harness(initialDarkTheme: true); + var viewModel = harness.CreateViewModel(); + + viewModel.Settings.UseDarkTheme = true; + + harness.Theme.Verify(service => service.ApplyTheme(It.IsAny()), Times.Never); + harness.Tray.Verify(service => service.ApplyTheme(It.IsAny()), Times.Never); + harness.Logging.Verify( + service => service.LogUserActionAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ChangingApplyPersistentRulesOnProcessStart_UpdatesSettingAndLogsVisibleActivityEntry() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + + viewModel.Settings.ApplyPersistentRulesOnProcessStart = false; + + Assert.False(viewModel.Settings.ApplyPersistentRulesOnProcessStart); + harness.Logging.Verify( + service => service.LogUserActionAsync( + "SettingsChanged", + "[Settings] Apply saved rules at process start disabled.", + null), + Times.Once); + var disabledEntry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Settings", disabledEntry.Category); + Assert.Equal("[Settings] Apply saved rules at process start disabled.", disabledEntry.Message); + + viewModel.Settings.ApplyPersistentRulesOnProcessStart = true; + + Assert.True(viewModel.Settings.ApplyPersistentRulesOnProcessStart); + harness.Logging.Verify( + service => service.LogUserActionAsync( + "SettingsChanged", + "[Settings] Apply saved rules at process start enabled.", + null), + Times.Once); + var entries = await harness.Audit.GetEntriesAsync(); + Assert.Contains(entries, entry => entry.Message == "[Settings] Apply saved rules at process start enabled."); + } + + [Fact] + public async Task ChangingLanguage_AppliesLanguageAndLogsVisibleActivityEntry() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + + viewModel.Settings.Language = "zh-CN"; + + harness.Localization.Verify(service => service.ApplyLanguage("zh-CN"), Times.Once); + harness.Logging.Verify( + service => service.LogUserActionAsync( + "LanguageChanged", + "Language changed to Simplified Chinese", + null), + Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Language changed to Simplified Chinese", entry.Message); + Assert.Equal("Language changed to Simplified Chinese.", viewModel.StatusMessage); + } + + [Fact] + public void ChangingLanguage_UsesLocalizedStatusMessage() + { + var harness = new Harness(); + harness.Localization + .Setup(service => service.GetString(It.IsAny())) + .Returns(key => key switch + { + "Settings_LanguageSimplifiedChinese" => "รงยฎโ‚ฌรคยฝโ€œรคยธยญรฆโ€“โ€ก", + "Settings_StatusLanguageChangedFormat" => "รจยฏยญรจยจโ‚ฌรฅยทยฒรฅห†โ€กรฆยขรคยธยบ{0}รฃโ‚ฌโ€š", + _ => key, + }); + var viewModel = harness.CreateViewModel(); + + viewModel.Settings.Language = "zh-CN"; + + Assert.Equal("รจยฏยญรจยจโ‚ฌรฅยทยฒรฅห†โ€กรฆยขรคยธยบรงยฎโ‚ฌรคยฝโ€œรคยธยญรฆโ€“โ€กรฃโ‚ฌโ€š", viewModel.StatusMessage); + } + + [Fact] + public async Task SaveSettingsCommand_PersistsSelectedLanguage() + { + var harness = new Harness(); + ApplicationSettingsModel? savedSettings = null; + harness.SettingsService + .Setup(service => service.UpdateSettingsAsync(It.IsAny())) + .Callback(settings => savedSettings = (ApplicationSettingsModel)settings.Clone()) + .Returns(Task.CompletedTask); + var viewModel = harness.CreateViewModel(); + viewModel.Settings.Language = "zh-CN"; + + await ((IAsyncRelayCommand)viewModel.SaveSettingsCommand).ExecuteAsync(null); + + Assert.NotNull(savedSettings); + Assert.Equal("zh-CN", savedSettings.Language); + Assert.False(viewModel.HasUnsavedChanges); + } + + [Fact] + public void SettingsView_ExposesPersistentRuleAutoApplyToggle() + { + var settingsViewPath = Path.Combine( + AppContext.BaseDirectory, + "..", + "..", + "..", + "..", + "..", + "Views", + "SettingsView.xaml"); + var serialized = File.ReadAllText(settingsViewPath); + + Assert.Contains("Text=\"{DynamicResource SettingsView_RulesAutomation}\" Style=\"{StaticResource SectionHeaderStyle}\"", serialized, StringComparison.Ordinal); + Assert.Contains("Text=\"{DynamicResource SettingsView_ApplyOnStart}\"", serialized, StringComparison.Ordinal); + Assert.Contains("TextWrapping=\"Wrap\"", serialized, StringComparison.Ordinal); + Assert.Contains("IsChecked=\"{Binding Settings.ApplyPersistentRulesOnProcessStart}\"", serialized, StringComparison.Ordinal); + Assert.Contains("Text=\"{DynamicResource SettingsView_ApplyOnStartDescription}\"", serialized, StringComparison.Ordinal); + } + + [Fact] + public void SettingsView_ExposesOptionalChineseLanguageSelection() + { + var settingsViewPath = Path.Combine( + AppContext.BaseDirectory, + "..", + "..", + "..", + "..", + "..", + "Views", + "SettingsView.xaml"); + var serialized = File.ReadAllText(settingsViewPath); + + Assert.Contains("SelectedValue=\"{Binding Settings.Language}\"", serialized, StringComparison.Ordinal); + Assert.Contains("Tag=\"en-US\"", serialized, StringComparison.Ordinal); + Assert.Contains("Tag=\"zh-CN\"", serialized, StringComparison.Ordinal); + } + + private sealed class Harness + { + private readonly ApplicationSettingsModel settings; + + public Mock SettingsService { get; } = new(MockBehavior.Loose); + + public Mock Notifications { get; } = new(MockBehavior.Loose); + + public Mock Autostart { get; } = new(MockBehavior.Loose); + + public Mock PowerPlans { get; } = new(MockBehavior.Loose); + + public Mock Associations { get; } = new(MockBehavior.Loose); + + public Mock ProcessMonitorManager { get; } = new(MockBehavior.Loose); + + public Mock Theme { get; } = new(MockBehavior.Loose); + + public Mock Tray { get; } = new(MockBehavior.Loose); + + public Mock Localization { get; } = new(MockBehavior.Loose); + + public Mock Updates { get; } = new(MockBehavior.Loose); + + public Mock VersionProvider { get; } = new(MockBehavior.Loose); + + public Mock Logging { get; } = new(MockBehavior.Loose); + + public ActivityAuditService Audit { get; } = new(NullLogger.Instance); + + public Harness(bool initialDarkTheme = false) + { + this.settings = new ApplicationSettingsModel + { + UseDarkTheme = initialDarkTheme, + HasUserThemePreference = initialDarkTheme, + }; + this.SettingsService.SetupGet(service => service.Settings).Returns(this.settings); + this.Autostart + .Setup(service => service.CheckAutostartStatusAsync()) + .ReturnsAsync(true); + this.PowerPlans + .Setup(service => service.GetPowerPlansAsync()) + .ReturnsAsync(new ObservableCollection()); + this.PowerPlans + .Setup(service => service.GetCustomPowerPlansAsync()) + .ReturnsAsync(new ObservableCollection()); + this.PowerPlans + .Setup(service => service.GetActivePowerPlan()) + .ReturnsAsync((PowerPlanModel?)null); + this.Associations + .Setup(service => service.GetDefaultPowerPlanAsync()) + .ReturnsAsync((string.Empty, string.Empty)); + this.VersionProvider.SetupGet(service => service.DisplayVersion).Returns("v1.3.1"); + this.VersionProvider.SetupGet(service => service.CurrentVersion).Returns(new SemanticVersion(1, 3, 1)); + } + + public SettingsViewModel CreateViewModel() => + new( + NullLogger.Instance, + this.SettingsService.Object, + this.Notifications.Object, + this.Autostart.Object, + this.PowerPlans.Object, + this.Associations.Object, + this.ProcessMonitorManager.Object, + this.Theme.Object, + this.Tray.Object, + this.Updates.Object, + this.VersionProvider.Object, + this.Localization.Object, + this.Logging.Object, + this.Audit); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/StartupMinimizedSuggestionPolicyTests.cs b/Tests/ThreadPilot.Core.Tests/StartupMinimizedSuggestionPolicyTests.cs index 01e2786..20729ce 100644 --- a/Tests/ThreadPilot.Core.Tests/StartupMinimizedSuggestionPolicyTests.cs +++ b/Tests/ThreadPilot.Core.Tests/StartupMinimizedSuggestionPolicyTests.cs @@ -1,36 +1,36 @@ -namespace ThreadPilot.Core.Tests -{ - using ThreadPilot.Helpers; - using ThreadPilot.Models; - - public sealed class StartupMinimizedSuggestionPolicyTests - { - [Fact] - public void ShouldShow_ReturnsTrue_ForFirstVisibleNormalLaunchWithoutStartMinimized() - { - var settings = new ApplicationSettingsModel(); - var behavior = StartupWindowBehavior.Resolve(isAutostart: false, startMinimized: false); - - Assert.True(StartupMinimizedSuggestionPolicy.ShouldShow(settings, behavior)); - } - - [Fact] - public void ShouldShow_ReturnsFalse_WhenStartupIsSilent() - { - var settings = new ApplicationSettingsModel(); - var behavior = StartupWindowBehavior.Resolve(isAutostart: false, startMinimized: true); - - Assert.False(StartupMinimizedSuggestionPolicy.ShouldShow(settings, behavior)); - } - - [Fact] - public void ShouldShow_ReturnsFalse_WhenSuggestionWasAlreadySeen() - { - var settings = new ApplicationSettingsModel(); - settings.HasSeenStartupMinimizedSuggestion = true; - var behavior = StartupWindowBehavior.Resolve(isAutostart: false, startMinimized: false); - - Assert.False(StartupMinimizedSuggestionPolicy.ShouldShow(settings, behavior)); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Helpers; + using ThreadPilot.Models; + + public sealed class StartupMinimizedSuggestionPolicyTests + { + [Fact] + public void ShouldShow_ReturnsTrue_ForFirstVisibleNormalLaunchWithoutStartMinimized() + { + var settings = new ApplicationSettingsModel(); + var behavior = StartupWindowBehavior.Resolve(isAutostart: false, startMinimized: false); + + Assert.True(StartupMinimizedSuggestionPolicy.ShouldShow(settings, behavior)); + } + + [Fact] + public void ShouldShow_ReturnsFalse_WhenStartupIsSilent() + { + var settings = new ApplicationSettingsModel(); + var behavior = StartupWindowBehavior.Resolve(isAutostart: false, startMinimized: true); + + Assert.False(StartupMinimizedSuggestionPolicy.ShouldShow(settings, behavior)); + } + + [Fact] + public void ShouldShow_ReturnsFalse_WhenSuggestionWasAlreadySeen() + { + var settings = new ApplicationSettingsModel(); + settings.HasSeenStartupMinimizedSuggestion = true; + var behavior = StartupWindowBehavior.Resolve(isAutostart: false, startMinimized: false); + + Assert.False(StartupMinimizedSuggestionPolicy.ShouldShow(settings, behavior)); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/StartupWindowBehaviorTests.cs b/Tests/ThreadPilot.Core.Tests/StartupWindowBehaviorTests.cs index 7437e96..8808dd8 100644 --- a/Tests/ThreadPilot.Core.Tests/StartupWindowBehaviorTests.cs +++ b/Tests/ThreadPilot.Core.Tests/StartupWindowBehaviorTests.cs @@ -1,60 +1,60 @@ -namespace ThreadPilot.Core.Tests -{ - using System.Windows; - using ThreadPilot.Helpers; - - public sealed class StartupWindowBehaviorTests - { - [Fact] - public void Resolve_ShowsNormalWindow_ForManualLaunchWithoutStartMinimized() - { - var behavior = StartupWindowBehavior.Resolve(isAutostart: false, startMinimized: false); - - Assert.True(behavior.ShouldShowWindow); - Assert.True(behavior.ShowInTaskbar); - Assert.Equal(Visibility.Visible, behavior.Visibility); - Assert.Equal(WindowState.Normal, behavior.WindowState); - Assert.False(behavior.HideAfterShow); - Assert.True(behavior.ActivateAfterShow); - } - - [Fact] - public void Resolve_HidesToTray_ForManualLaunchWithStartMinimized() - { - var behavior = StartupWindowBehavior.Resolve(isAutostart: false, startMinimized: true); - - Assert.False(behavior.ShouldShowWindow); - Assert.False(behavior.ShowInTaskbar); - Assert.Equal(Visibility.Hidden, behavior.Visibility); - Assert.Equal(WindowState.Minimized, behavior.WindowState); - Assert.False(behavior.HideAfterShow); - Assert.False(behavior.ActivateAfterShow); - } - - [Fact] - public void Resolve_HidesToTray_ForAutostartWithStartMinimized() - { - var behavior = StartupWindowBehavior.Resolve(isAutostart: true, startMinimized: true); - - Assert.False(behavior.ShouldShowWindow); - Assert.False(behavior.ShowInTaskbar); - Assert.Equal(Visibility.Hidden, behavior.Visibility); - Assert.Equal(WindowState.Minimized, behavior.WindowState); - Assert.False(behavior.HideAfterShow); - Assert.False(behavior.ActivateAfterShow); - } - - [Fact] - public void Resolve_ShowsNormalWindow_ForAutostartWithStartMinimizedDisabled() - { - var behavior = StartupWindowBehavior.Resolve(isAutostart: true, startMinimized: false); - - Assert.True(behavior.ShouldShowWindow); - Assert.True(behavior.ShowInTaskbar); - Assert.Equal(Visibility.Visible, behavior.Visibility); - Assert.Equal(WindowState.Normal, behavior.WindowState); - Assert.False(behavior.HideAfterShow); - Assert.True(behavior.ActivateAfterShow); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using System.Windows; + using ThreadPilot.Helpers; + + public sealed class StartupWindowBehaviorTests + { + [Fact] + public void Resolve_ShowsNormalWindow_ForManualLaunchWithoutStartMinimized() + { + var behavior = StartupWindowBehavior.Resolve(isAutostart: false, startMinimized: false); + + Assert.True(behavior.ShouldShowWindow); + Assert.True(behavior.ShowInTaskbar); + Assert.Equal(Visibility.Visible, behavior.Visibility); + Assert.Equal(WindowState.Normal, behavior.WindowState); + Assert.False(behavior.HideAfterShow); + Assert.True(behavior.ActivateAfterShow); + } + + [Fact] + public void Resolve_HidesToTray_ForManualLaunchWithStartMinimized() + { + var behavior = StartupWindowBehavior.Resolve(isAutostart: false, startMinimized: true); + + Assert.False(behavior.ShouldShowWindow); + Assert.False(behavior.ShowInTaskbar); + Assert.Equal(Visibility.Hidden, behavior.Visibility); + Assert.Equal(WindowState.Minimized, behavior.WindowState); + Assert.False(behavior.HideAfterShow); + Assert.False(behavior.ActivateAfterShow); + } + + [Fact] + public void Resolve_HidesToTray_ForAutostartWithStartMinimized() + { + var behavior = StartupWindowBehavior.Resolve(isAutostart: true, startMinimized: true); + + Assert.False(behavior.ShouldShowWindow); + Assert.False(behavior.ShowInTaskbar); + Assert.Equal(Visibility.Hidden, behavior.Visibility); + Assert.Equal(WindowState.Minimized, behavior.WindowState); + Assert.False(behavior.HideAfterShow); + Assert.False(behavior.ActivateAfterShow); + } + + [Fact] + public void Resolve_ShowsNormalWindow_ForAutostartWithStartMinimizedDisabled() + { + var behavior = StartupWindowBehavior.Resolve(isAutostart: true, startMinimized: false); + + Assert.True(behavior.ShouldShowWindow); + Assert.True(behavior.ShowInTaskbar); + Assert.Equal(Visibility.Visible, behavior.Visibility); + Assert.Equal(WindowState.Normal, behavior.WindowState); + Assert.False(behavior.HideAfterShow); + Assert.True(behavior.ActivateAfterShow); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/SystemTrayPlacementHelperTests.cs b/Tests/ThreadPilot.Core.Tests/SystemTrayPlacementHelperTests.cs index bd94dc9..a0e1f48 100644 --- a/Tests/ThreadPilot.Core.Tests/SystemTrayPlacementHelperTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SystemTrayPlacementHelperTests.cs @@ -1,65 +1,65 @@ -namespace ThreadPilot.Core.Tests -{ - using System.Drawing; - using ThreadPilot.Services; - - public sealed class SystemTrayPlacementHelperTests - { - [Fact] - public void ResolveMenuOpenPoint_UsesCursorPositionOnFirstOpen() - { - var cursor = new Point(1200, 700); - - var result = SystemTrayMenuPlacement.ResolveMenuOpenPoint( - cursor, - Point.Empty, - Rectangle.Empty, - new Rectangle(0, 0, 1920, 1080)); - - Assert.Equal(cursor, result); - Assert.NotEqual(Point.Empty, result); - } - - [Fact] - public void ResolveMenuOpenPoint_FallsBackToLastKnownPoint_WhenCursorIsUnavailable() - { - var lastKnown = new Point(1600, 900); - - var result = SystemTrayMenuPlacement.ResolveMenuOpenPoint( - Point.Empty, - lastKnown, - Rectangle.Empty, - new Rectangle(0, 0, 1920, 1080)); - - Assert.Equal(lastKnown, result); - } - - [Fact] - public void ResolveMenuOpenPoint_WhenFirstCursorIsUnavailable_UsesTaskbarAreaInsteadOfTopLeft() - { - var result = SystemTrayMenuPlacement.ResolveMenuOpenPoint( - Point.Empty, - Point.Empty, - Rectangle.Empty, - new Rectangle(0, 0, 1920, 1080)); - - Assert.NotEqual(Point.Empty, result); - Assert.True(result.X > 0); - Assert.True(result.Y > 0); - } - - [Fact] - public void ResolveMenuOpenPoint_WhenTrayBoundsAreInvalid_FallsBackToCursor() - { - var cursor = new Point(900, 500); - - var result = SystemTrayMenuPlacement.ResolveMenuOpenPoint( - cursor, - Point.Empty, - Rectangle.Empty, - new Rectangle(0, 0, 1920, 1080)); - - Assert.Equal(cursor, result); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using System.Drawing; + using ThreadPilot.Services; + + public sealed class SystemTrayPlacementHelperTests + { + [Fact] + public void ResolveMenuOpenPoint_UsesCursorPositionOnFirstOpen() + { + var cursor = new Point(1200, 700); + + var result = SystemTrayMenuPlacement.ResolveMenuOpenPoint( + cursor, + Point.Empty, + Rectangle.Empty, + new Rectangle(0, 0, 1920, 1080)); + + Assert.Equal(cursor, result); + Assert.NotEqual(Point.Empty, result); + } + + [Fact] + public void ResolveMenuOpenPoint_FallsBackToLastKnownPoint_WhenCursorIsUnavailable() + { + var lastKnown = new Point(1600, 900); + + var result = SystemTrayMenuPlacement.ResolveMenuOpenPoint( + Point.Empty, + lastKnown, + Rectangle.Empty, + new Rectangle(0, 0, 1920, 1080)); + + Assert.Equal(lastKnown, result); + } + + [Fact] + public void ResolveMenuOpenPoint_WhenFirstCursorIsUnavailable_UsesTaskbarAreaInsteadOfTopLeft() + { + var result = SystemTrayMenuPlacement.ResolveMenuOpenPoint( + Point.Empty, + Point.Empty, + Rectangle.Empty, + new Rectangle(0, 0, 1920, 1080)); + + Assert.NotEqual(Point.Empty, result); + Assert.True(result.X > 0); + Assert.True(result.Y > 0); + } + + [Fact] + public void ResolveMenuOpenPoint_WhenTrayBoundsAreInvalid_FallsBackToCursor() + { + var cursor = new Point(900, 500); + + var result = SystemTrayMenuPlacement.ResolveMenuOpenPoint( + cursor, + Point.Empty, + Rectangle.Empty, + new Rectangle(0, 0, 1920, 1080)); + + Assert.Equal(cursor, result); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/SystemTrayStatusUpdaterTests.cs b/Tests/ThreadPilot.Core.Tests/SystemTrayStatusUpdaterTests.cs index ae0b3f4..e856ca2 100644 --- a/Tests/ThreadPilot.Core.Tests/SystemTrayStatusUpdaterTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SystemTrayStatusUpdaterTests.cs @@ -1,72 +1,72 @@ -namespace ThreadPilot.Core.Tests -{ - using System.Collections.ObjectModel; - using Moq; - using ThreadPilot.Models; - using ThreadPilot.Services; - - public sealed class SystemTrayStatusUpdaterTests - { - [Fact] - public async Task UpdateContextMenuAsync_DiagnosticsHidden_DoesNotResolvePerformanceService() - { - var harness = new Harness(); - var updater = harness.CreateUpdater(performanceFactory: () => throw new InvalidOperationException("Performance service should not be resolved.")); - - await updater.UpdateContextMenuAsync(harness.Tray.Object); - - harness.Tray.Verify(x => x.UpdatePowerPlans(It.IsAny>(), It.IsAny()), Times.Once); - harness.Tray.Verify(x => x.UpdateProfiles(It.IsAny>()), Times.Once); - harness.Tray.Verify(x => x.UpdateSystemStatus("Balanced"), Times.Once); - harness.Tray.Verify(x => x.UpdateSystemStatus(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task UpdateStatusAsync_DiagnosticsHidden_DoesNotRequestLightweightMetrics() - { - var harness = new Harness(); - var updater = harness.CreateUpdater(performanceFactory: () => throw new InvalidOperationException("Performance service should not be resolved.")); - - var updated = await updater.UpdateStatusAsync(harness.Tray.Object, action => - { - action(); - return Task.CompletedTask; - }); - - Assert.True(updated); - Assert.False(updater.ShouldRunPerformanceStatusUpdates); - harness.Tray.Verify(x => x.UpdateSystemStatus("Balanced"), Times.Once); - harness.Tray.Verify(x => x.UpdateSystemStatus(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - - private sealed class Harness - { - public Mock Tray { get; } = new(MockBehavior.Strict); - - public Mock PowerPlan { get; } = new(MockBehavior.Strict); - - public Harness() - { - var activePlan = new PowerPlanModel { Guid = "balanced", Name = "Balanced" }; - this.PowerPlan - .Setup(x => x.GetPowerPlansAsync()) - .ReturnsAsync(new ObservableCollection { activePlan }); - this.PowerPlan - .Setup(x => x.GetActivePowerPlan()) - .ReturnsAsync(activePlan); - - this.Tray - .Setup(x => x.UpdatePowerPlans(It.IsAny>(), It.IsAny())); - this.Tray - .Setup(x => x.UpdateProfiles(It.IsAny>())); - this.Tray - .Setup(x => x.UpdateSystemStatus(It.IsAny())); - } - - public SystemTrayStatusUpdater CreateUpdater(Func performanceFactory) => - new( - this.PowerPlan.Object, - new Lazy(performanceFactory)); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using System.Collections.ObjectModel; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class SystemTrayStatusUpdaterTests + { + [Fact] + public async Task UpdateContextMenuAsync_DiagnosticsHidden_DoesNotResolvePerformanceService() + { + var harness = new Harness(); + var updater = harness.CreateUpdater(performanceFactory: () => throw new InvalidOperationException("Performance service should not be resolved.")); + + await updater.UpdateContextMenuAsync(harness.Tray.Object); + + harness.Tray.Verify(x => x.UpdatePowerPlans(It.IsAny>(), It.IsAny()), Times.Once); + harness.Tray.Verify(x => x.UpdateProfiles(It.IsAny>()), Times.Once); + harness.Tray.Verify(x => x.UpdateSystemStatus("Balanced"), Times.Once); + harness.Tray.Verify(x => x.UpdateSystemStatus(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdateStatusAsync_DiagnosticsHidden_DoesNotRequestLightweightMetrics() + { + var harness = new Harness(); + var updater = harness.CreateUpdater(performanceFactory: () => throw new InvalidOperationException("Performance service should not be resolved.")); + + var updated = await updater.UpdateStatusAsync(harness.Tray.Object, action => + { + action(); + return Task.CompletedTask; + }); + + Assert.True(updated); + Assert.False(updater.ShouldRunPerformanceStatusUpdates); + harness.Tray.Verify(x => x.UpdateSystemStatus("Balanced"), Times.Once); + harness.Tray.Verify(x => x.UpdateSystemStatus(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + private sealed class Harness + { + public Mock Tray { get; } = new(MockBehavior.Strict); + + public Mock PowerPlan { get; } = new(MockBehavior.Strict); + + public Harness() + { + var activePlan = new PowerPlanModel { Guid = "balanced", Name = "Balanced" }; + this.PowerPlan + .Setup(x => x.GetPowerPlansAsync()) + .ReturnsAsync(new ObservableCollection { activePlan }); + this.PowerPlan + .Setup(x => x.GetActivePowerPlan()) + .ReturnsAsync(activePlan); + + this.Tray + .Setup(x => x.UpdatePowerPlans(It.IsAny>(), It.IsAny())); + this.Tray + .Setup(x => x.UpdateProfiles(It.IsAny>())); + this.Tray + .Setup(x => x.UpdateSystemStatus(It.IsAny())); + } + + public SystemTrayStatusUpdater CreateUpdater(Func performanceFactory) => + new( + this.PowerPlan.Object, + new Lazy(performanceFactory)); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/SystemTweaksServiceTests.cs b/Tests/ThreadPilot.Core.Tests/SystemTweaksServiceTests.cs index f0f3f9b..83b5c4d 100644 --- a/Tests/ThreadPilot.Core.Tests/SystemTweaksServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SystemTweaksServiceTests.cs @@ -1,24 +1,24 @@ -namespace ThreadPilot.Core.Tests -{ - using ThreadPilot.Services; - - public sealed class SystemTweaksServiceTests - { - [Fact] - public void GetHighSchedulingCategoryRegistryValue_WhenEnabled_ReturnsWin32PrioritySeparation26() - { - var value = SystemTweaksService.GetHighSchedulingCategoryRegistryValue(enabled: true); - - Assert.Equal(26, value); - Assert.Equal(0x1A, value); - } - - [Fact] - public void GetHighSchedulingCategoryRegistryValue_WhenDisabled_KeepsDefaultRevertValue() - { - var value = SystemTweaksService.GetHighSchedulingCategoryRegistryValue(enabled: false); - - Assert.Equal(2, value); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Services; + + public sealed class SystemTweaksServiceTests + { + [Fact] + public void GetHighSchedulingCategoryRegistryValue_WhenEnabled_ReturnsWin32PrioritySeparation26() + { + var value = SystemTweaksService.GetHighSchedulingCategoryRegistryValue(enabled: true); + + Assert.Equal(26, value); + Assert.Equal(0x1A, value); + } + + [Fact] + public void GetHighSchedulingCategoryRegistryValue_WhenDisabled_KeepsDefaultRevertValue() + { + var value = SystemTweaksService.GetHighSchedulingCategoryRegistryValue(enabled: false); + + Assert.Equal(2, value); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/SystemTweaksViewModelTests.cs b/Tests/ThreadPilot.Core.Tests/SystemTweaksViewModelTests.cs index 7ee02d9..f9b0bc4 100644 --- a/Tests/ThreadPilot.Core.Tests/SystemTweaksViewModelTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SystemTweaksViewModelTests.cs @@ -1,166 +1,166 @@ -namespace ThreadPilot.Core.Tests -{ - using Microsoft.Extensions.Logging.Abstractions; - using Moq; - using ThreadPilot.Services; - using ThreadPilot.ViewModels; - - public sealed class SystemTweaksViewModelTests - { - [Theory] - [InlineData(SystemTweak.CoreParking, "Core Parking")] - [InlineData(SystemTweak.CStates, "C-States")] - [InlineData(SystemTweak.SysMain, "SysMain Service")] - [InlineData(SystemTweak.Prefetch, "Prefetch")] - [InlineData(SystemTweak.PowerThrottling, "Power Throttling")] - [InlineData(SystemTweak.Hpet, "HPET")] - [InlineData(SystemTweak.HighSchedulingCategory, "High Scheduling Category")] - [InlineData(SystemTweak.MenuShowDelay, "Menu Show Delay")] - public async Task ToggleTweakCommand_CallsExpectedServiceAndLogsSuccess(SystemTweak tweakType, string name) - { - var harness = new Harness(); - harness.SetupTweak(tweakType, setResult: true); - var viewModel = harness.CreateViewModel(); - var item = viewModel.TweakItems.Single(tweak => tweak.TweakType == tweakType); - - Assert.NotNull(item.ToggleCommand); - await item.ToggleCommand.ExecuteAsync(item); - - harness.VerifySetCalled(tweakType); - harness.Logging.Verify( - service => service.LogUserActionAsync( - "SystemTweakApplied", - $"{name} enabled", - tweakType.ToString()), - Times.Once); - var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); - Assert.Equal("Tweaks", entry.Category); - Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); - Assert.Equal($"{name} enabled", entry.Message); - Assert.Equal($"{name} enabled successfully", viewModel.StatusMessage); - } - - [Fact] - public async Task ToggleTweakCommand_WhenServiceFails_LogsFailureAndShowsSafeStatus() - { - var harness = new Harness(); - harness.Tweaks - .Setup(service => service.SetCoreParkingAsync(true)) - .ReturnsAsync(false); - var viewModel = harness.CreateViewModel(); - var item = viewModel.TweakItems.Single(tweak => tweak.TweakType == SystemTweak.CoreParking); - - Assert.NotNull(item.ToggleCommand); - await item.ToggleCommand.ExecuteAsync(item); - - harness.Logging.Verify( - service => service.LogUserActionAsync( - "SystemTweakFailed", - "Failed to enable Core Parking", - "CoreParking"), - Times.Once); - var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); - Assert.Equal("Tweaks", entry.Category); - Assert.Equal(ActivityAuditSeverity.Error, entry.Severity); - Assert.Equal("Failed to enable Core Parking", entry.Message); - Assert.True(viewModel.HasError); - Assert.Equal("Failed to toggle Core Parking", viewModel.ErrorMessage); - } - - private sealed class Harness - { - public Mock Tweaks { get; } = new(MockBehavior.Loose); - - public Mock Notifications { get; } = new(MockBehavior.Loose); - - public Mock Logging { get; } = new(MockBehavior.Loose); - - public ActivityAuditService Audit { get; } = new(NullLogger.Instance); - - public void SetupTweak(SystemTweak tweakType, bool setResult) - { - switch (tweakType) - { - case SystemTweak.CoreParking: - this.Tweaks.Setup(service => service.SetCoreParkingAsync(true)).ReturnsAsync(setResult); - this.Tweaks.Setup(service => service.GetCoreParkingStatusAsync()).ReturnsAsync(CreateEnabledStatus()); - break; - case SystemTweak.CStates: - this.Tweaks.Setup(service => service.SetCStatesAsync(true)).ReturnsAsync(setResult); - this.Tweaks.Setup(service => service.GetCStatesStatusAsync()).ReturnsAsync(CreateEnabledStatus()); - break; - case SystemTweak.SysMain: - this.Tweaks.Setup(service => service.SetSysMainAsync(true)).ReturnsAsync(setResult); - this.Tweaks.Setup(service => service.GetSysMainStatusAsync()).ReturnsAsync(CreateEnabledStatus()); - break; - case SystemTweak.Prefetch: - this.Tweaks.Setup(service => service.SetPrefetchAsync(true)).ReturnsAsync(setResult); - this.Tweaks.Setup(service => service.GetPrefetchStatusAsync()).ReturnsAsync(CreateEnabledStatus()); - break; - case SystemTweak.PowerThrottling: - this.Tweaks.Setup(service => service.SetPowerThrottlingAsync(true)).ReturnsAsync(setResult); - this.Tweaks.Setup(service => service.GetPowerThrottlingStatusAsync()).ReturnsAsync(CreateEnabledStatus()); - break; - case SystemTweak.Hpet: - this.Tweaks.Setup(service => service.SetHpetAsync(true)).ReturnsAsync(setResult); - this.Tweaks.Setup(service => service.GetHpetStatusAsync()).ReturnsAsync(CreateEnabledStatus()); - break; - case SystemTweak.HighSchedulingCategory: - this.Tweaks.Setup(service => service.SetHighSchedulingCategoryAsync(true)).ReturnsAsync(setResult); - this.Tweaks.Setup(service => service.GetHighSchedulingCategoryStatusAsync()).ReturnsAsync(CreateEnabledStatus()); - break; - case SystemTweak.MenuShowDelay: - this.Tweaks.Setup(service => service.SetMenuShowDelayAsync(true)).ReturnsAsync(setResult); - this.Tweaks.Setup(service => service.GetMenuShowDelayStatusAsync()).ReturnsAsync(CreateEnabledStatus()); - break; - default: - throw new ArgumentOutOfRangeException(nameof(tweakType), tweakType, null); - } - } - - public void VerifySetCalled(SystemTweak tweakType) - { - switch (tweakType) - { - case SystemTweak.CoreParking: - this.Tweaks.Verify(service => service.SetCoreParkingAsync(true), Times.Once); - break; - case SystemTweak.CStates: - this.Tweaks.Verify(service => service.SetCStatesAsync(true), Times.Once); - break; - case SystemTweak.SysMain: - this.Tweaks.Verify(service => service.SetSysMainAsync(true), Times.Once); - break; - case SystemTweak.Prefetch: - this.Tweaks.Verify(service => service.SetPrefetchAsync(true), Times.Once); - break; - case SystemTweak.PowerThrottling: - this.Tweaks.Verify(service => service.SetPowerThrottlingAsync(true), Times.Once); - break; - case SystemTweak.Hpet: - this.Tweaks.Verify(service => service.SetHpetAsync(true), Times.Once); - break; - case SystemTweak.HighSchedulingCategory: - this.Tweaks.Verify(service => service.SetHighSchedulingCategoryAsync(true), Times.Once); - break; - case SystemTweak.MenuShowDelay: - this.Tweaks.Verify(service => service.SetMenuShowDelayAsync(true), Times.Once); - break; - default: - throw new ArgumentOutOfRangeException(nameof(tweakType), tweakType, null); - } - } - - public SystemTweaksViewModel CreateViewModel() => - new( - this.Tweaks.Object, - this.Notifications.Object, - NullLogger.Instance, - this.Logging.Object, - this.Audit); - - private static TweakStatus CreateEnabledStatus() => - new() { IsEnabled = true, IsAvailable = true }; - } - } -} +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public sealed class SystemTweaksViewModelTests + { + [Theory] + [InlineData(SystemTweak.CoreParking, "Core Parking")] + [InlineData(SystemTweak.CStates, "C-States")] + [InlineData(SystemTweak.SysMain, "SysMain Service")] + [InlineData(SystemTweak.Prefetch, "Prefetch")] + [InlineData(SystemTweak.PowerThrottling, "Power Throttling")] + [InlineData(SystemTweak.Hpet, "HPET")] + [InlineData(SystemTweak.HighSchedulingCategory, "High Scheduling Category")] + [InlineData(SystemTweak.MenuShowDelay, "Menu Show Delay")] + public async Task ToggleTweakCommand_CallsExpectedServiceAndLogsSuccess(SystemTweak tweakType, string name) + { + var harness = new Harness(); + harness.SetupTweak(tweakType, setResult: true); + var viewModel = harness.CreateViewModel(); + var item = viewModel.TweakItems.Single(tweak => tweak.TweakType == tweakType); + + Assert.NotNull(item.ToggleCommand); + await item.ToggleCommand.ExecuteAsync(item); + + harness.VerifySetCalled(tweakType); + harness.Logging.Verify( + service => service.LogUserActionAsync( + "SystemTweakApplied", + $"{name} enabled", + tweakType.ToString()), + Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Tweaks", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + Assert.Equal($"{name} enabled", entry.Message); + Assert.Equal($"{name} enabled successfully", viewModel.StatusMessage); + } + + [Fact] + public async Task ToggleTweakCommand_WhenServiceFails_LogsFailureAndShowsSafeStatus() + { + var harness = new Harness(); + harness.Tweaks + .Setup(service => service.SetCoreParkingAsync(true)) + .ReturnsAsync(false); + var viewModel = harness.CreateViewModel(); + var item = viewModel.TweakItems.Single(tweak => tweak.TweakType == SystemTweak.CoreParking); + + Assert.NotNull(item.ToggleCommand); + await item.ToggleCommand.ExecuteAsync(item); + + harness.Logging.Verify( + service => service.LogUserActionAsync( + "SystemTweakFailed", + "Failed to enable Core Parking", + "CoreParking"), + Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Tweaks", entry.Category); + Assert.Equal(ActivityAuditSeverity.Error, entry.Severity); + Assert.Equal("Failed to enable Core Parking", entry.Message); + Assert.True(viewModel.HasError); + Assert.Equal("Failed to toggle Core Parking", viewModel.ErrorMessage); + } + + private sealed class Harness + { + public Mock Tweaks { get; } = new(MockBehavior.Loose); + + public Mock Notifications { get; } = new(MockBehavior.Loose); + + public Mock Logging { get; } = new(MockBehavior.Loose); + + public ActivityAuditService Audit { get; } = new(NullLogger.Instance); + + public void SetupTweak(SystemTweak tweakType, bool setResult) + { + switch (tweakType) + { + case SystemTweak.CoreParking: + this.Tweaks.Setup(service => service.SetCoreParkingAsync(true)).ReturnsAsync(setResult); + this.Tweaks.Setup(service => service.GetCoreParkingStatusAsync()).ReturnsAsync(CreateEnabledStatus()); + break; + case SystemTweak.CStates: + this.Tweaks.Setup(service => service.SetCStatesAsync(true)).ReturnsAsync(setResult); + this.Tweaks.Setup(service => service.GetCStatesStatusAsync()).ReturnsAsync(CreateEnabledStatus()); + break; + case SystemTweak.SysMain: + this.Tweaks.Setup(service => service.SetSysMainAsync(true)).ReturnsAsync(setResult); + this.Tweaks.Setup(service => service.GetSysMainStatusAsync()).ReturnsAsync(CreateEnabledStatus()); + break; + case SystemTweak.Prefetch: + this.Tweaks.Setup(service => service.SetPrefetchAsync(true)).ReturnsAsync(setResult); + this.Tweaks.Setup(service => service.GetPrefetchStatusAsync()).ReturnsAsync(CreateEnabledStatus()); + break; + case SystemTweak.PowerThrottling: + this.Tweaks.Setup(service => service.SetPowerThrottlingAsync(true)).ReturnsAsync(setResult); + this.Tweaks.Setup(service => service.GetPowerThrottlingStatusAsync()).ReturnsAsync(CreateEnabledStatus()); + break; + case SystemTweak.Hpet: + this.Tweaks.Setup(service => service.SetHpetAsync(true)).ReturnsAsync(setResult); + this.Tweaks.Setup(service => service.GetHpetStatusAsync()).ReturnsAsync(CreateEnabledStatus()); + break; + case SystemTweak.HighSchedulingCategory: + this.Tweaks.Setup(service => service.SetHighSchedulingCategoryAsync(true)).ReturnsAsync(setResult); + this.Tweaks.Setup(service => service.GetHighSchedulingCategoryStatusAsync()).ReturnsAsync(CreateEnabledStatus()); + break; + case SystemTweak.MenuShowDelay: + this.Tweaks.Setup(service => service.SetMenuShowDelayAsync(true)).ReturnsAsync(setResult); + this.Tweaks.Setup(service => service.GetMenuShowDelayStatusAsync()).ReturnsAsync(CreateEnabledStatus()); + break; + default: + throw new ArgumentOutOfRangeException(nameof(tweakType), tweakType, null); + } + } + + public void VerifySetCalled(SystemTweak tweakType) + { + switch (tweakType) + { + case SystemTweak.CoreParking: + this.Tweaks.Verify(service => service.SetCoreParkingAsync(true), Times.Once); + break; + case SystemTweak.CStates: + this.Tweaks.Verify(service => service.SetCStatesAsync(true), Times.Once); + break; + case SystemTweak.SysMain: + this.Tweaks.Verify(service => service.SetSysMainAsync(true), Times.Once); + break; + case SystemTweak.Prefetch: + this.Tweaks.Verify(service => service.SetPrefetchAsync(true), Times.Once); + break; + case SystemTweak.PowerThrottling: + this.Tweaks.Verify(service => service.SetPowerThrottlingAsync(true), Times.Once); + break; + case SystemTweak.Hpet: + this.Tweaks.Verify(service => service.SetHpetAsync(true), Times.Once); + break; + case SystemTweak.HighSchedulingCategory: + this.Tweaks.Verify(service => service.SetHighSchedulingCategoryAsync(true), Times.Once); + break; + case SystemTweak.MenuShowDelay: + this.Tweaks.Verify(service => service.SetMenuShowDelayAsync(true), Times.Once); + break; + default: + throw new ArgumentOutOfRangeException(nameof(tweakType), tweakType, null); + } + } + + public SystemTweaksViewModel CreateViewModel() => + new( + this.Tweaks.Object, + this.Notifications.Object, + NullLogger.Instance, + this.Logging.Object, + this.Audit); + + private static TweakStatus CreateEnabledStatus() => + new() { IsEnabled = true, IsAvailable = true }; + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/TaskSafetyTests.cs b/Tests/ThreadPilot.Core.Tests/TaskSafetyTests.cs index 0a8eb97..b11a6fb 100644 --- a/Tests/ThreadPilot.Core.Tests/TaskSafetyTests.cs +++ b/Tests/ThreadPilot.Core.Tests/TaskSafetyTests.cs @@ -1,47 +1,38 @@ -/* - * ThreadPilot - async safety unit tests. - */ -namespace ThreadPilot.Core.Tests -{ - using ThreadPilot.Services; - - /// - /// Unit tests for . - /// - public sealed class TaskSafetyTests - { - /// - /// Ensures faulted tasks are observed and routed to the provided callback. - /// - [Fact] - public async Task FireAndForget_InvokesErrorCallback_ForFaultedTask() - { - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var expected = new InvalidOperationException("boom"); - - TaskSafety.FireAndForget(Task.FromException(expected), ex => completion.TrySetResult(ex)); - - var finishedTask = await Task.WhenAny(completion.Task, Task.Delay(TimeSpan.FromSeconds(2))); - Assert.Same(completion.Task, finishedTask); - var observed = await completion.Task; - Assert.IsType(observed); - Assert.Equal("boom", observed.Message); - } - - /// - /// Ensures cancellation does not trigger the error callback. - /// - [Fact] - public async Task FireAndForget_DoesNotInvokeErrorCallback_ForCanceledTask() - { - var callbackInvoked = false; - using var cancellation = new CancellationTokenSource(); - cancellation.Cancel(); - - TaskSafety.FireAndForget(Task.FromCanceled(cancellation.Token), _ => callbackInvoked = true); - - await Task.Delay(150); - Assert.False(callbackInvoked); - } - } -} +/* + * ThreadPilot - async safety unit tests. + */ +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Services; + + public sealed class TaskSafetyTests + { + [Fact] + public async Task FireAndForget_InvokesErrorCallback_ForFaultedTask() + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var expected = new InvalidOperationException("boom"); + + TaskSafety.FireAndForget(Task.FromException(expected), ex => completion.TrySetResult(ex)); + + var finishedTask = await Task.WhenAny(completion.Task, Task.Delay(TimeSpan.FromSeconds(2))); + Assert.Same(completion.Task, finishedTask); + var observed = await completion.Task; + Assert.IsType(observed); + Assert.Equal("boom", observed.Message); + } + + [Fact] + public async Task FireAndForget_DoesNotInvokeErrorCallback_ForCanceledTask() + { + var callbackInvoked = false; + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + TaskSafety.FireAndForget(Task.FromCanceled(cancellation.Token), _ => callbackInvoked = true); + + await Task.Delay(150); + Assert.False(callbackInvoked); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ThemeDictionaryPolicyTests.cs b/Tests/ThreadPilot.Core.Tests/ThemeDictionaryPolicyTests.cs index 8f4e384..5a07992 100644 --- a/Tests/ThreadPilot.Core.Tests/ThemeDictionaryPolicyTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ThemeDictionaryPolicyTests.cs @@ -1,139 +1,139 @@ -namespace ThreadPilot.Core.Tests -{ - using System.Reflection; - using System.Windows; - using ThreadPilot.Services; - - public sealed class ThemeDictionaryPolicyTests - { - [Theory] - [InlineData("Themes/FluentDark.xaml")] - [InlineData("Themes/FluentLight.xaml")] - [InlineData("/ThreadPilot;component/Themes/FluentDark.xaml")] - [InlineData("pack://application:,,,/ThreadPilot;component/Themes/FluentLight.xaml")] - public void IsThreadPilotThemeDictionary_RecognizesAppThemeDictionaries(string source) - { - Assert.True(ThemeDictionaryPolicy.IsThreadPilotThemeDictionary(source)); - } - - [Fact] - public void GetInsertionIndex_AppendsThemeDictionaryToPreserveAppResourcePrecedence() - { - Assert.Equal(3, ThemeDictionaryPolicy.GetInsertionIndex(3)); - } - - [Fact] - public void ReplaceThreadPilotThemeDictionary_RemovesOldThemeDictionariesAndAppendsRequestedTheme() - { - var lightThemeUri = new Uri("Themes/FluentLight.xaml", UriKind.Relative); - var darkThemeUri = new Uri("/ThreadPilot;component/Themes/FluentDark.xaml", UriKind.Relative); - var resources = new ResourceDictionary(); - resources.MergedDictionaries.Add(new ResourceDictionary()); - resources.MergedDictionaries.Add(CreateDictionaryWithSource(lightThemeUri)); - resources.MergedDictionaries.Add(CreateDictionaryWithSource(darkThemeUri)); - - var activeDictionary = ThemeDictionaryPolicy.ReplaceThreadPilotThemeDictionary( - resources, - lightThemeUri, - CreateDictionaryWithSource); - - Assert.Same(activeDictionary, resources.MergedDictionaries[^1]); - Assert.Equal(lightThemeUri.OriginalString, activeDictionary.Source.OriginalString); - Assert.Single( - resources.MergedDictionaries, - dictionary => ThemeDictionaryPolicy.IsThreadPilotThemeDictionary(dictionary.Source?.OriginalString)); - } - - [Fact] - public void ReplaceThreadPilotThemeDictionary_WhenRequestedThemeIsAlreadyActive_ReusesExistingDictionary() - { - var darkThemeUri = new Uri("Themes/FluentDark.xaml", UriKind.Relative); - var resources = new ResourceDictionary(); - var activeDictionary = CreateDictionaryWithSource(darkThemeUri); - resources.MergedDictionaries.Add(new ResourceDictionary()); - resources.MergedDictionaries.Add(activeDictionary); - var factoryCalls = 0; - - var result = ThemeDictionaryPolicy.ReplaceThreadPilotThemeDictionary( - resources, - darkThemeUri, - uri => - { - factoryCalls++; - return CreateDictionaryWithSource(uri); - }); - - Assert.Same(activeDictionary, result); - Assert.Equal(0, factoryCalls); - Assert.Same(activeDictionary, resources.MergedDictionaries[^1]); - } - - [Theory] - [InlineData("Themes/FluentDark.xaml")] - [InlineData("Themes/FluentLight.xaml")] - public void ThemeDictionaries_DefineSharedVisualResourceKeys(string themePath) - { - var themeText = File.ReadAllText(GetRepositoryFilePath(themePath)); - - Assert.Contains("StandardCardCornerRadius", themeText, StringComparison.Ordinal); - Assert.Contains("StandardCardPadding", themeText, StringComparison.Ordinal); - Assert.Contains("StandardCardStyle", themeText, StringComparison.Ordinal); - Assert.Contains("PageTitleTextStyle", themeText, StringComparison.Ordinal); - Assert.Contains("PageSubtitleTextStyle", themeText, StringComparison.Ordinal); - Assert.Contains("SectionTitleTextStyle", themeText, StringComparison.Ordinal); - Assert.Contains("QuietRowBackgroundBrush", themeText, StringComparison.Ordinal); - Assert.Contains("StatusPillBackgroundBrush", themeText, StringComparison.Ordinal); - Assert.Contains("AppFontFamily", themeText, StringComparison.Ordinal); - Assert.Contains("MaskSelectedBackgroundBrush", themeText, StringComparison.Ordinal); - Assert.Contains("MaskSelectedListBackgroundBrush", themeText, StringComparison.Ordinal); - Assert.Contains("MaskSelectedBorderBrush", themeText, StringComparison.Ordinal); - } - - [Fact] - public void DarkTheme_MaskListSelectionUsesSubtleTintWithoutAccentForeground() - { - var themeText = File.ReadAllText(GetRepositoryFilePath("Themes/FluentDark.xaml")); - - Assert.Contains("x:Key=\"MaskSelectedListBackgroundBrush\"", themeText, StringComparison.Ordinal); - Assert.Contains("Opacity=\"0.05\"", themeText, StringComparison.Ordinal); - Assert.Contains("x:Key=\"MaskSelectedBorderBrush\"", themeText, StringComparison.Ordinal); - Assert.DoesNotContain( - "x:Key=\"MaskSelectedListBackgroundBrush\" Color=\"{StaticResource AccentFillColorDefault}\"", - themeText, - StringComparison.Ordinal); - Assert.DoesNotContain( - "x:Key=\"MaskSelectedListForegroundBrush\" Color=\"{StaticResource TextOnAccentFillColorPrimary}\"", - themeText, - StringComparison.Ordinal); - } - - private static ResourceDictionary CreateDictionaryWithSource(Uri source) - { - var dictionary = new ResourceDictionary(); - var sourceField = typeof(ResourceDictionary).GetField("_source", BindingFlags.Instance | BindingFlags.NonPublic); - if (sourceField == null) - { - throw new InvalidOperationException("ResourceDictionary source field was not found."); - } - - sourceField.SetValue(dictionary, source); - return dictionary; - } - - private static string GetRepositoryFilePath(string relativePath) - { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory != null && !File.Exists(Path.Combine(directory.FullName, "ThreadPilot.csproj"))) - { - directory = directory.Parent; - } - - if (directory == null) - { - throw new InvalidOperationException("Repository root was not found."); - } - - return Path.Combine(directory.FullName, relativePath); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using System.Reflection; + using System.Windows; + using ThreadPilot.Services; + + public sealed class ThemeDictionaryPolicyTests + { + [Theory] + [InlineData("Themes/FluentDark.xaml")] + [InlineData("Themes/FluentLight.xaml")] + [InlineData("/ThreadPilot;component/Themes/FluentDark.xaml")] + [InlineData("pack://application:,,,/ThreadPilot;component/Themes/FluentLight.xaml")] + public void IsThreadPilotThemeDictionary_RecognizesAppThemeDictionaries(string source) + { + Assert.True(ThemeDictionaryPolicy.IsThreadPilotThemeDictionary(source)); + } + + [Fact] + public void GetInsertionIndex_AppendsThemeDictionaryToPreserveAppResourcePrecedence() + { + Assert.Equal(3, ThemeDictionaryPolicy.GetInsertionIndex(3)); + } + + [Fact] + public void ReplaceThreadPilotThemeDictionary_RemovesOldThemeDictionariesAndAppendsRequestedTheme() + { + var lightThemeUri = new Uri("Themes/FluentLight.xaml", UriKind.Relative); + var darkThemeUri = new Uri("/ThreadPilot;component/Themes/FluentDark.xaml", UriKind.Relative); + var resources = new ResourceDictionary(); + resources.MergedDictionaries.Add(new ResourceDictionary()); + resources.MergedDictionaries.Add(CreateDictionaryWithSource(lightThemeUri)); + resources.MergedDictionaries.Add(CreateDictionaryWithSource(darkThemeUri)); + + var activeDictionary = ThemeDictionaryPolicy.ReplaceThreadPilotThemeDictionary( + resources, + lightThemeUri, + CreateDictionaryWithSource); + + Assert.Same(activeDictionary, resources.MergedDictionaries[^1]); + Assert.Equal(lightThemeUri.OriginalString, activeDictionary.Source.OriginalString); + Assert.Single( + resources.MergedDictionaries, + dictionary => ThemeDictionaryPolicy.IsThreadPilotThemeDictionary(dictionary.Source?.OriginalString)); + } + + [Fact] + public void ReplaceThreadPilotThemeDictionary_WhenRequestedThemeIsAlreadyActive_ReusesExistingDictionary() + { + var darkThemeUri = new Uri("Themes/FluentDark.xaml", UriKind.Relative); + var resources = new ResourceDictionary(); + var activeDictionary = CreateDictionaryWithSource(darkThemeUri); + resources.MergedDictionaries.Add(new ResourceDictionary()); + resources.MergedDictionaries.Add(activeDictionary); + var factoryCalls = 0; + + var result = ThemeDictionaryPolicy.ReplaceThreadPilotThemeDictionary( + resources, + darkThemeUri, + uri => + { + factoryCalls++; + return CreateDictionaryWithSource(uri); + }); + + Assert.Same(activeDictionary, result); + Assert.Equal(0, factoryCalls); + Assert.Same(activeDictionary, resources.MergedDictionaries[^1]); + } + + [Theory] + [InlineData("Themes/FluentDark.xaml")] + [InlineData("Themes/FluentLight.xaml")] + public void ThemeDictionaries_DefineSharedVisualResourceKeys(string themePath) + { + var themeText = File.ReadAllText(GetRepositoryFilePath(themePath)); + + Assert.Contains("StandardCardCornerRadius", themeText, StringComparison.Ordinal); + Assert.Contains("StandardCardPadding", themeText, StringComparison.Ordinal); + Assert.Contains("StandardCardStyle", themeText, StringComparison.Ordinal); + Assert.Contains("PageTitleTextStyle", themeText, StringComparison.Ordinal); + Assert.Contains("PageSubtitleTextStyle", themeText, StringComparison.Ordinal); + Assert.Contains("SectionTitleTextStyle", themeText, StringComparison.Ordinal); + Assert.Contains("QuietRowBackgroundBrush", themeText, StringComparison.Ordinal); + Assert.Contains("StatusPillBackgroundBrush", themeText, StringComparison.Ordinal); + Assert.Contains("AppFontFamily", themeText, StringComparison.Ordinal); + Assert.Contains("MaskSelectedBackgroundBrush", themeText, StringComparison.Ordinal); + Assert.Contains("MaskSelectedListBackgroundBrush", themeText, StringComparison.Ordinal); + Assert.Contains("MaskSelectedBorderBrush", themeText, StringComparison.Ordinal); + } + + [Fact] + public void DarkTheme_MaskListSelectionUsesSubtleTintWithoutAccentForeground() + { + var themeText = File.ReadAllText(GetRepositoryFilePath("Themes/FluentDark.xaml")); + + Assert.Contains("x:Key=\"MaskSelectedListBackgroundBrush\"", themeText, StringComparison.Ordinal); + Assert.Contains("Opacity=\"0.05\"", themeText, StringComparison.Ordinal); + Assert.Contains("x:Key=\"MaskSelectedBorderBrush\"", themeText, StringComparison.Ordinal); + Assert.DoesNotContain( + "x:Key=\"MaskSelectedListBackgroundBrush\" Color=\"{StaticResource AccentFillColorDefault}\"", + themeText, + StringComparison.Ordinal); + Assert.DoesNotContain( + "x:Key=\"MaskSelectedListForegroundBrush\" Color=\"{StaticResource TextOnAccentFillColorPrimary}\"", + themeText, + StringComparison.Ordinal); + } + + private static ResourceDictionary CreateDictionaryWithSource(Uri source) + { + var dictionary = new ResourceDictionary(); + var sourceField = typeof(ResourceDictionary).GetField("_source", BindingFlags.Instance | BindingFlags.NonPublic); + if (sourceField == null) + { + throw new InvalidOperationException("ResourceDictionary source field was not found."); + } + + sourceField.SetValue(dictionary, source); + return dictionary; + } + + private static string GetRepositoryFilePath(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "ThreadPilot.csproj"))) + { + directory = directory.Parent; + } + + if (directory == null) + { + throw new InvalidOperationException("Repository root was not found."); + } + + return Path.Combine(directory.FullName, relativePath); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs b/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs index 2f9729e..b3be706 100644 --- a/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs @@ -1,323 +1,323 @@ -namespace ThreadPilot.Core.Tests -{ - using System; - using System.Collections.Generic; - using System.IO; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging.Abstractions; - using Moq; - using ThreadPilot.Models; - using ThreadPilot.Services; - using ThreadPilot.Services.Abstractions; - - public sealed class UpdateServiceTests - { - [Fact] - public void SemanticVersion_OrdersStableAbovePrerelease() - { - Assert.True(SemanticVersion.TryParse("v1.4.0-beta.1", out var prerelease)); - Assert.True(SemanticVersion.TryParse("1.4.0", out var stable)); - - Assert.True(stable > prerelease); - } - - [Fact] - public async Task GitHubUpdateChecker_ExcludesPrereleasesByDefault() - { - var checker = new GitHubUpdateChecker(new FakeGitHubReleaseClient( - """ - [ - { "tag_name": "v1.5.0-beta.1", "prerelease": true, "draft": false, "html_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.5.0-beta.1", "assets": [] }, - { "tag_name": "v1.4.0", "prerelease": false, "draft": false, "html_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.4.0", "assets": [] } - ] - """)); - - var release = await checker.GetLatestReleaseInfoAsync("PrimeBuild-pc", "ThreadPilot"); - - Assert.NotNull(release); - Assert.Equal("1.4.0", release.Version.ToString()); - } - - [Fact] - public async Task CheckForUpdatesAsync_StartupSkipsWhenLastCheckInsideInterval() - { - var harness = new Harness(); - harness.Settings.LastUpdateCheckUtc = harness.Clock.UtcNow.AddDays(-2); - - var result = await harness.Service.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup)); - - Assert.Equal(UpdateCheckStatus.Skipped, result.Status); - Assert.False(harness.ReleaseClient.RequestedReleases); - } - - [Fact] - public async Task CheckForUpdatesAsync_ManualFindsNewerStableRelease() - { - var harness = new Harness(); - - var result = await harness.Service.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Manual)); - - Assert.True(result.IsUpdateAvailable); - Assert.Equal("1.4.0", result.Release?.Version.ToString()); - Assert.Equal(harness.Clock.UtcNow, harness.SavedSettings?.LastUpdateCheckUtc); - } - - [Fact] - public void UpdateAssetSelector_SelectsInstallerAndRejectsPortable() - { - var release = CreateRelease( - new UpdateAsset("ThreadPilot_v1.4.0_Portable.zip", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Portable.zip"), 1), - new UpdateAsset("ThreadPilot_v1.4.0_Setup.exe", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Setup.exe"), 1)); - - var selected = UpdateAssetSelector.TrySelectInstaller(release, out var asset); - - Assert.True(selected); - Assert.Equal("ThreadPilot_v1.4.0_Setup.exe", asset.Name); - } - - [Fact] - public async Task DownloadInstallerAsync_VerifiesChecksum() - { - using var tempRoot = new TempDirectory(); - var installerBytes = Encoding.UTF8.GetBytes("installer-content"); - var expectedHash = ComputeSha256(installerBytes); - var client = new FakeUpdateDownloadClient(installerBytes, $"{expectedHash} ThreadPilot_v1.4.0_Setup.exe"); - var service = CreateDownloadService(tempRoot.Path, client); - - var result = await service.DownloadInstallerAsync(CreateReleaseWithInstallerAndChecksum()); - - Assert.True(result.ChecksumVerified); - Assert.True(File.Exists(result.InstallerPath)); - } - - [Fact] - public async Task DownloadInstallerAsync_RejectsInvalidChecksumAndCleansTemp() - { - using var tempRoot = new TempDirectory(); - var client = new FakeUpdateDownloadClient(Encoding.UTF8.GetBytes("installer-content"), $"{new string('0', 64)} ThreadPilot_v1.4.0_Setup.exe"); - var service = CreateDownloadService(tempRoot.Path, client); - - await Assert.ThrowsAsync(() => service.DownloadInstallerAsync(CreateReleaseWithInstallerAndChecksum())); - Assert.Empty(Directory.GetDirectories(tempRoot.Path)); - } - - [Fact] - public void UpdateTempDirectoryProvider_DoesNotDeleteOutsideUpdateRoot() - { - using var tempRoot = new TempDirectory(); - using var outside = new TempDirectory(); - File.WriteAllText(Path.Combine(outside.Path, "settings.json"), "{}"); - var provider = new UpdateTempDirectoryProvider(tempRoot.Path); - - provider.Cleanup(outside.Path); - - Assert.True(File.Exists(Path.Combine(outside.Path, "settings.json"))); - } - - [Fact] - public async Task StartupCheck_DoesNotDownloadOrInstallWithoutUserConsent() - { - var harness = new Harness(); - - var result = await harness.Service.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup)); - - Assert.True(result.IsUpdateAvailable); - harness.Download.Verify(service => service.DownloadInstallerAsync(It.IsAny(), It.IsAny()), Times.Never); - harness.Installer.Verify(service => service.LaunchInstallerElevatedAsync(It.IsAny(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task DownloadAndInstallAsync_StartsInstallerAndRequestsShutdown() - { - var harness = new Harness(); - harness.Download - .Setup(service => service.DownloadInstallerAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new UpdateDownloadResult( - Path.Combine(harness.TempDirectory, "ThreadPilot_v1.4.0_Setup.exe"), - harness.TempDirectory, - true, - UpdateSignatureStatus.Unknown, - "ok")); - File.WriteAllText(Path.Combine(harness.TempDirectory, "ThreadPilot_v1.4.0_Setup.exe"), "installer"); - - var result = await harness.Service.DownloadAndInstallAsync(CreateReleaseWithInstallerAndChecksum()); - - Assert.Equal(UpdateInstallStatus.Started, result.Status); - harness.Installer.Verify(service => service.LaunchInstallerElevatedAsync(It.IsAny(), It.IsAny()), Times.Once); - harness.Shutdown.Verify(service => service.RequestShutdownForUpdate(), Times.Once); - } - - private static UpdateDownloadService CreateDownloadService(string tempRoot, IUpdateDownloadClient client) - { - var signature = new Mock(); - signature.Setup(verifier => verifier.Verify(It.IsAny())).Returns(UpdateSignatureStatus.Unknown); - return new UpdateDownloadService( - client, - new UpdateTempDirectoryProvider(tempRoot), - signature.Object, - NullLogger.Instance); - } - - private static UpdateReleaseInfo CreateReleaseWithInstallerAndChecksum() - { - return CreateRelease( - new UpdateAsset("ThreadPilot_v1.4.0_Setup.exe", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Setup.exe"), 10), - new UpdateAsset("SHA256SUMS.txt", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/SHA256SUMS.txt"), 10)); - } - - private static UpdateReleaseInfo CreateRelease(params UpdateAsset[] assets) - { - return new UpdateReleaseInfo( - new SemanticVersion(1, 4, 0), - "v1.4.0", - new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.4.0"), - false, - assets); - } - - private static string ComputeSha256(byte[] bytes) - { - var hash = System.Security.Cryptography.SHA256.HashData(bytes); - return Convert.ToHexString(hash); - } - - private sealed class Harness - { - public ApplicationSettingsModel Settings { get; } = new(); - - public ApplicationSettingsModel? SavedSettings { get; private set; } - - public FakeClock Clock { get; } = new(); - - public FakeGitHubReleaseClient ReleaseClient { get; } = new( - """ - [ - { - "tag_name": "v1.4.0", - "prerelease": false, - "draft": false, - "html_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.4.0", - "assets": [ - { "name": "ThreadPilot_v1.4.0_Setup.exe", "browser_download_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Setup.exe", "size": 100 }, - { "name": "SHA256SUMS.txt", "browser_download_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/SHA256SUMS.txt", "size": 100 } - ] - } - ] - """); - - public Mock Download { get; } = new(MockBehavior.Strict); - - public Mock Installer { get; } = new(MockBehavior.Strict); - - public Mock Shutdown { get; } = new(MockBehavior.Strict); - - public string TempDirectory { get; } - - public UpdateService Service { get; } - - public Harness() - { - this.TempDirectory = Directory.CreateTempSubdirectory("ThreadPilotUpdateTest").FullName; - var settingsService = new Mock(); - settingsService.SetupGet(service => service.Settings).Returns(() => (ApplicationSettingsModel)this.Settings.Clone()); - settingsService - .Setup(service => service.UpdateSettingsAsync(It.IsAny())) - .Callback(settings => - { - this.SavedSettings = (ApplicationSettingsModel)settings.Clone(); - this.Settings.CopyFrom(settings); - }) - .Returns(Task.CompletedTask); - - var versionProvider = new Mock(); - versionProvider.SetupGet(provider => provider.CurrentVersion).Returns(new SemanticVersion(1, 3, 1)); - versionProvider.SetupGet(provider => provider.DisplayVersion).Returns("v1.3.1"); - - var tempProvider = new Mock(); - tempProvider.Setup(provider => provider.Cleanup(It.IsAny())); - - this.Shutdown.Setup(service => service.RequestShutdownForUpdate()); - this.Installer - .Setup(service => service.LaunchInstallerElevatedAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - - this.Service = new UpdateService( - new GitHubUpdateChecker(this.ReleaseClient), - settingsService.Object, - versionProvider.Object, - this.Download.Object, - this.Installer.Object, - tempProvider.Object, - this.Shutdown.Object, - this.Clock, - NullLogger.Instance); - } - } - - private sealed class FakeClock : IUpdateClock - { - public DateTimeOffset UtcNow { get; } = new(2026, 6, 7, 12, 0, 0, TimeSpan.Zero); - } - - private sealed class FakeGitHubReleaseClient : IGitHubReleaseClient - { - private readonly string releasesJson; - - public bool RequestedReleases { get; private set; } - - public FakeGitHubReleaseClient(string releasesJson) - { - this.releasesJson = releasesJson; - } - - public Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default) - { - throw new NotSupportedException(); - } - - public Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default) - { - this.RequestedReleases = true; - return Task.FromResult(this.releasesJson); - } - } - - private sealed class FakeUpdateDownloadClient : IUpdateDownloadClient - { - private readonly byte[] fileBytes; - private readonly string? checksumsText; - - public FakeUpdateDownloadClient(byte[] fileBytes, string? checksumsText) - { - this.fileBytes = fileBytes; - this.checksumsText = checksumsText; - } - - public Task DownloadFileAsync(Uri uri, string destinationPath, CancellationToken cancellationToken = default) - { - File.WriteAllBytes(destinationPath, this.fileBytes); - return Task.CompletedTask; - } - - public Task TryDownloadStringAsync(Uri uri, CancellationToken cancellationToken = default) - { - return Task.FromResult(this.checksumsText); - } - } - - private sealed class TempDirectory : IDisposable - { - public string Path { get; } = Directory.CreateTempSubdirectory("ThreadPilotUpdateTest").FullName; - - public void Dispose() - { - if (Directory.Exists(this.Path)) - { - Directory.Delete(this.Path, recursive: true); - } - } - } - } -} +namespace ThreadPilot.Core.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.Services.Abstractions; + + public sealed class UpdateServiceTests + { + [Fact] + public void SemanticVersion_OrdersStableAbovePrerelease() + { + Assert.True(SemanticVersion.TryParse("v1.4.0-beta.1", out var prerelease)); + Assert.True(SemanticVersion.TryParse("1.4.0", out var stable)); + + Assert.True(stable > prerelease); + } + + [Fact] + public async Task GitHubUpdateChecker_ExcludesPrereleasesByDefault() + { + var checker = new GitHubUpdateChecker(new FakeGitHubReleaseClient( + """ + [ + { "tag_name": "v1.5.0-beta.1", "prerelease": true, "draft": false, "html_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.5.0-beta.1", "assets": [] }, + { "tag_name": "v1.4.0", "prerelease": false, "draft": false, "html_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.4.0", "assets": [] } + ] + """)); + + var release = await checker.GetLatestReleaseInfoAsync("PrimeBuild-pc", "ThreadPilot"); + + Assert.NotNull(release); + Assert.Equal("1.4.0", release.Version.ToString()); + } + + [Fact] + public async Task CheckForUpdatesAsync_StartupSkipsWhenLastCheckInsideInterval() + { + var harness = new Harness(); + harness.Settings.LastUpdateCheckUtc = harness.Clock.UtcNow.AddDays(-2); + + var result = await harness.Service.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup)); + + Assert.Equal(UpdateCheckStatus.Skipped, result.Status); + Assert.False(harness.ReleaseClient.RequestedReleases); + } + + [Fact] + public async Task CheckForUpdatesAsync_ManualFindsNewerStableRelease() + { + var harness = new Harness(); + + var result = await harness.Service.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Manual)); + + Assert.True(result.IsUpdateAvailable); + Assert.Equal("1.4.0", result.Release?.Version.ToString()); + Assert.Equal(harness.Clock.UtcNow, harness.SavedSettings?.LastUpdateCheckUtc); + } + + [Fact] + public void UpdateAssetSelector_SelectsInstallerAndRejectsPortable() + { + var release = CreateRelease( + new UpdateAsset("ThreadPilot_v1.4.0_Portable.zip", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Portable.zip"), 1), + new UpdateAsset("ThreadPilot_v1.4.0_Setup.exe", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Setup.exe"), 1)); + + var selected = UpdateAssetSelector.TrySelectInstaller(release, out var asset); + + Assert.True(selected); + Assert.Equal("ThreadPilot_v1.4.0_Setup.exe", asset.Name); + } + + [Fact] + public async Task DownloadInstallerAsync_VerifiesChecksum() + { + using var tempRoot = new TempDirectory(); + var installerBytes = Encoding.UTF8.GetBytes("installer-content"); + var expectedHash = ComputeSha256(installerBytes); + var client = new FakeUpdateDownloadClient(installerBytes, $"{expectedHash} ThreadPilot_v1.4.0_Setup.exe"); + var service = CreateDownloadService(tempRoot.Path, client); + + var result = await service.DownloadInstallerAsync(CreateReleaseWithInstallerAndChecksum()); + + Assert.True(result.ChecksumVerified); + Assert.True(File.Exists(result.InstallerPath)); + } + + [Fact] + public async Task DownloadInstallerAsync_RejectsInvalidChecksumAndCleansTemp() + { + using var tempRoot = new TempDirectory(); + var client = new FakeUpdateDownloadClient(Encoding.UTF8.GetBytes("installer-content"), $"{new string('0', 64)} ThreadPilot_v1.4.0_Setup.exe"); + var service = CreateDownloadService(tempRoot.Path, client); + + await Assert.ThrowsAsync(() => service.DownloadInstallerAsync(CreateReleaseWithInstallerAndChecksum())); + Assert.Empty(Directory.GetDirectories(tempRoot.Path)); + } + + [Fact] + public void UpdateTempDirectoryProvider_DoesNotDeleteOutsideUpdateRoot() + { + using var tempRoot = new TempDirectory(); + using var outside = new TempDirectory(); + File.WriteAllText(Path.Combine(outside.Path, "settings.json"), "{}"); + var provider = new UpdateTempDirectoryProvider(tempRoot.Path); + + provider.Cleanup(outside.Path); + + Assert.True(File.Exists(Path.Combine(outside.Path, "settings.json"))); + } + + [Fact] + public async Task StartupCheck_DoesNotDownloadOrInstallWithoutUserConsent() + { + var harness = new Harness(); + + var result = await harness.Service.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup)); + + Assert.True(result.IsUpdateAvailable); + harness.Download.Verify(service => service.DownloadInstallerAsync(It.IsAny(), It.IsAny()), Times.Never); + harness.Installer.Verify(service => service.LaunchInstallerElevatedAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task DownloadAndInstallAsync_StartsInstallerAndRequestsShutdown() + { + var harness = new Harness(); + harness.Download + .Setup(service => service.DownloadInstallerAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UpdateDownloadResult( + Path.Combine(harness.TempDirectory, "ThreadPilot_v1.4.0_Setup.exe"), + harness.TempDirectory, + true, + UpdateSignatureStatus.Unknown, + "ok")); + File.WriteAllText(Path.Combine(harness.TempDirectory, "ThreadPilot_v1.4.0_Setup.exe"), "installer"); + + var result = await harness.Service.DownloadAndInstallAsync(CreateReleaseWithInstallerAndChecksum()); + + Assert.Equal(UpdateInstallStatus.Started, result.Status); + harness.Installer.Verify(service => service.LaunchInstallerElevatedAsync(It.IsAny(), It.IsAny()), Times.Once); + harness.Shutdown.Verify(service => service.RequestShutdownForUpdate(), Times.Once); + } + + private static UpdateDownloadService CreateDownloadService(string tempRoot, IUpdateDownloadClient client) + { + var signature = new Mock(); + signature.Setup(verifier => verifier.Verify(It.IsAny())).Returns(UpdateSignatureStatus.Unknown); + return new UpdateDownloadService( + client, + new UpdateTempDirectoryProvider(tempRoot), + signature.Object, + NullLogger.Instance); + } + + private static UpdateReleaseInfo CreateReleaseWithInstallerAndChecksum() + { + return CreateRelease( + new UpdateAsset("ThreadPilot_v1.4.0_Setup.exe", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Setup.exe"), 10), + new UpdateAsset("SHA256SUMS.txt", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/SHA256SUMS.txt"), 10)); + } + + private static UpdateReleaseInfo CreateRelease(params UpdateAsset[] assets) + { + return new UpdateReleaseInfo( + new SemanticVersion(1, 4, 0), + "v1.4.0", + new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.4.0"), + false, + assets); + } + + private static string ComputeSha256(byte[] bytes) + { + var hash = System.Security.Cryptography.SHA256.HashData(bytes); + return Convert.ToHexString(hash); + } + + private sealed class Harness + { + public ApplicationSettingsModel Settings { get; } = new(); + + public ApplicationSettingsModel? SavedSettings { get; private set; } + + public FakeClock Clock { get; } = new(); + + public FakeGitHubReleaseClient ReleaseClient { get; } = new( + """ + [ + { + "tag_name": "v1.4.0", + "prerelease": false, + "draft": false, + "html_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.4.0", + "assets": [ + { "name": "ThreadPilot_v1.4.0_Setup.exe", "browser_download_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Setup.exe", "size": 100 }, + { "name": "SHA256SUMS.txt", "browser_download_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/SHA256SUMS.txt", "size": 100 } + ] + } + ] + """); + + public Mock Download { get; } = new(MockBehavior.Strict); + + public Mock Installer { get; } = new(MockBehavior.Strict); + + public Mock Shutdown { get; } = new(MockBehavior.Strict); + + public string TempDirectory { get; } + + public UpdateService Service { get; } + + public Harness() + { + this.TempDirectory = Directory.CreateTempSubdirectory("ThreadPilotUpdateTest").FullName; + var settingsService = new Mock(); + settingsService.SetupGet(service => service.Settings).Returns(() => (ApplicationSettingsModel)this.Settings.Clone()); + settingsService + .Setup(service => service.UpdateSettingsAsync(It.IsAny())) + .Callback(settings => + { + this.SavedSettings = (ApplicationSettingsModel)settings.Clone(); + this.Settings.CopyFrom(settings); + }) + .Returns(Task.CompletedTask); + + var versionProvider = new Mock(); + versionProvider.SetupGet(provider => provider.CurrentVersion).Returns(new SemanticVersion(1, 3, 1)); + versionProvider.SetupGet(provider => provider.DisplayVersion).Returns("v1.3.1"); + + var tempProvider = new Mock(); + tempProvider.Setup(provider => provider.Cleanup(It.IsAny())); + + this.Shutdown.Setup(service => service.RequestShutdownForUpdate()); + this.Installer + .Setup(service => service.LaunchInstallerElevatedAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + this.Service = new UpdateService( + new GitHubUpdateChecker(this.ReleaseClient), + settingsService.Object, + versionProvider.Object, + this.Download.Object, + this.Installer.Object, + tempProvider.Object, + this.Shutdown.Object, + this.Clock, + NullLogger.Instance); + } + } + + private sealed class FakeClock : IUpdateClock + { + public DateTimeOffset UtcNow { get; } = new(2026, 6, 7, 12, 0, 0, TimeSpan.Zero); + } + + private sealed class FakeGitHubReleaseClient : IGitHubReleaseClient + { + private readonly string releasesJson; + + public bool RequestedReleases { get; private set; } + + public FakeGitHubReleaseClient(string releasesJson) + { + this.releasesJson = releasesJson; + } + + public Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default) + { + this.RequestedReleases = true; + return Task.FromResult(this.releasesJson); + } + } + + private sealed class FakeUpdateDownloadClient : IUpdateDownloadClient + { + private readonly byte[] fileBytes; + private readonly string? checksumsText; + + public FakeUpdateDownloadClient(byte[] fileBytes, string? checksumsText) + { + this.fileBytes = fileBytes; + this.checksumsText = checksumsText; + } + + public Task DownloadFileAsync(Uri uri, string destinationPath, CancellationToken cancellationToken = default) + { + File.WriteAllBytes(destinationPath, this.fileBytes); + return Task.CompletedTask; + } + + public Task TryDownloadStringAsync(Uri uri, CancellationToken cancellationToken = default) + { + return Task.FromResult(this.checksumsText); + } + } + + private sealed class TempDirectory : IDisposable + { + public string Path { get; } = Directory.CreateTempSubdirectory("ThreadPilotUpdateTest").FullName; + + public void Dispose() + { + if (Directory.Exists(this.Path)) + { + Directory.Delete(this.Path, recursive: true); + } + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/WindowPlacementHelperTests.cs b/Tests/ThreadPilot.Core.Tests/WindowPlacementHelperTests.cs index 5abb3bc..841b524 100644 --- a/Tests/ThreadPilot.Core.Tests/WindowPlacementHelperTests.cs +++ b/Tests/ThreadPilot.Core.Tests/WindowPlacementHelperTests.cs @@ -1,86 +1,86 @@ -namespace ThreadPilot.Core.Tests -{ - using ThreadPilot.Helpers; - - public sealed class WindowPlacementHelperTests - { - [Fact] - public void CorrectWindowBounds_WhenValuesAreInvalid_CentersDefaultSizeOnPrimaryWorkingArea() - { - var monitors = new[] - { - new MonitorWorkingArea(0, 0, 1024, 768, true), - }; - - var result = WindowPlacementHelper.CorrectWindowBounds( - new WindowBounds(double.NaN, double.NegativeInfinity, 0, double.PositiveInfinity), - monitors); - - Assert.True(result.WasCorrected); - Assert.Equal(0, result.Bounds.Left); - Assert.Equal(0, result.Bounds.Top); - Assert.Equal(1024, result.Bounds.Width); - Assert.Equal(768, result.Bounds.Height); - } - - [Fact] - public void CorrectWindowBounds_WhenMostlyOffScreen_CentersOnNearestWorkingArea() - { - var monitors = new[] - { - new MonitorWorkingArea(0, 0, 1920, 1040, true), - new MonitorWorkingArea(1920, 0, 1280, 984, false), - }; - - var result = WindowPlacementHelper.CorrectWindowBounds( - new WindowBounds(3100, -700, 900, 600), - monitors); - - Assert.True(result.WasCorrected); - Assert.Equal(2110, result.Bounds.Left); - Assert.Equal(192, result.Bounds.Top); - Assert.Equal(900, result.Bounds.Width); - Assert.Equal(600, result.Bounds.Height); - } - - [Fact] - public void CorrectWindowBounds_WhenPartiallyOutside_ClampsInsideIntersectingWorkingArea() - { - var monitors = new[] - { - new MonitorWorkingArea(-1080, 0, 1080, 1880, false), - new MonitorWorkingArea(0, 0, 2560, 1400, true), - }; - - var result = WindowPlacementHelper.CorrectWindowBounds( - new WindowBounds(-100, 120, 1280, 864), - monitors); - - Assert.True(result.WasCorrected); - Assert.Equal(0, result.Bounds.Left); - Assert.Equal(120, result.Bounds.Top); - Assert.Equal(1280, result.Bounds.Width); - Assert.Equal(864, result.Bounds.Height); - } - - [Fact] - public void CorrectWindowBounds_WhenAlreadyVisibleOnSecondaryMonitor_DoesNotMoveWindow() - { - var monitors = new[] - { - new MonitorWorkingArea(0, 0, 1920, 1040, true), - new MonitorWorkingArea(1920, 0, 1280, 984, false), - }; - - var result = WindowPlacementHelper.CorrectWindowBounds( - new WindowBounds(2000, 100, 900, 600), - monitors); - - Assert.False(result.WasCorrected); - Assert.Equal(2000, result.Bounds.Left); - Assert.Equal(100, result.Bounds.Top); - Assert.Equal(900, result.Bounds.Width); - Assert.Equal(600, result.Bounds.Height); - } - } -} +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Helpers; + + public sealed class WindowPlacementHelperTests + { + [Fact] + public void CorrectWindowBounds_WhenValuesAreInvalid_CentersDefaultSizeOnPrimaryWorkingArea() + { + var monitors = new[] + { + new MonitorWorkingArea(0, 0, 1024, 768, true), + }; + + var result = WindowPlacementHelper.CorrectWindowBounds( + new WindowBounds(double.NaN, double.NegativeInfinity, 0, double.PositiveInfinity), + monitors); + + Assert.True(result.WasCorrected); + Assert.Equal(0, result.Bounds.Left); + Assert.Equal(0, result.Bounds.Top); + Assert.Equal(1024, result.Bounds.Width); + Assert.Equal(768, result.Bounds.Height); + } + + [Fact] + public void CorrectWindowBounds_WhenMostlyOffScreen_CentersOnNearestWorkingArea() + { + var monitors = new[] + { + new MonitorWorkingArea(0, 0, 1920, 1040, true), + new MonitorWorkingArea(1920, 0, 1280, 984, false), + }; + + var result = WindowPlacementHelper.CorrectWindowBounds( + new WindowBounds(3100, -700, 900, 600), + monitors); + + Assert.True(result.WasCorrected); + Assert.Equal(2110, result.Bounds.Left); + Assert.Equal(192, result.Bounds.Top); + Assert.Equal(900, result.Bounds.Width); + Assert.Equal(600, result.Bounds.Height); + } + + [Fact] + public void CorrectWindowBounds_WhenPartiallyOutside_ClampsInsideIntersectingWorkingArea() + { + var monitors = new[] + { + new MonitorWorkingArea(-1080, 0, 1080, 1880, false), + new MonitorWorkingArea(0, 0, 2560, 1400, true), + }; + + var result = WindowPlacementHelper.CorrectWindowBounds( + new WindowBounds(-100, 120, 1280, 864), + monitors); + + Assert.True(result.WasCorrected); + Assert.Equal(0, result.Bounds.Left); + Assert.Equal(120, result.Bounds.Top); + Assert.Equal(1280, result.Bounds.Width); + Assert.Equal(864, result.Bounds.Height); + } + + [Fact] + public void CorrectWindowBounds_WhenAlreadyVisibleOnSecondaryMonitor_DoesNotMoveWindow() + { + var monitors = new[] + { + new MonitorWorkingArea(0, 0, 1920, 1040, true), + new MonitorWorkingArea(1920, 0, 1280, 984, false), + }; + + var result = WindowPlacementHelper.CorrectWindowBounds( + new WindowBounds(2000, 100, 900, 600), + monitors); + + Assert.False(result.WasCorrected); + Assert.Equal(2000, result.Bounds.Left); + Assert.Equal(100, result.Bounds.Top); + Assert.Equal(900, result.Bounds.Width); + Assert.Equal(600, result.Bounds.Height); + } + } +} diff --git a/ThreadPilot.csproj b/ThreadPilot.csproj index b244865..5200042 100644 --- a/ThreadPilot.csproj +++ b/ThreadPilot.csproj @@ -1,77 +1,68 @@ - - - - WinExe - net8.0-windows10.0.22000.0 - enable - enable - true - true - app.manifest - assets\icons\ico.ico - true - true - win-x64 - false - link - true - CS1998;CS0067;CS0414;WFAC010;IL3000;MVVMTK0034 - 1.4.0 - 1.4.0.0 - 1.4.0.0 - 1.4.0 - - - - - - - - - - - - - - - - - - - - - - Powerplans\%(RecursiveDir)%(Filename)%(Extension) - PreserveNewest - PreserveNewest - - - - - - - MSBuild:Compile - - - - - - - - - - - - - - - - - - - - - - - - + + + + WinExe + net8.0-windows10.0.22000.0 + enable + enable + true + true + app.manifest + assets\icons\ico.ico + true + true + win-x64 + false + link + true + CS1998;CS0067;CS0414;WFAC010;IL3000;MVVMTK0034 + 1.4.0 + 1.4.0.0 + 1.4.0.0 + 1.4.0 + + + + + + + + + + + + + + + + + + + + Powerplans\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + PreserveNewest + + + + + + + MSBuild:Compile + + + + + + + + + + + + + + + + + diff --git a/ViewModels/BaseViewModel.cs b/ViewModels/BaseViewModel.cs index 8cbe570..277a9ec 100644 --- a/ViewModels/BaseViewModel.cs +++ b/ViewModels/BaseViewModel.cs @@ -1,328 +1,279 @@ -/* - * 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.ViewModels -{ - using System; - using System.Threading; - using System.Threading.Tasks; - using CommunityToolkit.Mvvm.ComponentModel; - using Microsoft.Extensions.Logging; - using ThreadPilot.Services; - - /// - /// Base ViewModel with common functionality for all ViewModels. - /// - public abstract partial class BaseViewModel : ObservableObject, IDisposable - { - protected readonly ILogger Logger; - protected readonly IEnhancedLoggingService? EnhancedLoggingService; - protected readonly IActivityAuditService? ActivityAuditService; - private bool disposed; - private CancellationTokenSource? statusLifetimeCts; - private bool preserveStatusUntilReplaced; - private const int StatusVisibleDurationMs = 1500; - private const int StatusFadeDurationMs = 500; - - [ObservableProperty] - private bool isBusy; - - [ObservableProperty] - private string statusMessage = string.Empty; - - [ObservableProperty] - private double statusOpacity = 1.0; - - [ObservableProperty] - private bool hasError; - - [ObservableProperty] - private string errorMessage = string.Empty; - - protected BaseViewModel( - ILogger logger, - IEnhancedLoggingService? enhancedLoggingService = null, - IActivityAuditService? activityAuditService = null) - { - this.Logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.EnhancedLoggingService = enhancedLoggingService; - this.ActivityAuditService = activityAuditService; - } - - /// - /// Set status message and busy state. - /// - protected void SetStatus(string message, bool isBusyState = true) - { - this.SetStatus(message, isBusyState, preserveUntilReplaced: false); - } - - /// - /// Set a critical status that should not be cleared by immediate cleanup paths. - /// - protected void SetCriticalStatus(string message) - { - this.SetStatus(message, isBusyState: false, preserveUntilReplaced: true); - } - - private void SetStatus(string message, bool isBusyState, bool preserveUntilReplaced) - { - this.CancelStatusLifetime(); - this.preserveStatusUntilReplaced = preserveUntilReplaced; - this.StatusOpacity = 1.0; - this.StatusMessage = message; - this.IsBusy = isBusyState; - this.ClearError(); - - if (!string.IsNullOrWhiteSpace(message) && !isBusyState) - { - _ = this.StartStatusLifetimeAsync(message); - } - } - - /// - /// Clear status and busy state. - /// - protected void ClearStatus() - { - if (this.preserveStatusUntilReplaced) - { - this.IsBusy = false; - return; - } - - this.CancelStatusLifetime(); - this.StatusMessage = string.Empty; - this.StatusOpacity = 1.0; - this.IsBusy = false; - } - - /// - /// Set error message and clear busy state. - /// - protected void SetError(string message, Exception? exception = null) - { - this.ErrorMessage = message; - this.HasError = true; - this.IsBusy = false; - - if (exception != null) - { - this.Logger.LogError(exception, "Error in {ViewModelType}: {Message}", this.GetType().Name, message); - } - else - { - this.Logger.LogWarning("Error in {ViewModelType}: {Message}", this.GetType().Name, message); - } - } - - /// - /// Clear error state. - /// - protected void ClearError() - { - this.ErrorMessage = string.Empty; - this.HasError = false; - } - - /// - /// Execute an async operation with error handling and status updates. - /// - protected async Task ExecuteAsync(Func operation, string? statusMessage = null, string? successMessage = null) - { - try - { - if (!string.IsNullOrEmpty(statusMessage)) - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus(statusMessage); - }); - } - - await operation(); - - if (!string.IsNullOrEmpty(successMessage)) - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus(successMessage, false); - }); - } - else - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.ClearStatus(); - }); - } - } - catch (Exception ex) - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetError($"Operation failed: {ex.Message}", ex); - }); - } - } - - /// - /// Execute an async operation with return value and error handling. - /// - protected async Task ExecuteAsync(Func> operation, string? statusMessage = null, string? successMessage = null) - { - try - { - if (!string.IsNullOrEmpty(statusMessage)) - { - // Marshal UI updates to the UI thread to prevent cross-thread access exceptions - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus(statusMessage); - }); - } - - var result = await operation(); - - if (!string.IsNullOrEmpty(successMessage)) - { - // Marshal UI updates to the UI thread to prevent cross-thread access exceptions - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus(successMessage, false); - }); - } - else - { - // Marshal UI updates to the UI thread to prevent cross-thread access exceptions - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.ClearStatus(); - }); - } - - return result; - } - catch (Exception ex) - { - // Marshal UI updates to the UI thread to prevent cross-thread access exceptions - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetError($"Operation failed: {ex.Message}", ex); - }); - return default; - } - } - - /// - /// Log user action for audit purposes. - /// - protected async Task LogUserActionAsync(string action, string details, string? context = null) - { - try - { - if (this.EnhancedLoggingService != null) - { - await this.EnhancedLoggingService.LogUserActionAsync(action, details, context); - } - - if (this.ActivityAuditService != null) - { - await this.ActivityAuditService.LogUserActionAsync(action, details, context); - } - } - catch (Exception ex) - { - this.Logger.LogError(ex, "Failed to log user action: {Action}", action); - } - } - - /// - /// Initialize the ViewModel - override in derived classes. - /// - public virtual async Task InitializeAsync() - { - // Base implementation does nothing - await Task.CompletedTask; - } - - /// - /// Cleanup resources - override in derived classes. - /// - protected virtual void OnDispose() - { - this.CancelStatusLifetime(); - // Base implementation does nothing - } - - private async Task StartStatusLifetimeAsync(string expectedMessage) - { - var cts = new CancellationTokenSource(); - this.statusLifetimeCts = cts; - - try - { - await Task.Delay(StatusVisibleDurationMs, cts.Token); - - const int fadeSteps = 5; - var stepDelay = StatusFadeDurationMs / fadeSteps; - - for (var i = 1; i <= fadeSteps; i++) - { - await Task.Delay(stepDelay, cts.Token); - if (this.StatusMessage != expectedMessage) - { - return; - } - - this.StatusOpacity = 1.0 - ((double)i / fadeSteps); - } - - if (this.StatusMessage == expectedMessage) - { - this.StatusMessage = string.Empty; - this.StatusOpacity = 1.0; - this.IsBusy = false; - } - } - catch (OperationCanceledException) - { - // Expected when status message is replaced. - } - } - - private void CancelStatusLifetime() - { - if (this.statusLifetimeCts == null) - { - return; - } - - this.statusLifetimeCts.Cancel(); - this.statusLifetimeCts.Dispose(); - this.statusLifetimeCts = null; - } - - public void Dispose() - { - if (!this.disposed) - { - this.OnDispose(); - this.disposed = true; - } - } - } -} +namespace ThreadPilot.ViewModels +{ + using System; + using System.Threading; + using System.Threading.Tasks; + using CommunityToolkit.Mvvm.ComponentModel; + using Microsoft.Extensions.Logging; + using ThreadPilot.Services; + + public abstract partial class BaseViewModel : ObservableObject, IDisposable + { + protected readonly ILogger Logger; + protected readonly IEnhancedLoggingService? EnhancedLoggingService; + protected readonly IActivityAuditService? ActivityAuditService; + private bool disposed; + private CancellationTokenSource? statusLifetimeCts; + private bool preserveStatusUntilReplaced; + private const int StatusVisibleDurationMs = 1500; + private const int StatusFadeDurationMs = 500; + + [ObservableProperty] + private bool isBusy; + + [ObservableProperty] + private string statusMessage = string.Empty; + + [ObservableProperty] + private double statusOpacity = 1.0; + + [ObservableProperty] + private bool hasError; + + [ObservableProperty] + private string errorMessage = string.Empty; + + protected BaseViewModel( + ILogger logger, + IEnhancedLoggingService? enhancedLoggingService = null, + IActivityAuditService? activityAuditService = null) + { + this.Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.EnhancedLoggingService = enhancedLoggingService; + this.ActivityAuditService = activityAuditService; + } + + protected void SetStatus(string message, bool isBusyState = true) + { + this.SetStatus(message, isBusyState, preserveUntilReplaced: false); + } + + protected void SetCriticalStatus(string message) + { + this.SetStatus(message, isBusyState: false, preserveUntilReplaced: true); + } + + private void SetStatus(string message, bool isBusyState, bool preserveUntilReplaced) + { + this.CancelStatusLifetime(); + this.preserveStatusUntilReplaced = preserveUntilReplaced; + this.StatusOpacity = 1.0; + this.StatusMessage = message; + this.IsBusy = isBusyState; + this.ClearError(); + + if (!string.IsNullOrWhiteSpace(message) && !isBusyState) + { + _ = this.StartStatusLifetimeAsync(message); + } + } + + protected void ClearStatus() + { + if (this.preserveStatusUntilReplaced) + { + this.IsBusy = false; + return; + } + + this.CancelStatusLifetime(); + this.StatusMessage = string.Empty; + this.StatusOpacity = 1.0; + this.IsBusy = false; + } + + protected void SetError(string message, Exception? exception = null) + { + this.ErrorMessage = message; + this.HasError = true; + this.IsBusy = false; + + if (exception != null) + { + this.Logger.LogError(exception, "Error in {ViewModelType}: {Message}", this.GetType().Name, message); + } + else + { + this.Logger.LogWarning("Error in {ViewModelType}: {Message}", this.GetType().Name, message); + } + } + + protected void ClearError() + { + this.ErrorMessage = string.Empty; + this.HasError = false; + } + + protected async Task ExecuteAsync(Func operation, string? statusMessage = null, string? successMessage = null) + { + try + { + if (!string.IsNullOrEmpty(statusMessage)) + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus(statusMessage); + }); + } + + await operation(); + + if (!string.IsNullOrEmpty(successMessage)) + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus(successMessage, false); + }); + } + else + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.ClearStatus(); + }); + } + } + catch (Exception ex) + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetError($"Operation failed: {ex.Message}", ex); + }); + } + } + + protected async Task ExecuteAsync(Func> operation, string? statusMessage = null, string? successMessage = null) + { + try + { + if (!string.IsNullOrEmpty(statusMessage)) + { + // Marshal UI updates to the UI thread to prevent cross-thread access exceptions + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus(statusMessage); + }); + } + + var result = await operation(); + + if (!string.IsNullOrEmpty(successMessage)) + { + // Marshal UI updates to the UI thread to prevent cross-thread access exceptions + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus(successMessage, false); + }); + } + else + { + // Marshal UI updates to the UI thread to prevent cross-thread access exceptions + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.ClearStatus(); + }); + } + + return result; + } + catch (Exception ex) + { + // Marshal UI updates to the UI thread to prevent cross-thread access exceptions + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetError($"Operation failed: {ex.Message}", ex); + }); + return default; + } + } + + protected async Task LogUserActionAsync(string action, string details, string? context = null) + { + try + { + if (this.EnhancedLoggingService != null) + { + await this.EnhancedLoggingService.LogUserActionAsync(action, details, context); + } + + if (this.ActivityAuditService != null) + { + await this.ActivityAuditService.LogUserActionAsync(action, details, context); + } + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Failed to log user action: {Action}", action); + } + } + + public virtual async Task InitializeAsync() + { + // Base implementation does nothing + await Task.CompletedTask; + } + + protected virtual void OnDispose() + { + this.CancelStatusLifetime(); + // Base implementation does nothing + } + + private async Task StartStatusLifetimeAsync(string expectedMessage) + { + var cts = new CancellationTokenSource(); + this.statusLifetimeCts = cts; + + try + { + await Task.Delay(StatusVisibleDurationMs, cts.Token); + + const int fadeSteps = 5; + var stepDelay = StatusFadeDurationMs / fadeSteps; + + for (var i = 1; i <= fadeSteps; i++) + { + await Task.Delay(stepDelay, cts.Token); + if (this.StatusMessage != expectedMessage) + { + return; + } + + this.StatusOpacity = 1.0 - ((double)i / fadeSteps); + } + + if (this.StatusMessage == expectedMessage) + { + this.StatusMessage = string.Empty; + this.StatusOpacity = 1.0; + this.IsBusy = false; + } + } + catch (OperationCanceledException) + { + // Expected when status message is replaced. + } + } + + private void CancelStatusLifetime() + { + if (this.statusLifetimeCts == null) + { + return; + } + + this.statusLifetimeCts.Cancel(); + this.statusLifetimeCts.Dispose(); + this.statusLifetimeCts = null; + } + + public void Dispose() + { + if (!this.disposed) + { + this.OnDispose(); + this.disposed = true; + } + } + } +} diff --git a/ViewModels/DiagnosticsViewModelProvider.cs b/ViewModels/DiagnosticsViewModelProvider.cs deleted file mode 100644 index b562383..0000000 --- a/ViewModels/DiagnosticsViewModelProvider.cs +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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.ViewModels -{ - using System; - - public interface IDiagnosticsViewModelProvider - { - bool IsCreated { get; } - - PerformanceViewModel GetOrCreate(); - } - - public sealed class DiagnosticsViewModelProvider : IDiagnosticsViewModelProvider - { - private readonly Lazy performanceViewModel; - - public DiagnosticsViewModelProvider(Lazy performanceViewModel) - { - this.performanceViewModel = performanceViewModel ?? throw new ArgumentNullException(nameof(performanceViewModel)); - } - - public bool IsCreated => this.performanceViewModel.IsValueCreated; - - public PerformanceViewModel GetOrCreate() => this.performanceViewModel.Value; - } -} diff --git a/ViewModels/LogViewerViewModel.cs b/ViewModels/LogViewerViewModel.cs index 021092c..3bab86b 100644 --- a/ViewModels/LogViewerViewModel.cs +++ b/ViewModels/LogViewerViewModel.cs @@ -1,516 +1,494 @@ -/* - * 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 . - */ -using System; -using System.Collections.ObjectModel; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using System.Windows.Input; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Microsoft.Extensions.Logging; -using ThreadPilot.Models; -using ThreadPilot.Services; - -namespace ThreadPilot.ViewModels -{ - /// - /// ViewModel for the log viewer and management interface. - /// - public partial class LogViewerViewModel : ObservableObject - { - private readonly IActivityAuditService activityAuditService; - private readonly IEnhancedLoggingService loggingService; - private readonly IApplicationSettingsService settingsService; - private readonly ILogger logger; - - [ObservableProperty] - private ObservableCollection logEntries = new(); - - [ObservableProperty] - private LogEntryDisplayModel? selectedLogEntry; - - [ObservableProperty] - private string searchText = string.Empty; - - [ObservableProperty] - private LogLevel selectedLogLevel = LogLevel.Information; - - [ObservableProperty] - private string selectedCategory = "All"; - - [ObservableProperty] - private DateTime fromDate = DateTime.Today.AddDays(-7); - - [ObservableProperty] - private DateTime toDate = DateTime.Today.AddDays(1); - - [ObservableProperty] - private bool isLoading; - - [ObservableProperty] - private string statusMessage = "Ready"; - - [ObservableProperty] - private LogFileStatistics? logStatistics; - - [ObservableProperty] - private bool enableDebugLogging; - - [ObservableProperty] - private int maxLogFileSizeMb = 10; - - [ObservableProperty] - private int logRetentionDays = 7; - - [ObservableProperty] - private bool autoRefresh = true; - - [ObservableProperty] - private int refreshIntervalSeconds = 30; - - public ObservableCollection AvailableCategories { get; } = new() - { - "All", - "Process", - "Affinity", - "Priority", - "Memory Priority", - "Rules", - "Power Plans", - "Settings", - "Tweaks", - "Optimization", - "Diagnostics", - "Safety", - }; - - public ObservableCollection AvailableLogLevels { get; } = new() - { - LogLevel.Trace, LogLevel.Debug, LogLevel.Information, LogLevel.Warning, LogLevel.Error, LogLevel.Critical - }; - - public ICommand RefreshLogsCommand { get; } - - public ICommand ClearLogsCommand { get; } - - public ICommand ExportLogsCommand { get; } - - public ICommand CleanupOldLogsCommand { get; } - - public ICommand SaveSettingsCommand { get; } - - public ICommand OpenLogDirectoryCommand { get; } - - public ICommand CopyLogEntryCommand { get; } - - public LogViewerViewModel( - IActivityAuditService activityAuditService, - IEnhancedLoggingService loggingService, - IApplicationSettingsService settingsService, - ILogger logger) - { - this.activityAuditService = activityAuditService ?? throw new ArgumentNullException(nameof(activityAuditService)); - this.loggingService = loggingService ?? throw new ArgumentNullException(nameof(loggingService)); - this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - - // Initialize commands - this.RefreshLogsCommand = new AsyncRelayCommand(this.RefreshLogsAsync); - this.ClearLogsCommand = new AsyncRelayCommand(this.ClearLogsAsync); - this.ExportLogsCommand = new AsyncRelayCommand(this.ExportLogsAsync); - this.CleanupOldLogsCommand = new AsyncRelayCommand(this.CleanupOldLogsAsync); - this.SaveSettingsCommand = new AsyncRelayCommand(this.SaveSettingsAsync); - this.OpenLogDirectoryCommand = new RelayCommand(this.OpenLogDirectory); - this.CopyLogEntryCommand = new RelayCommand(this.CopyLogEntry); - - // Load initial settings - this.LoadSettings(); - this.activityAuditService.EntryAdded += this.OnActivityEntryAdded; - - // Start auto-refresh if enabled - if (this.autoRefresh) - { - this.StartAutoRefresh(); - } - } - - public async Task InitializeAsync() - { - try - { - this.IsLoading = true; - this.StatusMessage = "Loading activity..."; - - await this.RefreshLogsAsync(); - await this.RefreshStatisticsAsync(); - - this.StatusMessage = "Ready"; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to initialize log viewer"); - this.StatusMessage = $"Error: {ex.Message}"; - } - finally - { - this.IsLoading = false; - } - } - - private async Task RefreshLogsAsync() - { - try - { - this.IsLoading = true; - this.StatusMessage = "Refreshing activity..."; - - var logEntries = await this.activityAuditService.GetEntriesAsync(this.FromDate, this.ToDate); - - // Filter by category and log level - var filteredEntries = logEntries.Where(entry => - this.ShouldDisplay(entry)).ToList(); - - // Convert to display models - var displayModels = filteredEntries.Select(ToDisplayModel).ToList(); - - // PERFORMANCE OPTIMIZATION: Replace collection instead of Clear() + Add() loop - await InvokeOnUiAsync(() => - { - this.LogEntries = new ObservableCollection(displayModels); - this.StatusMessage = $"Loaded {this.LogEntries.Count} log entries"; - }); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to refresh logs"); - this.StatusMessage = $"Error refreshing activity: {ex.Message}"; - } - finally - { - this.IsLoading = false; - } - } - - private async Task RefreshStatisticsAsync() - { - try - { - this.LogStatistics = await this.loggingService.GetLogStatisticsAsync(); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to refresh log statistics"); - } - } - - private async Task ClearLogsAsync() - { - try - { - this.LogEntries.Clear(); - this.StatusMessage = "Activity display cleared"; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to clear logs"); - this.StatusMessage = $"Error clearing logs: {ex.Message}"; - } - } - - private async Task ExportLogsAsync() - { - try - { - this.IsLoading = true; - this.StatusMessage = "Exporting activity..."; - - var entries = await this.activityAuditService.GetEntriesAsync(this.FromDate, this.ToDate); - var exportPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.Desktop), - $"ThreadPilot_Activity_{DateTime.Now:yyyyMMdd_HHmmss}.txt"); - var exportLines = entries.Select(e => - $"{e.Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{e.Severity}] {e.Category}: {e.Message}" + - (string.IsNullOrWhiteSpace(e.Details) ? string.Empty : $" ({e.Details})")); - await File.WriteAllLinesAsync(exportPath, exportLines); - this.StatusMessage = $"Activity exported to: {exportPath}"; - - await this.activityAuditService.LogInfoAsync( - "Diagnostics", - $"Activity exported to {Path.GetFileName(exportPath)}", - $"DateRange: {this.FromDate:yyyy-MM-dd} to {this.ToDate:yyyy-MM-dd}"); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to export logs"); - this.StatusMessage = $"Error exporting logs: {ex.Message}"; - } - finally - { - this.IsLoading = false; - } - } - - private async Task CleanupOldLogsAsync() - { - try - { - this.IsLoading = true; - this.StatusMessage = "Cleaning up old logs..."; - - await this.loggingService.CleanupOldLogsAsync(); - await this.RefreshStatisticsAsync(); - - this.StatusMessage = "Old diagnostic log files cleaned up successfully"; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to cleanup old logs"); - this.StatusMessage = $"Error cleaning up logs: {ex.Message}"; - } - finally - { - this.IsLoading = false; - } - } - - private async Task SaveSettingsAsync() - { - try - { - await this.loggingService.UpdateConfigurationAsync(this.EnableDebugLogging, this.MaxLogFileSizeMb, this.LogRetentionDays); - - this.StatusMessage = "Diagnostic logging settings saved successfully"; - await this.activityAuditService.LogInfoAsync( - "Diagnostics", - "Diagnostic logging settings saved", - $"Debug: {this.EnableDebugLogging}, MaxSize: {this.MaxLogFileSizeMb}MB, Retention: {this.LogRetentionDays} days"); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to save logging settings"); - this.StatusMessage = $"Error saving settings: {ex.Message}"; - } - } - - private void OpenLogDirectory() - { - try - { - var logDirectory = this.loggingService.LogDirectoryPath; - if (Directory.Exists(logDirectory)) - { - System.Diagnostics.Process.Start("explorer.exe", logDirectory); - } - else - { - this.StatusMessage = "Log directory not found"; - } - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to open log directory"); - this.StatusMessage = $"Error opening log directory: {ex.Message}"; - } - } - - private void CopyLogEntry(LogEntryDisplayModel? logEntry) - { - if (logEntry == null) - { - return; - } - - try - { - var logText = $"[{logEntry.Timestamp:yyyy-MM-dd HH:mm:ss.fff}] [{logEntry.Status}] {logEntry.Category}: {logEntry.Message}"; - if (!string.IsNullOrEmpty(logEntry.Details)) - { - logText += $"\nDetails: {logEntry.Details}"; - } - - System.Windows.Clipboard.SetText(logText); - this.StatusMessage = "Log entry copied to clipboard"; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to copy log entry to clipboard"); - this.StatusMessage = "Failed to copy log entry"; - } - } - - private void LoadSettings() - { - var settings = this.settingsService.Settings; - this.EnableDebugLogging = settings.EnableDebugLogging; - this.MaxLogFileSizeMb = settings.MaxLogFileSizeMb; - this.LogRetentionDays = settings.LogRetentionDays; - } - - private void StartAutoRefresh() - { - // Implementation for auto-refresh timer would go here - // For now, we'll keep it simple without the timer - } - - private void OnActivityEntryAdded(object? sender, ActivityAuditEntry entry) - { - if (!this.ShouldDisplay(entry)) - { - return; - } - - _ = InvokeOnUiAsync(() => - { - this.LogEntries.Insert(0, ToDisplayModel(entry)); - while (this.LogEntries.Count > 1000) - { - this.LogEntries.RemoveAt(this.LogEntries.Count - 1); - } - - this.StatusMessage = $"Loaded {this.LogEntries.Count} activity entries"; - }); - } - - private bool ShouldDisplay(ActivityAuditEntry entry) - { - var categoryMatch = this.SelectedCategory == "All" || entry.Category == this.SelectedCategory; - var levelMatch = ToLogLevel(entry.Severity) >= this.SelectedLogLevel; - var searchMatch = string.IsNullOrEmpty(this.SearchText) || - entry.Message.Contains(this.SearchText, StringComparison.OrdinalIgnoreCase) || - entry.Category.Contains(this.SearchText, StringComparison.OrdinalIgnoreCase) || - (entry.Details?.Contains(this.SearchText, StringComparison.OrdinalIgnoreCase) ?? false); - - return categoryMatch && levelMatch && searchMatch; - } - - private static LogEntryDisplayModel ToDisplayModel(ActivityAuditEntry entry) => - new() - { - Timestamp = entry.Timestamp, - Level = ToLogLevel(entry.Severity), - AuditSeverity = entry.Severity, - Category = entry.Category, - Message = entry.Message, - Details = entry.Details, - }; - - partial void OnSearchTextChanged(string value) - { - // Trigger refresh when search text changes - marshal to UI thread to prevent cross-thread access exceptions - _ = InvokeOnUiAsync(async () => await this.RefreshLogsAsync()); - } - - partial void OnSelectedCategoryChanged(string value) - { - // Trigger refresh when category changes - marshal to UI thread to prevent cross-thread access exceptions - _ = InvokeOnUiAsync(async () => await this.RefreshLogsAsync()); - } - - partial void OnSelectedLogLevelChanged(LogLevel value) - { - // Trigger refresh when log level changes - marshal to UI thread to prevent cross-thread access exceptions - _ = InvokeOnUiAsync(async () => await this.RefreshLogsAsync()); - } - - 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; - } - - private static Task InvokeOnUiAsync(Func action) - { - var dispatcher = System.Windows.Application.Current?.Dispatcher; - if (dispatcher == null || dispatcher.CheckAccess()) - { - return action(); - } - - return dispatcher.InvokeAsync(action).Task.Unwrap(); - } - - private static LogLevel ToLogLevel(ActivityAuditSeverity severity) => - severity switch - { - ActivityAuditSeverity.Error => LogLevel.Error, - ActivityAuditSeverity.Warning => LogLevel.Warning, - _ => LogLevel.Information, - }; - } - - /// - /// Display model for log entries in the UI. - /// - public class LogEntryDisplayModel - { - public DateTime Timestamp { get; set; } - - public LogLevel Level { get; set; } - - public ActivityAuditSeverity? AuditSeverity { get; set; } - - public string Category { get; set; } = string.Empty; - - public string Message { get; set; } = string.Empty; - - public string? Exception { get; set; } - - public string? Details { get; set; } - - public Dictionary Properties { get; set; } = new(); - - public string? CorrelationId { get; set; } - - public string LevelColor => this.AuditSeverity switch - { - ActivityAuditSeverity.Error => "#FF4444", - ActivityAuditSeverity.Warning => "#FFA500", - ActivityAuditSeverity.Success => "#107C10", - ActivityAuditSeverity.Info => "#0066CC", - _ => this.Level switch - { - LogLevel.Critical => "#FF0000", - LogLevel.Error => "#FF4444", - LogLevel.Warning => "#FFA500", - LogLevel.Information => "#0066CC", - LogLevel.Debug => "#808080", - LogLevel.Trace => "#C0C0C0", - _ => "#000000" - }, - }; - - public string Status => this.AuditSeverity?.ToString() ?? this.Level.ToString(); - - public string FormattedTimestamp => this.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff"); - - public string ShortMessage => this.Message.Length > 100 ? this.Message.Substring(0, 100) + "..." : this.Message; - - public bool HasException => !string.IsNullOrEmpty(this.Exception); - - public bool HasDetails => !string.IsNullOrEmpty(this.Details); - - public bool HasProperties => this.Properties.Any(); - } -} - +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using ThreadPilot.Models; +using ThreadPilot.Services; + +namespace ThreadPilot.ViewModels +{ + public partial class LogViewerViewModel : ObservableObject + { + private readonly IActivityAuditService activityAuditService; + private readonly IEnhancedLoggingService loggingService; + private readonly IApplicationSettingsService settingsService; + private readonly ILogger logger; + + [ObservableProperty] + private ObservableCollection logEntries = new(); + + [ObservableProperty] + private LogEntryDisplayModel? selectedLogEntry; + + [ObservableProperty] + private string searchText = string.Empty; + + [ObservableProperty] + private LogLevel selectedLogLevel = LogLevel.Information; + + [ObservableProperty] + private string selectedCategory = "All"; + + [ObservableProperty] + private DateTime fromDate = DateTime.Today.AddDays(-7); + + [ObservableProperty] + private DateTime toDate = DateTime.Today.AddDays(1); + + [ObservableProperty] + private bool isLoading; + + [ObservableProperty] + private string statusMessage = "Ready"; + + [ObservableProperty] + private LogFileStatistics? logStatistics; + + [ObservableProperty] + private bool enableDebugLogging; + + [ObservableProperty] + private int maxLogFileSizeMb = 10; + + [ObservableProperty] + private int logRetentionDays = 7; + + [ObservableProperty] + private bool autoRefresh = true; + + [ObservableProperty] + private int refreshIntervalSeconds = 30; + + public ObservableCollection AvailableCategories { get; } = new() + { + "All", + "Process", + "Affinity", + "Priority", + "Memory Priority", + "Rules", + "Power Plans", + "Settings", + "Tweaks", + "Optimization", + "Diagnostics", + "Safety", + }; + + public ObservableCollection AvailableLogLevels { get; } = new() + { + LogLevel.Trace, LogLevel.Debug, LogLevel.Information, LogLevel.Warning, LogLevel.Error, LogLevel.Critical + }; + + public ICommand RefreshLogsCommand { get; } + + public ICommand ClearLogsCommand { get; } + + public ICommand ExportLogsCommand { get; } + + public ICommand CleanupOldLogsCommand { get; } + + public ICommand SaveSettingsCommand { get; } + + public ICommand OpenLogDirectoryCommand { get; } + + public ICommand CopyLogEntryCommand { get; } + + public LogViewerViewModel( + IActivityAuditService activityAuditService, + IEnhancedLoggingService loggingService, + IApplicationSettingsService settingsService, + ILogger logger) + { + this.activityAuditService = activityAuditService ?? throw new ArgumentNullException(nameof(activityAuditService)); + this.loggingService = loggingService ?? throw new ArgumentNullException(nameof(loggingService)); + this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + // Initialize commands + this.RefreshLogsCommand = new AsyncRelayCommand(this.RefreshLogsAsync); + this.ClearLogsCommand = new AsyncRelayCommand(this.ClearLogsAsync); + this.ExportLogsCommand = new AsyncRelayCommand(this.ExportLogsAsync); + this.CleanupOldLogsCommand = new AsyncRelayCommand(this.CleanupOldLogsAsync); + this.SaveSettingsCommand = new AsyncRelayCommand(this.SaveSettingsAsync); + this.OpenLogDirectoryCommand = new RelayCommand(this.OpenLogDirectory); + this.CopyLogEntryCommand = new RelayCommand(this.CopyLogEntry); + + // Load initial settings + this.LoadSettings(); + this.activityAuditService.EntryAdded += this.OnActivityEntryAdded; + + // Start auto-refresh if enabled + if (this.autoRefresh) + { + this.StartAutoRefresh(); + } + } + + public async Task InitializeAsync() + { + try + { + this.IsLoading = true; + this.StatusMessage = "Loading activity..."; + + await this.RefreshLogsAsync(); + await this.RefreshStatisticsAsync(); + + this.StatusMessage = "Ready"; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to initialize log viewer"); + this.StatusMessage = $"Error: {ex.Message}"; + } + finally + { + this.IsLoading = false; + } + } + + private async Task RefreshLogsAsync() + { + try + { + this.IsLoading = true; + this.StatusMessage = "Refreshing activity..."; + + var logEntries = await this.activityAuditService.GetEntriesAsync(this.FromDate, this.ToDate); + + // Filter by category and log level + var filteredEntries = logEntries.Where(entry => + this.ShouldDisplay(entry)).ToList(); + + // Convert to display models + var displayModels = filteredEntries.Select(ToDisplayModel).ToList(); + + // PERFORMANCE OPTIMIZATION: Replace collection instead of Clear() + Add() loop + await InvokeOnUiAsync(() => + { + this.LogEntries = new ObservableCollection(displayModels); + this.StatusMessage = $"Loaded {this.LogEntries.Count} log entries"; + }); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to refresh logs"); + this.StatusMessage = $"Error refreshing activity: {ex.Message}"; + } + finally + { + this.IsLoading = false; + } + } + + private async Task RefreshStatisticsAsync() + { + try + { + this.LogStatistics = await this.loggingService.GetLogStatisticsAsync(); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to refresh log statistics"); + } + } + + private async Task ClearLogsAsync() + { + try + { + this.LogEntries.Clear(); + this.StatusMessage = "Activity display cleared"; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to clear logs"); + this.StatusMessage = $"Error clearing logs: {ex.Message}"; + } + } + + private async Task ExportLogsAsync() + { + try + { + this.IsLoading = true; + this.StatusMessage = "Exporting activity..."; + + var entries = await this.activityAuditService.GetEntriesAsync(this.FromDate, this.ToDate); + var exportPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Desktop), + $"ThreadPilot_Activity_{DateTime.Now:yyyyMMdd_HHmmss}.txt"); + var exportLines = entries.Select(e => + $"{e.Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{e.Severity}] {e.Category}: {e.Message}" + + (string.IsNullOrWhiteSpace(e.Details) ? string.Empty : $" ({e.Details})")); + await File.WriteAllLinesAsync(exportPath, exportLines); + this.StatusMessage = $"Activity exported to: {exportPath}"; + + await this.activityAuditService.LogInfoAsync( + "Diagnostics", + $"Activity exported to {Path.GetFileName(exportPath)}", + $"DateRange: {this.FromDate:yyyy-MM-dd} to {this.ToDate:yyyy-MM-dd}"); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to export logs"); + this.StatusMessage = $"Error exporting logs: {ex.Message}"; + } + finally + { + this.IsLoading = false; + } + } + + private async Task CleanupOldLogsAsync() + { + try + { + this.IsLoading = true; + this.StatusMessage = "Cleaning up old logs..."; + + await this.loggingService.CleanupOldLogsAsync(); + await this.RefreshStatisticsAsync(); + + this.StatusMessage = "Old diagnostic log files cleaned up successfully"; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to cleanup old logs"); + this.StatusMessage = $"Error cleaning up logs: {ex.Message}"; + } + finally + { + this.IsLoading = false; + } + } + + private async Task SaveSettingsAsync() + { + try + { + await this.loggingService.UpdateConfigurationAsync(this.EnableDebugLogging, this.MaxLogFileSizeMb, this.LogRetentionDays); + + this.StatusMessage = "Diagnostic logging settings saved successfully"; + await this.activityAuditService.LogInfoAsync( + "Diagnostics", + "Diagnostic logging settings saved", + $"Debug: {this.EnableDebugLogging}, MaxSize: {this.MaxLogFileSizeMb}MB, Retention: {this.LogRetentionDays} days"); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to save logging settings"); + this.StatusMessage = $"Error saving settings: {ex.Message}"; + } + } + + private void OpenLogDirectory() + { + try + { + var logDirectory = this.loggingService.LogDirectoryPath; + if (Directory.Exists(logDirectory)) + { + System.Diagnostics.Process.Start("explorer.exe", logDirectory); + } + else + { + this.StatusMessage = "Log directory not found"; + } + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to open log directory"); + this.StatusMessage = $"Error opening log directory: {ex.Message}"; + } + } + + private void CopyLogEntry(LogEntryDisplayModel? logEntry) + { + if (logEntry == null) + { + return; + } + + try + { + var logText = $"[{logEntry.Timestamp:yyyy-MM-dd HH:mm:ss.fff}] [{logEntry.Status}] {logEntry.Category}: {logEntry.Message}"; + if (!string.IsNullOrEmpty(logEntry.Details)) + { + logText += $"\nDetails: {logEntry.Details}"; + } + + System.Windows.Clipboard.SetText(logText); + this.StatusMessage = "Log entry copied to clipboard"; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to copy log entry to clipboard"); + this.StatusMessage = "Failed to copy log entry"; + } + } + + private void LoadSettings() + { + var settings = this.settingsService.Settings; + this.EnableDebugLogging = settings.EnableDebugLogging; + this.MaxLogFileSizeMb = settings.MaxLogFileSizeMb; + this.LogRetentionDays = settings.LogRetentionDays; + } + + private void StartAutoRefresh() + { + // Implementation for auto-refresh timer would go here + // For now, we'll keep it simple without the timer + } + + private void OnActivityEntryAdded(object? sender, ActivityAuditEntry entry) + { + if (!this.ShouldDisplay(entry)) + { + return; + } + + _ = InvokeOnUiAsync(() => + { + this.LogEntries.Insert(0, ToDisplayModel(entry)); + while (this.LogEntries.Count > 1000) + { + this.LogEntries.RemoveAt(this.LogEntries.Count - 1); + } + + this.StatusMessage = $"Loaded {this.LogEntries.Count} activity entries"; + }); + } + + private bool ShouldDisplay(ActivityAuditEntry entry) + { + var categoryMatch = this.SelectedCategory == "All" || entry.Category == this.SelectedCategory; + var levelMatch = ToLogLevel(entry.Severity) >= this.SelectedLogLevel; + var searchMatch = string.IsNullOrEmpty(this.SearchText) || + entry.Message.Contains(this.SearchText, StringComparison.OrdinalIgnoreCase) || + entry.Category.Contains(this.SearchText, StringComparison.OrdinalIgnoreCase) || + (entry.Details?.Contains(this.SearchText, StringComparison.OrdinalIgnoreCase) ?? false); + + return categoryMatch && levelMatch && searchMatch; + } + + private static LogEntryDisplayModel ToDisplayModel(ActivityAuditEntry entry) => + new() + { + Timestamp = entry.Timestamp, + Level = ToLogLevel(entry.Severity), + AuditSeverity = entry.Severity, + Category = entry.Category, + Message = entry.Message, + Details = entry.Details, + }; + + partial void OnSearchTextChanged(string value) + { + // Trigger refresh when search text changes - marshal to UI thread to prevent cross-thread access exceptions + _ = InvokeOnUiAsync(async () => await this.RefreshLogsAsync()); + } + + partial void OnSelectedCategoryChanged(string value) + { + // Trigger refresh when category changes - marshal to UI thread to prevent cross-thread access exceptions + _ = InvokeOnUiAsync(async () => await this.RefreshLogsAsync()); + } + + partial void OnSelectedLogLevelChanged(LogLevel value) + { + // Trigger refresh when log level changes - marshal to UI thread to prevent cross-thread access exceptions + _ = InvokeOnUiAsync(async () => await this.RefreshLogsAsync()); + } + + 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; + } + + private static Task InvokeOnUiAsync(Func action) + { + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher == null || dispatcher.CheckAccess()) + { + return action(); + } + + return dispatcher.InvokeAsync(action).Task.Unwrap(); + } + + private static LogLevel ToLogLevel(ActivityAuditSeverity severity) => + severity switch + { + ActivityAuditSeverity.Error => LogLevel.Error, + ActivityAuditSeverity.Warning => LogLevel.Warning, + _ => LogLevel.Information, + }; + } + + public class LogEntryDisplayModel + { + public DateTime Timestamp { get; set; } + + public LogLevel Level { get; set; } + + public ActivityAuditSeverity? AuditSeverity { get; set; } + + public string Category { get; set; } = string.Empty; + + public string Message { get; set; } = string.Empty; + + public string? Exception { get; set; } + + public string? Details { get; set; } + + public Dictionary Properties { get; set; } = new(); + + public string? CorrelationId { get; set; } + + public string LevelColor => this.AuditSeverity switch + { + ActivityAuditSeverity.Error => "#FF4444", + ActivityAuditSeverity.Warning => "#FFA500", + ActivityAuditSeverity.Success => "#107C10", + ActivityAuditSeverity.Info => "#0066CC", + _ => this.Level switch + { + LogLevel.Critical => "#FF0000", + LogLevel.Error => "#FF4444", + LogLevel.Warning => "#FFA500", + LogLevel.Information => "#0066CC", + LogLevel.Debug => "#808080", + LogLevel.Trace => "#C0C0C0", + _ => "#000000" + }, + }; + + public string Status => this.AuditSeverity?.ToString() ?? this.Level.ToString(); + + public string FormattedTimestamp => this.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff"); + + public string ShortMessage => this.Message.Length > 100 ? this.Message.Substring(0, 100) + "..." : this.Message; + + public bool HasException => !string.IsNullOrEmpty(this.Exception); + + public bool HasDetails => !string.IsNullOrEmpty(this.Details); + + public bool HasProperties => this.Properties.Any(); + } +} + diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs index ad4a2e0..0f8c1bb 100644 --- a/ViewModels/MainWindowViewModel.cs +++ b/ViewModels/MainWindowViewModel.cs @@ -1,236 +1,220 @@ -/* - * 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 -{ - using System; - using System.Reflection; - using System.Threading.Tasks; - using CommunityToolkit.Mvvm.ComponentModel; - using CommunityToolkit.Mvvm.Input; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - using ThreadPilot.Services; - using ThreadPilot.ViewModels; - - public partial class MainWindowViewModel : BaseViewModel - { - private readonly IProcessMonitorManagerService? processMonitorManagerService; - private readonly INotificationService? notificationService; - private readonly IElevationService? elevationService; - private readonly ISecurityService? securityService; - - [ObservableProperty] - private bool isProcessMonitoringActive = false; - - [ObservableProperty] - private string processMonitoringStatusText = "Automation Monitoring: Inactive"; - - [ObservableProperty] - private bool isRunningAsAdministrator = false; - - [ObservableProperty] - private string elevationStatusText = "Checking elevation status..."; - - [ObservableProperty] - private bool showElevationPrompt = false; - - [ObservableProperty] - private string initializationStage = "Starting ThreadPilot..."; - - [ObservableProperty] - private string initializationDetails = "Preparing startup sequence."; - - [ObservableProperty] - private bool isDarkTheme = false; - - [ObservableProperty] - private string applicationVersion = "v0.0.0"; - - public MainWindowViewModel( - ILogger logger, - IEnhancedLoggingService? enhancedLoggingService = null, - IProcessMonitorManagerService? processMonitorManagerService = null, - INotificationService? notificationService = null, - IElevationService? elevationService = null, - ISecurityService? securityService = null, - IActivityAuditService? activityAuditService = null) - : base(logger, enhancedLoggingService, activityAuditService) - { - this.processMonitorManagerService = processMonitorManagerService; - this.notificationService = notificationService; - this.elevationService = elevationService; - this.securityService = securityService; - this.ApplicationVersion = GetApplicationVersion(); - } - - public override async Task InitializeAsync() - { - await this.ExecuteAsync( - async () => - { - // Subscribe to service events - if (this.processMonitorManagerService != null) - { - this.processMonitorManagerService.ServiceStatusChanged += this.OnServiceStatusChanged; - } - - // Initialize status - await this.UpdateStatusAsync(); - this.UpdateElevationStatus(); - - await this.LogUserActionAsync("MainWindow", "Initialized main window", "Application startup"); - }, "Initializing main window..."); - } - - [RelayCommand] - private async Task ToggleProcessMonitoringAsync() - { - if (this.processMonitorManagerService == null) - { - return; - } - - await this.ExecuteAsync( - async () => - { - if (this.IsProcessMonitoringActive) - { - await this.processMonitorManagerService.StopAsync(); - await this.LogUserActionAsync("ProcessMonitoring", "Stopped automation monitoring", "User action"); - } - else - { - await this.processMonitorManagerService.StartAsync(); - await this.LogUserActionAsync("ProcessMonitoring", "Started automation monitoring", "User action"); - } - }, this.IsProcessMonitoringActive ? "Stopping automation monitoring..." : "Starting automation monitoring..."); - } - - [RelayCommand] - private async Task RequestElevationAsync() - { - if (this.elevationService == null) - { - return; - } - - await this.ExecuteAsync( - async () => - { - var success = await this.elevationService.RequestElevationIfNeeded(); - if (success) - { - await this.LogUserActionAsync("Elevation", "Requested elevation", "User action"); - } - else - { - await this.LogUserActionAsync("Elevation", "Elevation request failed or cancelled", "User action"); - } - }, "Requesting elevation..."); - } - - private async Task UpdateStatusAsync() - { - try - { - // Update process monitoring status - if (this.processMonitorManagerService != null) - { - this.IsProcessMonitoringActive = this.processMonitorManagerService.IsRunning; - this.ProcessMonitoringStatusText = this.IsProcessMonitoringActive - ? "Automation Monitoring: Active" - : "Automation Monitoring: Inactive"; - } - - // Update elevation status - this.UpdateElevationStatus(); - } - catch (Exception ex) - { - this.SetError("Failed to update status", ex); - } - } - - private void UpdateElevationStatus() - { - if (this.elevationService == null) - { - this.IsRunningAsAdministrator = false; - this.ElevationStatusText = "Elevation service not available"; - this.ShowElevationPrompt = false; - return; - } - - this.IsRunningAsAdministrator = this.elevationService.IsRunningAsAdministrator(); - this.ElevationStatusText = this.elevationService.GetElevationStatus(); - this.ShowElevationPrompt = !this.IsRunningAsAdministrator; - } - - private void OnServiceStatusChanged(object? sender, ServiceStatusEventArgs e) - { - // Marshal UI updates to the UI thread to prevent cross-thread access exceptions - System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.IsProcessMonitoringActive = e.IsRunning; - this.ProcessMonitoringStatusText = $"Automation Monitoring: {e.Status}"; - }); - } - - public void UpdateProcessMonitoringStatus(bool isActive, string status) - { - if (System.Windows.Application.Current.Dispatcher.CheckAccess()) - { - this.IsProcessMonitoringActive = isActive; - this.ProcessMonitoringStatusText = $"Automation Monitoring: {status}"; - } - else - { - System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.IsProcessMonitoringActive = isActive; - this.ProcessMonitoringStatusText = $"Automation Monitoring: {status}"; - }); - } - } - - protected override void OnDispose() - { - if (this.processMonitorManagerService != null) - { - this.processMonitorManagerService.ServiceStatusChanged -= this.OnServiceStatusChanged; - } - - base.OnDispose(); - } - - private static string GetApplicationVersion() - { - var assembly = typeof(MainWindowViewModel).Assembly; - var informationalVersion = assembly - .GetCustomAttribute()? - .InformationalVersion; - - var normalizedVersion = string.IsNullOrWhiteSpace(informationalVersion) - ? assembly.GetName().Version?.ToString(3) ?? "0.0.0" - : informationalVersion.Split('+')[0]; - - return normalizedVersion.StartsWith("v", StringComparison.OrdinalIgnoreCase) - ? normalizedVersion - : $"v{normalizedVersion}"; - } - } -} +namespace ThreadPilot +{ + using System; + using System.Reflection; + using System.Threading.Tasks; + using CommunityToolkit.Mvvm.ComponentModel; + using CommunityToolkit.Mvvm.Input; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public partial class MainWindowViewModel : BaseViewModel + { + private readonly IProcessMonitorManagerService? processMonitorManagerService; + private readonly INotificationService? notificationService; + private readonly IElevationService? elevationService; + private readonly ISecurityService? securityService; + + [ObservableProperty] + private bool isProcessMonitoringActive = false; + + [ObservableProperty] + private string processMonitoringStatusText = "Automation Monitoring: Inactive"; + + [ObservableProperty] + private bool isRunningAsAdministrator = false; + + [ObservableProperty] + private string elevationStatusText = "Checking elevation status..."; + + [ObservableProperty] + private bool showElevationPrompt = false; + + [ObservableProperty] + private string initializationStage = "Starting ThreadPilot..."; + + [ObservableProperty] + private string initializationDetails = "Preparing startup sequence."; + + [ObservableProperty] + private bool isDarkTheme = false; + + [ObservableProperty] + private string applicationVersion = "v0.0.0"; + + public MainWindowViewModel( + ILogger logger, + IEnhancedLoggingService? enhancedLoggingService = null, + IProcessMonitorManagerService? processMonitorManagerService = null, + INotificationService? notificationService = null, + IElevationService? elevationService = null, + ISecurityService? securityService = null, + IActivityAuditService? activityAuditService = null) + : base(logger, enhancedLoggingService, activityAuditService) + { + this.processMonitorManagerService = processMonitorManagerService; + this.notificationService = notificationService; + this.elevationService = elevationService; + this.securityService = securityService; + this.ApplicationVersion = GetApplicationVersion(); + } + + public override async Task InitializeAsync() + { + await this.ExecuteAsync( + async () => + { + // Subscribe to service events + if (this.processMonitorManagerService != null) + { + this.processMonitorManagerService.ServiceStatusChanged += this.OnServiceStatusChanged; + } + + // Initialize status + await this.UpdateStatusAsync(); + this.UpdateElevationStatus(); + + await this.LogUserActionAsync("MainWindow", "Initialized main window", "Application startup"); + }, "Initializing main window..."); + } + + [RelayCommand] + private async Task ToggleProcessMonitoringAsync() + { + if (this.processMonitorManagerService == null) + { + return; + } + + await this.ExecuteAsync( + async () => + { + if (this.IsProcessMonitoringActive) + { + await this.processMonitorManagerService.StopAsync(); + await this.LogUserActionAsync("ProcessMonitoring", "Stopped automation monitoring", "User action"); + } + else + { + await this.processMonitorManagerService.StartAsync(); + await this.LogUserActionAsync("ProcessMonitoring", "Started automation monitoring", "User action"); + } + }, this.IsProcessMonitoringActive ? "Stopping automation monitoring..." : "Starting automation monitoring..."); + } + + [RelayCommand] + private async Task RequestElevationAsync() + { + if (this.elevationService == null) + { + return; + } + + await this.ExecuteAsync( + async () => + { + var success = await this.elevationService.RequestElevationIfNeeded(); + if (success) + { + await this.LogUserActionAsync("Elevation", "Requested elevation", "User action"); + } + else + { + await this.LogUserActionAsync("Elevation", "Elevation request failed or cancelled", "User action"); + } + }, "Requesting elevation..."); + } + + private async Task UpdateStatusAsync() + { + try + { + // Update process monitoring status + if (this.processMonitorManagerService != null) + { + this.IsProcessMonitoringActive = this.processMonitorManagerService.IsRunning; + this.ProcessMonitoringStatusText = this.IsProcessMonitoringActive + ? "Automation Monitoring: Active" + : "Automation Monitoring: Inactive"; + } + + // Update elevation status + this.UpdateElevationStatus(); + } + catch (Exception ex) + { + this.SetError("Failed to update status", ex); + } + } + + private void UpdateElevationStatus() + { + if (this.elevationService == null) + { + this.IsRunningAsAdministrator = false; + this.ElevationStatusText = "Elevation service not available"; + this.ShowElevationPrompt = false; + return; + } + + this.IsRunningAsAdministrator = this.elevationService.IsRunningAsAdministrator(); + this.ElevationStatusText = this.elevationService.GetElevationStatus(); + this.ShowElevationPrompt = !this.IsRunningAsAdministrator; + } + + private void OnServiceStatusChanged(object? sender, ServiceStatusEventArgs e) + { + // Marshal UI updates to the UI thread to prevent cross-thread access exceptions + System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.IsProcessMonitoringActive = e.IsRunning; + this.ProcessMonitoringStatusText = $"Automation Monitoring: {e.Status}"; + }); + } + + public void UpdateProcessMonitoringStatus(bool isActive, string status) + { + if (System.Windows.Application.Current.Dispatcher.CheckAccess()) + { + this.IsProcessMonitoringActive = isActive; + this.ProcessMonitoringStatusText = $"Automation Monitoring: {status}"; + } + else + { + System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.IsProcessMonitoringActive = isActive; + this.ProcessMonitoringStatusText = $"Automation Monitoring: {status}"; + }); + } + } + + protected override void OnDispose() + { + if (this.processMonitorManagerService != null) + { + this.processMonitorManagerService.ServiceStatusChanged -= this.OnServiceStatusChanged; + } + + base.OnDispose(); + } + + private static string GetApplicationVersion() + { + var assembly = typeof(MainWindowViewModel).Assembly; + var informationalVersion = assembly + .GetCustomAttribute()? + .InformationalVersion; + + var normalizedVersion = string.IsNullOrWhiteSpace(informationalVersion) + ? assembly.GetName().Version?.ToString(3) ?? "0.0.0" + : informationalVersion.Split('+')[0]; + + return normalizedVersion.StartsWith("v", StringComparison.OrdinalIgnoreCase) + ? normalizedVersion + : $"v{normalizedVersion}"; + } + } +} diff --git a/ViewModels/MasksViewModel.cs b/ViewModels/MasksViewModel.cs index 8659220..47635d9 100644 --- a/ViewModels/MasksViewModel.cs +++ b/ViewModels/MasksViewModel.cs @@ -1,19 +1,3 @@ -/* - * 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.ViewModels { using System; @@ -30,9 +14,6 @@ namespace ThreadPilot.ViewModels using MessageBoxImage = System.Windows.MessageBoxImage; using MessageBoxResult = System.Windows.MessageBoxResult; - /// - /// Wrapper for individual core bit in the mask, similar to CPUSetSetter's MaskBitViewModel. - /// public partial class CoreBitViewModel : ObservableObject { private readonly ObservableCollection boolMask; @@ -60,13 +41,6 @@ public CoreBitViewModel(ObservableCollection boolMask, int index) } } - /// - /// ViewModel for managing CPU core affinity masks. - /// This ViewModel manages CPU mask presets for editing and storage only. - /// It does not apply affinity to any process. Per-process affinity application - /// is handled by ProcessViewModel through ProcessAffinityApplyCoordinator. - /// Based on CPUSetSetter's MasksTabViewModel. - /// public partial class MasksViewModel : ObservableObject { private readonly ICoreMaskService coreMaskService; @@ -87,10 +61,6 @@ public partial class MasksViewModel : ObservableObject [ObservableProperty] private ObservableCollection coreBits = new(); - /// - /// Gets a value indicating whether can delete if: mask selected, not "All Cores" baseline, not actively applied to processes - /// Note: The actual validation happens in DeleteMask command with proper async checks. - /// public bool CanDeleteMask => this.SelectedCoreMask != null && this.SelectedCoreMask.Name != "All Cores"; public bool CanDuplicateMask => this.SelectedCoreMask != null; diff --git a/ViewModels/PerformanceViewModel.cs b/ViewModels/PerformanceViewModel.cs index 1697f24..88720c7 100644 --- a/ViewModels/PerformanceViewModel.cs +++ b/ViewModels/PerformanceViewModel.cs @@ -1,946 +1,930 @@ -/* - * 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.ViewModels -{ - using System; - using System.Collections.Generic; - using System.Collections.ObjectModel; - using System.Diagnostics; - using System.Linq; - using System.Threading; - using System.Threading.Tasks; - using CommunityToolkit.Mvvm.ComponentModel; - using CommunityToolkit.Mvvm.Input; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - using ThreadPilot.Services; - - public partial class PerformanceViewModel : BaseViewModel - { - private readonly IPerformanceMonitoringService performanceService; - private readonly IProcessService processService; - private readonly IProcessPowerPlanAssociationService associationService; - private readonly IPowerPlanService powerPlanService; - private readonly IProcessMonitorManagerService processMonitorManagerService; - private readonly ISystemTweaksService systemTweaksService; - private readonly ILogger logger; - - [ObservableProperty] - private ObservableCollection coreUsages = new(); - - [ObservableProperty] - private ObservableCollection topCpuProcesses = new(); - - [ObservableProperty] - private ObservableCollection historicalData = new(); - - [ObservableProperty] - private ObservableCollection timelineEvents = new(); - - [ObservableProperty] - private ProcessPerformanceInfo? selectedHotspotProcess; - - [ObservableProperty] - private bool isMonitoring; - - [ObservableProperty] - private string monitoringStatusText = "Live metrics stopped"; - - [ObservableProperty] - private DateTime lastUpdateTime; - - [ObservableProperty] - private double totalCpuUsage; - - [ObservableProperty] - private long totalMemoryUsage; - - [ObservableProperty] - private long totalMemory; - - [ObservableProperty] - private long availableMemory; - - [ObservableProperty] - private double memoryUsagePercentage; - - [ObservableProperty] - private int activeProcessCount; - - [ObservableProperty] - private string cpuUsageText = "0.0%"; - - [ObservableProperty] - private string memoryUsageText = "0 MB / 0 MB"; - - [ObservableProperty] - private string processCountText = "0"; - - [ObservableProperty] - private string currentGlobalPowerPlanText = "Unknown"; - - [ObservableProperty] - private string monitoringStateText = "Stopped"; - - [ObservableProperty] - private string selectedProcessName = "No hotspot selected"; - - [ObservableProperty] - private string selectedProcessExecutable = "-"; - - [ObservableProperty] - private string selectedProcessCpuText = "-"; - - [ObservableProperty] - private string selectedProcessMemoryText = "-"; - - [ObservableProperty] - private string selectedProcessRuleStatus = "No linked rule"; - - [ObservableProperty] - private string selectedProcessRuleSummary = "Create a rule from this process to automate affinity and priority behavior."; - - [ObservableProperty] - private string selectedProcessLastApplyText = "No recent automation event"; - - [ObservableProperty] - private bool canCreateRuleFromSelectedProcess; - - [ObservableProperty] - private string timelineSampleCountText = "0 samples"; - - [ObservableProperty] - private string lastTimelineEventText = "No events yet"; - - [ObservableProperty] - private int updateInterval = 2000; - - [ObservableProperty] - private bool showCoreDetails = true; - - [ObservableProperty] - private bool showProcessDetails = true; - - [ObservableProperty] - private bool showOnlyRuleBackedHotspots; - - [ObservableProperty] - private bool showOnlyActionableHotspots = true; - - [ObservableProperty] - private string sortMode = "Cpu"; - - [ObservableProperty] - private string lastManualRefreshText = "Not refreshed yet"; - - [ObservableProperty] - private string processSearchText = string.Empty; - - [ObservableProperty] - private bool isRuleCreateBusy; - - [ObservableProperty] - private bool isPopupVisible; - - [ObservableProperty] - private string popupTitle = string.Empty; - - [ObservableProperty] - private string popupContent = string.Empty; - - [ObservableProperty] - private int blurRadius; - - private readonly Dictionary lastRuleApplyByExecutable = new(StringComparer.OrdinalIgnoreCase); - private readonly SemaphoreSlim topProcessRefreshGate = new(1, 1); - private bool pendingTopProcessRefresh; - private bool diagnosticsActivated; - - public PerformanceViewModel( - IPerformanceMonitoringService performanceService, - IProcessService processService, - IProcessPowerPlanAssociationService associationService, - IPowerPlanService powerPlanService, - IProcessMonitorManagerService processMonitorManagerService, - ISystemTweaksService systemTweaksService, - ILogger logger, - IEnhancedLoggingService? enhancedLoggingService = null, - IActivityAuditService? activityAuditService = null) - : base(logger, enhancedLoggingService, activityAuditService) - { - this.performanceService = performanceService; - this.processService = processService; - this.associationService = associationService; - this.powerPlanService = powerPlanService; - this.processMonitorManagerService = processMonitorManagerService; - this.systemTweaksService = systemTweaksService; - this.logger = logger; - - this.performanceService.MetricsUpdated += this.OnMetricsUpdated; - this.processMonitorManagerService.ProcessPowerPlanChanged += this.OnProcessPowerPlanChanged; - this.powerPlanService.PowerPlanChanged += this.OnPowerPlanChanged; - this.systemTweaksService.TweakStatusChanged += this.OnTweakStatusChanged; - } - - public override async Task InitializeAsync() - { - this.MonitoringStateText = "Stopped"; - this.MonitoringStatusText = "Diagnostics inactive"; - this.SetStatus("Diagnostics are inactive until opened.", false); - await Task.CompletedTask; - } - - public async Task ActivateDiagnosticsAsync() - { - await this.ActivateDiagnosticsCoreAsync(auditActivity: false); - } - - private async Task ActivateDiagnosticsCoreAsync(bool auditActivity) - { - try - { - this.SetStatus("Loading optional diagnostics..."); - - var snapshotLoaded = await this.RefreshMetricsSnapshotAsync(auditActivity); - await this.LoadHistoricalDataAsync(); - - this.diagnosticsActivated = true; - this.MonitoringStateText = this.IsMonitoring ? "Active" : "Stopped"; - if (snapshotLoaded) - { - this.MonitoringStatusText = this.IsMonitoring ? "Live metrics active" : "Diagnostics snapshot loaded"; - this.SetStatus("Optional diagnostics loaded", false); - } - else - { - this.MonitoringStatusText = "Diagnostics snapshot failed"; - } - } - catch (Exception ex) - { - this.SetError("Failed to load optional diagnostics", ex); - this.logger.LogError(ex, "Error loading optional diagnostics"); - } - } - - public async Task DeactivateDiagnosticsAsync() - { - try - { - if (this.IsMonitoring) - { - await this.performanceService.StopMonitoringAsync(); - } - - this.IsMonitoring = false; - this.MonitoringStatusText = this.diagnosticsActivated - ? "Diagnostics inactive" - : "Live metrics stopped"; - this.MonitoringStateText = "Stopped"; - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to deactivate optional diagnostics"); - } - } - - [RelayCommand] - private async Task StartMonitoringAsync() - { - try - { - if (!this.diagnosticsActivated) - { - await this.ActivateDiagnosticsAsync(); - } - - this.SetStatus("Starting performance monitoring..."); - await this.performanceService.StartMonitoringAsync(); - - this.IsMonitoring = true; - this.MonitoringStatusText = "Live metrics active"; - this.MonitoringStateText = "Active"; - this.AddTimelineEvent("Live Metrics", "Live metrics started.", "Info"); - - this.SetStatus("Performance monitoring started", false); - await this.LogUserActionAsync("OptimizationMonitoringStarted", "Performance monitoring started"); - } - catch (Exception ex) - { - this.SetError("Failed to start performance monitoring", ex); - await this.LogUserActionAsync("OptimizationActionFailed", $"Failed to start performance monitoring: {ex.Message}"); - } - } - - [RelayCommand] - private async Task StopMonitoringAsync() - { - try - { - this.SetStatus("Stopping performance monitoring..."); - await this.performanceService.StopMonitoringAsync(); - - this.IsMonitoring = false; - this.MonitoringStatusText = "Live metrics stopped"; - this.MonitoringStateText = "Stopped"; - this.AddTimelineEvent("Live Metrics", "Live metrics stopped.", "Warning"); - - this.SetStatus("Performance monitoring stopped", false); - await this.LogUserActionAsync("OptimizationMonitoringStopped", "Performance monitoring stopped"); - } - catch (Exception ex) - { - this.SetError("Failed to stop performance monitoring", ex); - await this.LogUserActionAsync("OptimizationActionFailed", $"Failed to stop performance monitoring: {ex.Message}"); - } - } - - public async Task SuspendBackgroundMonitoringAsync() - { - try - { - await this.DeactivateDiagnosticsAsync(); - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to suspend performance diagnostics"); - } - } - - public async Task ResumeBackgroundMonitoringAsync() - { - await Task.CompletedTask; - } - - [RelayCommand] - private async Task RefreshMetricsAsync() - { - if (!this.diagnosticsActivated) - { - await this.ActivateDiagnosticsCoreAsync(auditActivity: true); - return; - } - - await this.RefreshMetricsSnapshotAsync(auditActivity: true); - } - - private async Task RefreshMetricsSnapshotAsync(bool auditActivity) - { - try - { - this.SetStatus("Refreshing performance snapshot..."); - - var metrics = await this.performanceService.GetSystemMetricsAsync(); - await this.RefreshGlobalPowerPlanAsync(); - await this.LoadTopProcessesAsync(); - this.UpdateMetrics(metrics); - - this.LastManualRefreshText = $"Refreshed at {DateTime.Now:HH:mm:ss}"; - this.SetStatus("Performance snapshot refreshed", false); - if (auditActivity) - { - await this.LogUserActionAsync("OptimizationSnapshotRefreshed", "Performance snapshot refreshed"); - } - return true; - } - catch (Exception ex) - { - this.SetError("Failed to refresh performance snapshot", ex); - if (auditActivity) - { - await this.LogUserActionAsync("OptimizationActionFailed", $"Failed to refresh performance snapshot: {ex.Message}"); - } - return false; - } - } - - [RelayCommand] - private async Task ClearHistoricalDataAsync() - { - try - { - await this.performanceService.ClearHistoricalDataAsync(); - this.HistoricalData.Clear(); - this.UpdateTimelineSummary(); - this.AddTimelineEvent("History", "Historical metrics cleared.", "Info"); - this.SetStatus("Historical data cleared", false); - await this.LogUserActionAsync("OptimizationHistoryCleared", "Historical metrics cleared"); - } - catch (Exception ex) - { - this.SetError("Failed to clear historical data", ex); - await this.LogUserActionAsync("OptimizationActionFailed", $"Failed to clear historical data: {ex.Message}"); - } - } - - [RelayCommand] - private async Task LoadHistoricalDataAsync() - { - try - { - var history = await this.performanceService.GetHistoricalDataAsync(TimeSpan.FromHours(1)); - this.HistoricalData = new ObservableCollection(history); - this.UpdateTimelineSummary(); - } - catch (Exception ex) - { - this.SetError("Failed to load historical data", ex); - } - } - - [RelayCommand] - private async Task CreateRuleFromSelectedProcessAsync() - { - if (this.SelectedHotspotProcess == null || this.IsRuleCreateBusy) - { - return; - } - - this.IsRuleCreateBusy = true; - - try - { - var liveProcesses = await this.processService.GetProcessesAsync(); - var targetProcess = liveProcesses.FirstOrDefault(p => p.ProcessId == this.SelectedHotspotProcess.ProcessId) - ?? liveProcesses.FirstOrDefault(p => - string.Equals(p.Name, this.SelectedHotspotProcess.ProcessName, StringComparison.OrdinalIgnoreCase)); - - if (targetProcess == null) - { - this.SetStatus("Selected hotspot process is no longer running", false); - return; - } - - var activePlan = await this.powerPlanService.GetActivePowerPlan(); - if (activePlan == null) - { - this.SetStatus("Could not resolve active global power plan", false); - return; - } - - var executableName = NormalizeExecutableName(targetProcess.Name); - var existing = await this.associationService.FindAssociationByExecutableAsync(executableName); - - if (existing == null) - { - var association = new ProcessPowerPlanAssociation - { - ExecutableName = executableName, - ExecutablePath = targetProcess.ExecutablePath ?? string.Empty, - PowerPlanGuid = activePlan.Guid, - PowerPlanName = activePlan.Name, - ProcessPriority = targetProcess.Priority.ToString(), - MatchByPath = !string.IsNullOrWhiteSpace(targetProcess.ExecutablePath), - Priority = 0, - Description = $"Created from Performance hotspot on {DateTime.Now:g}", - IsEnabled = true, - UpdatedAt = DateTime.UtcNow, - }; - - var added = await this.associationService.AddAssociationAsync(association); - if (!added) - { - this.SetStatus("A rule already exists and could not be created", false); - return; - } - - this.AddTimelineEvent("Rule", $"Rule created for {executableName} from hotspot panel.", "Success"); - this.SetStatus($"Rule created for {executableName} and ready for automation.", false); - await this.LogUserActionAsync("PersistentRuleSaved", $"Rule created for {executableName} from hotspot panel"); - } - else - { - existing.ExecutablePath = targetProcess.ExecutablePath ?? existing.ExecutablePath; - existing.PowerPlanGuid = activePlan.Guid; - existing.PowerPlanName = activePlan.Name; - existing.ProcessPriority = targetProcess.Priority.ToString(); - existing.IsEnabled = true; - existing.MatchByPath = !string.IsNullOrWhiteSpace(existing.ExecutablePath); - existing.Description = $"Updated from Performance hotspot on {DateTime.Now:g}"; - existing.UpdatedAt = DateTime.UtcNow; - - var updated = await this.associationService.UpdateAssociationAsync(existing); - if (!updated) - { - this.SetStatus("Failed to update existing rule from hotspot", false); - return; - } - - this.AddTimelineEvent("Rule", $"Rule updated for {executableName} from hotspot panel.", "Success"); - this.SetStatus($"Rule updated for {executableName} from hotspot panel.", false); - await this.LogUserActionAsync("PersistentRuleUpdated", $"Rule updated for {executableName} from hotspot panel"); - } - - await this.RefreshSelectedProcessRuleImpactAsync(); - await this.LoadTopProcessesAsync(); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to create or update rule from performance hotspot"); - this.SetError("Failed to create rule from selected hotspot", ex); - await this.LogUserActionAsync("PersistentRuleSaveFailed", $"Failed to create rule from selected hotspot: {ex.Message}"); - } - finally - { - this.IsRuleCreateBusy = false; - } - } - - [RelayCommand] - private void ToggleCoreDetails() - { - this.ShowCoreDetails = !this.ShowCoreDetails; - } - - [RelayCommand] - private void ToggleProcessDetails() - { - this.ShowProcessDetails = !this.ShowProcessDetails; - } - - [RelayCommand] - private void ShowPopup((string Title, string Content) parameters) - { - this.PopupTitle = parameters.Title; - this.PopupContent = parameters.Content; - this.IsPopupVisible = true; - } - - [RelayCommand] - private void HidePopup() - { - this.IsPopupVisible = false; - } - - partial void OnSelectedHotspotProcessChanged(ProcessPerformanceInfo? value) - { - _ = RefreshSelectedProcessRuleImpactAsync(); - CanCreateRuleFromSelectedProcess = value != null; - } - - partial void OnShowOnlyRuleBackedHotspotsChanged(bool value) - { - if (this.diagnosticsActivated) - { - _ = LoadTopProcessesAsync(); - } - } - - partial void OnShowOnlyActionableHotspotsChanged(bool value) - { - if (this.diagnosticsActivated) - { - _ = LoadTopProcessesAsync(); - } - } - - partial void OnSortModeChanged(string value) - { - if (this.diagnosticsActivated) - { - _ = LoadTopProcessesAsync(); - } - } - - partial void OnProcessSearchTextChanged(string value) - { - if (this.diagnosticsActivated) - { - _ = LoadTopProcessesAsync(); - } - } - - partial void OnIsPopupVisibleChanged(bool value) - { - this.BlurRadius = value ? 15 : 0; - } - - private async Task RefreshSelectedProcessRuleImpactAsync() - { - try - { - if (this.SelectedHotspotProcess == null) - { - this.SelectedProcessName = "No hotspot selected"; - this.SelectedProcessExecutable = "-"; - this.SelectedProcessCpuText = "-"; - this.SelectedProcessMemoryText = "-"; - this.SelectedProcessRuleStatus = "No linked rule"; - this.SelectedProcessRuleSummary = "Create a rule from this process to automate affinity and priority behavior."; - this.SelectedProcessLastApplyText = "No recent automation event"; - return; - } - - var executableName = NormalizeExecutableName(this.SelectedHotspotProcess.ProcessName); - var association = await this.associationService.FindAssociationByExecutableAsync(executableName); - - this.SelectedProcessName = this.SelectedHotspotProcess.ProcessName; - this.SelectedProcessExecutable = string.IsNullOrWhiteSpace(this.SelectedHotspotProcess.ExecutablePath) - ? executableName - : this.SelectedHotspotProcess.ExecutablePath; - this.SelectedProcessCpuText = $"{this.SelectedHotspotProcess.CpuUsage:F1}% CPU"; - this.SelectedProcessMemoryText = FormatBytes(this.SelectedHotspotProcess.MemoryUsage); - - if (association == null) - { - this.SelectedProcessRuleStatus = "No linked rule"; - this.SelectedProcessRuleSummary = "No automation rule matches this executable yet."; - } - else - { - this.SelectedProcessRuleStatus = association.IsEnabled ? "Linked rule is active" : "Linked rule is disabled"; - this.SelectedProcessRuleSummary = BuildRuleSummary(association); - } - - if (this.lastRuleApplyByExecutable.TryGetValue(executableName, out var appliedAt)) - { - this.SelectedProcessLastApplyText = $"Last rule application: {appliedAt:HH:mm:ss}"; - } - else - { - this.SelectedProcessLastApplyText = "No recent automation event"; - } - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to refresh rule impact panel"); - } - } - - private async Task LoadTopProcessesAsync() - { - if (this.topProcessRefreshGate.CurrentCount == 0) - { - this.pendingTopProcessRefresh = true; - return; - } - - await this.topProcessRefreshGate.WaitAsync(); - - try - { - do - { - this.pendingTopProcessRefresh = false; - - var topCpu = await this.performanceService.GetTopCpuProcessesAsync(25); - var topMemory = await this.performanceService.GetTopMemoryProcessesAsync(25); - - var merged = topCpu - .Concat(topMemory) - .GroupBy(p => p.ProcessId) - .Select(g => g.OrderByDescending(x => x.CpuUsage).First()) - .ToList(); - - var associations = await this.associationService.GetAssociationsAsync(); - var associationSet = associations - .Select(a => NormalizeExecutableName(a.ExecutableName)) - .Where(name => !string.IsNullOrWhiteSpace(name)) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - IEnumerable filtered = merged; - - if (!string.IsNullOrWhiteSpace(this.ProcessSearchText)) - { - filtered = filtered.Where(p => p.ProcessName.Contains(this.ProcessSearchText, StringComparison.OrdinalIgnoreCase)); - } - - if (this.ShowOnlyRuleBackedHotspots) - { - filtered = filtered.Where(p => associationSet.Contains(NormalizeExecutableName(p.ProcessName))); - } - - if (this.ShowOnlyActionableHotspots) - { - filtered = filtered.Where(p => p.CpuUsage >= 1.0 || p.MemoryUsage >= (200L * 1024 * 1024)); - } - - filtered = this.SortMode switch - { - "Memory" => filtered.OrderByDescending(p => p.MemoryUsage), - "Name" => filtered.OrderBy(p => p.ProcessName), - _ => filtered.OrderByDescending(p => p.CpuUsage), - }; - - var snapshot = filtered.Take(50).ToList(); - - var dispatcher = System.Windows.Application.Current?.Dispatcher; - void Apply() - { - this.TopCpuProcesses = new ObservableCollection(snapshot); - - if (this.SelectedHotspotProcess != null) - { - var refreshedSelection = this.TopCpuProcesses.FirstOrDefault(p => p.ProcessId == this.SelectedHotspotProcess.ProcessId); - if (refreshedSelection != null) - { - this.SelectedHotspotProcess = refreshedSelection; - } - } - } - - if (dispatcher != null && !dispatcher.CheckAccess()) - { - await dispatcher.InvokeAsync(Apply); - } - else - { - Apply(); - } - - await this.RefreshSelectedProcessRuleImpactAsync(); - } - while (this.pendingTopProcessRefresh); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error loading hotspot process lists"); - } - finally - { - this.topProcessRefreshGate.Release(); - } - } - - private async Task RefreshGlobalPowerPlanAsync() - { - try - { - var activePlan = await this.powerPlanService.GetActivePowerPlan(); - this.CurrentGlobalPowerPlanText = activePlan?.Name ?? "Unknown"; - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed to refresh global power plan text"); - this.CurrentGlobalPowerPlanText = "Unknown"; - } - } - - private void OnMetricsUpdated(object? sender, PerformanceMetricsUpdatedEventArgs e) - { - try - { - System.Windows.Application.Current.Dispatcher.Invoke(() => - { - this.UpdateMetrics(e.Metrics); - _ = this.LoadTopProcessesAsync(); - }); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error updating performance metrics in UI"); - } - } - - private void UpdateMetrics(SystemPerformanceMetrics metrics) - { - this.TotalCpuUsage = metrics.TotalCpuUsage; - this.TotalMemoryUsage = metrics.TotalMemoryUsage; - this.AvailableMemory = metrics.AvailableMemory; - this.TotalMemory = metrics.TotalMemory; - this.MemoryUsagePercentage = metrics.MemoryUsagePercentage; - this.ActiveProcessCount = metrics.ActiveProcessCount; - this.LastUpdateTime = metrics.Timestamp; - - this.CpuUsageText = $"{this.TotalCpuUsage:F1}%"; - this.MemoryUsageText = $"{FormatBytes(this.TotalMemoryUsage)} / {FormatBytes(this.TotalMemory)}"; - this.ProcessCountText = this.ActiveProcessCount.ToString(); - - void Apply() - { - this.CoreUsages = new ObservableCollection(metrics.CpuCoreUsages); - - if (this.IsMonitoring) - { - this.HistoricalData.Add(metrics); - while (this.HistoricalData.Count > 360) - { - this.HistoricalData.RemoveAt(0); - } - } - - this.UpdateTimelineSummary(); - } - - var dispatcher = System.Windows.Application.Current?.Dispatcher; - if (dispatcher != null && !dispatcher.CheckAccess()) - { - dispatcher.Invoke(Apply); - } - else - { - Apply(); - } - } - - private void OnProcessPowerPlanChanged(object? sender, ProcessPowerPlanChangeEventArgs e) - { - try - { - var executable = NormalizeExecutableName(e.Process.Name); - this.lastRuleApplyByExecutable[executable] = e.Timestamp; - - var detail = $"{e.Action}: {e.Process.Name} -> {e.NewPowerPlan?.Name ?? "Unknown"}"; - this.AddTimelineEvent("Rule Applied", detail, "Success"); - - _ = this.RefreshSelectedProcessRuleImpactAsync(); - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "Failed handling process power plan change event"); - } - } - - private void OnPowerPlanChanged(object? sender, PowerPlanChangedEventArgs e) - { - var detail = $"Global plan changed to {e.NewPowerPlan?.Name ?? "Unknown"}"; - this.AddTimelineEvent("Power Plan", detail, "Info"); - this.CurrentGlobalPowerPlanText = e.NewPowerPlan?.Name ?? "Unknown"; - } - - private void OnTweakStatusChanged(object? sender, TweakStatusChangedEventArgs e) - { - var state = e.Status.IsEnabled ? "enabled" : "disabled"; - this.AddTimelineEvent("Tweak", $"{e.TweakName} {state}", "Warning"); - } - - private void AddTimelineEvent(string category, string detail, string severity) - { - var evt = new PerformanceTimelineEvent - { - Category = category, - Detail = detail, - Severity = severity, - Timestamp = DateTime.Now, - }; - - void Apply() - { - this.TimelineEvents.Insert(0, evt); - while (this.TimelineEvents.Count > 200) - { - this.TimelineEvents.RemoveAt(this.TimelineEvents.Count - 1); - } - - this.UpdateTimelineSummary(); - } - - var dispatcher = System.Windows.Application.Current?.Dispatcher; - if (dispatcher != null && !dispatcher.CheckAccess()) - { - dispatcher.Invoke(Apply); - } - else - { - Apply(); - } - } - - private void UpdateTimelineSummary() - { - this.TimelineSampleCountText = $"{this.HistoricalData.Count} samples"; - if (this.TimelineEvents.Count == 0) - { - this.LastTimelineEventText = "No events yet"; - return; - } - - var latest = this.TimelineEvents[0]; - this.LastTimelineEventText = $"{latest.Timestamp:HH:mm:ss} - {latest.Category}"; - } - - private static string BuildRuleSummary(ProcessPowerPlanAssociation association) - { - var parts = new List(); - if (!string.IsNullOrWhiteSpace(association.PowerPlanName)) - { - parts.Add($"Plan: {association.PowerPlanName}"); - } - - if (!string.IsNullOrWhiteSpace(association.CoreMaskName)) - { - parts.Add($"Mask: {association.CoreMaskName}"); - } - - if (!string.IsNullOrWhiteSpace(association.ProcessPriority)) - { - parts.Add($"Priority: {association.ProcessPriority}"); - } - - return parts.Count == 0 - ? "Rule exists but has no advanced affinity/priority settings." - : string.Join(" | ", parts); - } - - private static string NormalizeExecutableName(string? name) - { - if (string.IsNullOrWhiteSpace(name)) - { - return string.Empty; - } - - return System.IO.Path.GetFileNameWithoutExtension(name.Trim()); - } - - private static string FormatBytes(long bytes) - { - if (bytes <= 0) - { - return "0 MB"; - } - - const double kb = 1024d; - const double mb = kb * 1024d; - const double gb = mb * 1024d; - - if (bytes >= gb) - { - return $"{bytes / gb:F2} GB"; - } - - return $"{bytes / mb:F0} MB"; - } - - protected override void OnDispose() - { - this.performanceService.MetricsUpdated -= this.OnMetricsUpdated; - this.processMonitorManagerService.ProcessPowerPlanChanged -= this.OnProcessPowerPlanChanged; - this.powerPlanService.PowerPlanChanged -= this.OnPowerPlanChanged; - this.systemTweaksService.TweakStatusChanged -= this.OnTweakStatusChanged; - - this.topProcessRefreshGate.Dispose(); - - if (this.IsMonitoring) - { - _ = Task.Run(async () => await this.performanceService.StopMonitoringAsync()); - } - - base.OnDispose(); - } - } - - public class PerformanceTimelineEvent - { - public DateTime Timestamp { get; set; } - - public string Category { get; set; } = string.Empty; - - public string Detail { get; set; } = string.Empty; - - public string Severity { get; set; } = "Info"; - } -} +namespace ThreadPilot.ViewModels +{ + using System; + using System.Collections.Generic; + using System.Collections.ObjectModel; + using System.Diagnostics; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using CommunityToolkit.Mvvm.ComponentModel; + using CommunityToolkit.Mvvm.Input; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public partial class PerformanceViewModel : BaseViewModel + { + private readonly IPerformanceMonitoringService performanceService; + private readonly IProcessService processService; + private readonly IProcessPowerPlanAssociationService associationService; + private readonly IPowerPlanService powerPlanService; + private readonly IProcessMonitorManagerService processMonitorManagerService; + private readonly ISystemTweaksService systemTweaksService; + private readonly ILogger logger; + + [ObservableProperty] + private ObservableCollection coreUsages = new(); + + [ObservableProperty] + private ObservableCollection topCpuProcesses = new(); + + [ObservableProperty] + private ObservableCollection historicalData = new(); + + [ObservableProperty] + private ObservableCollection timelineEvents = new(); + + [ObservableProperty] + private ProcessPerformanceInfo? selectedHotspotProcess; + + [ObservableProperty] + private bool isMonitoring; + + [ObservableProperty] + private string monitoringStatusText = "Live metrics stopped"; + + [ObservableProperty] + private DateTime lastUpdateTime; + + [ObservableProperty] + private double totalCpuUsage; + + [ObservableProperty] + private long totalMemoryUsage; + + [ObservableProperty] + private long totalMemory; + + [ObservableProperty] + private long availableMemory; + + [ObservableProperty] + private double memoryUsagePercentage; + + [ObservableProperty] + private int activeProcessCount; + + [ObservableProperty] + private string cpuUsageText = "0.0%"; + + [ObservableProperty] + private string memoryUsageText = "0 MB / 0 MB"; + + [ObservableProperty] + private string processCountText = "0"; + + [ObservableProperty] + private string currentGlobalPowerPlanText = "Unknown"; + + [ObservableProperty] + private string monitoringStateText = "Stopped"; + + [ObservableProperty] + private string selectedProcessName = "No hotspot selected"; + + [ObservableProperty] + private string selectedProcessExecutable = "-"; + + [ObservableProperty] + private string selectedProcessCpuText = "-"; + + [ObservableProperty] + private string selectedProcessMemoryText = "-"; + + [ObservableProperty] + private string selectedProcessRuleStatus = "No linked rule"; + + [ObservableProperty] + private string selectedProcessRuleSummary = "Create a rule from this process to automate affinity and priority behavior."; + + [ObservableProperty] + private string selectedProcessLastApplyText = "No recent automation event"; + + [ObservableProperty] + private bool canCreateRuleFromSelectedProcess; + + [ObservableProperty] + private string timelineSampleCountText = "0 samples"; + + [ObservableProperty] + private string lastTimelineEventText = "No events yet"; + + [ObservableProperty] + private int updateInterval = 2000; + + [ObservableProperty] + private bool showCoreDetails = true; + + [ObservableProperty] + private bool showProcessDetails = true; + + [ObservableProperty] + private bool showOnlyRuleBackedHotspots; + + [ObservableProperty] + private bool showOnlyActionableHotspots = true; + + [ObservableProperty] + private string sortMode = "Cpu"; + + [ObservableProperty] + private string lastManualRefreshText = "Not refreshed yet"; + + [ObservableProperty] + private string processSearchText = string.Empty; + + [ObservableProperty] + private bool isRuleCreateBusy; + + [ObservableProperty] + private bool isPopupVisible; + + [ObservableProperty] + private string popupTitle = string.Empty; + + [ObservableProperty] + private string popupContent = string.Empty; + + [ObservableProperty] + private int blurRadius; + + private readonly Dictionary lastRuleApplyByExecutable = new(StringComparer.OrdinalIgnoreCase); + private readonly SemaphoreSlim topProcessRefreshGate = new(1, 1); + private bool pendingTopProcessRefresh; + private bool diagnosticsActivated; + + public PerformanceViewModel( + IPerformanceMonitoringService performanceService, + IProcessService processService, + IProcessPowerPlanAssociationService associationService, + IPowerPlanService powerPlanService, + IProcessMonitorManagerService processMonitorManagerService, + ISystemTweaksService systemTweaksService, + ILogger logger, + IEnhancedLoggingService? enhancedLoggingService = null, + IActivityAuditService? activityAuditService = null) + : base(logger, enhancedLoggingService, activityAuditService) + { + this.performanceService = performanceService; + this.processService = processService; + this.associationService = associationService; + this.powerPlanService = powerPlanService; + this.processMonitorManagerService = processMonitorManagerService; + this.systemTweaksService = systemTweaksService; + this.logger = logger; + + this.performanceService.MetricsUpdated += this.OnMetricsUpdated; + this.processMonitorManagerService.ProcessPowerPlanChanged += this.OnProcessPowerPlanChanged; + this.powerPlanService.PowerPlanChanged += this.OnPowerPlanChanged; + this.systemTweaksService.TweakStatusChanged += this.OnTweakStatusChanged; + } + + public override async Task InitializeAsync() + { + this.MonitoringStateText = "Stopped"; + this.MonitoringStatusText = "Diagnostics inactive"; + this.SetStatus("Diagnostics are inactive until opened.", false); + await Task.CompletedTask; + } + + public async Task ActivateDiagnosticsAsync() + { + await this.ActivateDiagnosticsCoreAsync(auditActivity: false); + } + + private async Task ActivateDiagnosticsCoreAsync(bool auditActivity) + { + try + { + this.SetStatus("Loading optional diagnostics..."); + + var snapshotLoaded = await this.RefreshMetricsSnapshotAsync(auditActivity); + await this.LoadHistoricalDataAsync(); + + this.diagnosticsActivated = true; + this.MonitoringStateText = this.IsMonitoring ? "Active" : "Stopped"; + if (snapshotLoaded) + { + this.MonitoringStatusText = this.IsMonitoring ? "Live metrics active" : "Diagnostics snapshot loaded"; + this.SetStatus("Optional diagnostics loaded", false); + } + else + { + this.MonitoringStatusText = "Diagnostics snapshot failed"; + } + } + catch (Exception ex) + { + this.SetError("Failed to load optional diagnostics", ex); + this.logger.LogError(ex, "Error loading optional diagnostics"); + } + } + + public async Task DeactivateDiagnosticsAsync() + { + try + { + if (this.IsMonitoring) + { + await this.performanceService.StopMonitoringAsync(); + } + + this.IsMonitoring = false; + this.MonitoringStatusText = this.diagnosticsActivated + ? "Diagnostics inactive" + : "Live metrics stopped"; + this.MonitoringStateText = "Stopped"; + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to deactivate optional diagnostics"); + } + } + + [RelayCommand] + private async Task StartMonitoringAsync() + { + try + { + if (!this.diagnosticsActivated) + { + await this.ActivateDiagnosticsAsync(); + } + + this.SetStatus("Starting performance monitoring..."); + await this.performanceService.StartMonitoringAsync(); + + this.IsMonitoring = true; + this.MonitoringStatusText = "Live metrics active"; + this.MonitoringStateText = "Active"; + this.AddTimelineEvent("Live Metrics", "Live metrics started.", "Info"); + + this.SetStatus("Performance monitoring started", false); + await this.LogUserActionAsync("OptimizationMonitoringStarted", "Performance monitoring started"); + } + catch (Exception ex) + { + this.SetError("Failed to start performance monitoring", ex); + await this.LogUserActionAsync("OptimizationActionFailed", $"Failed to start performance monitoring: {ex.Message}"); + } + } + + [RelayCommand] + private async Task StopMonitoringAsync() + { + try + { + this.SetStatus("Stopping performance monitoring..."); + await this.performanceService.StopMonitoringAsync(); + + this.IsMonitoring = false; + this.MonitoringStatusText = "Live metrics stopped"; + this.MonitoringStateText = "Stopped"; + this.AddTimelineEvent("Live Metrics", "Live metrics stopped.", "Warning"); + + this.SetStatus("Performance monitoring stopped", false); + await this.LogUserActionAsync("OptimizationMonitoringStopped", "Performance monitoring stopped"); + } + catch (Exception ex) + { + this.SetError("Failed to stop performance monitoring", ex); + await this.LogUserActionAsync("OptimizationActionFailed", $"Failed to stop performance monitoring: {ex.Message}"); + } + } + + public async Task SuspendBackgroundMonitoringAsync() + { + try + { + await this.DeactivateDiagnosticsAsync(); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to suspend performance diagnostics"); + } + } + + public async Task ResumeBackgroundMonitoringAsync() + { + await Task.CompletedTask; + } + + [RelayCommand] + private async Task RefreshMetricsAsync() + { + if (!this.diagnosticsActivated) + { + await this.ActivateDiagnosticsCoreAsync(auditActivity: true); + return; + } + + await this.RefreshMetricsSnapshotAsync(auditActivity: true); + } + + private async Task RefreshMetricsSnapshotAsync(bool auditActivity) + { + try + { + this.SetStatus("Refreshing performance snapshot..."); + + var metrics = await this.performanceService.GetSystemMetricsAsync(); + await this.RefreshGlobalPowerPlanAsync(); + await this.LoadTopProcessesAsync(); + this.UpdateMetrics(metrics); + + this.LastManualRefreshText = $"Refreshed at {DateTime.Now:HH:mm:ss}"; + this.SetStatus("Performance snapshot refreshed", false); + if (auditActivity) + { + await this.LogUserActionAsync("OptimizationSnapshotRefreshed", "Performance snapshot refreshed"); + } + return true; + } + catch (Exception ex) + { + this.SetError("Failed to refresh performance snapshot", ex); + if (auditActivity) + { + await this.LogUserActionAsync("OptimizationActionFailed", $"Failed to refresh performance snapshot: {ex.Message}"); + } + return false; + } + } + + [RelayCommand] + private async Task ClearHistoricalDataAsync() + { + try + { + await this.performanceService.ClearHistoricalDataAsync(); + this.HistoricalData.Clear(); + this.UpdateTimelineSummary(); + this.AddTimelineEvent("History", "Historical metrics cleared.", "Info"); + this.SetStatus("Historical data cleared", false); + await this.LogUserActionAsync("OptimizationHistoryCleared", "Historical metrics cleared"); + } + catch (Exception ex) + { + this.SetError("Failed to clear historical data", ex); + await this.LogUserActionAsync("OptimizationActionFailed", $"Failed to clear historical data: {ex.Message}"); + } + } + + [RelayCommand] + private async Task LoadHistoricalDataAsync() + { + try + { + var history = await this.performanceService.GetHistoricalDataAsync(TimeSpan.FromHours(1)); + this.HistoricalData = new ObservableCollection(history); + this.UpdateTimelineSummary(); + } + catch (Exception ex) + { + this.SetError("Failed to load historical data", ex); + } + } + + [RelayCommand] + private async Task CreateRuleFromSelectedProcessAsync() + { + if (this.SelectedHotspotProcess == null || this.IsRuleCreateBusy) + { + return; + } + + this.IsRuleCreateBusy = true; + + try + { + var liveProcesses = await this.processService.GetProcessesAsync(); + var targetProcess = liveProcesses.FirstOrDefault(p => p.ProcessId == this.SelectedHotspotProcess.ProcessId) + ?? liveProcesses.FirstOrDefault(p => + string.Equals(p.Name, this.SelectedHotspotProcess.ProcessName, StringComparison.OrdinalIgnoreCase)); + + if (targetProcess == null) + { + this.SetStatus("Selected hotspot process is no longer running", false); + return; + } + + var activePlan = await this.powerPlanService.GetActivePowerPlan(); + if (activePlan == null) + { + this.SetStatus("Could not resolve active global power plan", false); + return; + } + + var executableName = NormalizeExecutableName(targetProcess.Name); + var existing = await this.associationService.FindAssociationByExecutableAsync(executableName); + + if (existing == null) + { + var association = new ProcessPowerPlanAssociation + { + ExecutableName = executableName, + ExecutablePath = targetProcess.ExecutablePath ?? string.Empty, + PowerPlanGuid = activePlan.Guid, + PowerPlanName = activePlan.Name, + ProcessPriority = targetProcess.Priority.ToString(), + MatchByPath = !string.IsNullOrWhiteSpace(targetProcess.ExecutablePath), + Priority = 0, + Description = $"Created from Performance hotspot on {DateTime.Now:g}", + IsEnabled = true, + UpdatedAt = DateTime.UtcNow, + }; + + var added = await this.associationService.AddAssociationAsync(association); + if (!added) + { + this.SetStatus("A rule already exists and could not be created", false); + return; + } + + this.AddTimelineEvent("Rule", $"Rule created for {executableName} from hotspot panel.", "Success"); + this.SetStatus($"Rule created for {executableName} and ready for automation.", false); + await this.LogUserActionAsync("PersistentRuleSaved", $"Rule created for {executableName} from hotspot panel"); + } + else + { + existing.ExecutablePath = targetProcess.ExecutablePath ?? existing.ExecutablePath; + existing.PowerPlanGuid = activePlan.Guid; + existing.PowerPlanName = activePlan.Name; + existing.ProcessPriority = targetProcess.Priority.ToString(); + existing.IsEnabled = true; + existing.MatchByPath = !string.IsNullOrWhiteSpace(existing.ExecutablePath); + existing.Description = $"Updated from Performance hotspot on {DateTime.Now:g}"; + existing.UpdatedAt = DateTime.UtcNow; + + var updated = await this.associationService.UpdateAssociationAsync(existing); + if (!updated) + { + this.SetStatus("Failed to update existing rule from hotspot", false); + return; + } + + this.AddTimelineEvent("Rule", $"Rule updated for {executableName} from hotspot panel.", "Success"); + this.SetStatus($"Rule updated for {executableName} from hotspot panel.", false); + await this.LogUserActionAsync("PersistentRuleUpdated", $"Rule updated for {executableName} from hotspot panel"); + } + + await this.RefreshSelectedProcessRuleImpactAsync(); + await this.LoadTopProcessesAsync(); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to create or update rule from performance hotspot"); + this.SetError("Failed to create rule from selected hotspot", ex); + await this.LogUserActionAsync("PersistentRuleSaveFailed", $"Failed to create rule from selected hotspot: {ex.Message}"); + } + finally + { + this.IsRuleCreateBusy = false; + } + } + + [RelayCommand] + private void ToggleCoreDetails() + { + this.ShowCoreDetails = !this.ShowCoreDetails; + } + + [RelayCommand] + private void ToggleProcessDetails() + { + this.ShowProcessDetails = !this.ShowProcessDetails; + } + + [RelayCommand] + private void ShowPopup((string Title, string Content) parameters) + { + this.PopupTitle = parameters.Title; + this.PopupContent = parameters.Content; + this.IsPopupVisible = true; + } + + [RelayCommand] + private void HidePopup() + { + this.IsPopupVisible = false; + } + + partial void OnSelectedHotspotProcessChanged(ProcessPerformanceInfo? value) + { + _ = RefreshSelectedProcessRuleImpactAsync(); + CanCreateRuleFromSelectedProcess = value != null; + } + + partial void OnShowOnlyRuleBackedHotspotsChanged(bool value) + { + if (this.diagnosticsActivated) + { + _ = LoadTopProcessesAsync(); + } + } + + partial void OnShowOnlyActionableHotspotsChanged(bool value) + { + if (this.diagnosticsActivated) + { + _ = LoadTopProcessesAsync(); + } + } + + partial void OnSortModeChanged(string value) + { + if (this.diagnosticsActivated) + { + _ = LoadTopProcessesAsync(); + } + } + + partial void OnProcessSearchTextChanged(string value) + { + if (this.diagnosticsActivated) + { + _ = LoadTopProcessesAsync(); + } + } + + partial void OnIsPopupVisibleChanged(bool value) + { + this.BlurRadius = value ? 15 : 0; + } + + private async Task RefreshSelectedProcessRuleImpactAsync() + { + try + { + if (this.SelectedHotspotProcess == null) + { + this.SelectedProcessName = "No hotspot selected"; + this.SelectedProcessExecutable = "-"; + this.SelectedProcessCpuText = "-"; + this.SelectedProcessMemoryText = "-"; + this.SelectedProcessRuleStatus = "No linked rule"; + this.SelectedProcessRuleSummary = "Create a rule from this process to automate affinity and priority behavior."; + this.SelectedProcessLastApplyText = "No recent automation event"; + return; + } + + var executableName = NormalizeExecutableName(this.SelectedHotspotProcess.ProcessName); + var association = await this.associationService.FindAssociationByExecutableAsync(executableName); + + this.SelectedProcessName = this.SelectedHotspotProcess.ProcessName; + this.SelectedProcessExecutable = string.IsNullOrWhiteSpace(this.SelectedHotspotProcess.ExecutablePath) + ? executableName + : this.SelectedHotspotProcess.ExecutablePath; + this.SelectedProcessCpuText = $"{this.SelectedHotspotProcess.CpuUsage:F1}% CPU"; + this.SelectedProcessMemoryText = FormatBytes(this.SelectedHotspotProcess.MemoryUsage); + + if (association == null) + { + this.SelectedProcessRuleStatus = "No linked rule"; + this.SelectedProcessRuleSummary = "No automation rule matches this executable yet."; + } + else + { + this.SelectedProcessRuleStatus = association.IsEnabled ? "Linked rule is active" : "Linked rule is disabled"; + this.SelectedProcessRuleSummary = BuildRuleSummary(association); + } + + if (this.lastRuleApplyByExecutable.TryGetValue(executableName, out var appliedAt)) + { + this.SelectedProcessLastApplyText = $"Last rule application: {appliedAt:HH:mm:ss}"; + } + else + { + this.SelectedProcessLastApplyText = "No recent automation event"; + } + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to refresh rule impact panel"); + } + } + + private async Task LoadTopProcessesAsync() + { + if (this.topProcessRefreshGate.CurrentCount == 0) + { + this.pendingTopProcessRefresh = true; + return; + } + + await this.topProcessRefreshGate.WaitAsync(); + + try + { + do + { + this.pendingTopProcessRefresh = false; + + var topCpu = await this.performanceService.GetTopCpuProcessesAsync(25); + var topMemory = await this.performanceService.GetTopMemoryProcessesAsync(25); + + var merged = topCpu + .Concat(topMemory) + .GroupBy(p => p.ProcessId) + .Select(g => g.OrderByDescending(x => x.CpuUsage).First()) + .ToList(); + + var associations = await this.associationService.GetAssociationsAsync(); + var associationSet = associations + .Select(a => NormalizeExecutableName(a.ExecutableName)) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + IEnumerable filtered = merged; + + if (!string.IsNullOrWhiteSpace(this.ProcessSearchText)) + { + filtered = filtered.Where(p => p.ProcessName.Contains(this.ProcessSearchText, StringComparison.OrdinalIgnoreCase)); + } + + if (this.ShowOnlyRuleBackedHotspots) + { + filtered = filtered.Where(p => associationSet.Contains(NormalizeExecutableName(p.ProcessName))); + } + + if (this.ShowOnlyActionableHotspots) + { + filtered = filtered.Where(p => p.CpuUsage >= 1.0 || p.MemoryUsage >= (200L * 1024 * 1024)); + } + + filtered = this.SortMode switch + { + "Memory" => filtered.OrderByDescending(p => p.MemoryUsage), + "Name" => filtered.OrderBy(p => p.ProcessName), + _ => filtered.OrderByDescending(p => p.CpuUsage), + }; + + var snapshot = filtered.Take(50).ToList(); + + var dispatcher = System.Windows.Application.Current?.Dispatcher; + void Apply() + { + this.TopCpuProcesses = new ObservableCollection(snapshot); + + if (this.SelectedHotspotProcess != null) + { + var refreshedSelection = this.TopCpuProcesses.FirstOrDefault(p => p.ProcessId == this.SelectedHotspotProcess.ProcessId); + if (refreshedSelection != null) + { + this.SelectedHotspotProcess = refreshedSelection; + } + } + } + + if (dispatcher != null && !dispatcher.CheckAccess()) + { + await dispatcher.InvokeAsync(Apply); + } + else + { + Apply(); + } + + await this.RefreshSelectedProcessRuleImpactAsync(); + } + while (this.pendingTopProcessRefresh); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error loading hotspot process lists"); + } + finally + { + this.topProcessRefreshGate.Release(); + } + } + + private async Task RefreshGlobalPowerPlanAsync() + { + try + { + var activePlan = await this.powerPlanService.GetActivePowerPlan(); + this.CurrentGlobalPowerPlanText = activePlan?.Name ?? "Unknown"; + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to refresh global power plan text"); + this.CurrentGlobalPowerPlanText = "Unknown"; + } + } + + private void OnMetricsUpdated(object? sender, PerformanceMetricsUpdatedEventArgs e) + { + try + { + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + this.UpdateMetrics(e.Metrics); + _ = this.LoadTopProcessesAsync(); + }); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error updating performance metrics in UI"); + } + } + + private void UpdateMetrics(SystemPerformanceMetrics metrics) + { + this.TotalCpuUsage = metrics.TotalCpuUsage; + this.TotalMemoryUsage = metrics.TotalMemoryUsage; + this.AvailableMemory = metrics.AvailableMemory; + this.TotalMemory = metrics.TotalMemory; + this.MemoryUsagePercentage = metrics.MemoryUsagePercentage; + this.ActiveProcessCount = metrics.ActiveProcessCount; + this.LastUpdateTime = metrics.Timestamp; + + this.CpuUsageText = $"{this.TotalCpuUsage:F1}%"; + this.MemoryUsageText = $"{FormatBytes(this.TotalMemoryUsage)} / {FormatBytes(this.TotalMemory)}"; + this.ProcessCountText = this.ActiveProcessCount.ToString(); + + void Apply() + { + this.CoreUsages = new ObservableCollection(metrics.CpuCoreUsages); + + if (this.IsMonitoring) + { + this.HistoricalData.Add(metrics); + while (this.HistoricalData.Count > 360) + { + this.HistoricalData.RemoveAt(0); + } + } + + this.UpdateTimelineSummary(); + } + + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher != null && !dispatcher.CheckAccess()) + { + dispatcher.Invoke(Apply); + } + else + { + Apply(); + } + } + + private void OnProcessPowerPlanChanged(object? sender, ProcessPowerPlanChangeEventArgs e) + { + try + { + var executable = NormalizeExecutableName(e.Process.Name); + this.lastRuleApplyByExecutable[executable] = e.Timestamp; + + var detail = $"{e.Action}: {e.Process.Name} -> {e.NewPowerPlan?.Name ?? "Unknown"}"; + this.AddTimelineEvent("Rule Applied", detail, "Success"); + + _ = this.RefreshSelectedProcessRuleImpactAsync(); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed handling process power plan change event"); + } + } + + private void OnPowerPlanChanged(object? sender, PowerPlanChangedEventArgs e) + { + var detail = $"Global plan changed to {e.NewPowerPlan?.Name ?? "Unknown"}"; + this.AddTimelineEvent("Power Plan", detail, "Info"); + this.CurrentGlobalPowerPlanText = e.NewPowerPlan?.Name ?? "Unknown"; + } + + private void OnTweakStatusChanged(object? sender, TweakStatusChangedEventArgs e) + { + var state = e.Status.IsEnabled ? "enabled" : "disabled"; + this.AddTimelineEvent("Tweak", $"{e.TweakName} {state}", "Warning"); + } + + private void AddTimelineEvent(string category, string detail, string severity) + { + var evt = new PerformanceTimelineEvent + { + Category = category, + Detail = detail, + Severity = severity, + Timestamp = DateTime.Now, + }; + + void Apply() + { + this.TimelineEvents.Insert(0, evt); + while (this.TimelineEvents.Count > 200) + { + this.TimelineEvents.RemoveAt(this.TimelineEvents.Count - 1); + } + + this.UpdateTimelineSummary(); + } + + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher != null && !dispatcher.CheckAccess()) + { + dispatcher.Invoke(Apply); + } + else + { + Apply(); + } + } + + private void UpdateTimelineSummary() + { + this.TimelineSampleCountText = $"{this.HistoricalData.Count} samples"; + if (this.TimelineEvents.Count == 0) + { + this.LastTimelineEventText = "No events yet"; + return; + } + + var latest = this.TimelineEvents[0]; + this.LastTimelineEventText = $"{latest.Timestamp:HH:mm:ss} - {latest.Category}"; + } + + private static string BuildRuleSummary(ProcessPowerPlanAssociation association) + { + var parts = new List(); + if (!string.IsNullOrWhiteSpace(association.PowerPlanName)) + { + parts.Add($"Plan: {association.PowerPlanName}"); + } + + if (!string.IsNullOrWhiteSpace(association.CoreMaskName)) + { + parts.Add($"Mask: {association.CoreMaskName}"); + } + + if (!string.IsNullOrWhiteSpace(association.ProcessPriority)) + { + parts.Add($"Priority: {association.ProcessPriority}"); + } + + return parts.Count == 0 + ? "Rule exists but has no advanced affinity/priority settings." + : string.Join(" | ", parts); + } + + private static string NormalizeExecutableName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return string.Empty; + } + + return System.IO.Path.GetFileNameWithoutExtension(name.Trim()); + } + + private static string FormatBytes(long bytes) + { + if (bytes <= 0) + { + return "0 MB"; + } + + const double kb = 1024d; + const double mb = kb * 1024d; + const double gb = mb * 1024d; + + if (bytes >= gb) + { + return $"{bytes / gb:F2} GB"; + } + + return $"{bytes / mb:F0} MB"; + } + + protected override void OnDispose() + { + this.performanceService.MetricsUpdated -= this.OnMetricsUpdated; + this.processMonitorManagerService.ProcessPowerPlanChanged -= this.OnProcessPowerPlanChanged; + this.powerPlanService.PowerPlanChanged -= this.OnPowerPlanChanged; + this.systemTweaksService.TweakStatusChanged -= this.OnTweakStatusChanged; + + this.topProcessRefreshGate.Dispose(); + + if (this.IsMonitoring) + { + _ = Task.Run(async () => await this.performanceService.StopMonitoringAsync()); + } + + base.OnDispose(); + } + } + + public class PerformanceTimelineEvent + { + public DateTime Timestamp { get; set; } + + public string Category { get; set; } = string.Empty; + + public string Detail { get; set; } = string.Empty; + + public string Severity { get; set; } = "Info"; + } +} diff --git a/ViewModels/PowerPlanViewModel.cs b/ViewModels/PowerPlanViewModel.cs index eceb7b2..1a39584 100644 --- a/ViewModels/PowerPlanViewModel.cs +++ b/ViewModels/PowerPlanViewModel.cs @@ -1,341 +1,325 @@ -/* - * 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.ViewModels -{ - using System; - using System.Collections.ObjectModel; - using System.Linq; - using System.Threading; - using System.Threading.Tasks; - using System.Windows.Input; - using CommunityToolkit.Mvvm.ComponentModel; - using CommunityToolkit.Mvvm.Input; - using Microsoft.Extensions.Logging; - using Microsoft.Win32; - using ThreadPilot.Models; - using ThreadPilot.Services; - using ThreadPilot.ViewModels; - - public partial class PowerPlanViewModel : BaseViewModel - { - private readonly IPowerPlanService powerPlanService; - private System.Timers.Timer? refreshTimer; - private bool isAutoRefreshPaused = true; - private int isRefreshInProgress; - - [ObservableProperty] - private ObservableCollection powerPlans = new(); - - [ObservableProperty] - private ObservableCollection customPowerPlans = new(); - - [ObservableProperty] - private PowerPlanModel? selectedPowerPlan; - - [ObservableProperty] - private PowerPlanModel? selectedCustomPlan; - - [ObservableProperty] - private PowerPlanModel? activePowerPlan; - - public PowerPlanViewModel( - ILogger logger, - IPowerPlanService powerPlanService, - IEnhancedLoggingService? enhancedLoggingService = null, - IActivityAuditService? activityAuditService = null) - : base(logger, enhancedLoggingService, activityAuditService) - { - this.powerPlanService = powerPlanService; - this.SetupRefreshTimer(); - } - - private void SetupRefreshTimer() - { - this.refreshTimer = new System.Timers.Timer(10000); // PERFORMANCE OPTIMIZATION: Increased to 10 second refresh - power plans change infrequently - this.refreshTimer.Elapsed += async (s, e) => - { - if (this.isAutoRefreshPaused) - { - return; - } - - if (Interlocked.Exchange(ref this.isRefreshInProgress, 1) == 1) - { - return; - } - - try - { - // Marshal timer callback to UI thread to prevent cross-thread access exceptions - await System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => - { - if (!this.isAutoRefreshPaused) - { - await this.RefreshPowerPlansCoreAsync(reportStatus: false); - } - }); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Power plan refresh timer error: {ex.Message}"); - } - finally - { - Interlocked.Exchange(ref this.isRefreshInProgress, 0); - } - }; - } - - public void PauseAutoRefresh() - { - this.isAutoRefreshPaused = true; - this.refreshTimer?.Stop(); - } - - public void ResumeAutoRefresh(bool refreshImmediately = true) - { - var wasPaused = this.isAutoRefreshPaused; - this.isAutoRefreshPaused = false; - this.refreshTimer?.Start(); - - if (!refreshImmediately || !wasPaused) - { - return; - } - - _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => - { - try - { - await this.RefreshPowerPlansCoreAsync(reportStatus: false); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Power plan immediate refresh error: {ex.Message}"); - } - }); - } - - [RelayCommand] - public async Task LoadPowerPlans() - { - try - { - this.SetStatus("Loading power plans..."); - await this.RefreshPowerPlansCoreAsync(reportStatus: false); - this.SetStatus("Power plans loaded.", false); - } - catch (Exception ex) - { - await this.SetOperationFailedAsync($"Error loading power plans: {ex.Message}", "PowerPlanLoadFailed"); - } - } - - [RelayCommand] - private async Task RefreshPowerPlans() - { - if (this.IsBusy) - { - return; - } - - try - { - await this.RefreshPowerPlansCoreAsync(reportStatus: true); - } - catch (Exception ex) - { - await this.SetOperationFailedAsync($"Error refreshing power plans: {ex.Message}", "PowerPlanRefreshFailed"); - } - } - - [RelayCommand] - private async Task SetActivePlan() - { - if (this.SelectedPowerPlan == null) - { - return; - } - - try - { - var targetPlan = this.SelectedPowerPlan; - this.SetStatus($"Setting active power plan to {targetPlan.Name}..."); - var success = await this.powerPlanService.SetActivePowerPlan(targetPlan); - - if (success) - { - this.ActivePowerPlan = targetPlan; - await this.RefreshPowerPlansCoreAsync(reportStatus: false); - this.SetStatus($"Power plan applied: {targetPlan.Name}.", false); - await this.LogUserActionAsync("PowerPlanApplied", $"Applied power plan {targetPlan.Name}", $"Guid: {targetPlan.Guid}"); - } - else - { - await this.SetOperationFailedAsync($"Failed to set power plan {targetPlan.Name}", "PowerPlanApplyFailed"); - } - } - catch (Exception ex) - { - await this.SetOperationFailedAsync($"Error setting power plan: {ex.Message}", "PowerPlanApplyFailed"); - } - } - - [RelayCommand] - private async Task ImportCustomPlan() - { - if (this.SelectedCustomPlan == null) - { - return; - } - - try - { - var customPlan = this.SelectedCustomPlan; - this.SetStatus($"Importing custom power plan {customPlan.Name}..."); - var success = await this.powerPlanService.ImportCustomPowerPlan(customPlan.FilePath); - - if (success) - { - await this.RefreshPowerPlansCoreAsync(reportStatus: false); - this.SetStatus($"Power plan imported: {customPlan.Name}.", false); - await this.LogUserActionAsync("PowerPlanImported", $"Imported power plan {customPlan.Name}", customPlan.FilePath); - } - else - { - await this.SetOperationFailedAsync($"Failed to import power plan {customPlan.Name}", "PowerPlanImportFailed"); - } - } - catch (Exception ex) - { - await this.SetOperationFailedAsync($"Error importing power plan: {ex.Message}", "PowerPlanImportFailed"); - } - } - - [RelayCommand] - private async Task AddCustomPlanFile() - { - try - { - var dialog = new OpenFileDialog - { - Title = "Select custom power plan", - Filter = "Power Plan Files (*.pow)|*.pow|All Files (*.*)|*.*", - FilterIndex = 1, - CheckFileExists = true, - CheckPathExists = true, - Multiselect = false, - }; - - if (dialog.ShowDialog() != true) - { - return; - } - - this.SetStatus("Adding custom power plan file..."); - var success = await this.powerPlanService.AddCustomPowerPlanFileAsync(dialog.FileName); - - if (success) - { - await this.RefreshPowerPlansCoreAsync(reportStatus: false); - this.SetStatus("Custom power plan added to library.", false); - await this.LogUserActionAsync("PowerPlanAdded", "Added custom power plan file", dialog.FileName); - } - else - { - await this.SetOperationFailedAsync("Failed to add custom power plan file.", "PowerPlanAddFailed"); - } - } - catch (Exception ex) - { - await this.SetOperationFailedAsync($"Error adding custom power plan file: {ex.Message}", "PowerPlanAddFailed"); - } - } - - [RelayCommand] - private async Task DeletePowerPlan(PowerPlanModel? powerPlan) - { - var targetPlan = powerPlan ?? this.SelectedPowerPlan; - if (targetPlan == null) - { - return; - } - - var activePlan = this.ActivePowerPlan ?? await this.powerPlanService.GetActivePowerPlan(); - if (targetPlan.IsActive || string.Equals(targetPlan.Guid, activePlan?.Guid, StringComparison.OrdinalIgnoreCase)) - { - await this.SetOperationFailedAsync("Switch to another power plan before deleting the active plan.", "PowerPlanDeleteBlocked"); - return; - } - - try - { - this.SetStatus($"Deleting power plan {targetPlan.Name}..."); - var success = await this.powerPlanService.DeletePowerPlanAsync(targetPlan.Guid); - if (!success) - { - await this.SetOperationFailedAsync( - $"Could not delete power plan {targetPlan.Name}. Windows may not allow this plan to be removed.", - "PowerPlanDeleteFailed"); - return; - } - - await this.RefreshPowerPlansCoreAsync(reportStatus: false); - this.SetStatus($"Power plan deleted: {targetPlan.Name}.", false); - await this.LogUserActionAsync("PowerPlanDeleted", $"Deleted power plan {targetPlan.Name}", $"Guid: {targetPlan.Guid}"); - } - catch (Exception ex) - { - await this.SetOperationFailedAsync($"Error deleting power plan: {ex.Message}", "PowerPlanDeleteFailed"); - } - } - - private async Task RefreshPowerPlansCoreAsync(bool reportStatus) - { - var currentPlans = await this.powerPlanService.GetPowerPlansAsync(); - var currentActive = await this.powerPlanService.GetActivePowerPlan(); - var customPlans = await this.powerPlanService.GetCustomPowerPlansAsync(); - - this.PowerPlans = new ObservableCollection(currentPlans); - this.CustomPowerPlans = new ObservableCollection(customPlans); - this.ActivePowerPlan = currentActive; - - foreach (var plan in this.PowerPlans) - { - plan.IsActive = string.Equals(plan.Guid, currentActive?.Guid, StringComparison.OrdinalIgnoreCase); - } - - if (this.SelectedPowerPlan != null) - { - this.SelectedPowerPlan = this.PowerPlans.FirstOrDefault(p => p.Guid == this.SelectedPowerPlan.Guid); - } - - if (reportStatus) - { - this.SetStatus("Power plans refreshed.", false); - await this.LogUserActionAsync("PowerPlansRefreshed", "Refreshed power plan list"); - } - } - - private async Task SetOperationFailedAsync(string message, string action) - { - this.SetStatus(message, false); - this.SetError(message); - await this.LogUserActionAsync(action, message); - } - } -} +namespace ThreadPilot.ViewModels +{ + using System; + using System.Collections.ObjectModel; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using System.Windows.Input; + using CommunityToolkit.Mvvm.ComponentModel; + using CommunityToolkit.Mvvm.Input; + using Microsoft.Extensions.Logging; + using Microsoft.Win32; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public partial class PowerPlanViewModel : BaseViewModel + { + private readonly IPowerPlanService powerPlanService; + private System.Timers.Timer? refreshTimer; + private bool isAutoRefreshPaused = true; + private int isRefreshInProgress; + + [ObservableProperty] + private ObservableCollection powerPlans = new(); + + [ObservableProperty] + private ObservableCollection customPowerPlans = new(); + + [ObservableProperty] + private PowerPlanModel? selectedPowerPlan; + + [ObservableProperty] + private PowerPlanModel? selectedCustomPlan; + + [ObservableProperty] + private PowerPlanModel? activePowerPlan; + + public PowerPlanViewModel( + ILogger logger, + IPowerPlanService powerPlanService, + IEnhancedLoggingService? enhancedLoggingService = null, + IActivityAuditService? activityAuditService = null) + : base(logger, enhancedLoggingService, activityAuditService) + { + this.powerPlanService = powerPlanService; + this.SetupRefreshTimer(); + } + + private void SetupRefreshTimer() + { + this.refreshTimer = new System.Timers.Timer(10000); // PERFORMANCE OPTIMIZATION: Increased to 10 second refresh - power plans change infrequently + this.refreshTimer.Elapsed += async (s, e) => + { + if (this.isAutoRefreshPaused) + { + return; + } + + if (Interlocked.Exchange(ref this.isRefreshInProgress, 1) == 1) + { + return; + } + + try + { + // Marshal timer callback to UI thread to prevent cross-thread access exceptions + await System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => + { + if (!this.isAutoRefreshPaused) + { + await this.RefreshPowerPlansCoreAsync(reportStatus: false); + } + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Power plan refresh timer error: {ex.Message}"); + } + finally + { + Interlocked.Exchange(ref this.isRefreshInProgress, 0); + } + }; + } + + public void PauseAutoRefresh() + { + this.isAutoRefreshPaused = true; + this.refreshTimer?.Stop(); + } + + public void ResumeAutoRefresh(bool refreshImmediately = true) + { + var wasPaused = this.isAutoRefreshPaused; + this.isAutoRefreshPaused = false; + this.refreshTimer?.Start(); + + if (!refreshImmediately || !wasPaused) + { + return; + } + + _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => + { + try + { + await this.RefreshPowerPlansCoreAsync(reportStatus: false); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Power plan immediate refresh error: {ex.Message}"); + } + }); + } + + [RelayCommand] + public async Task LoadPowerPlans() + { + try + { + this.SetStatus("Loading power plans..."); + await this.RefreshPowerPlansCoreAsync(reportStatus: false); + this.SetStatus("Power plans loaded.", false); + } + catch (Exception ex) + { + await this.SetOperationFailedAsync($"Error loading power plans: {ex.Message}", "PowerPlanLoadFailed"); + } + } + + [RelayCommand] + private async Task RefreshPowerPlans() + { + if (this.IsBusy) + { + return; + } + + try + { + await this.RefreshPowerPlansCoreAsync(reportStatus: true); + } + catch (Exception ex) + { + await this.SetOperationFailedAsync($"Error refreshing power plans: {ex.Message}", "PowerPlanRefreshFailed"); + } + } + + [RelayCommand] + private async Task SetActivePlan() + { + if (this.SelectedPowerPlan == null) + { + return; + } + + try + { + var targetPlan = this.SelectedPowerPlan; + this.SetStatus($"Setting active power plan to {targetPlan.Name}..."); + var success = await this.powerPlanService.SetActivePowerPlan(targetPlan); + + if (success) + { + this.ActivePowerPlan = targetPlan; + await this.RefreshPowerPlansCoreAsync(reportStatus: false); + this.SetStatus($"Power plan applied: {targetPlan.Name}.", false); + await this.LogUserActionAsync("PowerPlanApplied", $"Applied power plan {targetPlan.Name}", $"Guid: {targetPlan.Guid}"); + } + else + { + await this.SetOperationFailedAsync($"Failed to set power plan {targetPlan.Name}", "PowerPlanApplyFailed"); + } + } + catch (Exception ex) + { + await this.SetOperationFailedAsync($"Error setting power plan: {ex.Message}", "PowerPlanApplyFailed"); + } + } + + [RelayCommand] + private async Task ImportCustomPlan() + { + if (this.SelectedCustomPlan == null) + { + return; + } + + try + { + var customPlan = this.SelectedCustomPlan; + this.SetStatus($"Importing custom power plan {customPlan.Name}..."); + var success = await this.powerPlanService.ImportCustomPowerPlan(customPlan.FilePath); + + if (success) + { + await this.RefreshPowerPlansCoreAsync(reportStatus: false); + this.SetStatus($"Power plan imported: {customPlan.Name}.", false); + await this.LogUserActionAsync("PowerPlanImported", $"Imported power plan {customPlan.Name}", customPlan.FilePath); + } + else + { + await this.SetOperationFailedAsync($"Failed to import power plan {customPlan.Name}", "PowerPlanImportFailed"); + } + } + catch (Exception ex) + { + await this.SetOperationFailedAsync($"Error importing power plan: {ex.Message}", "PowerPlanImportFailed"); + } + } + + [RelayCommand] + private async Task AddCustomPlanFile() + { + try + { + var dialog = new OpenFileDialog + { + Title = "Select custom power plan", + Filter = "Power Plan Files (*.pow)|*.pow|All Files (*.*)|*.*", + FilterIndex = 1, + CheckFileExists = true, + CheckPathExists = true, + Multiselect = false, + }; + + if (dialog.ShowDialog() != true) + { + return; + } + + this.SetStatus("Adding custom power plan file..."); + var success = await this.powerPlanService.AddCustomPowerPlanFileAsync(dialog.FileName); + + if (success) + { + await this.RefreshPowerPlansCoreAsync(reportStatus: false); + this.SetStatus("Custom power plan added to library.", false); + await this.LogUserActionAsync("PowerPlanAdded", "Added custom power plan file", dialog.FileName); + } + else + { + await this.SetOperationFailedAsync("Failed to add custom power plan file.", "PowerPlanAddFailed"); + } + } + catch (Exception ex) + { + await this.SetOperationFailedAsync($"Error adding custom power plan file: {ex.Message}", "PowerPlanAddFailed"); + } + } + + [RelayCommand] + private async Task DeletePowerPlan(PowerPlanModel? powerPlan) + { + var targetPlan = powerPlan ?? this.SelectedPowerPlan; + if (targetPlan == null) + { + return; + } + + var activePlan = this.ActivePowerPlan ?? await this.powerPlanService.GetActivePowerPlan(); + if (targetPlan.IsActive || string.Equals(targetPlan.Guid, activePlan?.Guid, StringComparison.OrdinalIgnoreCase)) + { + await this.SetOperationFailedAsync("Switch to another power plan before deleting the active plan.", "PowerPlanDeleteBlocked"); + return; + } + + try + { + this.SetStatus($"Deleting power plan {targetPlan.Name}..."); + var success = await this.powerPlanService.DeletePowerPlanAsync(targetPlan.Guid); + if (!success) + { + await this.SetOperationFailedAsync( + $"Could not delete power plan {targetPlan.Name}. Windows may not allow this plan to be removed.", + "PowerPlanDeleteFailed"); + return; + } + + await this.RefreshPowerPlansCoreAsync(reportStatus: false); + this.SetStatus($"Power plan deleted: {targetPlan.Name}.", false); + await this.LogUserActionAsync("PowerPlanDeleted", $"Deleted power plan {targetPlan.Name}", $"Guid: {targetPlan.Guid}"); + } + catch (Exception ex) + { + await this.SetOperationFailedAsync($"Error deleting power plan: {ex.Message}", "PowerPlanDeleteFailed"); + } + } + + private async Task RefreshPowerPlansCoreAsync(bool reportStatus) + { + var currentPlans = await this.powerPlanService.GetPowerPlansAsync(); + var currentActive = await this.powerPlanService.GetActivePowerPlan(); + var customPlans = await this.powerPlanService.GetCustomPowerPlansAsync(); + + this.PowerPlans = new ObservableCollection(currentPlans); + this.CustomPowerPlans = new ObservableCollection(customPlans); + this.ActivePowerPlan = currentActive; + + foreach (var plan in this.PowerPlans) + { + plan.IsActive = string.Equals(plan.Guid, currentActive?.Guid, StringComparison.OrdinalIgnoreCase); + } + + if (this.SelectedPowerPlan != null) + { + this.SelectedPowerPlan = this.PowerPlans.FirstOrDefault(p => p.Guid == this.SelectedPowerPlan.Guid); + } + + if (reportStatus) + { + this.SetStatus("Power plans refreshed.", false); + await this.LogUserActionAsync("PowerPlansRefreshed", "Refreshed power plan list"); + } + } + + private async Task SetOperationFailedAsync(string message, string action) + { + this.SetStatus(message, false); + this.SetError(message); + await this.LogUserActionAsync(action, message); + } + } +} diff --git a/ViewModels/ProcessPowerPlanAssociationViewModel.cs b/ViewModels/ProcessPowerPlanAssociationViewModel.cs index 3346e08..e8a9e19 100644 --- a/ViewModels/ProcessPowerPlanAssociationViewModel.cs +++ b/ViewModels/ProcessPowerPlanAssociationViewModel.cs @@ -1,601 +1,585 @@ -/* - * 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.ViewModels -{ - using System; - using System.Collections.ObjectModel; - using System.IO; - using System.Linq; - using System.Threading.Tasks; - using System.Windows.Input; - using CommunityToolkit.Mvvm.ComponentModel; - using CommunityToolkit.Mvvm.Input; - using Microsoft.Extensions.Logging; - using Microsoft.Win32; - using ThreadPilot.Models; - using ThreadPilot.Services; - using ThreadPilot.ViewModels; - - public partial class ProcessPowerPlanAssociationViewModel : BaseViewModel - { - private readonly IProcessPowerPlanAssociationService associationService; - private readonly IPowerPlanService powerPlanService; - private readonly IProcessService processService; - private readonly IProcessMonitorManagerService monitorManagerService; - private readonly ICoreMaskService coreMaskService; - - [ObservableProperty] - private ObservableCollection associations = new(); - - [ObservableProperty] - private ObservableCollection availablePowerPlans = new(); - - [ObservableProperty] - private ObservableCollection availableCoreMasks = new(); - - [ObservableProperty] - private ObservableCollection availablePriorities = new() - { - "Idle", - "BelowNormal", - "Normal", - "AboveNormal", - "High", - "RealTime", - }; - - [ObservableProperty] - private ObservableCollection runningProcesses = new(); - - [ObservableProperty] - private ProcessPowerPlanAssociation? selectedAssociation; - - [ObservableProperty] - private PowerPlanModel? selectedPowerPlan; - - [ObservableProperty] - private CoreMask? selectedCoreMask; - - [ObservableProperty] - private string? selectedProcessPriority; - - [ObservableProperty] - private ProcessModel? selectedProcess; - - [ObservableProperty] - private string newExecutableName = string.Empty; - - [ObservableProperty] - private string newExecutablePath = string.Empty; - - // Properties for the selected executable (read-only display) - [ObservableProperty] - private string selectedExecutableDisplayName = "No executable selected"; - - [ObservableProperty] - private string selectedExecutableFullPath = string.Empty; - - [ObservableProperty] - private bool hasSelectedExecutable = false; - - [ObservableProperty] - private bool matchByPath = false; - - [ObservableProperty] - private int priority = 0; - - [ObservableProperty] - private string description = string.Empty; - - [ObservableProperty] - private PowerPlanModel? defaultPowerPlan; - - [ObservableProperty] - private bool isMonitoringEnabled = true; - - [ObservableProperty] - private bool isEventBasedMonitoringEnabled = true; - - [ObservableProperty] - private bool isFallbackPollingEnabled = true; - - [ObservableProperty] - private int pollingIntervalSeconds = 5; - - [ObservableProperty] - private bool preventDuplicatePowerPlanChanges = true; - - [ObservableProperty] - private int powerPlanChangeDelayMs = 250; - - [ObservableProperty] - private string serviceStatus = "Stopped"; - - [ObservableProperty] - private bool isServiceRunning = false; - - public ProcessPowerPlanAssociationViewModel( - ILogger logger, - IProcessPowerPlanAssociationService associationService, - IPowerPlanService powerPlanService, - IProcessService processService, - IProcessMonitorManagerService monitorManagerService, - ICoreMaskService coreMaskService, - IEnhancedLoggingService? enhancedLoggingService = null) - : base(logger, enhancedLoggingService) - { - this.associationService = associationService ?? throw new ArgumentNullException(nameof(associationService)); - this.powerPlanService = powerPlanService ?? throw new ArgumentNullException(nameof(powerPlanService)); - this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); - this.monitorManagerService = monitorManagerService ?? throw new ArgumentNullException(nameof(monitorManagerService)); - this.coreMaskService = coreMaskService ?? throw new ArgumentNullException(nameof(coreMaskService)); - - // Subscribe to events - this.associationService.ConfigurationChanged += this.OnConfigurationChanged; - this.monitorManagerService.ServiceStatusChanged += this.OnServiceStatusChanged; - this.monitorManagerService.ProcessPowerPlanChanged += this.OnProcessPowerPlanChanged; - - // Initialize - _ = this.InitializeAsync(); - } - - public override async Task InitializeAsync() - { - await this.LoadDataAsync(); - this.UpdateServiceStatus(); - } - - partial void OnSelectedAssociationChanged(ProcessPowerPlanAssociation? value) - { - if (value == null) - { - return; - } - - PopulateEditorFromAssociation(value); - } - - [RelayCommand] - public async Task LoadDataAsync() - { - try - { - this.SetStatus("Loading data..."); - - // Load associations - var associationsData = await this.associationService.GetAssociationsAsync(); - this.Associations = new ObservableCollection(associationsData); - - // Load power plans - var powerPlans = await this.powerPlanService.GetPowerPlansAsync(); - this.AvailablePowerPlans = powerPlans; - - // Load core masks - await this.coreMaskService.InitializeAsync(); - this.AvailableCoreMasks = this.coreMaskService.AvailableMasks; - - // Load running processes - var processes = await this.processService.GetProcessesAsync(); - this.RunningProcesses = processes; - - // Load configuration settings - var config = this.associationService.Configuration; - this.IsEventBasedMonitoringEnabled = config.IsEventBasedMonitoringEnabled; - this.IsFallbackPollingEnabled = config.IsFallbackPollingEnabled; - this.PollingIntervalSeconds = config.PollingIntervalSeconds; - this.PreventDuplicatePowerPlanChanges = config.PreventDuplicatePowerPlanChanges; - this.PowerPlanChangeDelayMs = config.PowerPlanChangeDelayMs; - - // Load default power plan - var (defaultGuid, defaultName) = await this.associationService.GetDefaultPowerPlanAsync(); - this.DefaultPowerPlan = this.AvailablePowerPlans.FirstOrDefault(p => p.Guid == defaultGuid); - - this.ClearStatus(); - } - catch (Exception ex) - { - this.SetStatus($"Error loading data: {ex.Message}", false); - } - } - - [RelayCommand] - public async Task AddAssociationAsync() - { - try - { - if (string.IsNullOrWhiteSpace(this.NewExecutableName) || this.SelectedPowerPlan == null) - { - this.SetStatus("Please select an executable and a power plan", false); - return; - } - - this.SetStatus("Adding association..."); - - var association = new ProcessPowerPlanAssociation - { - ExecutableName = this.NewExecutableName.Trim(), - ExecutablePath = this.NewExecutablePath.Trim(), - PowerPlanGuid = this.SelectedPowerPlan.Guid, - PowerPlanName = this.SelectedPowerPlan.Name, - CoreMaskId = this.SelectedCoreMask?.Id, - CoreMaskName = this.SelectedCoreMask?.Name, - ProcessPriority = this.SelectedProcessPriority, - MatchByPath = this.MatchByPath, - Priority = this.Priority, - Description = this.Description.Trim(), - IsEnabled = true, - }; - - var success = await this.associationService.AddAssociationAsync(association); - if (success) - { - // Clear form - this.NewExecutableName = string.Empty; - this.NewExecutablePath = string.Empty; - this.SelectedPowerPlan = null; - this.SelectedCoreMask = null; - this.SelectedProcessPriority = null; - this.MatchByPath = false; - this.Priority = 0; - this.Description = string.Empty; - this.SelectedAssociation = null; - - await this.LoadDataAsync(); - this.SetStatus("Rule created and applied successfully.", false); - } - else - { - this.SetStatus("Failed to add rule - it may already exist", false); - } - } - catch (Exception ex) - { - this.SetStatus($"Error adding rule: {ex.Message}", false); - } - } - - [RelayCommand] - public async Task UpdateAssociationAsync() - { - try - { - if (this.SelectedAssociation == null) - { - this.SetStatus("Please select a rule to update", false); - return; - } - - if (string.IsNullOrWhiteSpace(this.NewExecutableName) || this.SelectedPowerPlan == null) - { - this.SetStatus("Executable and power plan are required", false); - return; - } - - this.SetStatus("Updating rule..."); - - this.ApplyEditorToAssociation(this.SelectedAssociation); - - var success = await this.associationService.UpdateAssociationAsync(this.SelectedAssociation); - if (success) - { - await this.LoadDataAsync(); - this.SetStatus("Rule updated and applied successfully.", false); - } - else - { - this.SetStatus("Failed to update rule", false); - } - } - catch (Exception ex) - { - this.SetStatus($"Error updating rule: {ex.Message}", false); - } - } - - [RelayCommand] - public async Task RemoveAssociationAsync() - { - try - { - if (this.SelectedAssociation == null) - { - this.SetStatus("Please select a rule to remove", false); - return; - } - - this.SetStatus("Removing rule..."); - - var success = await this.associationService.RemoveAssociationAsync(this.SelectedAssociation.Id); - if (success) - { - this.SelectedAssociation = null; - await this.LoadDataAsync(); - this.SetStatus("Rule removed successfully"); - } - else - { - this.SetStatus("Failed to remove rule", false); - } - } - catch (Exception ex) - { - this.SetStatus($"Error removing rule: {ex.Message}", false); - } - } - - [RelayCommand] - public async Task SetDefaultPowerPlanAsync() - { - try - { - if (this.DefaultPowerPlan == null) - { - this.SetStatus("Please select a default power plan", false); - return; - } - - this.SetStatus("Setting default power plan..."); - - var success = await this.associationService.SetDefaultPowerPlanAsync(this.DefaultPowerPlan.Guid, this.DefaultPowerPlan.Name); - if (success) - { - this.SetStatus("Default power plan set successfully"); - } - else - { - this.SetStatus("Failed to set default power plan", false); - } - } - catch (Exception ex) - { - this.SetStatus($"Error setting default power plan: {ex.Message}", false); - } - } - - [RelayCommand] - public async Task StartMonitoringAsync() - { - try - { - this.SetStatus("Starting automation monitoring..."); - await this.monitorManagerService.StartAsync(); - } - catch (Exception ex) - { - this.SetStatus($"Error starting automation monitoring: {ex.Message}", false); - } - } - - [RelayCommand] - public async Task StopMonitoringAsync() - { - try - { - this.SetStatus("Stopping automation monitoring..."); - await this.monitorManagerService.StopAsync(); - } - catch (Exception ex) - { - this.SetStatus($"Error stopping automation monitoring: {ex.Message}", false); - } - } - - [RelayCommand] - public async Task SaveConfigurationAsync() - { - try - { - this.SetStatus("Saving configuration..."); - - // Update configuration with current settings - var config = this.associationService.Configuration; - config.IsEventBasedMonitoringEnabled = this.IsEventBasedMonitoringEnabled; - config.IsFallbackPollingEnabled = this.IsFallbackPollingEnabled; - config.PollingIntervalSeconds = this.PollingIntervalSeconds; - config.PreventDuplicatePowerPlanChanges = this.PreventDuplicatePowerPlanChanges; - config.PowerPlanChangeDelayMs = this.PowerPlanChangeDelayMs; - - var success = await this.associationService.SaveConfigurationAsync(); - if (success) - { - this.SetStatus("Configuration saved and active.", false); - } - else - { - this.SetStatus("Failed to save configuration", false); - } - } - catch (Exception ex) - { - this.SetStatus($"Error saving configuration: {ex.Message}", false); - } - } - - [RelayCommand] - public void UseSelectedProcessForAssociation() - { - if (this.SelectedProcess != null) - { - this.NewExecutableName = this.SelectedProcess.Name; - this.NewExecutablePath = this.SelectedProcess.ExecutablePath; - - // Update the selected executable display - this.UpdateSelectedExecutableDisplay(this.SelectedProcess.ExecutablePath, this.SelectedProcess.Name); - } - } - - [RelayCommand] - public void BrowseExecutable() - { - try - { - var openFileDialog = new Microsoft.Win32.OpenFileDialog - { - Title = "Select Executable File", - Filter = "Executable Files (*.exe)|*.exe|All Files (*.*)|*.*", - FilterIndex = 1, - CheckFileExists = true, - CheckPathExists = true, - Multiselect = false, - }; - - if (openFileDialog.ShowDialog() == true) - { - var selectedFilePath = openFileDialog.FileName; - - // Validate that it's an executable file - if (!this.IsValidExecutable(selectedFilePath)) - { - this.SetStatus("Selected file is not a valid executable", false); - return; - } - - // Extract executable name from the full path - var executableName = Path.GetFileName(selectedFilePath); - - // Auto-populate the fields - this.NewExecutableName = executableName; - this.NewExecutablePath = selectedFilePath; - - // Update the display - this.UpdateSelectedExecutableDisplay(selectedFilePath, executableName); - - this.SetStatus($"Selected executable: {executableName}"); - } - } - catch (Exception ex) - { - this.SetStatus($"Error selecting executable: {ex.Message}", false); - } - } - - [RelayCommand] - public void ClearSelectedExecutable() - { - this.NewExecutableName = string.Empty; - this.NewExecutablePath = string.Empty; - this.SelectedExecutableDisplayName = "No executable selected"; - this.SelectedExecutableFullPath = string.Empty; - this.HasSelectedExecutable = false; - this.SetStatus("Executable selection cleared"); - } - - private void OnConfigurationChanged(object? sender, ConfigurationChangedEventArgs e) - { - // Reload data when configuration changes - marshal to UI thread to prevent cross-thread access exceptions - _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => await this.LoadDataAsync()); - } - - private void OnServiceStatusChanged(object? sender, ServiceStatusEventArgs e) - { - // Marshal UI updates to the UI thread to prevent cross-thread access exceptions - System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.ServiceStatus = e.Status; - this.IsServiceRunning = e.IsRunning; - }); - } - - private bool IsValidExecutable(string filePath) - { - try - { - if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) - { - return false; - } - - var extension = Path.GetExtension(filePath); - return string.Equals(extension, ".exe", StringComparison.OrdinalIgnoreCase); - } - catch - { - return false; - } - } - - private void UpdateSelectedExecutableDisplay(string fullPath, string executableName) - { - if (string.IsNullOrWhiteSpace(fullPath)) - { - this.SelectedExecutableDisplayName = "No executable selected"; - this.SelectedExecutableFullPath = string.Empty; - this.HasSelectedExecutable = false; - } - else - { - this.SelectedExecutableDisplayName = executableName; - this.SelectedExecutableFullPath = fullPath; - this.HasSelectedExecutable = true; - } - } - - private void PopulateEditorFromAssociation(ProcessPowerPlanAssociation association) - { - this.NewExecutableName = association.ExecutableName ?? string.Empty; - this.NewExecutablePath = association.ExecutablePath ?? string.Empty; - this.MatchByPath = association.MatchByPath; - this.Priority = association.Priority; - this.Description = association.Description ?? string.Empty; - this.SelectedProcessPriority = association.ProcessPriority; - - this.SelectedPowerPlan = this.AvailablePowerPlans.FirstOrDefault(p => - string.Equals(p.Guid, association.PowerPlanGuid, StringComparison.OrdinalIgnoreCase)); - - this.SelectedCoreMask = this.AvailableCoreMasks.FirstOrDefault(m => - string.Equals(m.Id, association.CoreMaskId, StringComparison.Ordinal)); - - this.UpdateSelectedExecutableDisplay( - this.NewExecutablePath, - string.IsNullOrWhiteSpace(this.NewExecutableName) - ? Path.GetFileName(this.NewExecutablePath) - : this.NewExecutableName); - } - - private void ApplyEditorToAssociation(ProcessPowerPlanAssociation association) - { - association.ExecutableName = this.NewExecutableName.Trim(); - association.ExecutablePath = this.NewExecutablePath.Trim(); - association.MatchByPath = this.MatchByPath; - association.Priority = this.Priority; - association.Description = this.Description.Trim(); - association.ProcessPriority = this.SelectedProcessPriority; - association.PowerPlanGuid = this.SelectedPowerPlan?.Guid ?? string.Empty; - association.PowerPlanName = this.SelectedPowerPlan?.Name ?? string.Empty; - association.CoreMaskId = this.SelectedCoreMask?.Id; - association.CoreMaskName = this.SelectedCoreMask?.Name; - association.UpdatedAt = DateTime.UtcNow; - } - - private void OnProcessPowerPlanChanged(object? sender, ProcessPowerPlanChangeEventArgs e) - { - // Marshal UI updates to the UI thread to prevent cross-thread access exceptions - System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - // Update status when power plan changes occur - this.SetStatus($"Power plan changed: {e.NewPowerPlan?.Name} for {e.Process.Name}", false); - }); - } - - private void UpdateServiceStatus() - { - this.ServiceStatus = this.monitorManagerService.Status; - this.IsServiceRunning = this.monitorManagerService.IsRunning; - } - } -} - +namespace ThreadPilot.ViewModels +{ + using System; + using System.Collections.ObjectModel; + using System.IO; + using System.Linq; + using System.Threading.Tasks; + using System.Windows.Input; + using CommunityToolkit.Mvvm.ComponentModel; + using CommunityToolkit.Mvvm.Input; + using Microsoft.Extensions.Logging; + using Microsoft.Win32; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public partial class ProcessPowerPlanAssociationViewModel : BaseViewModel + { + private readonly IProcessPowerPlanAssociationService associationService; + private readonly IPowerPlanService powerPlanService; + private readonly IProcessService processService; + private readonly IProcessMonitorManagerService monitorManagerService; + private readonly ICoreMaskService coreMaskService; + + [ObservableProperty] + private ObservableCollection associations = new(); + + [ObservableProperty] + private ObservableCollection availablePowerPlans = new(); + + [ObservableProperty] + private ObservableCollection availableCoreMasks = new(); + + [ObservableProperty] + private ObservableCollection availablePriorities = new() + { + "Idle", + "BelowNormal", + "Normal", + "AboveNormal", + "High", + "RealTime", + }; + + [ObservableProperty] + private ObservableCollection runningProcesses = new(); + + [ObservableProperty] + private ProcessPowerPlanAssociation? selectedAssociation; + + [ObservableProperty] + private PowerPlanModel? selectedPowerPlan; + + [ObservableProperty] + private CoreMask? selectedCoreMask; + + [ObservableProperty] + private string? selectedProcessPriority; + + [ObservableProperty] + private ProcessModel? selectedProcess; + + [ObservableProperty] + private string newExecutableName = string.Empty; + + [ObservableProperty] + private string newExecutablePath = string.Empty; + + // Properties for the selected executable (read-only display) + [ObservableProperty] + private string selectedExecutableDisplayName = "No executable selected"; + + [ObservableProperty] + private string selectedExecutableFullPath = string.Empty; + + [ObservableProperty] + private bool hasSelectedExecutable = false; + + [ObservableProperty] + private bool matchByPath = false; + + [ObservableProperty] + private int priority = 0; + + [ObservableProperty] + private string description = string.Empty; + + [ObservableProperty] + private PowerPlanModel? defaultPowerPlan; + + [ObservableProperty] + private bool isMonitoringEnabled = true; + + [ObservableProperty] + private bool isEventBasedMonitoringEnabled = true; + + [ObservableProperty] + private bool isFallbackPollingEnabled = true; + + [ObservableProperty] + private int pollingIntervalSeconds = 5; + + [ObservableProperty] + private bool preventDuplicatePowerPlanChanges = true; + + [ObservableProperty] + private int powerPlanChangeDelayMs = 250; + + [ObservableProperty] + private string serviceStatus = "Stopped"; + + [ObservableProperty] + private bool isServiceRunning = false; + + public ProcessPowerPlanAssociationViewModel( + ILogger logger, + IProcessPowerPlanAssociationService associationService, + IPowerPlanService powerPlanService, + IProcessService processService, + IProcessMonitorManagerService monitorManagerService, + ICoreMaskService coreMaskService, + IEnhancedLoggingService? enhancedLoggingService = null) + : base(logger, enhancedLoggingService) + { + this.associationService = associationService ?? throw new ArgumentNullException(nameof(associationService)); + this.powerPlanService = powerPlanService ?? throw new ArgumentNullException(nameof(powerPlanService)); + this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); + this.monitorManagerService = monitorManagerService ?? throw new ArgumentNullException(nameof(monitorManagerService)); + this.coreMaskService = coreMaskService ?? throw new ArgumentNullException(nameof(coreMaskService)); + + // Subscribe to events + this.associationService.ConfigurationChanged += this.OnConfigurationChanged; + this.monitorManagerService.ServiceStatusChanged += this.OnServiceStatusChanged; + this.monitorManagerService.ProcessPowerPlanChanged += this.OnProcessPowerPlanChanged; + + // Initialize + _ = this.InitializeAsync(); + } + + public override async Task InitializeAsync() + { + await this.LoadDataAsync(); + this.UpdateServiceStatus(); + } + + partial void OnSelectedAssociationChanged(ProcessPowerPlanAssociation? value) + { + if (value == null) + { + return; + } + + PopulateEditorFromAssociation(value); + } + + [RelayCommand] + public async Task LoadDataAsync() + { + try + { + this.SetStatus("Loading data..."); + + // Load associations + var associationsData = await this.associationService.GetAssociationsAsync(); + this.Associations = new ObservableCollection(associationsData); + + // Load power plans + var powerPlans = await this.powerPlanService.GetPowerPlansAsync(); + this.AvailablePowerPlans = powerPlans; + + // Load core masks + await this.coreMaskService.InitializeAsync(); + this.AvailableCoreMasks = this.coreMaskService.AvailableMasks; + + // Load running processes + var processes = await this.processService.GetProcessesAsync(); + this.RunningProcesses = processes; + + // Load configuration settings + var config = this.associationService.Configuration; + this.IsEventBasedMonitoringEnabled = config.IsEventBasedMonitoringEnabled; + this.IsFallbackPollingEnabled = config.IsFallbackPollingEnabled; + this.PollingIntervalSeconds = config.PollingIntervalSeconds; + this.PreventDuplicatePowerPlanChanges = config.PreventDuplicatePowerPlanChanges; + this.PowerPlanChangeDelayMs = config.PowerPlanChangeDelayMs; + + // Load default power plan + var (defaultGuid, defaultName) = await this.associationService.GetDefaultPowerPlanAsync(); + this.DefaultPowerPlan = this.AvailablePowerPlans.FirstOrDefault(p => p.Guid == defaultGuid); + + this.ClearStatus(); + } + catch (Exception ex) + { + this.SetStatus($"Error loading data: {ex.Message}", false); + } + } + + [RelayCommand] + public async Task AddAssociationAsync() + { + try + { + if (string.IsNullOrWhiteSpace(this.NewExecutableName) || this.SelectedPowerPlan == null) + { + this.SetStatus("Please select an executable and a power plan", false); + return; + } + + this.SetStatus("Adding association..."); + + var association = new ProcessPowerPlanAssociation + { + ExecutableName = this.NewExecutableName.Trim(), + ExecutablePath = this.NewExecutablePath.Trim(), + PowerPlanGuid = this.SelectedPowerPlan.Guid, + PowerPlanName = this.SelectedPowerPlan.Name, + CoreMaskId = this.SelectedCoreMask?.Id, + CoreMaskName = this.SelectedCoreMask?.Name, + ProcessPriority = this.SelectedProcessPriority, + MatchByPath = this.MatchByPath, + Priority = this.Priority, + Description = this.Description.Trim(), + IsEnabled = true, + }; + + var success = await this.associationService.AddAssociationAsync(association); + if (success) + { + // Clear form + this.NewExecutableName = string.Empty; + this.NewExecutablePath = string.Empty; + this.SelectedPowerPlan = null; + this.SelectedCoreMask = null; + this.SelectedProcessPriority = null; + this.MatchByPath = false; + this.Priority = 0; + this.Description = string.Empty; + this.SelectedAssociation = null; + + await this.LoadDataAsync(); + this.SetStatus("Rule created and applied successfully.", false); + } + else + { + this.SetStatus("Failed to add rule - it may already exist", false); + } + } + catch (Exception ex) + { + this.SetStatus($"Error adding rule: {ex.Message}", false); + } + } + + [RelayCommand] + public async Task UpdateAssociationAsync() + { + try + { + if (this.SelectedAssociation == null) + { + this.SetStatus("Please select a rule to update", false); + return; + } + + if (string.IsNullOrWhiteSpace(this.NewExecutableName) || this.SelectedPowerPlan == null) + { + this.SetStatus("Executable and power plan are required", false); + return; + } + + this.SetStatus("Updating rule..."); + + this.ApplyEditorToAssociation(this.SelectedAssociation); + + var success = await this.associationService.UpdateAssociationAsync(this.SelectedAssociation); + if (success) + { + await this.LoadDataAsync(); + this.SetStatus("Rule updated and applied successfully.", false); + } + else + { + this.SetStatus("Failed to update rule", false); + } + } + catch (Exception ex) + { + this.SetStatus($"Error updating rule: {ex.Message}", false); + } + } + + [RelayCommand] + public async Task RemoveAssociationAsync() + { + try + { + if (this.SelectedAssociation == null) + { + this.SetStatus("Please select a rule to remove", false); + return; + } + + this.SetStatus("Removing rule..."); + + var success = await this.associationService.RemoveAssociationAsync(this.SelectedAssociation.Id); + if (success) + { + this.SelectedAssociation = null; + await this.LoadDataAsync(); + this.SetStatus("Rule removed successfully"); + } + else + { + this.SetStatus("Failed to remove rule", false); + } + } + catch (Exception ex) + { + this.SetStatus($"Error removing rule: {ex.Message}", false); + } + } + + [RelayCommand] + public async Task SetDefaultPowerPlanAsync() + { + try + { + if (this.DefaultPowerPlan == null) + { + this.SetStatus("Please select a default power plan", false); + return; + } + + this.SetStatus("Setting default power plan..."); + + var success = await this.associationService.SetDefaultPowerPlanAsync(this.DefaultPowerPlan.Guid, this.DefaultPowerPlan.Name); + if (success) + { + this.SetStatus("Default power plan set successfully"); + } + else + { + this.SetStatus("Failed to set default power plan", false); + } + } + catch (Exception ex) + { + this.SetStatus($"Error setting default power plan: {ex.Message}", false); + } + } + + [RelayCommand] + public async Task StartMonitoringAsync() + { + try + { + this.SetStatus("Starting automation monitoring..."); + await this.monitorManagerService.StartAsync(); + } + catch (Exception ex) + { + this.SetStatus($"Error starting automation monitoring: {ex.Message}", false); + } + } + + [RelayCommand] + public async Task StopMonitoringAsync() + { + try + { + this.SetStatus("Stopping automation monitoring..."); + await this.monitorManagerService.StopAsync(); + } + catch (Exception ex) + { + this.SetStatus($"Error stopping automation monitoring: {ex.Message}", false); + } + } + + [RelayCommand] + public async Task SaveConfigurationAsync() + { + try + { + this.SetStatus("Saving configuration..."); + + // Update configuration with current settings + var config = this.associationService.Configuration; + config.IsEventBasedMonitoringEnabled = this.IsEventBasedMonitoringEnabled; + config.IsFallbackPollingEnabled = this.IsFallbackPollingEnabled; + config.PollingIntervalSeconds = this.PollingIntervalSeconds; + config.PreventDuplicatePowerPlanChanges = this.PreventDuplicatePowerPlanChanges; + config.PowerPlanChangeDelayMs = this.PowerPlanChangeDelayMs; + + var success = await this.associationService.SaveConfigurationAsync(); + if (success) + { + this.SetStatus("Configuration saved and active.", false); + } + else + { + this.SetStatus("Failed to save configuration", false); + } + } + catch (Exception ex) + { + this.SetStatus($"Error saving configuration: {ex.Message}", false); + } + } + + [RelayCommand] + public void UseSelectedProcessForAssociation() + { + if (this.SelectedProcess != null) + { + this.NewExecutableName = this.SelectedProcess.Name; + this.NewExecutablePath = this.SelectedProcess.ExecutablePath; + + // Update the selected executable display + this.UpdateSelectedExecutableDisplay(this.SelectedProcess.ExecutablePath, this.SelectedProcess.Name); + } + } + + [RelayCommand] + public void BrowseExecutable() + { + try + { + var openFileDialog = new Microsoft.Win32.OpenFileDialog + { + Title = "Select Executable File", + Filter = "Executable Files (*.exe)|*.exe|All Files (*.*)|*.*", + FilterIndex = 1, + CheckFileExists = true, + CheckPathExists = true, + Multiselect = false, + }; + + if (openFileDialog.ShowDialog() == true) + { + var selectedFilePath = openFileDialog.FileName; + + // Validate that it's an executable file + if (!this.IsValidExecutable(selectedFilePath)) + { + this.SetStatus("Selected file is not a valid executable", false); + return; + } + + // Extract executable name from the full path + var executableName = Path.GetFileName(selectedFilePath); + + // Auto-populate the fields + this.NewExecutableName = executableName; + this.NewExecutablePath = selectedFilePath; + + // Update the display + this.UpdateSelectedExecutableDisplay(selectedFilePath, executableName); + + this.SetStatus($"Selected executable: {executableName}"); + } + } + catch (Exception ex) + { + this.SetStatus($"Error selecting executable: {ex.Message}", false); + } + } + + [RelayCommand] + public void ClearSelectedExecutable() + { + this.NewExecutableName = string.Empty; + this.NewExecutablePath = string.Empty; + this.SelectedExecutableDisplayName = "No executable selected"; + this.SelectedExecutableFullPath = string.Empty; + this.HasSelectedExecutable = false; + this.SetStatus("Executable selection cleared"); + } + + private void OnConfigurationChanged(object? sender, ConfigurationChangedEventArgs e) + { + // Reload data when configuration changes - marshal to UI thread to prevent cross-thread access exceptions + _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => await this.LoadDataAsync()); + } + + private void OnServiceStatusChanged(object? sender, ServiceStatusEventArgs e) + { + // Marshal UI updates to the UI thread to prevent cross-thread access exceptions + System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.ServiceStatus = e.Status; + this.IsServiceRunning = e.IsRunning; + }); + } + + private bool IsValidExecutable(string filePath) + { + try + { + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + { + return false; + } + + var extension = Path.GetExtension(filePath); + return string.Equals(extension, ".exe", StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + private void UpdateSelectedExecutableDisplay(string fullPath, string executableName) + { + if (string.IsNullOrWhiteSpace(fullPath)) + { + this.SelectedExecutableDisplayName = "No executable selected"; + this.SelectedExecutableFullPath = string.Empty; + this.HasSelectedExecutable = false; + } + else + { + this.SelectedExecutableDisplayName = executableName; + this.SelectedExecutableFullPath = fullPath; + this.HasSelectedExecutable = true; + } + } + + private void PopulateEditorFromAssociation(ProcessPowerPlanAssociation association) + { + this.NewExecutableName = association.ExecutableName ?? string.Empty; + this.NewExecutablePath = association.ExecutablePath ?? string.Empty; + this.MatchByPath = association.MatchByPath; + this.Priority = association.Priority; + this.Description = association.Description ?? string.Empty; + this.SelectedProcessPriority = association.ProcessPriority; + + this.SelectedPowerPlan = this.AvailablePowerPlans.FirstOrDefault(p => + string.Equals(p.Guid, association.PowerPlanGuid, StringComparison.OrdinalIgnoreCase)); + + this.SelectedCoreMask = this.AvailableCoreMasks.FirstOrDefault(m => + string.Equals(m.Id, association.CoreMaskId, StringComparison.Ordinal)); + + this.UpdateSelectedExecutableDisplay( + this.NewExecutablePath, + string.IsNullOrWhiteSpace(this.NewExecutableName) + ? Path.GetFileName(this.NewExecutablePath) + : this.NewExecutableName); + } + + private void ApplyEditorToAssociation(ProcessPowerPlanAssociation association) + { + association.ExecutableName = this.NewExecutableName.Trim(); + association.ExecutablePath = this.NewExecutablePath.Trim(); + association.MatchByPath = this.MatchByPath; + association.Priority = this.Priority; + association.Description = this.Description.Trim(); + association.ProcessPriority = this.SelectedProcessPriority; + association.PowerPlanGuid = this.SelectedPowerPlan?.Guid ?? string.Empty; + association.PowerPlanName = this.SelectedPowerPlan?.Name ?? string.Empty; + association.CoreMaskId = this.SelectedCoreMask?.Id; + association.CoreMaskName = this.SelectedCoreMask?.Name; + association.UpdatedAt = DateTime.UtcNow; + } + + private void OnProcessPowerPlanChanged(object? sender, ProcessPowerPlanChangeEventArgs e) + { + // Marshal UI updates to the UI thread to prevent cross-thread access exceptions + System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + // Update status when power plan changes occur + this.SetStatus($"Power plan changed: {e.NewPowerPlan?.Name} for {e.Process.Name}", false); + }); + } + + private void UpdateServiceStatus() + { + this.ServiceStatus = this.monitorManagerService.Status; + this.IsServiceRunning = this.monitorManagerService.IsRunning; + } + } +} + diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index fc14e5f..5b995cf 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -1,2438 +1,2409 @@ -/* - * 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.ViewModels -{ - using System; - using System.Collections.ObjectModel; - using System.ComponentModel; - using System.Diagnostics; - using System.IO; - using System.Linq; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - using System.Windows.Input; - using CommunityToolkit.Mvvm.ComponentModel; - using CommunityToolkit.Mvvm.Input; - using Microsoft.Extensions.Logging; - using ThreadPilot.Models; - using ThreadPilot.Services; - - public partial class ProcessViewModel : BaseViewModel - { - private async Task ApplyFiltersOnUiAsync() - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(this.FilterProcesses); - } - - private void SetupVirtualizedProcessService() - { - // Configure virtualization settings - this.virtualizedProcessService.Configuration.BatchSize = 50; - this.virtualizedProcessService.Configuration.EnableBackgroundLoading = true; - - // Subscribe to events - this.virtualizedProcessService.BatchLoadProgress += this.OnBatchLoadProgress; - this.virtualizedProcessService.BackgroundBatchLoaded += this.OnBackgroundBatchLoaded; - } - - private void OnBatchLoadProgress(object? sender, BatchLoadProgressEventArgs e) - { - if (this.isUiRefreshPaused || !this.isProcessViewActive) - { - return; - } - - _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.LoadingProgress = e.ProgressPercentage; - this.LoadingStatusText = e.StatusMessage; - }); - } - - private void OnBackgroundBatchLoaded(object? sender, ProcessBatchResult e) - { - this.Logger.LogDebug( - "Background batch {BatchIndex} loaded with {ProcessCount} processes", - e.BatchIndex, e.Processes.Count); - } - - public override async Task InitializeAsync() - { - try - { - // Update status on UI thread - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus("Initializing CPU topology and power plans..."); - }); - System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: Starting initialization"); - - // Initialize CPU topology - System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: About to detect CPU topology"); - await this.cpuTopologyService.DetectTopologyAsync(); - System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: CPU topology detection completed"); - - // Initialize core masks service - System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: About to initialize core masks"); - await this.coreMaskService.InitializeAsync(); - this.AvailableCoreMasks = this.coreMaskService.AvailableMasks; - this.SelectedCoreMask = this.coreMaskService.DefaultMask; - System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: Core masks initialized"); - - // Load power plans - System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: About to load power plans"); - await this.RefreshPowerPlansAsync(); - System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: Power plans loaded"); - - // Load processes automatically on startup (Bug #8 fix) - System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: About to load processes automatically"); - await this.LoadProcessesCommand.ExecuteAsync(null); - - // Access process count on UI thread to avoid threading issues - int processCount = 0; - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - processCount = this.Processes?.Count ?? 0; - }); - System.Diagnostics.Debug.WriteLine($"ProcessViewModel.InitializeAsync: Processes loaded automatically, count: {processCount}"); - - // Start refresh timer for real-time updates - System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: Starting refresh timer"); - this.refreshTimer?.Start(); - System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: Initialization completed successfully"); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"ProcessViewModel.InitializeAsync: Exception occurred: {ex.Message}"); - // Update status on UI thread - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus($"Failed to initialize: {ex.Message}", false); - }); - } - } - - private async Task RefreshPowerPlansAsync() - { - try - { - var plans = await this.powerPlanService.GetPowerPlansAsync(); - var activePlan = await this.powerPlanService.GetActivePowerPlan(); - - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.PowerPlans.Clear(); - foreach (var plan in plans) - { - plan.IsActive = plan.Guid == activePlan?.Guid; - this.PowerPlans.Add(plan); - } - - this.SelectedPowerPlan = this.PowerPlans.FirstOrDefault(p => p.Guid == activePlan?.Guid); - }); - } - catch (Exception ex) - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus($"Failed to load power plans: {ex.Message}", false); - }); - } - } - - partial void OnSelectedProcessChanged(ProcessModel? value) - { - this.UpdateSelectedProcessSummary(value); - - if (value != null && CpuTopology != null) - { - this.HasPendingAffinityEdits = false; - this.UpdateAffinityDisplayState(); - // Immediately fetch and display real-time process information - TaskSafety.FireAndForget(HandleSelectedProcessChangedAsync(value), ex => - { - this.Logger.LogWarning(ex, "Failed while handling selected process change for {ProcessName}", value.Name); - }); - } - else if (value == null) - { - // Clear selection - this.ClearProcessSelection(); - } - - // Update system tray context menu - this.systemTrayService.UpdateContextMenu(value?.Name, value != null); - } - - private void UpdateSelectedProcessSummary(ProcessModel? process) - { - TaskSafety.FireAndForget( - 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 - { - // First check if the process is still running - bool isStillRunning = await this.processService.IsProcessStillRunning(value); - if (!isStillRunning) - { - System.Windows.Application.Current.Dispatcher.Invoke(() => - { - this.SetStatus($"Process {value.Name} (PID: {value.ProcessId}) has terminated", false); - this.SelectedProcess = null; - this.ClearProcessSelection(); - }); - return; - } - - // Refresh process info to get current state from OS - await this.processService.RefreshProcessInfo(value); - - // Update UI on main thread with fresh data - System.Windows.Application.Current.Dispatcher.Invoke(() => - { - this.UpdateCoreSelections(value.ProcessorAffinity); - this.UpdateAffinityDisplayState(); - value.ForceNotifyProcessorAffinityChanged(); - - // Update priority display - trigger property change to refresh ComboBox - this.OnPropertyChanged(nameof(this.SelectedProcess)); - - // Update feature states from the selected process - this.IsIdleServerDisabled = value.IsIdleServerDisabled; - this.IsRegistryPriorityEnabled = value.IsRegistryPriorityEnabled; - - // BUG FIX: Update status without setting busy state for process selection - this.SetStatus( - $"Selected process: {value.Name} (PID: {value.ProcessId}) - " + - $"Priority: {value.Priority}, Affinity: 0x{value.ProcessorAffinity:X}", false); - }); - if (ReferenceEquals(this.SelectedProcess, value)) - { - // Keep this second update for refreshed process fields and the latest operation message. - this.UpdateSelectedProcessSummary(value); - } - - // Load current power plan association if available - await this.LoadProcessPowerPlanAssociation(value); - } - catch (InvalidOperationException ex) when (ex.Message.Contains("terminated") || ex.Message.Contains("exited") || ex.Message.Contains("no longer exists")) - { - // Process has terminated - this.Logger.LogInformation("Process {ProcessName} (PID: {ProcessId}) has terminated", value.Name, value.ProcessId); - System.Windows.Application.Current.Dispatcher.Invoke(() => - { - this.SetStatus($"Process {value.Name} (PID: {value.ProcessId}) has terminated", false); - this.SelectedProcess = null; - this.ClearProcessSelection(); - }); - } - catch (Exception ex) - { - this.Logger.LogWarning(ex, "Failed to refresh process info for {ProcessName}", value.Name); - System.Windows.Application.Current.Dispatcher.Invoke(() => - { - this.SetStatus($"Warning: Could not access process {value.Name} - it may have terminated or require elevated privileges", false); - }); - if (ReferenceEquals(this.SelectedProcess, value)) - { - this.UpdateSelectedProcessSummary(value); - } - } - } - - private void OnTrayQuickApplyRequested(object? sender, EventArgs e) - { - TaskSafety.FireAndForget(this.OnTrayQuickApplyRequestedAsync(), ex => - { - this.Logger.LogWarning(ex, "Quick apply request failed"); - }); - } - - private async Task OnTrayQuickApplyRequestedAsync() - { - try - { - // Marshal UI operations to the UI thread to prevent cross-thread access exceptions - await System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => - { - await this.QuickApplyAffinityAndPowerPlanCommand.ExecuteAsync(null); - this.systemTrayService.ShowBalloonTip( - "ThreadPilot", - $"Pending settings applied to {this.SelectedProcess?.Name ?? "selected process"}", 2000); - }); - } - catch (Exception ex) - { - // Marshal UI operations to the UI thread to prevent cross-thread access exceptions - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.systemTrayService.ShowBalloonTip( - "ThreadPilot Error", - $"Failed to apply pending settings: {ex.Message}", 3000); - }); - } - } - - private void OnTopologyDetected(object? sender, CpuTopologyDetectedEventArgs e) - { - // Ensure all UI updates happen on the dispatcher thread - System.Windows.Application.Current.Dispatcher.Invoke(() => - { - this.CpuTopology = e.Topology; - this.IsTopologyDetectionSuccessful = e.DetectionSuccessful; - - if (e.DetectionSuccessful) - { - this.TopologyStatus = $"Detected: {e.Topology.TotalLogicalCores} logical CPUs, " + - $"{e.Topology.TotalPhysicalCores} physical CPUs"; - this.AreAdvancedFeaturesAvailable = e.Topology.HasIntelHybrid || e.Topology.HasAmdCcd || e.Topology.HasHyperThreading; - } - else - { - this.TopologyStatus = $"Detection failed: {e.ErrorMessage ?? "Unknown error"}"; - this.AreAdvancedFeaturesAvailable = false; - } - - this.UpdateCpuCores(); - this.UpdateAffinityPresets(); - this.UpdateHyperThreadingStatus(); - }); - } - - private void UpdateCpuCores() - { - if (this.CpuTopology == null) - { - return; - } - - this.CpuCores.Clear(); - foreach (var core in this.CpuTopology.LogicalCores) - { - core.PropertyChanged -= this.OnCorePropertyChanged; - core.PropertyChanged += this.OnCorePropertyChanged; - this.CpuCores.Add(core); - } - } - - private void OnCorePropertyChanged(object? sender, PropertyChangedEventArgs e) - { - // Note: Advanced CPU Affinity cores are now read-only (ProcessView.xaml has IsHitTestVisible="False") - // This event handler is kept for compatibility but should not be triggered - // Core modifications are done exclusively through the Core Mask tab - if (this.suppressCoreSelectionEvents) - { - return; - } - - this.Logger.LogDebug("Core property changed but cores are read-only - no action taken"); - } - - private void UpdateHyperThreadingStatus() - { - if (this.CpuTopology == null) - { - this.HyperThreadingStatusText = "Multi-Threading: Unknown"; - this.IsHyperThreadingActive = false; - return; - } - - // Determine if hyperthreading/SMT is present and active - bool hasMultiThreading = this.CpuTopology.HasHyperThreading; - this.IsHyperThreadingActive = hasMultiThreading; - - // Determine the appropriate technology name based on CPU vendor - string technologyName = "Multi-Threading"; - if (this.CpuTopology.CpuBrand.Contains("Intel", StringComparison.OrdinalIgnoreCase)) - { - technologyName = "Hyper-Threading"; - } - else if (this.CpuTopology.CpuBrand.Contains("AMD", StringComparison.OrdinalIgnoreCase)) - { - technologyName = "SMT"; - } - - // Set the status text - string status = hasMultiThreading ? "Active" : "Not Available"; - this.HyperThreadingStatusText = $"{technologyName}: {status}"; - - this.Logger.LogInformation( - "Updated hyperthreading status: {StatusText} (Active: {IsActive})", - this.HyperThreadingStatusText, this.IsHyperThreadingActive); - } - - private void UpdateAffinityPresets() - { - this.AffinityPresets.Clear(); - var presets = this.cpuTopologyService.GetAffinityPresets(); - foreach (var preset in presets) - { - this.AffinityPresets.Add(preset); - } - } - - private void UpdateCoreSelections(long affinityMask, bool forceSync = false) - { - if (this.CpuTopology == null || this.CpuCores.Count == 0) - { - this.Logger.LogWarning( - "Cannot update core selections: CpuTopology={CpuTopology}, CpuCores.Count={CpuCoresCount}", - this.CpuTopology != null, this.CpuCores.Count); - return; - } - - if (this.HasPendingAffinityEdits && !forceSync) - { - this.Logger.LogDebug("Skipping affinity sync because user edits are pending"); - return; - } - - this.Logger.LogDebug( - "Updating core selections for affinity mask 0x{AffinityMask:X} ({AffinityMaskBinary})", - affinityMask, Convert.ToString(affinityMask, 2).PadLeft(Environment.ProcessorCount, '0')); - - // Update each core's selection state based on the actual OS affinity mask - var updatedCores = new List<(int CoreId, bool WasSelected, bool IsSelected)>(); - - try - { - this.suppressCoreSelectionEvents = true; - - foreach (var core in this.CpuCores) - { - bool wasSelected = core.IsSelected; - bool shouldBeSelected = (affinityMask & core.AffinityMask) != 0; - - if (wasSelected != shouldBeSelected) - { - core.IsSelected = shouldBeSelected; - updatedCores.Add((core.LogicalCoreId, wasSelected, shouldBeSelected)); - } - } - } - finally - { - this.suppressCoreSelectionEvents = false; - } - - // The UI will automatically update since CpuCoreModel now implements INotifyPropertyChanged - // No need to force collection refresh as individual property changes will be notified - - // Log the affinity update for debugging - var selectedCoreIds = this.CpuCores.Where(c => c.IsSelected).Select(c => c.LogicalCoreId).OrderBy(id => id).ToList(); - var totalCores = this.CpuCores.Count; - var selectedCount = selectedCoreIds.Count; - - this.Logger.LogInformation( - "Updated core selections for affinity mask 0x{AffinityMask:X}: " + - "Selected {SelectedCount}/{TotalCores} cores: [{CoreIds}]", - affinityMask, selectedCount, totalCores, string.Join(", ", selectedCoreIds)); - - if (updatedCores.Count > 0) - { - this.Logger.LogDebug( - "Core selection changes: {Changes}", - string.Join("; ", updatedCores.Select(c => $"Core {c.CoreId}: {c.WasSelected} -> {c.IsSelected}"))); - } - else - { - this.Logger.LogDebug("No core selection changes needed - UI already matches affinity mask"); - } - - if (forceSync) - { - this.HasPendingAffinityEdits = false; - } - - this.UpdateAffinityDisplayState(); - } - - private long CalculateAffinityMask() - { - if (this.CpuTopology == null) - { - return 0; - } - - var selectedCores = this.CpuCores.Where(core => core.IsSelected); - - // Note: Removed hyperthreading filtering - user can manually select desired cores - // All selected cores (including HT siblings) are now included in the affinity mask - - return selectedCores.Aggregate(0L, (mask, core) => mask | core.AffinityMask); - } - - private List GetPendingCoreSelectionMask() - { - return this.CpuCores - .OrderBy(core => core.LogicalCoreId) - .Select(core => core.IsSelected) - .ToList(); - } - - 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() - { - if (!this.ShouldRunProcessUiRefresh() || !this.IsVirtualizationEnabled || !this.HasMoreBatches || this.IsBusy) - { - return; - } - - try - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus($"Loading more processes (batch {this.CurrentBatchIndex + 2})..."); - }); - - var nextBatchIndex = this.CurrentBatchIndex + 1; - var batch = await this.virtualizedProcessService.LoadProcessBatchAsync(nextBatchIndex, this.ShowActiveApplicationsOnly); - - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - // Add new processes to existing collection - foreach (var process in batch.Processes) - { - this.Processes.Add(process); - } - - this.CurrentBatchIndex = batch.BatchIndex; - this.TotalBatches = batch.TotalBatches; - this.HasMoreBatches = batch.HasMoreBatches; - this.TotalProcessCount = batch.TotalProcessCount; - - this.FilterProcesses(); - - // BUG FIX: Ensure loading state is properly cleared - this.ClearStatus(); - this.LoadingProgress = 0.0; - this.LoadingStatusText = string.Empty; - }); - - await this.PreloadNextBatchIfAllowedAsync(this.CurrentBatchIndex); - } - catch (Exception ex) - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - // BUG FIX: Ensure loading state is cleared even on error - this.LoadingProgress = 0.0; - this.LoadingStatusText = string.Empty; - this.SetStatus($"Error loading more processes: {ex.Message}", false); - }); - } - } - - [RelayCommand] - public async Task LoadProcesses() - { - if (!this.ShouldRunProcessUiRefresh()) - { - return; - } - - try - { - System.Diagnostics.Debug.WriteLine($"LoadProcesses: Starting, ShowActiveApplicationsOnly={this.ShowActiveApplicationsOnly}"); - - // PERFORMANCE IMPROVEMENT: Progressive loading with status updates - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.LoadingProgress = 0.0; - this.LoadingStatusText = this.ShowActiveApplicationsOnly ? "Loading active applications..." : "Loading processes..."; - this.SetStatus(this.LoadingStatusText); - }); - - ObservableCollection newProcesses; - - // VIRTUALIZATION ENHANCEMENT: Use virtualized loading for large process lists - if (this.IsVirtualizationEnabled) - { - System.Diagnostics.Debug.WriteLine("LoadProcesses: Using virtualized loading"); - await this.virtualizedProcessService.InitializeAsync(); - - var totalCount = await this.virtualizedProcessService.GetTotalProcessCountAsync(this.ShowActiveApplicationsOnly); - if (totalCount > this.virtualizedProcessService.Configuration.BatchSize) - { - // Load first batch only - var batch = await this.virtualizedProcessService.LoadProcessBatchAsync(0, this.ShowActiveApplicationsOnly); - newProcesses = new ObservableCollection(batch.Processes); - - // Update virtualization state - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.CurrentBatchIndex = batch.BatchIndex; - this.TotalBatches = batch.TotalBatches; - this.HasMoreBatches = batch.HasMoreBatches; - this.TotalProcessCount = batch.TotalProcessCount; - }); - - await this.PreloadNextBatchIfAllowedAsync(0); - } - else - { - // Small list, load all processes normally - newProcesses = this.ShowActiveApplicationsOnly - ? await this.processService.GetActiveApplicationsAsync() - : await this.processService.GetProcessesAsync(); - } - } - else - { - // Traditional loading - if (this.ShowActiveApplicationsOnly) - { - System.Diagnostics.Debug.WriteLine("LoadProcesses: Getting active applications"); - newProcesses = await this.processService.GetActiveApplicationsAsync(); - } - else - { - System.Diagnostics.Debug.WriteLine("LoadProcesses: Getting all processes"); - newProcesses = await this.processService.GetProcessesAsync(); - } - } - - // Update UI on the UI thread - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.Processes = newProcesses; - System.Diagnostics.Debug.WriteLine($"LoadProcesses: Retrieved {this.Processes?.Count ?? 0} processes"); - this.FilterProcesses(); - System.Diagnostics.Debug.WriteLine($"LoadProcesses: After filtering, {this.FilteredProcesses?.Count ?? 0} processes visible"); - - // BUG FIX: Ensure loading state is properly cleared - this.ClearStatus(); - this.LoadingProgress = 0.0; - this.LoadingStatusText = string.Empty; - }); - - System.Diagnostics.Debug.WriteLine("LoadProcesses: Completed successfully"); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"LoadProcesses: Exception occurred: {ex.Message}"); - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - // BUG FIX: Ensure loading state is cleared even on error - this.LoadingProgress = 0.0; - this.LoadingStatusText = string.Empty; - this.SetStatus($"Error loading processes: {ex.Message}", false); - }); - } - } - - [RelayCommand] - private async Task RefreshProcesses() - { - if (!this.ShouldRunProcessUiRefresh()) - { - return; - } - - if (this.IsBusy || Interlocked.Exchange(ref this.isRefreshProcessesInProgress, 1) == 1) - { - return; - } - - try - { - var selectedProcessId = this.SelectedProcess?.ProcessId; - - var currentProcesses = this.ShowActiveApplicationsOnly - ? await this.processService.GetActiveApplicationsAsync() - : await this.processService.GetProcessesAsync(); - - await InvokeOnUiAsync(() => - { - var deltaResult = ProcessListDeltaUpdater.ApplyDelta( - this.Processes, - currentProcesses, - selectedProcessId); - - this.FilterProcesses(); - - if (deltaResult.SelectedProcess != null) - { - var processToSelect = this.FilteredProcesses.FirstOrDefault( - p => p.ProcessId == deltaResult.SelectedProcess.ProcessId); - if (processToSelect != null) - { - this.SelectedProcess = processToSelect; - } - } - else if (deltaResult.SelectedProcessTerminated) - { - this.SelectedProcess = null; - this.ClearProcessSelection(); - } - }); - } - catch (Exception ex) - { - await InvokeOnUiAsync(() => - { - this.SetStatus($"Error refreshing processes: {ex.Message}", false); - }); - } - finally - { - Interlocked.Exchange(ref this.isRefreshProcessesInProgress, 0); - } - } - - [RelayCommand] - private async Task SetAffinity() - { - var selectedProcess = this.SelectedProcess; - if (selectedProcess == null) - { - 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"); - } - - [RelayCommand] - private async Task SaveCurrentSettingsAsRule(ProcessModel? process) - { - var targetProcess = process ?? this.SelectedProcess; - if (targetProcess == null) - { - return; - } - - if (this.processRuleCreationService == null) - { - this.SetContextError("Persistent rules are unavailable."); - await this.UpdateSelectedProcessSummaryAsync(targetProcess); - return; - } - - if (!ReferenceEquals(this.SelectedProcess, targetProcess)) - { - this.SelectedProcess = targetProcess; - } - - await this.UpdateSelectedProcessSummaryAsync(targetProcess); - - var currentCoreSelection = this.HasPendingAffinityEdits && this.CpuCores.Count > 0 - ? this.GetPendingCoreSelectionMask() - : null; - var result = await this.processRuleCreationService.SaveCurrentSettingsAsRuleAsync( - targetProcess, - currentCoreSelection, - this.SelectedProcessSummary.MemoryPriority); - - this.ApplyRuleCreationResultStatus(result); - await this.LogUserActionAsync( - result.Success ? "PersistentRuleSaved" : "PersistentRuleSaveFailed", - result.UserMessage, - $"Process: {targetProcess.Name}, PID: {targetProcess.ProcessId}"); - await this.UpdateSelectedProcessSummaryAsync(targetProcess); - } - - [RelayCommand] - private async Task ApplyAffinityAndSaveAsRule(ProcessModel? process) - { - if (process == null) - { - return; - } - - if (this.processRuleCreationService == null) - { - this.SetContextError("Persistent rules are unavailable."); - await this.UpdateSelectedProcessSummaryAsync(process); - return; - } - - if (!ReferenceEquals(this.SelectedProcess, process)) - { - this.SelectedProcess = process; - } - - var pendingSelection = this.GetPendingCoreSelectionMask(); - var applyResult = await this.processAffinityApplyCoordinator.ApplyCoreSelectionAsync( - process, - pendingSelection, - "Manual Process tab context menu CPU selection"); - - if (!applyResult.Success) - { - this.SetContextError(applyResult.Message); - await this.LogUserActionAsync( - "ProcessAffinityFailed", - applyResult.Message, - $"Process: {process.Name}, PID: {process.ProcessId}, RequestedMask: 0x{applyResult.RequestedMask:X}"); - await this.UpdateSelectedProcessSummaryAsync(process); - return; - } - - if (!applyResult.UsedCpuSets) - { - this.UpdateCoreSelections(process.ProcessorAffinity, true); - } - - process.ForceNotifyProcessorAffinityChanged(); - this.OnPropertyChanged(nameof(this.SelectedProcess)); - this.HasPendingAffinityEdits = false; - this.UpdateAffinityDisplayState(); - - var saveResult = applyResult.UsedCpuSets - ? await this.processRuleCreationService.SaveCurrentSettingsAsRuleAsync( - process, - pendingSelection, - currentMemoryPriority: null) - : await this.processRuleCreationService.SaveRuleAsync( - process, - new ProcessRuleCreationPayload - { - LegacyAffinityMask = applyResult.VerifiedMask == 0 - ? applyResult.RequestedMask - : applyResult.VerifiedMask, - }); - - this.ApplyRuleCreationResultStatus(saveResult); - await this.LogUserActionAsync( - saveResult.Success ? "PersistentRuleSaved" : "PersistentRuleSaveFailed", - saveResult.UserMessage, - $"Process: {process.Name}, PID: {process.ProcessId}"); - await this.UpdateSelectedProcessSummaryAsync(process); - } - - private void ApplyRuleCreationResultStatus(ProcessRuleCreationResult result) - { - if (result.Success) - { - this.SetStatus(result.UserMessage, false); - return; - } - - this.SetContextError(string.IsNullOrWhiteSpace(result.UserMessage) - ? ProcessRuleCreationService.NoCurrentSettingsMessage - : result.UserMessage); - } - - private async Task ApplyAffinityToProcessAsync(ProcessModel selectedProcess, string selectionReason) - { - try - { - var pendingSelection = this.GetPendingCoreSelectionMask(); - - await InvokeOnUiAsync(() => - { - this.SetStatus($"Setting affinity for {selectedProcess.Name}..."); - }); - - var result = await this.processAffinityApplyCoordinator.ApplyCoreSelectionAsync( - selectedProcess, - pendingSelection, - selectionReason); - - await InvokeOnUiAsync(() => - { - if (!result.UsedCpuSets) - { - this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); - } - - selectedProcess.ForceNotifyProcessorAffinityChanged(); - this.OnPropertyChanged(nameof(this.SelectedProcess)); - - 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.SelectedProcess = null; - this.ClearProcessSelection(); - this.SetCriticalStatus(result.Message); - _ = this.notificationService.ShowNotificationAsync("Affinity failed", result.Message, NotificationType.Warning); - } - else if (result.FailureReason == AffinityApplyFailureReason.AccessDenied) - { - this.SetCriticalStatus(result.Message); - _ = this.notificationService.ShowNotificationAsync("Affinity blocked", result.Message, NotificationType.Warning); - } - else if (result.IsInvalidTopology || result.IsLegacyFallbackBlocked) - { - this.SetCriticalStatus(result.Message); - _ = this.notificationService.ShowNotificationAsync("Affinity blocked", result.Message, NotificationType.Warning); - } - else - { - this.SetStatus(result.Message, false); - _ = this.notificationService.ShowNotificationAsync("Affinity error", result.Message, NotificationType.Error); - } - }); - - await this.LogUserActionAsync( - result.Success ? "ProcessAffinityApplied" : "ProcessAffinityFailed", - result.Message, - $"Process: {selectedProcess.Name}, PID: {selectedProcess.ProcessId}, RequestedMask: 0x{result.RequestedMask:X}, VerifiedMask: 0x{result.VerifiedMask:X}"); - await this.UpdateSelectedProcessSummaryAsync(selectedProcess); - } - catch (Exception ex) - { - var friendly = ex.Message; - _ = this.notificationService.ShowNotificationAsync("Affinity error", friendly, NotificationType.Error); - await this.LogUserActionAsync( - "ProcessAffinityFailed", - friendly, - $"Process: {selectedProcess.Name}, PID: {selectedProcess.ProcessId}"); - - await InvokeOnUiAsync(() => - { - this.SetCriticalStatus($"Error setting affinity: {friendly}"); - }); - - // Try to refresh process info even if setting failed, to show current state - try - { - if (this.SelectedProcess != null) - { - await this.processService.RefreshProcessInfo(this.SelectedProcess); - } - - await InvokeOnUiAsync(() => - { - if (this.SelectedProcess != null) - { - this.UpdateCoreSelections(this.SelectedProcess.ProcessorAffinity, true); - this.OnPropertyChanged(nameof(this.SelectedProcess)); - } - }); - } - catch - { - // Process may have terminated - } - - await this.UpdateSelectedProcessSummaryAsync(selectedProcess); - } - finally - { - 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; - } - - private static Task InvokeOnUiAsync(Func action) - { - var dispatcher = System.Windows.Application.Current?.Dispatcher; - if (dispatcher == null || dispatcher.CheckAccess()) - { - return action(); - } - - return dispatcher.InvokeAsync(action).Task.Unwrap(); - } - - [RelayCommand] - private async Task ApplyAffinityPreset(CpuAffinityPreset preset) - { - if (preset == null || !preset.IsAvailable || this.CpuTopology == null) - { - return; - } - - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - try - { - this.suppressCoreSelectionEvents = true; - - // Clear all selections first - foreach (var core in this.CpuCores) - { - core.IsSelected = false; - } - - // Apply preset mask - foreach (var core in this.CpuCores) - { - core.IsSelected = (preset.AffinityMask & core.AffinityMask) != 0; - } - - // Notify UI of changes - this.OnPropertyChanged(nameof(this.CpuCores)); - this.SetStatus($"Applied preset: {preset.Name}"); - } - finally - { - this.suppressCoreSelectionEvents = false; - } - - // Keep the preset as a pending selection; affinity changes require an explicit apply command. - this.HasPendingAffinityEdits = true; - this.UpdateAffinityDisplayState(); - }); - } - - - [RelayCommand] - private void CreateCustomMask() - { - // Request to switch to Core Masks tab - System.Windows.Application.Current.Dispatcher.Invoke(() => - { - var mainWindow = System.Windows.Application.Current.MainWindow; - if (mainWindow != null) - { - // Find the TabControl in MainWindow - var tabControl = FindVisualChild(mainWindow); - if (tabControl != null) - { - // Switch to Core Masks tab (index 1) - tabControl.SelectedIndex = 1; - } - } - }); - } - - private static T? FindVisualChild(System.Windows.DependencyObject obj) - where T : System.Windows.DependencyObject - { - for (int i = 0; i < System.Windows.Media.VisualTreeHelper.GetChildrenCount(obj); i++) - { - var child = System.Windows.Media.VisualTreeHelper.GetChild(obj, i); - if (child is T typedChild) - { - return typedChild; - } - - var childOfChild = FindVisualChild(child); - if (childOfChild != null) - { - return childOfChild; - } - } - return null; - } - - /// - /// Called when a CoreMask is selected from the ComboBox. - /// - partial void OnSelectedCoreMaskChanged(CoreMask? oldValue, CoreMask? newValue) - { - if (newValue == null) - return; - - UpdateCoreSelectionsFromMask(newValue); - } - - private async Task ApplyCoreMaskToProcessAsync(CoreMask mask) - { - var selectedProcess = this.SelectedProcess; - if (selectedProcess == null || mask == null) - { - return; - } - - this.IsBusy = true; - try - { - this.Logger.LogInformation( - "Applying mask '{MaskName}' to process {ProcessName} (PID: {ProcessId})", - mask.Name, selectedProcess.Name, selectedProcess.ProcessId); - - // Disable Windows Game Mode for better CPU affinity control - // Game Mode can interfere with CPU Sets, particularly on AMD systems - await this.gameModeService.DisableGameModeForAffinityAsync(); - - var result = await this.processAffinityApplyCoordinator.ApplyCoreMaskAsync(selectedProcess, mask); - - System.Windows.Application.Current.Dispatcher.Invoke(() => - { - selectedProcess.ForceNotifyProcessorAffinityChanged(); - if (!result.UsedCpuSets) - { - this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); - } - - this.OnPropertyChanged(nameof(this.SelectedProcess)); - }); - - 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, selectedProcess.Name); - this.SetStatus($"Error applying mask: {ex.Message}"); - } - finally - { - this.IsBusy = false; - } - } - - private void UpdateCoreSelectionsFromMask(CoreMask mask) - { - if (mask == null || this.CpuCores.Count == 0) - { - return; - } - - try - { - this.suppressCoreSelectionEvents = true; - - for (int i = 0; i < this.CpuCores.Count && i < mask.BoolMask.Count; i++) - { - this.CpuCores[i].IsSelected = mask.BoolMask[i]; - } - - this.OnPropertyChanged(nameof(this.CpuCores)); - this.HasPendingAffinityEdits = this.SelectedProcess != null; - this.UpdateAffinityDisplayState(); - } - finally - { - this.suppressCoreSelectionEvents = false; - } - } - - - [RelayCommand] - private async Task QuickApplyAffinityAndPowerPlan() - { - var selectedProcess = this.SelectedProcess; - if (selectedProcess == null) - { - return; - } - - try - { - var affinityAppliedWithCpuSets = false; - - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus($"Applying pending settings to {selectedProcess.Name}..."); - }); - - // Apply CPU affinity - var pendingSelection = this.GetPendingCoreSelectionMask(); - if (pendingSelection.Any(selected => selected)) - { - var result = await this.processAffinityApplyCoordinator.ApplyCoreSelectionAsync( - selectedProcess, - pendingSelection, - "Manual Process tab quick apply CPU selection"); - if (!result.Success) - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - if (!result.UsedCpuSets) - { - this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); - } - - selectedProcess.ForceNotifyProcessorAffinityChanged(); - this.OnPropertyChanged(nameof(this.SelectedProcess)); - this.SetStatus(result.Message, false); - }); - return; - } - - this.HasPendingAffinityEdits = false; - this.UpdateAffinityDisplayState(); - affinityAppliedWithCpuSets = result.UsedCpuSets; - } - - // Apply power plan if selected - if (this.SelectedPowerPlan != null) - { - await this.powerPlanService.SetActivePowerPlan(this.SelectedPowerPlan); - } - - await this.processService.RefreshProcessInfo(selectedProcess); - - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - if (!affinityAppliedWithCpuSets) - { - this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); - } - else - { - this.UpdateAffinityDisplayState(); - } - - selectedProcess.ForceNotifyProcessorAffinityChanged(); - this.OnPropertyChanged(nameof(this.SelectedProcess)); - }); - - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus($"Pending settings applied to {selectedProcess.Name}.", false); - }); - } - catch (Exception ex) - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus($"Error applying pending settings: {ex.Message}", false); - }); - } - } - - [RelayCommand] - private async Task RefreshTopology() - { - try - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus("Refreshing CPU topology..."); - }); - await this.cpuTopologyService.RefreshTopologyAsync(); - } - catch (Exception ex) - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus($"Error refreshing topology: {ex.Message}", false); - }); - } - } - - [RelayCommand] - private async Task SetPowerPlan() - { - if (this.SelectedPowerPlan == null) - { - return; - } - - try - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus($"Setting power plan to {this.SelectedPowerPlan.Name}..."); - }); - - var success = await this.powerPlanService.SetActivePowerPlan(this.SelectedPowerPlan); - - await this.RefreshPowerPlansAsync(); - - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - var activePlan = this.PowerPlans.FirstOrDefault(p => p.IsActive); - if (success && activePlan?.Guid == this.SelectedPowerPlan.Guid) - { - this.SetStatus($"Power plan set successfully to {this.SelectedPowerPlan.Name}", false); - } - else - { - this.SetStatus($"Power plan change attempted - current plan: {activePlan?.Name ?? "Unknown"}", false); - } - }); - } - catch (Exception ex) - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus($"Error setting power plan: {ex.Message}", false); - }); - - try - { - await this.RefreshPowerPlansAsync(); - } - catch - { - // ignored - } - } - finally - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(this.ClearStatus); - } - } - - [RelayCommand] - private async Task SetPriority(ProcessPriorityClass priority) - { - var selectedProcess = this.SelectedProcess; - if (selectedProcess == null) - { - return; - } - - if (ProcessPriorityGuardrails.IsBlocked(priority)) - { - var message = ProcessOperationUserMessages.RealtimePriorityBlocked; - await InvokeOnUiAsync(() => - { - this.SetCriticalStatus(message); - }); - _ = this.notificationService.ShowNotificationAsync("Priority blocked", message, NotificationType.Warning); - await this.LogUserActionAsync( - "ProcessPriorityBlocked", - message, - $"Process: {selectedProcess.Name}, PID: {selectedProcess.ProcessId}, Priority: {priority}"); - return; - } - - try - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus($"Setting priority for {selectedProcess.Name} to {priority}..."); - }); - - // Apply the priority change - await this.processService.SetProcessPriority(selectedProcess, priority); - - // Immediately refresh the process to get the actual system state - await this.processService.RefreshProcessInfo(selectedProcess); - - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - // Notify UI that the process properties have changed - this.OnPropertyChanged(nameof(this.SelectedProcess)); - - // Verify the priority was set correctly - if (selectedProcess.Priority == priority) - { - 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 {selectedProcess.Name}: {priority}.", false); - _ = this.notificationService.ShowNotificationAsync("Priority applied", $"{selectedProcess.Name}: {priority}", NotificationType.Success); - } - } - else - { - this.SetStatus($"Priority adjusted by system for {selectedProcess.Name} to {selectedProcess.Priority}.", false); - _ = this.notificationService.ShowNotificationAsync("Priority adjusted", $"{selectedProcess.Name}: {selectedProcess.Priority}", NotificationType.Warning); - } - }); - await this.LogUserActionAsync( - "ProcessPriorityChanged", - $"CPU priority changed for {selectedProcess.Name}: {priority}", - $"PID: {selectedProcess.ProcessId}"); - } - catch (Exception ex) - { - var message = ex.Message; - if (message.Contains("Realtime priority is blocked", StringComparison.OrdinalIgnoreCase)) - { - message = ProcessOperationUserMessages.RealtimePriorityBlocked; - _ = this.notificationService.ShowNotificationAsync("Priority blocked", message, NotificationType.Warning); - } - else if (message.Contains("Access denied", StringComparison.OrdinalIgnoreCase) || - message.Contains("anti-cheat", StringComparison.OrdinalIgnoreCase)) - { - message = ProcessOperationUserMessages.AccessDenied; - _ = this.notificationService.ShowNotificationAsync("Priority blocked", message, NotificationType.Warning); - } - else - { - _ = this.notificationService.ShowNotificationAsync("Priority error", message, NotificationType.Error); - } - - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetCriticalStatus($"Error setting priority: {message}"); - }); - await this.LogUserActionAsync( - message == ProcessOperationUserMessages.RealtimePriorityBlocked ? "ProcessPriorityBlocked" : "ProcessPriorityChangeFailed", - message, - $"Process: {selectedProcess.Name}, PID: {selectedProcess.ProcessId}, Priority: {priority}"); - - // Try to refresh process info even if setting failed, to show current state - try - { - await this.processService.RefreshProcessInfo(selectedProcess); - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.OnPropertyChanged(nameof(this.SelectedProcess)); - }); - } - catch - { - // Process may have terminated - } - } - finally - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(this.ClearStatus); - } - } - - [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.LogUserActionAsync( - "CpuSetsClearFailed", - ProcessOperationUserMessages.AccessDenied, - $"Process: {process.Name}, PID: {process.ProcessId}"); - await this.UpdateSelectedProcessSummaryAsync(process); - return; - } - - await this.processService.RefreshProcessInfo(process); - this.SetStatus($"CPU Sets cleared for {process.Name}.", false); - await this.LogUserActionAsync( - "CpuSetsCleared", - $"CPU Sets cleared for {process.Name}", - $"PID: {process.ProcessId}"); - await this.UpdateSelectedProcessSummaryAsync(process); - } - catch (Exception ex) - { - var message = MapProcessOperationException(ex); - this.SetContextError(message); - await this.LogUserActionAsync( - "CpuSetsClearFailed", - message, - $"Process: {process.Name}, PID: {process.ProcessId}"); - 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.LogUserActionAsync( - "ProcessInfoRefreshed", - $"Process info refreshed for {process.Name}.", - $"PID: {process.ProcessId}"); - await this.UpdateSelectedProcessSummaryAsync(process); - } - catch (Exception ex) - { - var message = MapProcessOperationException(ex); - this.SetContextError(message); - await this.LogUserActionAsync( - "ProcessInfoRefreshFailed", - message, - $"Process: {process.Name}, PID: {process.ProcessId}"); - 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)) - { - var message = $"Executable path is unavailable for {process.Name}."; - this.SetContextError(message); - await this.LogUserActionAsync( - "ProcessExecutableOpenFailed", - message, - $"Process: {process.Name}, PID: {process.ProcessId}"); - await this.UpdateSelectedProcessSummaryAsync(process); - return; - } - - try - { - this.executableLocationOpener(path); - this.SetStatus($"Opened executable location for {process.Name}.", false); - await this.LogUserActionAsync( - "ProcessExecutableLocationOpened", - $"Opened executable location for {process.Name}.", - path); - await this.UpdateSelectedProcessSummaryAsync(process); - } - catch (Exception ex) - { - var message = $"Could not open executable location: {ex.Message}"; - this.SetContextError(message); - await this.LogUserActionAsync( - "ProcessExecutableOpenFailed", - message, - $"Process: {process.Name}, PID: {process.ProcessId}"); - 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.LogUserActionAsync( - "ProcessInfoCopied", - $"Copied process info for {process.Name}.", - $"PID: {process.ProcessId}"); - await this.UpdateSelectedProcessSummaryAsync(process); - } - catch (Exception ex) - { - var message = $"Could not copy process info: {ex.Message}"; - this.SetContextError(message); - await this.LogUserActionAsync( - "ProcessInfoCopyFailed", - message, - $"Process: {process.Name}, PID: {process.ProcessId}"); - 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.LogUserActionAsync( - "ProcessPriorityBlocked", - ProcessOperationUserMessages.RealtimePriorityBlocked, - $"Process: {process.Name}, PID: {process.ProcessId}, Priority: {priority}"); - 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.LogUserActionAsync( - "ProcessPriorityChanged", - $"CPU priority changed for {process.Name}: {priority}", - $"PID: {process.ProcessId}"); - await this.UpdateSelectedProcessSummaryAsync(process); - } - catch (Exception ex) - { - var message = MapProcessOperationException(ex); - this.SetContextError(message); - _ = this.notificationService.ShowNotificationAsync("Priority blocked", message, NotificationType.Warning); - await this.LogUserActionAsync( - "ProcessPriorityChangeFailed", - message, - $"Process: {process.Name}, PID: {process.ProcessId}, Priority: {priority}"); - 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) - { - var message = string.IsNullOrWhiteSpace(result.UserMessage) - ? ProcessOperationUserMessages.AccessDenied - : result.UserMessage; - this.SetContextError(message); - await this.LogUserActionAsync( - "ProcessMemoryPriorityFailed", - message, - $"Process: {process.Name}, PID: {process.ProcessId}, Priority: {priority}"); - await this.UpdateSelectedProcessSummaryAsync(process); - return; - } - - this.SetStatus($"Memory priority applied successfully to {process.Name}: {priority}.", false); - await this.LogUserActionAsync( - "ProcessMemoryPriorityChanged", - $"Memory priority changed for {process.Name}: {priority}", - $"PID: {process.ProcessId}"); - await this.UpdateSelectedProcessSummaryAsync(process); - } - catch (Exception ex) - { - var message = MapProcessOperationException(ex); - this.SetContextError(message); - await this.LogUserActionAsync( - "ProcessMemoryPriorityFailed", - message, - $"Process: {process.Name}, PID: {process.ProcessId}, Priority: {priority}"); - 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() - { - if (this.SelectedProcess == null || string.IsNullOrWhiteSpace(this.ProfileName)) - { - return; - } - - try - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus($"Saving profile {this.ProfileName}..."); - }); - await this.processService.SaveProcessProfile(this.ProfileName, this.SelectedProcess); - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.ClearStatus(); - }); - } - catch (Exception ex) - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus($"Error saving profile: {ex.Message}", false); - }); - } - } - - [RelayCommand] - private async Task LoadProfile() - { - if (this.SelectedProcess == null || string.IsNullOrWhiteSpace(this.ProfileName)) - { - return; - } - - try - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus($"Loading profile {this.ProfileName}..."); - }); - var success = await this.processService.LoadProcessProfile(this.ProfileName, this.SelectedProcess); - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - if (success) - { - this.ClearStatus(); - } - else - { - this.SetCriticalStatus($"Profile {this.ProfileName} could not be fully applied."); - } - }); - } - catch (Exception ex) - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetCriticalStatus($"Error loading profile: {ex.Message}"); - }); - } - } - - private void SetupRefreshTimer() - { - this.refreshTimer = new System.Timers.Timer(5000); // PERFORMANCE OPTIMIZATION: Increased to 5 second refresh for better performance - this.refreshTimer.Elapsed += async (s, e) => - { - if (this.isUiRefreshPaused || !this.isProcessViewActive) - { - return; - } - - try - { - // Marshal timer callback to UI thread to prevent cross-thread access exceptions - await System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => - { - if (this.isUiRefreshPaused || !this.isProcessViewActive) - { - return; - } - - await this.RefreshProcessesCommand.ExecuteAsync(null); - }); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Timer refresh error: {ex.Message}"); - } - }; - // Don't start automatically - only start when needed - } - - public void PauseRefresh() - { - this.SetUiRefreshEnabled(false, refreshImmediately: false); - } - - public void ResumeRefresh() - { - this.SetUiRefreshEnabled(true, refreshImmediately: true); - } - - public void ApplyRefreshDecision(AppRefreshDecision decision) - { - ArgumentNullException.ThrowIfNull(decision); - - this.isVirtualizedPreloadAllowedByPolicy = decision.VirtualizedPreloadEnabled; - this.virtualizedProcessService.Configuration.EnableBackgroundLoading = this.ShouldPreloadVirtualizedBatches(); - this.SetUiRefreshEnabled( - decision.ProcessUiRefreshEnabled && !this.IsProcessListLocked, - decision.ImmediateProcessRefresh && !this.IsProcessListLocked); - } - - public void SetProcessViewActive(bool isActive) - { - if (this.isProcessViewActive == isActive) - { - this.virtualizedProcessService.Configuration.EnableBackgroundLoading = this.ShouldPreloadVirtualizedBatches(); - return; - } - - this.isProcessViewActive = isActive; - this.virtualizedProcessService.Configuration.EnableBackgroundLoading = this.ShouldPreloadVirtualizedBatches(); - - if (!isActive) - { - this.refreshTimer?.Stop(); - return; - } - - if (!this.isUiRefreshPaused) - { - this.refreshTimer?.Start(); - _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => - { - try - { - await this.RefreshProcessesCommand.ExecuteAsync(null); - } - catch (Exception ex) - { - this.Logger.LogDebug(ex, "Immediate process refresh after returning to process view failed"); - } - }); - } - } - - public void SetUiRefreshEnabled(bool enabled, bool refreshImmediately = true) - { - this.isUiRefreshPaused = !enabled; - this.virtualizedProcessService.Configuration.EnableBackgroundLoading = this.ShouldPreloadVirtualizedBatches(); - - if (!enabled) - { - this.refreshTimer?.Stop(); - return; - } - - if (this.isProcessViewActive) - { - this.refreshTimer?.Start(); - } - - if (!refreshImmediately || !this.isProcessViewActive) - { - return; - } - - TaskSafety.FireAndForget( - InvokeOnUiAsync(async () => - { - if (this.isUiRefreshPaused) - { - return; - } - - try - { - this.ClearStatus(); - await this.RefreshProcessesCommand.ExecuteAsync(null); - } - catch (Exception ex) - { - this.Logger.LogDebug(ex, "Immediate process refresh after resume failed"); - } - }), - ex => this.Logger.LogDebug(ex, "Immediate process refresh dispatch after resume failed")); - } - - partial void OnIsProcessListLockedChanged(bool value) - { - this.SetUiRefreshEnabled(!value, refreshImmediately: !value); - - _ = this.LogUserActionAsync( - "ProcessListLockChanged", - value ? "Lock process list enabled." : "Lock process list disabled."); - } - - private bool ShouldRunProcessUiRefresh() - { - return this.isProcessViewActive && !this.isUiRefreshPaused && !this.IsProcessListLocked; - } - - private bool ShouldPreloadVirtualizedBatches() - { - return this.IsVirtualizationEnabled - && this.isVirtualizedPreloadAllowedByPolicy - && this.ShouldRunProcessUiRefresh() - && !this.IsProcessListLocked; - } - - private Task PreloadNextBatchIfAllowedAsync(int currentBatchIndex) - { - return this.ShouldPreloadVirtualizedBatches() - ? this.virtualizedProcessService.PreloadNextBatchAsync(currentBatchIndex, this.ShowActiveApplicationsOnly) - : Task.CompletedTask; - } - - partial void OnSearchTextChanged(string value) - { - this.searchRefreshCoordinator.Schedule(); - } - - partial void OnShowActiveApplicationsOnlyChanged(bool value) - { - _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => - { - await LoadProcessesCommand.ExecuteAsync(null); - }); - } - - partial void OnHideSystemProcessesChanged(bool value) - { - this.filterRefreshCoordinator.Schedule(); - } - - partial void OnHideIdleProcessesChanged(bool value) - { - this.filterRefreshCoordinator.Schedule(); - } - - partial void OnSortModeChanged(string value) - { - this.filterRefreshCoordinator.Schedule(); - } - - partial void OnIsIdleServerDisabledChanged(bool value) - { - if (SelectedProcess != null) - { - _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => - { - await ToggleIdleServerAsync(value); - }); - } - } - - partial void OnIsRegistryPriorityEnabledChanged(bool value) - { - if (SelectedProcess != null) - { - _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => - { - await ToggleRegistryPriorityAsync(value); - }); - } - } - - private void FilterProcesses() - { - var dispatcher = System.Windows.Application.Current?.Dispatcher; - if (dispatcher != null && !dispatcher.CheckAccess()) - { - dispatcher.Invoke(this.FilterProcesses); - return; - } - - if (this.isApplyingFilter) - { - this.filterRefreshPending = true; - return; - } - - this.isApplyingFilter = true; - - try - { - do - { - this.filterRefreshPending = false; - - var criteria = new ProcessFilterCriteria - { - SearchText = this.SearchText, - HideSystemProcesses = this.HideSystemProcesses, - HideIdleProcesses = this.HideIdleProcesses, - SortMode = this.SortMode, - }; - - var filteredResults = this.processFilterService.FilterAndSort(this.Processes, criteria); - this.FilteredProcesses = new ObservableCollection(filteredResults); - } - while (this.filterRefreshPending); - } - finally - { - this.isApplyingFilter = false; - } - } - - private async Task LoadProcessPowerPlanAssociation(ProcessModel process) - { - try - { - await this.RefreshPowerPlansAsync(); - } - catch (Exception ex) - { - this.Logger.LogWarning(ex, "Failed to load power plan association for process {ProcessName}", process.Name); - } - } - - private void ClearProcessSelection() - { - // Clear CPU core selections - foreach (var core in this.CpuCores) - { - core.IsSelected = false; - } - - this.HasPendingAffinityEdits = false; - this.UpdateAffinityDisplayState(); - - // Reset power plan to current system default - _ = Task.Run(async () => - { - try - { - await this.RefreshPowerPlansAsync(); - } - catch (Exception ex) - { - this.Logger.LogWarning(ex, "Failed to reset power plan selection"); - } - }); - - // Notify UI of changes - System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - // Reset feature states - this.IsIdleServerDisabled = false; - this.IsRegistryPriorityEnabled = false; - - this.OnPropertyChanged(nameof(this.CpuCores)); - - // BUG FIX: Clear status without setting busy state and auto-clear after delay - this.SetStatus("Process selection cleared", false); - - // Clear the status after a short delay - _ = Task.Delay(2000).ContinueWith(_ => - { - System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - if (this.StatusMessage == "Process selection cleared") - { - this.ClearStatus(); - } - }); - }); - }); - } - - /// - /// Toggles the idle server functionality for the selected process. - /// - private async Task ToggleIdleServerAsync(bool disable) - { - if (this.SelectedProcess == null) - { - return; - } - - try - { - this.SetStatus($"{(disable ? "Disabling" : "Enabling")} idle server for {this.SelectedProcess.Name}..."); - - // Implementation for disabling/enabling idle server - // This typically involves setting process execution state or power management settings - var success = await this.processService.SetIdleServerStateAsync(this.SelectedProcess, !disable); - - if (success) - { - this.SelectedProcess.IsIdleServerDisabled = disable; - this.SetStatus($"Idle server {(disable ? "disabled" : "enabled")} for {this.SelectedProcess.Name}"); - - await this.LogUserActionAsync( - "IdleServer", - $"Idle server {(disable ? "disabled" : "enabled")} for process {this.SelectedProcess.Name}", - $"PID: {this.SelectedProcess.ProcessId}"); - } - else - { - this.SetStatus($"Failed to {(disable ? "disable" : "enable")} idle server for {this.SelectedProcess.Name}", false); - // Revert the UI state - this.IsIdleServerDisabled = !disable; - } - } - catch (Exception ex) - { - this.Logger.LogError(ex, "Error toggling idle server for process {ProcessName}", this.SelectedProcess.Name); - this.SetStatus($"Error: {ex.Message}", false); - // Revert the UI state - this.IsIdleServerDisabled = !disable; - } - } - - /// - /// Toggles registry-based priority enforcement for the selected process. - /// - private async Task ToggleRegistryPriorityAsync(bool enable) - { - if (this.SelectedProcess == null) - { - return; - } - - try - { - this.SetStatus($"{(enable ? "Enabling" : "Disabling")} registry priority enforcement for {this.SelectedProcess.Name}..."); - - // Implementation for registry-based priority setting - var success = await this.processService.SetRegistryPriorityAsync(this.SelectedProcess, enable, this.SelectedProcess.Priority); - - if (success) - { - this.SelectedProcess.IsRegistryPriorityEnabled = enable; - - if (enable) - { - this.SetStatus($"Registry priority enforcement enabled for {this.SelectedProcess.Name}. Process restart required for changes to take effect."); - - // Show notification about restart requirement - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - System.Windows.MessageBox.Show( - $"Registry priority has been set for {this.SelectedProcess.Name}.\n\n" + - "The process must be restarted for the registry changes to take effect.\n\n" + - $"{ProcessOperationUserMessages.PersistentLaunchTimePriorityNotice}\n\n" + - "This setting will persist across system reboots and will automatically apply the selected priority when the process starts.", - "Registry Priority Set - Restart Required", - System.Windows.MessageBoxButton.OK, - System.Windows.MessageBoxImage.Information); - }); - } - else - { - this.SetStatus($"Registry priority enforcement disabled for {this.SelectedProcess.Name}"); - } - - await this.LogUserActionAsync( - "RegistryPriority", - $"Registry priority enforcement {(enable ? "enabled" : "disabled")} for process {this.SelectedProcess.Name}", - $"PID: {this.SelectedProcess.ProcessId}, Priority: {this.SelectedProcess.Priority}"); - } - else - { - this.SetStatus($"Failed to {(enable ? "enable" : "disable")} registry priority enforcement for {this.SelectedProcess.Name}", false); - // Revert the UI state - this.IsRegistryPriorityEnabled = !enable; - } - } - catch (Exception ex) - { - this.Logger.LogError(ex, "Error toggling registry priority for process {ProcessName}", this.SelectedProcess.Name); - this.SetStatus($"Error: {ex.Message}", false); - // Revert the UI state - this.IsRegistryPriorityEnabled = !enable; - } - } - - /// - /// Saves the current process settings (affinity mask, priority, power plan) as an association - /// Based on CPUSetSetter's SetMask pattern. - /// - [RelayCommand] - private void OpenRulesTab() - { - this.OpenRulesRequested?.Invoke(this, EventArgs.Empty); - } - - [RelayCommand] - private async Task SaveCurrentAsAssociation() - { - if (this.SelectedProcess == null) - { - await this.notificationService.ShowNotificationAsync( - "No Process Selected", - "Please select a process to save as an association", NotificationType.Warning); - return; - } - - try - { - this.SetStatus($"Saving rule for {this.SelectedProcess.Name}..."); - - // Get current power plan - var currentPowerPlan = await this.powerPlanService.GetActivePowerPlan(); - - // Create new association - var association = new ProcessPowerPlanAssociation - { - ExecutableName = this.SelectedProcess.Name, - ExecutablePath = this.SelectedProcess.ExecutablePath ?? string.Empty, - PowerPlanGuid = currentPowerPlan?.Guid ?? string.Empty, - PowerPlanName = currentPowerPlan?.Name ?? "Unknown", - CoreMaskId = this.SelectedCoreMask?.Id, - CoreMaskName = this.SelectedCoreMask?.Name, - ProcessPriority = this.SelectedProcess.Priority.ToString(), - MatchByPath = !string.IsNullOrEmpty(this.SelectedProcess.ExecutablePath), - Priority = 0, - Description = $"Saved from Process Management on {DateTime.Now:g}", - IsEnabled = true, - }; - - // Try to add the association - var success = await this.associationService.AddAssociationAsync(association); - - if (success) - { - this.SetStatus($"Rule created for {this.SelectedProcess.Name} and ready for auto-apply.", false); - await this.notificationService.ShowNotificationAsync( - "Rule Saved", - $"Settings for {this.SelectedProcess.Name} saved successfully", NotificationType.Success); - - await this.LogUserActionAsync( - "SaveAssociation", - $"Saved association for process {this.SelectedProcess.Name}", - $"PID: {this.SelectedProcess.ProcessId}, PowerPlan: {currentPowerPlan?.Name}, " + - $"CoreMask: {this.SelectedCoreMask?.Name ?? "None"}, Priority: {this.SelectedProcess.Priority}"); - } - else - { - var existingAssociation = await this.associationService.FindAssociationByExecutableAsync(this.SelectedProcess.Name); - if (existingAssociation != null) - { - existingAssociation.ExecutablePath = association.ExecutablePath; - existingAssociation.PowerPlanGuid = association.PowerPlanGuid; - existingAssociation.PowerPlanName = association.PowerPlanName; - existingAssociation.CoreMaskId = association.CoreMaskId; - existingAssociation.CoreMaskName = association.CoreMaskName; - existingAssociation.ProcessPriority = association.ProcessPriority; - existingAssociation.MatchByPath = association.MatchByPath; - existingAssociation.Description = association.Description; - existingAssociation.IsEnabled = true; - existingAssociation.UpdatedAt = DateTime.UtcNow; - - var updated = await this.associationService.UpdateAssociationAsync(existingAssociation); - if (updated) - { - this.SetStatus($"Existing rule updated for {this.SelectedProcess.Name}.", false); - await this.notificationService.ShowNotificationAsync( - "Rule Updated", - $"Existing rule for {this.SelectedProcess.Name} was updated", NotificationType.Information); - } - else - { - this.SetStatus($"Failed to update existing rule for {this.SelectedProcess.Name}", false); - await this.notificationService.ShowNotificationAsync( - "Rule Update Failed", - $"Could not update existing rule for {this.SelectedProcess.Name}", NotificationType.Warning); - } - } - else - { - this.SetStatus($"Rule already exists for {this.SelectedProcess.Name}", false); - await this.notificationService.ShowNotificationAsync( - "Rule Exists", - $"A rule for {this.SelectedProcess.Name} already exists", NotificationType.Warning); - } - } - } - catch (Exception ex) - { - this.Logger.LogError(ex, "Error saving association for process {ProcessName}", this.SelectedProcess.Name); - this.SetStatus($"Error saving rule: {ex.Message}", false); - await this.notificationService.ShowNotificationAsync( - "Error", - $"Failed to save rule: {ex.Message}", NotificationType.Error); - } - } - - protected override void OnDispose() - { - this.refreshTimer?.Stop(); - this.refreshTimer?.Dispose(); - this.refreshTimer = null; - - this.searchRefreshCoordinator.Dispose(); - this.filterRefreshCoordinator.Dispose(); - - this.cpuTopologyService.TopologyDetected -= this.OnTopologyDetected; - this.systemTrayService.QuickApplyRequested -= this.OnTrayQuickApplyRequested; - - base.OnDispose(); - } - } -} +namespace ThreadPilot.ViewModels +{ + using System; + using System.Collections.ObjectModel; + using System.ComponentModel; + using System.Diagnostics; + using System.IO; + using System.Linq; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using System.Windows.Input; + using CommunityToolkit.Mvvm.ComponentModel; + using CommunityToolkit.Mvvm.Input; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public partial class ProcessViewModel : BaseViewModel + { + private async Task ApplyFiltersOnUiAsync() + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(this.FilterProcesses); + } + + private void SetupVirtualizedProcessService() + { + // Configure virtualization settings + this.virtualizedProcessService.Configuration.BatchSize = 50; + this.virtualizedProcessService.Configuration.EnableBackgroundLoading = true; + + // Subscribe to events + this.virtualizedProcessService.BatchLoadProgress += this.OnBatchLoadProgress; + this.virtualizedProcessService.BackgroundBatchLoaded += this.OnBackgroundBatchLoaded; + } + + private void OnBatchLoadProgress(object? sender, BatchLoadProgressEventArgs e) + { + if (this.isUiRefreshPaused || !this.isProcessViewActive) + { + return; + } + + _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.LoadingProgress = e.ProgressPercentage; + this.LoadingStatusText = e.StatusMessage; + }); + } + + private void OnBackgroundBatchLoaded(object? sender, ProcessBatchResult e) + { + this.Logger.LogDebug( + "Background batch {BatchIndex} loaded with {ProcessCount} processes", + e.BatchIndex, e.Processes.Count); + } + + public override async Task InitializeAsync() + { + try + { + // Update status on UI thread + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus("Initializing CPU topology and power plans..."); + }); + System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: Starting initialization"); + + // Initialize CPU topology + System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: About to detect CPU topology"); + await this.cpuTopologyService.DetectTopologyAsync(); + System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: CPU topology detection completed"); + + // Initialize core masks service + System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: About to initialize core masks"); + await this.coreMaskService.InitializeAsync(); + this.AvailableCoreMasks = this.coreMaskService.AvailableMasks; + this.SelectedCoreMask = this.coreMaskService.DefaultMask; + System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: Core masks initialized"); + + // Load power plans + System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: About to load power plans"); + await this.RefreshPowerPlansAsync(); + System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: Power plans loaded"); + + // Load processes automatically on startup (Bug #8 fix) + System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: About to load processes automatically"); + await this.LoadProcessesCommand.ExecuteAsync(null); + + // Access process count on UI thread to avoid threading issues + int processCount = 0; + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + processCount = this.Processes?.Count ?? 0; + }); + System.Diagnostics.Debug.WriteLine($"ProcessViewModel.InitializeAsync: Processes loaded automatically, count: {processCount}"); + + // Start refresh timer for real-time updates + System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: Starting refresh timer"); + this.refreshTimer?.Start(); + System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: Initialization completed successfully"); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"ProcessViewModel.InitializeAsync: Exception occurred: {ex.Message}"); + // Update status on UI thread + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus($"Failed to initialize: {ex.Message}", false); + }); + } + } + + private async Task RefreshPowerPlansAsync() + { + try + { + var plans = await this.powerPlanService.GetPowerPlansAsync(); + var activePlan = await this.powerPlanService.GetActivePowerPlan(); + + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.PowerPlans.Clear(); + foreach (var plan in plans) + { + plan.IsActive = plan.Guid == activePlan?.Guid; + this.PowerPlans.Add(plan); + } + + this.SelectedPowerPlan = this.PowerPlans.FirstOrDefault(p => p.Guid == activePlan?.Guid); + }); + } + catch (Exception ex) + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus($"Failed to load power plans: {ex.Message}", false); + }); + } + } + + partial void OnSelectedProcessChanged(ProcessModel? value) + { + this.UpdateSelectedProcessSummary(value); + + if (value != null && CpuTopology != null) + { + this.HasPendingAffinityEdits = false; + this.UpdateAffinityDisplayState(); + // Immediately fetch and display real-time process information + TaskSafety.FireAndForget(HandleSelectedProcessChangedAsync(value), ex => + { + this.Logger.LogWarning(ex, "Failed while handling selected process change for {ProcessName}", value.Name); + }); + } + else if (value == null) + { + // Clear selection + this.ClearProcessSelection(); + } + + // Update system tray context menu + this.systemTrayService.UpdateContextMenu(value?.Name, value != null); + } + + private void UpdateSelectedProcessSummary(ProcessModel? process) + { + TaskSafety.FireAndForget( + 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 + { + // First check if the process is still running + bool isStillRunning = await this.processService.IsProcessStillRunning(value); + if (!isStillRunning) + { + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + this.SetStatus($"Process {value.Name} (PID: {value.ProcessId}) has terminated", false); + this.SelectedProcess = null; + this.ClearProcessSelection(); + }); + return; + } + + // Refresh process info to get current state from OS + await this.processService.RefreshProcessInfo(value); + + // Update UI on main thread with fresh data + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + this.UpdateCoreSelections(value.ProcessorAffinity); + this.UpdateAffinityDisplayState(); + value.ForceNotifyProcessorAffinityChanged(); + + // Update priority display - trigger property change to refresh ComboBox + this.OnPropertyChanged(nameof(this.SelectedProcess)); + + // Update feature states from the selected process + this.IsIdleServerDisabled = value.IsIdleServerDisabled; + this.IsRegistryPriorityEnabled = value.IsRegistryPriorityEnabled; + + // BUG FIX: Update status without setting busy state for process selection + this.SetStatus( + $"Selected process: {value.Name} (PID: {value.ProcessId}) - " + + $"Priority: {value.Priority}, Affinity: 0x{value.ProcessorAffinity:X}", false); + }); + if (ReferenceEquals(this.SelectedProcess, value)) + { + // Keep this second update for refreshed process fields and the latest operation message. + this.UpdateSelectedProcessSummary(value); + } + + // Load current power plan association if available + await this.LoadProcessPowerPlanAssociation(value); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("terminated") || ex.Message.Contains("exited") || ex.Message.Contains("no longer exists")) + { + // Process has terminated + this.Logger.LogInformation("Process {ProcessName} (PID: {ProcessId}) has terminated", value.Name, value.ProcessId); + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + this.SetStatus($"Process {value.Name} (PID: {value.ProcessId}) has terminated", false); + this.SelectedProcess = null; + this.ClearProcessSelection(); + }); + } + catch (Exception ex) + { + this.Logger.LogWarning(ex, "Failed to refresh process info for {ProcessName}", value.Name); + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + this.SetStatus($"Warning: Could not access process {value.Name} - it may have terminated or require elevated privileges", false); + }); + if (ReferenceEquals(this.SelectedProcess, value)) + { + this.UpdateSelectedProcessSummary(value); + } + } + } + + private void OnTrayQuickApplyRequested(object? sender, EventArgs e) + { + TaskSafety.FireAndForget(this.OnTrayQuickApplyRequestedAsync(), ex => + { + this.Logger.LogWarning(ex, "Quick apply request failed"); + }); + } + + private async Task OnTrayQuickApplyRequestedAsync() + { + try + { + // Marshal UI operations to the UI thread to prevent cross-thread access exceptions + await System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => + { + await this.QuickApplyAffinityAndPowerPlanCommand.ExecuteAsync(null); + this.systemTrayService.ShowBalloonTip( + "ThreadPilot", + $"Pending settings applied to {this.SelectedProcess?.Name ?? "selected process"}", 2000); + }); + } + catch (Exception ex) + { + // Marshal UI operations to the UI thread to prevent cross-thread access exceptions + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.systemTrayService.ShowBalloonTip( + "ThreadPilot Error", + $"Failed to apply pending settings: {ex.Message}", 3000); + }); + } + } + + private void OnTopologyDetected(object? sender, CpuTopologyDetectedEventArgs e) + { + // Ensure all UI updates happen on the dispatcher thread + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + this.CpuTopology = e.Topology; + this.IsTopologyDetectionSuccessful = e.DetectionSuccessful; + + if (e.DetectionSuccessful) + { + this.TopologyStatus = $"Detected: {e.Topology.TotalLogicalCores} logical CPUs, " + + $"{e.Topology.TotalPhysicalCores} physical CPUs"; + this.AreAdvancedFeaturesAvailable = e.Topology.HasIntelHybrid || e.Topology.HasAmdCcd || e.Topology.HasHyperThreading; + } + else + { + this.TopologyStatus = $"Detection failed: {e.ErrorMessage ?? "Unknown error"}"; + this.AreAdvancedFeaturesAvailable = false; + } + + this.UpdateCpuCores(); + this.UpdateAffinityPresets(); + this.UpdateHyperThreadingStatus(); + }); + } + + private void UpdateCpuCores() + { + if (this.CpuTopology == null) + { + return; + } + + this.CpuCores.Clear(); + foreach (var core in this.CpuTopology.LogicalCores) + { + core.PropertyChanged -= this.OnCorePropertyChanged; + core.PropertyChanged += this.OnCorePropertyChanged; + this.CpuCores.Add(core); + } + } + + private void OnCorePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + // Note: Advanced CPU Affinity cores are now read-only (ProcessView.xaml has IsHitTestVisible="False") + // This event handler is kept for compatibility but should not be triggered + // Core modifications are done exclusively through the Core Mask tab + if (this.suppressCoreSelectionEvents) + { + return; + } + + this.Logger.LogDebug("Core property changed but cores are read-only - no action taken"); + } + + private void UpdateHyperThreadingStatus() + { + if (this.CpuTopology == null) + { + this.HyperThreadingStatusText = "Multi-Threading: Unknown"; + this.IsHyperThreadingActive = false; + return; + } + + // Determine if hyperthreading/SMT is present and active + bool hasMultiThreading = this.CpuTopology.HasHyperThreading; + this.IsHyperThreadingActive = hasMultiThreading; + + // Determine the appropriate technology name based on CPU vendor + string technologyName = "Multi-Threading"; + if (this.CpuTopology.CpuBrand.Contains("Intel", StringComparison.OrdinalIgnoreCase)) + { + technologyName = "Hyper-Threading"; + } + else if (this.CpuTopology.CpuBrand.Contains("AMD", StringComparison.OrdinalIgnoreCase)) + { + technologyName = "SMT"; + } + + // Set the status text + string status = hasMultiThreading ? "Active" : "Not Available"; + this.HyperThreadingStatusText = $"{technologyName}: {status}"; + + this.Logger.LogInformation( + "Updated hyperthreading status: {StatusText} (Active: {IsActive})", + this.HyperThreadingStatusText, this.IsHyperThreadingActive); + } + + private void UpdateAffinityPresets() + { + this.AffinityPresets.Clear(); + var presets = this.cpuTopologyService.GetAffinityPresets(); + foreach (var preset in presets) + { + this.AffinityPresets.Add(preset); + } + } + + private void UpdateCoreSelections(long affinityMask, bool forceSync = false) + { + if (this.CpuTopology == null || this.CpuCores.Count == 0) + { + this.Logger.LogWarning( + "Cannot update core selections: CpuTopology={CpuTopology}, CpuCores.Count={CpuCoresCount}", + this.CpuTopology != null, this.CpuCores.Count); + return; + } + + if (this.HasPendingAffinityEdits && !forceSync) + { + this.Logger.LogDebug("Skipping affinity sync because user edits are pending"); + return; + } + + this.Logger.LogDebug( + "Updating core selections for affinity mask 0x{AffinityMask:X} ({AffinityMaskBinary})", + affinityMask, Convert.ToString(affinityMask, 2).PadLeft(Environment.ProcessorCount, '0')); + + // Update each core's selection state based on the actual OS affinity mask + var updatedCores = new List<(int CoreId, bool WasSelected, bool IsSelected)>(); + + try + { + this.suppressCoreSelectionEvents = true; + + foreach (var core in this.CpuCores) + { + bool wasSelected = core.IsSelected; + bool shouldBeSelected = (affinityMask & core.AffinityMask) != 0; + + if (wasSelected != shouldBeSelected) + { + core.IsSelected = shouldBeSelected; + updatedCores.Add((core.LogicalCoreId, wasSelected, shouldBeSelected)); + } + } + } + finally + { + this.suppressCoreSelectionEvents = false; + } + + // The UI will automatically update since CpuCoreModel now implements INotifyPropertyChanged + // No need to force collection refresh as individual property changes will be notified + + // Log the affinity update for debugging + var selectedCoreIds = this.CpuCores.Where(c => c.IsSelected).Select(c => c.LogicalCoreId).OrderBy(id => id).ToList(); + var totalCores = this.CpuCores.Count; + var selectedCount = selectedCoreIds.Count; + + this.Logger.LogInformation( + "Updated core selections for affinity mask 0x{AffinityMask:X}: " + + "Selected {SelectedCount}/{TotalCores} cores: [{CoreIds}]", + affinityMask, selectedCount, totalCores, string.Join(", ", selectedCoreIds)); + + if (updatedCores.Count > 0) + { + this.Logger.LogDebug( + "Core selection changes: {Changes}", + string.Join("; ", updatedCores.Select(c => $"Core {c.CoreId}: {c.WasSelected} -> {c.IsSelected}"))); + } + else + { + this.Logger.LogDebug("No core selection changes needed - UI already matches affinity mask"); + } + + if (forceSync) + { + this.HasPendingAffinityEdits = false; + } + + this.UpdateAffinityDisplayState(); + } + + private long CalculateAffinityMask() + { + if (this.CpuTopology == null) + { + return 0; + } + + var selectedCores = this.CpuCores.Where(core => core.IsSelected); + + // Note: Removed hyperthreading filtering - user can manually select desired cores + // All selected cores (including HT siblings) are now included in the affinity mask + + return selectedCores.Aggregate(0L, (mask, core) => mask | core.AffinityMask); + } + + private List GetPendingCoreSelectionMask() + { + return this.CpuCores + .OrderBy(core => core.LogicalCoreId) + .Select(core => core.IsSelected) + .ToList(); + } + + 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() + { + if (!this.ShouldRunProcessUiRefresh() || !this.IsVirtualizationEnabled || !this.HasMoreBatches || this.IsBusy) + { + return; + } + + try + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus($"Loading more processes (batch {this.CurrentBatchIndex + 2})..."); + }); + + var nextBatchIndex = this.CurrentBatchIndex + 1; + var batch = await this.virtualizedProcessService.LoadProcessBatchAsync(nextBatchIndex, this.ShowActiveApplicationsOnly); + + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + // Add new processes to existing collection + foreach (var process in batch.Processes) + { + this.Processes.Add(process); + } + + this.CurrentBatchIndex = batch.BatchIndex; + this.TotalBatches = batch.TotalBatches; + this.HasMoreBatches = batch.HasMoreBatches; + this.TotalProcessCount = batch.TotalProcessCount; + + this.FilterProcesses(); + + // BUG FIX: Ensure loading state is properly cleared + this.ClearStatus(); + this.LoadingProgress = 0.0; + this.LoadingStatusText = string.Empty; + }); + + await this.PreloadNextBatchIfAllowedAsync(this.CurrentBatchIndex); + } + catch (Exception ex) + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + // BUG FIX: Ensure loading state is cleared even on error + this.LoadingProgress = 0.0; + this.LoadingStatusText = string.Empty; + this.SetStatus($"Error loading more processes: {ex.Message}", false); + }); + } + } + + [RelayCommand] + public async Task LoadProcesses() + { + if (!this.ShouldRunProcessUiRefresh()) + { + return; + } + + try + { + System.Diagnostics.Debug.WriteLine($"LoadProcesses: Starting, ShowActiveApplicationsOnly={this.ShowActiveApplicationsOnly}"); + + // PERFORMANCE IMPROVEMENT: Progressive loading with status updates + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.LoadingProgress = 0.0; + this.LoadingStatusText = this.ShowActiveApplicationsOnly ? "Loading active applications..." : "Loading processes..."; + this.SetStatus(this.LoadingStatusText); + }); + + ObservableCollection newProcesses; + + // VIRTUALIZATION ENHANCEMENT: Use virtualized loading for large process lists + if (this.IsVirtualizationEnabled) + { + System.Diagnostics.Debug.WriteLine("LoadProcesses: Using virtualized loading"); + await this.virtualizedProcessService.InitializeAsync(); + + var totalCount = await this.virtualizedProcessService.GetTotalProcessCountAsync(this.ShowActiveApplicationsOnly); + if (totalCount > this.virtualizedProcessService.Configuration.BatchSize) + { + // Load first batch only + var batch = await this.virtualizedProcessService.LoadProcessBatchAsync(0, this.ShowActiveApplicationsOnly); + newProcesses = new ObservableCollection(batch.Processes); + + // Update virtualization state + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.CurrentBatchIndex = batch.BatchIndex; + this.TotalBatches = batch.TotalBatches; + this.HasMoreBatches = batch.HasMoreBatches; + this.TotalProcessCount = batch.TotalProcessCount; + }); + + await this.PreloadNextBatchIfAllowedAsync(0); + } + else + { + // Small list, load all processes normally + newProcesses = this.ShowActiveApplicationsOnly + ? await this.processService.GetActiveApplicationsAsync() + : await this.processService.GetProcessesAsync(); + } + } + else + { + // Traditional loading + if (this.ShowActiveApplicationsOnly) + { + System.Diagnostics.Debug.WriteLine("LoadProcesses: Getting active applications"); + newProcesses = await this.processService.GetActiveApplicationsAsync(); + } + else + { + System.Diagnostics.Debug.WriteLine("LoadProcesses: Getting all processes"); + newProcesses = await this.processService.GetProcessesAsync(); + } + } + + // Update UI on the UI thread + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.Processes = newProcesses; + System.Diagnostics.Debug.WriteLine($"LoadProcesses: Retrieved {this.Processes?.Count ?? 0} processes"); + this.FilterProcesses(); + System.Diagnostics.Debug.WriteLine($"LoadProcesses: After filtering, {this.FilteredProcesses?.Count ?? 0} processes visible"); + + // BUG FIX: Ensure loading state is properly cleared + this.ClearStatus(); + this.LoadingProgress = 0.0; + this.LoadingStatusText = string.Empty; + }); + + System.Diagnostics.Debug.WriteLine("LoadProcesses: Completed successfully"); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"LoadProcesses: Exception occurred: {ex.Message}"); + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + // BUG FIX: Ensure loading state is cleared even on error + this.LoadingProgress = 0.0; + this.LoadingStatusText = string.Empty; + this.SetStatus($"Error loading processes: {ex.Message}", false); + }); + } + } + + [RelayCommand] + private async Task RefreshProcesses() + { + if (!this.ShouldRunProcessUiRefresh()) + { + return; + } + + if (this.IsBusy || Interlocked.Exchange(ref this.isRefreshProcessesInProgress, 1) == 1) + { + return; + } + + try + { + var selectedProcessId = this.SelectedProcess?.ProcessId; + + var currentProcesses = this.ShowActiveApplicationsOnly + ? await this.processService.GetActiveApplicationsAsync() + : await this.processService.GetProcessesAsync(); + + await InvokeOnUiAsync(() => + { + var deltaResult = ProcessListDeltaUpdater.ApplyDelta( + this.Processes, + currentProcesses, + selectedProcessId); + + this.FilterProcesses(); + + if (deltaResult.SelectedProcess != null) + { + var processToSelect = this.FilteredProcesses.FirstOrDefault( + p => p.ProcessId == deltaResult.SelectedProcess.ProcessId); + if (processToSelect != null) + { + this.SelectedProcess = processToSelect; + } + } + else if (deltaResult.SelectedProcessTerminated) + { + this.SelectedProcess = null; + this.ClearProcessSelection(); + } + }); + } + catch (Exception ex) + { + await InvokeOnUiAsync(() => + { + this.SetStatus($"Error refreshing processes: {ex.Message}", false); + }); + } + finally + { + Interlocked.Exchange(ref this.isRefreshProcessesInProgress, 0); + } + } + + [RelayCommand] + private async Task SetAffinity() + { + var selectedProcess = this.SelectedProcess; + if (selectedProcess == null) + { + 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"); + } + + [RelayCommand] + private async Task SaveCurrentSettingsAsRule(ProcessModel? process) + { + var targetProcess = process ?? this.SelectedProcess; + if (targetProcess == null) + { + return; + } + + if (this.processRuleCreationService == null) + { + this.SetContextError("Persistent rules are unavailable."); + await this.UpdateSelectedProcessSummaryAsync(targetProcess); + return; + } + + if (!ReferenceEquals(this.SelectedProcess, targetProcess)) + { + this.SelectedProcess = targetProcess; + } + + await this.UpdateSelectedProcessSummaryAsync(targetProcess); + + var currentCoreSelection = this.HasPendingAffinityEdits && this.CpuCores.Count > 0 + ? this.GetPendingCoreSelectionMask() + : null; + var result = await this.processRuleCreationService.SaveCurrentSettingsAsRuleAsync( + targetProcess, + currentCoreSelection, + this.SelectedProcessSummary.MemoryPriority); + + this.ApplyRuleCreationResultStatus(result); + await this.LogUserActionAsync( + result.Success ? "PersistentRuleSaved" : "PersistentRuleSaveFailed", + result.UserMessage, + $"Process: {targetProcess.Name}, PID: {targetProcess.ProcessId}"); + await this.UpdateSelectedProcessSummaryAsync(targetProcess); + } + + [RelayCommand] + private async Task ApplyAffinityAndSaveAsRule(ProcessModel? process) + { + if (process == null) + { + return; + } + + if (this.processRuleCreationService == null) + { + this.SetContextError("Persistent rules are unavailable."); + await this.UpdateSelectedProcessSummaryAsync(process); + return; + } + + if (!ReferenceEquals(this.SelectedProcess, process)) + { + this.SelectedProcess = process; + } + + var pendingSelection = this.GetPendingCoreSelectionMask(); + var applyResult = await this.processAffinityApplyCoordinator.ApplyCoreSelectionAsync( + process, + pendingSelection, + "Manual Process tab context menu CPU selection"); + + if (!applyResult.Success) + { + this.SetContextError(applyResult.Message); + await this.LogUserActionAsync( + "ProcessAffinityFailed", + applyResult.Message, + $"Process: {process.Name}, PID: {process.ProcessId}, RequestedMask: 0x{applyResult.RequestedMask:X}"); + await this.UpdateSelectedProcessSummaryAsync(process); + return; + } + + if (!applyResult.UsedCpuSets) + { + this.UpdateCoreSelections(process.ProcessorAffinity, true); + } + + process.ForceNotifyProcessorAffinityChanged(); + this.OnPropertyChanged(nameof(this.SelectedProcess)); + this.HasPendingAffinityEdits = false; + this.UpdateAffinityDisplayState(); + + var saveResult = applyResult.UsedCpuSets + ? await this.processRuleCreationService.SaveCurrentSettingsAsRuleAsync( + process, + pendingSelection, + currentMemoryPriority: null) + : await this.processRuleCreationService.SaveRuleAsync( + process, + new ProcessRuleCreationPayload + { + LegacyAffinityMask = applyResult.VerifiedMask == 0 + ? applyResult.RequestedMask + : applyResult.VerifiedMask, + }); + + this.ApplyRuleCreationResultStatus(saveResult); + await this.LogUserActionAsync( + saveResult.Success ? "PersistentRuleSaved" : "PersistentRuleSaveFailed", + saveResult.UserMessage, + $"Process: {process.Name}, PID: {process.ProcessId}"); + await this.UpdateSelectedProcessSummaryAsync(process); + } + + private void ApplyRuleCreationResultStatus(ProcessRuleCreationResult result) + { + if (result.Success) + { + this.SetStatus(result.UserMessage, false); + return; + } + + this.SetContextError(string.IsNullOrWhiteSpace(result.UserMessage) + ? ProcessRuleCreationService.NoCurrentSettingsMessage + : result.UserMessage); + } + + private async Task ApplyAffinityToProcessAsync(ProcessModel selectedProcess, string selectionReason) + { + try + { + var pendingSelection = this.GetPendingCoreSelectionMask(); + + await InvokeOnUiAsync(() => + { + this.SetStatus($"Setting affinity for {selectedProcess.Name}..."); + }); + + var result = await this.processAffinityApplyCoordinator.ApplyCoreSelectionAsync( + selectedProcess, + pendingSelection, + selectionReason); + + await InvokeOnUiAsync(() => + { + if (!result.UsedCpuSets) + { + this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); + } + + selectedProcess.ForceNotifyProcessorAffinityChanged(); + this.OnPropertyChanged(nameof(this.SelectedProcess)); + + 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.SelectedProcess = null; + this.ClearProcessSelection(); + this.SetCriticalStatus(result.Message); + _ = this.notificationService.ShowNotificationAsync("Affinity failed", result.Message, NotificationType.Warning); + } + else if (result.FailureReason == AffinityApplyFailureReason.AccessDenied) + { + this.SetCriticalStatus(result.Message); + _ = this.notificationService.ShowNotificationAsync("Affinity blocked", result.Message, NotificationType.Warning); + } + else if (result.IsInvalidTopology || result.IsLegacyFallbackBlocked) + { + this.SetCriticalStatus(result.Message); + _ = this.notificationService.ShowNotificationAsync("Affinity blocked", result.Message, NotificationType.Warning); + } + else + { + this.SetStatus(result.Message, false); + _ = this.notificationService.ShowNotificationAsync("Affinity error", result.Message, NotificationType.Error); + } + }); + + await this.LogUserActionAsync( + result.Success ? "ProcessAffinityApplied" : "ProcessAffinityFailed", + result.Message, + $"Process: {selectedProcess.Name}, PID: {selectedProcess.ProcessId}, RequestedMask: 0x{result.RequestedMask:X}, VerifiedMask: 0x{result.VerifiedMask:X}"); + await this.UpdateSelectedProcessSummaryAsync(selectedProcess); + } + catch (Exception ex) + { + var friendly = ex.Message; + _ = this.notificationService.ShowNotificationAsync("Affinity error", friendly, NotificationType.Error); + await this.LogUserActionAsync( + "ProcessAffinityFailed", + friendly, + $"Process: {selectedProcess.Name}, PID: {selectedProcess.ProcessId}"); + + await InvokeOnUiAsync(() => + { + this.SetCriticalStatus($"Error setting affinity: {friendly}"); + }); + + // Try to refresh process info even if setting failed, to show current state + try + { + if (this.SelectedProcess != null) + { + await this.processService.RefreshProcessInfo(this.SelectedProcess); + } + + await InvokeOnUiAsync(() => + { + if (this.SelectedProcess != null) + { + this.UpdateCoreSelections(this.SelectedProcess.ProcessorAffinity, true); + this.OnPropertyChanged(nameof(this.SelectedProcess)); + } + }); + } + catch + { + // Process may have terminated + } + + await this.UpdateSelectedProcessSummaryAsync(selectedProcess); + } + finally + { + 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; + } + + private static Task InvokeOnUiAsync(Func action) + { + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher == null || dispatcher.CheckAccess()) + { + return action(); + } + + return dispatcher.InvokeAsync(action).Task.Unwrap(); + } + + [RelayCommand] + private async Task ApplyAffinityPreset(CpuAffinityPreset preset) + { + if (preset == null || !preset.IsAvailable || this.CpuTopology == null) + { + return; + } + + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + try + { + this.suppressCoreSelectionEvents = true; + + // Clear all selections first + foreach (var core in this.CpuCores) + { + core.IsSelected = false; + } + + // Apply preset mask + foreach (var core in this.CpuCores) + { + core.IsSelected = (preset.AffinityMask & core.AffinityMask) != 0; + } + + // Notify UI of changes + this.OnPropertyChanged(nameof(this.CpuCores)); + this.SetStatus($"Applied preset: {preset.Name}"); + } + finally + { + this.suppressCoreSelectionEvents = false; + } + + // Keep the preset as a pending selection; affinity changes require an explicit apply command. + this.HasPendingAffinityEdits = true; + this.UpdateAffinityDisplayState(); + }); + } + + + [RelayCommand] + private void CreateCustomMask() + { + // Request to switch to Core Masks tab + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + var mainWindow = System.Windows.Application.Current.MainWindow; + if (mainWindow != null) + { + // Find the TabControl in MainWindow + var tabControl = FindVisualChild(mainWindow); + if (tabControl != null) + { + // Switch to Core Masks tab (index 1) + tabControl.SelectedIndex = 1; + } + } + }); + } + + private static T? FindVisualChild(System.Windows.DependencyObject obj) + where T : System.Windows.DependencyObject + { + for (int i = 0; i < System.Windows.Media.VisualTreeHelper.GetChildrenCount(obj); i++) + { + var child = System.Windows.Media.VisualTreeHelper.GetChild(obj, i); + if (child is T typedChild) + { + return typedChild; + } + + var childOfChild = FindVisualChild(child); + if (childOfChild != null) + { + return childOfChild; + } + } + return null; + } + + partial void OnSelectedCoreMaskChanged(CoreMask? oldValue, CoreMask? newValue) + { + if (newValue == null) + return; + + UpdateCoreSelectionsFromMask(newValue); + } + + private async Task ApplyCoreMaskToProcessAsync(CoreMask mask) + { + var selectedProcess = this.SelectedProcess; + if (selectedProcess == null || mask == null) + { + return; + } + + this.IsBusy = true; + try + { + this.Logger.LogInformation( + "Applying mask '{MaskName}' to process {ProcessName} (PID: {ProcessId})", + mask.Name, selectedProcess.Name, selectedProcess.ProcessId); + + // Disable Windows Game Mode for better CPU affinity control + // Game Mode can interfere with CPU Sets, particularly on AMD systems + await this.gameModeService.DisableGameModeForAffinityAsync(); + + var result = await this.processAffinityApplyCoordinator.ApplyCoreMaskAsync(selectedProcess, mask); + + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + selectedProcess.ForceNotifyProcessorAffinityChanged(); + if (!result.UsedCpuSets) + { + this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); + } + + this.OnPropertyChanged(nameof(this.SelectedProcess)); + }); + + 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, selectedProcess.Name); + this.SetStatus($"Error applying mask: {ex.Message}"); + } + finally + { + this.IsBusy = false; + } + } + + private void UpdateCoreSelectionsFromMask(CoreMask mask) + { + if (mask == null || this.CpuCores.Count == 0) + { + return; + } + + try + { + this.suppressCoreSelectionEvents = true; + + for (int i = 0; i < this.CpuCores.Count && i < mask.BoolMask.Count; i++) + { + this.CpuCores[i].IsSelected = mask.BoolMask[i]; + } + + this.OnPropertyChanged(nameof(this.CpuCores)); + this.HasPendingAffinityEdits = this.SelectedProcess != null; + this.UpdateAffinityDisplayState(); + } + finally + { + this.suppressCoreSelectionEvents = false; + } + } + + + [RelayCommand] + private async Task QuickApplyAffinityAndPowerPlan() + { + var selectedProcess = this.SelectedProcess; + if (selectedProcess == null) + { + return; + } + + try + { + var affinityAppliedWithCpuSets = false; + + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus($"Applying pending settings to {selectedProcess.Name}..."); + }); + + // Apply CPU affinity + var pendingSelection = this.GetPendingCoreSelectionMask(); + if (pendingSelection.Any(selected => selected)) + { + var result = await this.processAffinityApplyCoordinator.ApplyCoreSelectionAsync( + selectedProcess, + pendingSelection, + "Manual Process tab quick apply CPU selection"); + if (!result.Success) + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + if (!result.UsedCpuSets) + { + this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); + } + + selectedProcess.ForceNotifyProcessorAffinityChanged(); + this.OnPropertyChanged(nameof(this.SelectedProcess)); + this.SetStatus(result.Message, false); + }); + return; + } + + this.HasPendingAffinityEdits = false; + this.UpdateAffinityDisplayState(); + affinityAppliedWithCpuSets = result.UsedCpuSets; + } + + // Apply power plan if selected + if (this.SelectedPowerPlan != null) + { + await this.powerPlanService.SetActivePowerPlan(this.SelectedPowerPlan); + } + + await this.processService.RefreshProcessInfo(selectedProcess); + + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + if (!affinityAppliedWithCpuSets) + { + this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); + } + else + { + this.UpdateAffinityDisplayState(); + } + + selectedProcess.ForceNotifyProcessorAffinityChanged(); + this.OnPropertyChanged(nameof(this.SelectedProcess)); + }); + + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus($"Pending settings applied to {selectedProcess.Name}.", false); + }); + } + catch (Exception ex) + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus($"Error applying pending settings: {ex.Message}", false); + }); + } + } + + [RelayCommand] + private async Task RefreshTopology() + { + try + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus("Refreshing CPU topology..."); + }); + await this.cpuTopologyService.RefreshTopologyAsync(); + } + catch (Exception ex) + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus($"Error refreshing topology: {ex.Message}", false); + }); + } + } + + [RelayCommand] + private async Task SetPowerPlan() + { + if (this.SelectedPowerPlan == null) + { + return; + } + + try + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus($"Setting power plan to {this.SelectedPowerPlan.Name}..."); + }); + + var success = await this.powerPlanService.SetActivePowerPlan(this.SelectedPowerPlan); + + await this.RefreshPowerPlansAsync(); + + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + var activePlan = this.PowerPlans.FirstOrDefault(p => p.IsActive); + if (success && activePlan?.Guid == this.SelectedPowerPlan.Guid) + { + this.SetStatus($"Power plan set successfully to {this.SelectedPowerPlan.Name}", false); + } + else + { + this.SetStatus($"Power plan change attempted - current plan: {activePlan?.Name ?? "Unknown"}", false); + } + }); + } + catch (Exception ex) + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus($"Error setting power plan: {ex.Message}", false); + }); + + try + { + await this.RefreshPowerPlansAsync(); + } + catch + { + // ignored + } + } + finally + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(this.ClearStatus); + } + } + + [RelayCommand] + private async Task SetPriority(ProcessPriorityClass priority) + { + var selectedProcess = this.SelectedProcess; + if (selectedProcess == null) + { + return; + } + + if (ProcessPriorityGuardrails.IsBlocked(priority)) + { + var message = ProcessOperationUserMessages.RealtimePriorityBlocked; + await InvokeOnUiAsync(() => + { + this.SetCriticalStatus(message); + }); + _ = this.notificationService.ShowNotificationAsync("Priority blocked", message, NotificationType.Warning); + await this.LogUserActionAsync( + "ProcessPriorityBlocked", + message, + $"Process: {selectedProcess.Name}, PID: {selectedProcess.ProcessId}, Priority: {priority}"); + return; + } + + try + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus($"Setting priority for {selectedProcess.Name} to {priority}..."); + }); + + // Apply the priority change + await this.processService.SetProcessPriority(selectedProcess, priority); + + // Immediately refresh the process to get the actual system state + await this.processService.RefreshProcessInfo(selectedProcess); + + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + // Notify UI that the process properties have changed + this.OnPropertyChanged(nameof(this.SelectedProcess)); + + // Verify the priority was set correctly + if (selectedProcess.Priority == priority) + { + 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 {selectedProcess.Name}: {priority}.", false); + _ = this.notificationService.ShowNotificationAsync("Priority applied", $"{selectedProcess.Name}: {priority}", NotificationType.Success); + } + } + else + { + this.SetStatus($"Priority adjusted by system for {selectedProcess.Name} to {selectedProcess.Priority}.", false); + _ = this.notificationService.ShowNotificationAsync("Priority adjusted", $"{selectedProcess.Name}: {selectedProcess.Priority}", NotificationType.Warning); + } + }); + await this.LogUserActionAsync( + "ProcessPriorityChanged", + $"CPU priority changed for {selectedProcess.Name}: {priority}", + $"PID: {selectedProcess.ProcessId}"); + } + catch (Exception ex) + { + var message = ex.Message; + if (message.Contains("Realtime priority is blocked", StringComparison.OrdinalIgnoreCase)) + { + message = ProcessOperationUserMessages.RealtimePriorityBlocked; + _ = this.notificationService.ShowNotificationAsync("Priority blocked", message, NotificationType.Warning); + } + else if (message.Contains("Access denied", StringComparison.OrdinalIgnoreCase) || + message.Contains("anti-cheat", StringComparison.OrdinalIgnoreCase)) + { + message = ProcessOperationUserMessages.AccessDenied; + _ = this.notificationService.ShowNotificationAsync("Priority blocked", message, NotificationType.Warning); + } + else + { + _ = this.notificationService.ShowNotificationAsync("Priority error", message, NotificationType.Error); + } + + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetCriticalStatus($"Error setting priority: {message}"); + }); + await this.LogUserActionAsync( + message == ProcessOperationUserMessages.RealtimePriorityBlocked ? "ProcessPriorityBlocked" : "ProcessPriorityChangeFailed", + message, + $"Process: {selectedProcess.Name}, PID: {selectedProcess.ProcessId}, Priority: {priority}"); + + // Try to refresh process info even if setting failed, to show current state + try + { + await this.processService.RefreshProcessInfo(selectedProcess); + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.OnPropertyChanged(nameof(this.SelectedProcess)); + }); + } + catch + { + // Process may have terminated + } + } + finally + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(this.ClearStatus); + } + } + + [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.LogUserActionAsync( + "CpuSetsClearFailed", + ProcessOperationUserMessages.AccessDenied, + $"Process: {process.Name}, PID: {process.ProcessId}"); + await this.UpdateSelectedProcessSummaryAsync(process); + return; + } + + await this.processService.RefreshProcessInfo(process); + this.SetStatus($"CPU Sets cleared for {process.Name}.", false); + await this.LogUserActionAsync( + "CpuSetsCleared", + $"CPU Sets cleared for {process.Name}", + $"PID: {process.ProcessId}"); + await this.UpdateSelectedProcessSummaryAsync(process); + } + catch (Exception ex) + { + var message = MapProcessOperationException(ex); + this.SetContextError(message); + await this.LogUserActionAsync( + "CpuSetsClearFailed", + message, + $"Process: {process.Name}, PID: {process.ProcessId}"); + 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.LogUserActionAsync( + "ProcessInfoRefreshed", + $"Process info refreshed for {process.Name}.", + $"PID: {process.ProcessId}"); + await this.UpdateSelectedProcessSummaryAsync(process); + } + catch (Exception ex) + { + var message = MapProcessOperationException(ex); + this.SetContextError(message); + await this.LogUserActionAsync( + "ProcessInfoRefreshFailed", + message, + $"Process: {process.Name}, PID: {process.ProcessId}"); + 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)) + { + var message = $"Executable path is unavailable for {process.Name}."; + this.SetContextError(message); + await this.LogUserActionAsync( + "ProcessExecutableOpenFailed", + message, + $"Process: {process.Name}, PID: {process.ProcessId}"); + await this.UpdateSelectedProcessSummaryAsync(process); + return; + } + + try + { + this.executableLocationOpener(path); + this.SetStatus($"Opened executable location for {process.Name}.", false); + await this.LogUserActionAsync( + "ProcessExecutableLocationOpened", + $"Opened executable location for {process.Name}.", + path); + await this.UpdateSelectedProcessSummaryAsync(process); + } + catch (Exception ex) + { + var message = $"Could not open executable location: {ex.Message}"; + this.SetContextError(message); + await this.LogUserActionAsync( + "ProcessExecutableOpenFailed", + message, + $"Process: {process.Name}, PID: {process.ProcessId}"); + 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.LogUserActionAsync( + "ProcessInfoCopied", + $"Copied process info for {process.Name}.", + $"PID: {process.ProcessId}"); + await this.UpdateSelectedProcessSummaryAsync(process); + } + catch (Exception ex) + { + var message = $"Could not copy process info: {ex.Message}"; + this.SetContextError(message); + await this.LogUserActionAsync( + "ProcessInfoCopyFailed", + message, + $"Process: {process.Name}, PID: {process.ProcessId}"); + 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.LogUserActionAsync( + "ProcessPriorityBlocked", + ProcessOperationUserMessages.RealtimePriorityBlocked, + $"Process: {process.Name}, PID: {process.ProcessId}, Priority: {priority}"); + 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.LogUserActionAsync( + "ProcessPriorityChanged", + $"CPU priority changed for {process.Name}: {priority}", + $"PID: {process.ProcessId}"); + await this.UpdateSelectedProcessSummaryAsync(process); + } + catch (Exception ex) + { + var message = MapProcessOperationException(ex); + this.SetContextError(message); + _ = this.notificationService.ShowNotificationAsync("Priority blocked", message, NotificationType.Warning); + await this.LogUserActionAsync( + "ProcessPriorityChangeFailed", + message, + $"Process: {process.Name}, PID: {process.ProcessId}, Priority: {priority}"); + 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) + { + var message = string.IsNullOrWhiteSpace(result.UserMessage) + ? ProcessOperationUserMessages.AccessDenied + : result.UserMessage; + this.SetContextError(message); + await this.LogUserActionAsync( + "ProcessMemoryPriorityFailed", + message, + $"Process: {process.Name}, PID: {process.ProcessId}, Priority: {priority}"); + await this.UpdateSelectedProcessSummaryAsync(process); + return; + } + + this.SetStatus($"Memory priority applied successfully to {process.Name}: {priority}.", false); + await this.LogUserActionAsync( + "ProcessMemoryPriorityChanged", + $"Memory priority changed for {process.Name}: {priority}", + $"PID: {process.ProcessId}"); + await this.UpdateSelectedProcessSummaryAsync(process); + } + catch (Exception ex) + { + var message = MapProcessOperationException(ex); + this.SetContextError(message); + await this.LogUserActionAsync( + "ProcessMemoryPriorityFailed", + message, + $"Process: {process.Name}, PID: {process.ProcessId}, Priority: {priority}"); + 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() + { + if (this.SelectedProcess == null || string.IsNullOrWhiteSpace(this.ProfileName)) + { + return; + } + + try + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus($"Saving profile {this.ProfileName}..."); + }); + await this.processService.SaveProcessProfile(this.ProfileName, this.SelectedProcess); + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.ClearStatus(); + }); + } + catch (Exception ex) + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus($"Error saving profile: {ex.Message}", false); + }); + } + } + + [RelayCommand] + private async Task LoadProfile() + { + if (this.SelectedProcess == null || string.IsNullOrWhiteSpace(this.ProfileName)) + { + return; + } + + try + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetStatus($"Loading profile {this.ProfileName}..."); + }); + var success = await this.processService.LoadProcessProfile(this.ProfileName, this.SelectedProcess); + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + if (success) + { + this.ClearStatus(); + } + else + { + this.SetCriticalStatus($"Profile {this.ProfileName} could not be fully applied."); + } + }); + } + catch (Exception ex) + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetCriticalStatus($"Error loading profile: {ex.Message}"); + }); + } + } + + private void SetupRefreshTimer() + { + this.refreshTimer = new System.Timers.Timer(5000); // PERFORMANCE OPTIMIZATION: Increased to 5 second refresh for better performance + this.refreshTimer.Elapsed += async (s, e) => + { + if (this.isUiRefreshPaused || !this.isProcessViewActive) + { + return; + } + + try + { + // Marshal timer callback to UI thread to prevent cross-thread access exceptions + await System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => + { + if (this.isUiRefreshPaused || !this.isProcessViewActive) + { + return; + } + + await this.RefreshProcessesCommand.ExecuteAsync(null); + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Timer refresh error: {ex.Message}"); + } + }; + // Don't start automatically - only start when needed + } + + public void PauseRefresh() + { + this.SetUiRefreshEnabled(false, refreshImmediately: false); + } + + public void ResumeRefresh() + { + this.SetUiRefreshEnabled(true, refreshImmediately: true); + } + + public void ApplyRefreshDecision(AppRefreshDecision decision) + { + ArgumentNullException.ThrowIfNull(decision); + + this.isVirtualizedPreloadAllowedByPolicy = decision.VirtualizedPreloadEnabled; + this.virtualizedProcessService.Configuration.EnableBackgroundLoading = this.ShouldPreloadVirtualizedBatches(); + this.SetUiRefreshEnabled( + decision.ProcessUiRefreshEnabled && !this.IsProcessListLocked, + decision.ImmediateProcessRefresh && !this.IsProcessListLocked); + } + + public void SetProcessViewActive(bool isActive) + { + if (this.isProcessViewActive == isActive) + { + this.virtualizedProcessService.Configuration.EnableBackgroundLoading = this.ShouldPreloadVirtualizedBatches(); + return; + } + + this.isProcessViewActive = isActive; + this.virtualizedProcessService.Configuration.EnableBackgroundLoading = this.ShouldPreloadVirtualizedBatches(); + + if (!isActive) + { + this.refreshTimer?.Stop(); + return; + } + + if (!this.isUiRefreshPaused) + { + this.refreshTimer?.Start(); + _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => + { + try + { + await this.RefreshProcessesCommand.ExecuteAsync(null); + } + catch (Exception ex) + { + this.Logger.LogDebug(ex, "Immediate process refresh after returning to process view failed"); + } + }); + } + } + + public void SetUiRefreshEnabled(bool enabled, bool refreshImmediately = true) + { + this.isUiRefreshPaused = !enabled; + this.virtualizedProcessService.Configuration.EnableBackgroundLoading = this.ShouldPreloadVirtualizedBatches(); + + if (!enabled) + { + this.refreshTimer?.Stop(); + return; + } + + if (this.isProcessViewActive) + { + this.refreshTimer?.Start(); + } + + if (!refreshImmediately || !this.isProcessViewActive) + { + return; + } + + TaskSafety.FireAndForget( + InvokeOnUiAsync(async () => + { + if (this.isUiRefreshPaused) + { + return; + } + + try + { + this.ClearStatus(); + await this.RefreshProcessesCommand.ExecuteAsync(null); + } + catch (Exception ex) + { + this.Logger.LogDebug(ex, "Immediate process refresh after resume failed"); + } + }), + ex => this.Logger.LogDebug(ex, "Immediate process refresh dispatch after resume failed")); + } + + partial void OnIsProcessListLockedChanged(bool value) + { + this.SetUiRefreshEnabled(!value, refreshImmediately: !value); + + _ = this.LogUserActionAsync( + "ProcessListLockChanged", + value ? "Lock process list enabled." : "Lock process list disabled."); + } + + private bool ShouldRunProcessUiRefresh() + { + return this.isProcessViewActive && !this.isUiRefreshPaused && !this.IsProcessListLocked; + } + + private bool ShouldPreloadVirtualizedBatches() + { + return this.IsVirtualizationEnabled + && this.isVirtualizedPreloadAllowedByPolicy + && this.ShouldRunProcessUiRefresh() + && !this.IsProcessListLocked; + } + + private Task PreloadNextBatchIfAllowedAsync(int currentBatchIndex) + { + return this.ShouldPreloadVirtualizedBatches() + ? this.virtualizedProcessService.PreloadNextBatchAsync(currentBatchIndex, this.ShowActiveApplicationsOnly) + : Task.CompletedTask; + } + + partial void OnSearchTextChanged(string value) + { + this.searchRefreshCoordinator.Schedule(); + } + + partial void OnShowActiveApplicationsOnlyChanged(bool value) + { + _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => + { + await LoadProcessesCommand.ExecuteAsync(null); + }); + } + + partial void OnHideSystemProcessesChanged(bool value) + { + this.filterRefreshCoordinator.Schedule(); + } + + partial void OnHideIdleProcessesChanged(bool value) + { + this.filterRefreshCoordinator.Schedule(); + } + + partial void OnSortModeChanged(string value) + { + this.filterRefreshCoordinator.Schedule(); + } + + partial void OnIsIdleServerDisabledChanged(bool value) + { + if (SelectedProcess != null) + { + _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => + { + await ToggleIdleServerAsync(value); + }); + } + } + + partial void OnIsRegistryPriorityEnabledChanged(bool value) + { + if (SelectedProcess != null) + { + _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => + { + await ToggleRegistryPriorityAsync(value); + }); + } + } + + private void FilterProcesses() + { + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher != null && !dispatcher.CheckAccess()) + { + dispatcher.Invoke(this.FilterProcesses); + return; + } + + if (this.isApplyingFilter) + { + this.filterRefreshPending = true; + return; + } + + this.isApplyingFilter = true; + + try + { + do + { + this.filterRefreshPending = false; + + var criteria = new ProcessFilterCriteria + { + SearchText = this.SearchText, + HideSystemProcesses = this.HideSystemProcesses, + HideIdleProcesses = this.HideIdleProcesses, + SortMode = this.SortMode, + }; + + var filteredResults = this.processFilterService.FilterAndSort(this.Processes, criteria); + this.FilteredProcesses = new ObservableCollection(filteredResults); + } + while (this.filterRefreshPending); + } + finally + { + this.isApplyingFilter = false; + } + } + + private async Task LoadProcessPowerPlanAssociation(ProcessModel process) + { + try + { + await this.RefreshPowerPlansAsync(); + } + catch (Exception ex) + { + this.Logger.LogWarning(ex, "Failed to load power plan association for process {ProcessName}", process.Name); + } + } + + private void ClearProcessSelection() + { + // Clear CPU core selections + foreach (var core in this.CpuCores) + { + core.IsSelected = false; + } + + this.HasPendingAffinityEdits = false; + this.UpdateAffinityDisplayState(); + + // Reset power plan to current system default + _ = Task.Run(async () => + { + try + { + await this.RefreshPowerPlansAsync(); + } + catch (Exception ex) + { + this.Logger.LogWarning(ex, "Failed to reset power plan selection"); + } + }); + + // Notify UI of changes + System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + // Reset feature states + this.IsIdleServerDisabled = false; + this.IsRegistryPriorityEnabled = false; + + this.OnPropertyChanged(nameof(this.CpuCores)); + + // BUG FIX: Clear status without setting busy state and auto-clear after delay + this.SetStatus("Process selection cleared", false); + + // Clear the status after a short delay + _ = Task.Delay(2000).ContinueWith(_ => + { + System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + if (this.StatusMessage == "Process selection cleared") + { + this.ClearStatus(); + } + }); + }); + }); + } + + private async Task ToggleIdleServerAsync(bool disable) + { + if (this.SelectedProcess == null) + { + return; + } + + try + { + this.SetStatus($"{(disable ? "Disabling" : "Enabling")} idle server for {this.SelectedProcess.Name}..."); + + // Implementation for disabling/enabling idle server + // This typically involves setting process execution state or power management settings + var success = await this.processService.SetIdleServerStateAsync(this.SelectedProcess, !disable); + + if (success) + { + this.SelectedProcess.IsIdleServerDisabled = disable; + this.SetStatus($"Idle server {(disable ? "disabled" : "enabled")} for {this.SelectedProcess.Name}"); + + await this.LogUserActionAsync( + "IdleServer", + $"Idle server {(disable ? "disabled" : "enabled")} for process {this.SelectedProcess.Name}", + $"PID: {this.SelectedProcess.ProcessId}"); + } + else + { + this.SetStatus($"Failed to {(disable ? "disable" : "enable")} idle server for {this.SelectedProcess.Name}", false); + // Revert the UI state + this.IsIdleServerDisabled = !disable; + } + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Error toggling idle server for process {ProcessName}", this.SelectedProcess.Name); + this.SetStatus($"Error: {ex.Message}", false); + // Revert the UI state + this.IsIdleServerDisabled = !disable; + } + } + + private async Task ToggleRegistryPriorityAsync(bool enable) + { + if (this.SelectedProcess == null) + { + return; + } + + try + { + this.SetStatus($"{(enable ? "Enabling" : "Disabling")} registry priority enforcement for {this.SelectedProcess.Name}..."); + + // Implementation for registry-based priority setting + var success = await this.processService.SetRegistryPriorityAsync(this.SelectedProcess, enable, this.SelectedProcess.Priority); + + if (success) + { + this.SelectedProcess.IsRegistryPriorityEnabled = enable; + + if (enable) + { + this.SetStatus($"Registry priority enforcement enabled for {this.SelectedProcess.Name}. Process restart required for changes to take effect."); + + // Show notification about restart requirement + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + System.Windows.MessageBox.Show( + $"Registry priority has been set for {this.SelectedProcess.Name}.\n\n" + + "The process must be restarted for the registry changes to take effect.\n\n" + + $"{ProcessOperationUserMessages.PersistentLaunchTimePriorityNotice}\n\n" + + "This setting will persist across system reboots and will automatically apply the selected priority when the process starts.", + "Registry Priority Set - Restart Required", + System.Windows.MessageBoxButton.OK, + System.Windows.MessageBoxImage.Information); + }); + } + else + { + this.SetStatus($"Registry priority enforcement disabled for {this.SelectedProcess.Name}"); + } + + await this.LogUserActionAsync( + "RegistryPriority", + $"Registry priority enforcement {(enable ? "enabled" : "disabled")} for process {this.SelectedProcess.Name}", + $"PID: {this.SelectedProcess.ProcessId}, Priority: {this.SelectedProcess.Priority}"); + } + else + { + this.SetStatus($"Failed to {(enable ? "enable" : "disable")} registry priority enforcement for {this.SelectedProcess.Name}", false); + // Revert the UI state + this.IsRegistryPriorityEnabled = !enable; + } + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Error toggling registry priority for process {ProcessName}", this.SelectedProcess.Name); + this.SetStatus($"Error: {ex.Message}", false); + // Revert the UI state + this.IsRegistryPriorityEnabled = !enable; + } + } + + [RelayCommand] + private void OpenRulesTab() + { + this.OpenRulesRequested?.Invoke(this, EventArgs.Empty); + } + + [RelayCommand] + private async Task SaveCurrentAsAssociation() + { + if (this.SelectedProcess == null) + { + await this.notificationService.ShowNotificationAsync( + "No Process Selected", + "Please select a process to save as an association", NotificationType.Warning); + return; + } + + try + { + this.SetStatus($"Saving rule for {this.SelectedProcess.Name}..."); + + // Get current power plan + var currentPowerPlan = await this.powerPlanService.GetActivePowerPlan(); + + // Create new association + var association = new ProcessPowerPlanAssociation + { + ExecutableName = this.SelectedProcess.Name, + ExecutablePath = this.SelectedProcess.ExecutablePath ?? string.Empty, + PowerPlanGuid = currentPowerPlan?.Guid ?? string.Empty, + PowerPlanName = currentPowerPlan?.Name ?? "Unknown", + CoreMaskId = this.SelectedCoreMask?.Id, + CoreMaskName = this.SelectedCoreMask?.Name, + ProcessPriority = this.SelectedProcess.Priority.ToString(), + MatchByPath = !string.IsNullOrEmpty(this.SelectedProcess.ExecutablePath), + Priority = 0, + Description = $"Saved from Process Management on {DateTime.Now:g}", + IsEnabled = true, + }; + + // Try to add the association + var success = await this.associationService.AddAssociationAsync(association); + + if (success) + { + this.SetStatus($"Rule created for {this.SelectedProcess.Name} and ready for auto-apply.", false); + await this.notificationService.ShowNotificationAsync( + "Rule Saved", + $"Settings for {this.SelectedProcess.Name} saved successfully", NotificationType.Success); + + await this.LogUserActionAsync( + "SaveAssociation", + $"Saved association for process {this.SelectedProcess.Name}", + $"PID: {this.SelectedProcess.ProcessId}, PowerPlan: {currentPowerPlan?.Name}, " + + $"CoreMask: {this.SelectedCoreMask?.Name ?? "None"}, Priority: {this.SelectedProcess.Priority}"); + } + else + { + var existingAssociation = await this.associationService.FindAssociationByExecutableAsync(this.SelectedProcess.Name); + if (existingAssociation != null) + { + existingAssociation.ExecutablePath = association.ExecutablePath; + existingAssociation.PowerPlanGuid = association.PowerPlanGuid; + existingAssociation.PowerPlanName = association.PowerPlanName; + existingAssociation.CoreMaskId = association.CoreMaskId; + existingAssociation.CoreMaskName = association.CoreMaskName; + existingAssociation.ProcessPriority = association.ProcessPriority; + existingAssociation.MatchByPath = association.MatchByPath; + existingAssociation.Description = association.Description; + existingAssociation.IsEnabled = true; + existingAssociation.UpdatedAt = DateTime.UtcNow; + + var updated = await this.associationService.UpdateAssociationAsync(existingAssociation); + if (updated) + { + this.SetStatus($"Existing rule updated for {this.SelectedProcess.Name}.", false); + await this.notificationService.ShowNotificationAsync( + "Rule Updated", + $"Existing rule for {this.SelectedProcess.Name} was updated", NotificationType.Information); + } + else + { + this.SetStatus($"Failed to update existing rule for {this.SelectedProcess.Name}", false); + await this.notificationService.ShowNotificationAsync( + "Rule Update Failed", + $"Could not update existing rule for {this.SelectedProcess.Name}", NotificationType.Warning); + } + } + else + { + this.SetStatus($"Rule already exists for {this.SelectedProcess.Name}", false); + await this.notificationService.ShowNotificationAsync( + "Rule Exists", + $"A rule for {this.SelectedProcess.Name} already exists", NotificationType.Warning); + } + } + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Error saving association for process {ProcessName}", this.SelectedProcess.Name); + this.SetStatus($"Error saving rule: {ex.Message}", false); + await this.notificationService.ShowNotificationAsync( + "Error", + $"Failed to save rule: {ex.Message}", NotificationType.Error); + } + } + + protected override void OnDispose() + { + this.refreshTimer?.Stop(); + this.refreshTimer?.Dispose(); + this.refreshTimer = null; + + this.searchRefreshCoordinator.Dispose(); + this.filterRefreshCoordinator.Dispose(); + + this.cpuTopologyService.TopologyDetected -= this.OnTopologyDetected; + this.systemTrayService.QuickApplyRequested -= this.OnTrayQuickApplyRequested; + + base.OnDispose(); + } + } +} diff --git a/ViewModels/ProcessViewModel.cs b/ViewModels/ProcessViewModel.cs index 31ba5fc..62e9af8 100644 --- a/ViewModels/ProcessViewModel.cs +++ b/ViewModels/ProcessViewModel.cs @@ -1,282 +1,266 @@ -/* - * 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.ViewModels -{ - using System; - using System.Collections.Generic; - using System.Collections.ObjectModel; - using System.ComponentModel; - using System.Diagnostics; - using System.Linq; - using System.Threading.Tasks; - using System.Windows.Input; - using CommunityToolkit.Mvvm.ComponentModel; - using CommunityToolkit.Mvvm.Input; - using Microsoft.Extensions.Logging; - using Microsoft.Extensions.Logging.Abstractions; - using ThreadPilot.Models; - using ThreadPilot.Services; - - public partial class ProcessViewModel : BaseViewModel - { - public event EventHandler? OpenRulesRequested; - - private readonly IProcessService processService; - private readonly ProcessFilterService processFilterService; - private readonly IVirtualizedProcessService virtualizedProcessService; - private readonly ICpuTopologyService cpuTopologyService; - private readonly IPowerPlanService powerPlanService; - private readonly INotificationService notificationService; - private readonly ISystemTrayService systemTrayService; - private readonly ICoreMaskService coreMaskService; - private readonly IProcessPowerPlanAssociationService associationService; - private readonly IGameModeService gameModeService; - private readonly IAffinityApplyService affinityApplyService; - private readonly IProcessAffinityApplyCoordinator processAffinityApplyCoordinator; - private readonly IProcessMemoryPriorityService? memoryPriorityService; - private readonly IProcessRuleCreationService? processRuleCreationService; - private readonly Action clipboardSetter; - private readonly Action executableLocationOpener; - private System.Timers.Timer? refreshTimer; - private bool isUiRefreshPaused; - private bool isProcessViewActive = true; - private bool isVirtualizedPreloadAllowedByPolicy = true; - private int isRefreshProcessesInProgress; - private readonly ThrottledRefreshCoordinator searchRefreshCoordinator; - private readonly ThrottledRefreshCoordinator filterRefreshCoordinator; - private bool isApplyingFilter; - private bool filterRefreshPending; - private bool suppressCoreSelectionEvents; - - [ObservableProperty] - private ObservableCollection processes = new(); - - [ObservableProperty] - private ProcessModel? selectedProcess; - - [ObservableProperty] - private string searchText = string.Empty; - - [ObservableProperty] - private ObservableCollection filteredProcesses = new(); - - [ObservableProperty] - private string profileName = string.Empty; - - // CPU Topology and Affinity - [ObservableProperty] - private CpuTopologyModel? cpuTopology; - - [ObservableProperty] - private ObservableCollection cpuCores = new(); - - [ObservableProperty] - private ObservableCollection affinityPresets = new(); - - // Core Masks - Available masks from the service - [ObservableProperty] - private ObservableCollection availableCoreMasks = new(); - - [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; - - [ObservableProperty] - private string topologyStatus = "Detecting CPU topology..."; - - [ObservableProperty] - private bool areAdvancedFeaturesAvailable = false; - - [ObservableProperty] - private PowerPlanModel? selectedPowerPlan; - - [ObservableProperty] - private ObservableCollection powerPlans = new(); - - // Note: EnableHyperThreading property removed - now using read-only status indicator - - [ObservableProperty] - private bool showActiveApplicationsOnly = false; - - [ObservableProperty] - private bool hideSystemProcesses = false; - - [ObservableProperty] - private bool hideIdleProcesses = false; - - [ObservableProperty] - private string sortMode = "CpuUsage"; - - // Hyperthreading/SMT Status Properties - [ObservableProperty] - private string hyperThreadingStatusText = "Multi-Threading: Unknown"; - - [ObservableProperty] - private bool isHyperThreadingActive = false; - - // New feature properties - [ObservableProperty] - private bool isIdleServerDisabled = false; - - [ObservableProperty] - private bool isRegistryPriorityEnabled = false; - - [ObservableProperty] - private bool isProcessListLocked = false; - - // PERFORMANCE IMPROVEMENT: Progressive loading support - [ObservableProperty] - private double loadingProgress = 0.0; - - [ObservableProperty] - private string loadingStatusText = string.Empty; - - // VIRTUALIZATION ENHANCEMENT: Batch loading support - [ObservableProperty] - private int currentBatchIndex = 0; - - [ObservableProperty] - private int totalBatches = 0; - - [ObservableProperty] - private int totalProcessCount = 0; - - [ObservableProperty] - private bool hasMoreBatches = false; - - [ObservableProperty] - private bool isVirtualizationEnabled = true; - - public ProcessViewModel( - ILogger logger, - IProcessService processService, - ProcessFilterService processFilterService, - IVirtualizedProcessService virtualizedProcessService, - ICpuTopologyService cpuTopologyService, - IPowerPlanService powerPlanService, - INotificationService notificationService, - ISystemTrayService systemTrayService, - ICoreMaskService coreMaskService, - IProcessPowerPlanAssociationService associationService, - IGameModeService gameModeService, - IAffinityApplyService? affinityApplyService = null, - IProcessAffinityApplyCoordinator? processAffinityApplyCoordinator = null, - ICpuTopologyProvider? cpuTopologyProvider = null, - IEnhancedLoggingService? enhancedLoggingService = null, - IActivityAuditService? activityAuditService = null, - IProcessMemoryPriorityService? memoryPriorityService = null, - IPersistentProcessRuleStore? persistentRuleStore = null, - IPersistentProcessRuleMatcher? persistentRuleMatcher = null, - IProcessRuleCreationService? processRuleCreationService = null, - Action? clipboardSetter = null, - Action? executableLocationOpener = null, - ILocalizationService? localizationService = null) - : base(logger, enhancedLoggingService, activityAuditService) - { - this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); - this.processFilterService = processFilterService ?? throw new ArgumentNullException(nameof(processFilterService)); - this.virtualizedProcessService = virtualizedProcessService ?? throw new ArgumentNullException(nameof(virtualizedProcessService)); - this.cpuTopologyService = cpuTopologyService ?? throw new ArgumentNullException(nameof(cpuTopologyService)); - this.powerPlanService = powerPlanService ?? throw new ArgumentNullException(nameof(powerPlanService)); - this.notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService)); - this.systemTrayService = systemTrayService ?? throw new ArgumentNullException(nameof(systemTrayService)); - 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); - this.processAffinityApplyCoordinator = processAffinityApplyCoordinator ?? new ProcessAffinityApplyCoordinator( - this.affinityApplyService, - cpuTopologyProvider, - new CpuSelectionMigrationService(), - NullLogger.Instance); - this.memoryPriorityService = memoryPriorityService; - this.processRuleCreationService = processRuleCreationService ?? (persistentRuleStore == null - ? null - : new ProcessRuleCreationService( - persistentRuleStore, - cpuTopologyProvider, - new CpuSelectionMigrationService(), - NullLogger.Instance)); - this.clipboardSetter = clipboardSetter ?? System.Windows.Clipboard.SetText; - this.executableLocationOpener = executableLocationOpener ?? OpenExecutableLocationInExplorer; - this.SelectedProcessSummary = new SelectedProcessSummaryViewModel( - memoryPriorityService, - persistentRuleStore, - persistentRuleMatcher, - localizationService); - - // Subscribe to topology detection events - this.cpuTopologyService.TopologyDetected += this.OnTopologyDetected; - - // Subscribe to system tray events - this.systemTrayService.QuickApplyRequested += this.OnTrayQuickApplyRequested; - - this.searchRefreshCoordinator = new ThrottledRefreshCoordinator( - TimeSpan.FromMilliseconds(300), - this.ApplyFiltersOnUiAsync, - ex => this.Logger.LogWarning(ex, "Search filter refresh failed")); - - this.filterRefreshCoordinator = new ThrottledRefreshCoordinator( - TimeSpan.FromMilliseconds(100), - this.ApplyFiltersOnUiAsync, - ex => this.Logger.LogWarning(ex, "Filter refresh failed")); - - this.SetupRefreshTimer(); - this.SetupVirtualizedProcessService(); - // 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); - } - } -} +namespace ThreadPilot.ViewModels +{ + using System; + using System.Collections.Generic; + using System.Collections.ObjectModel; + using System.ComponentModel; + using System.Diagnostics; + using System.Linq; + using System.Threading.Tasks; + using System.Windows.Input; + using CommunityToolkit.Mvvm.ComponentModel; + using CommunityToolkit.Mvvm.Input; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public partial class ProcessViewModel : BaseViewModel + { + public event EventHandler? OpenRulesRequested; + + private readonly IProcessService processService; + private readonly ProcessFilterService processFilterService; + private readonly IVirtualizedProcessService virtualizedProcessService; + private readonly ICpuTopologyService cpuTopologyService; + private readonly IPowerPlanService powerPlanService; + private readonly INotificationService notificationService; + private readonly ISystemTrayService systemTrayService; + private readonly ICoreMaskService coreMaskService; + private readonly IProcessPowerPlanAssociationService associationService; + private readonly IGameModeService gameModeService; + private readonly IAffinityApplyService affinityApplyService; + private readonly IProcessAffinityApplyCoordinator processAffinityApplyCoordinator; + private readonly IProcessMemoryPriorityService? memoryPriorityService; + private readonly IProcessRuleCreationService? processRuleCreationService; + private readonly Action clipboardSetter; + private readonly Action executableLocationOpener; + private System.Timers.Timer? refreshTimer; + private bool isUiRefreshPaused; + private bool isProcessViewActive = true; + private bool isVirtualizedPreloadAllowedByPolicy = true; + private int isRefreshProcessesInProgress; + private readonly ThrottledRefreshCoordinator searchRefreshCoordinator; + private readonly ThrottledRefreshCoordinator filterRefreshCoordinator; + private bool isApplyingFilter; + private bool filterRefreshPending; + private bool suppressCoreSelectionEvents; + + [ObservableProperty] + private ObservableCollection processes = new(); + + [ObservableProperty] + private ProcessModel? selectedProcess; + + [ObservableProperty] + private string searchText = string.Empty; + + [ObservableProperty] + private ObservableCollection filteredProcesses = new(); + + [ObservableProperty] + private string profileName = string.Empty; + + // CPU Topology and Affinity + [ObservableProperty] + private CpuTopologyModel? cpuTopology; + + [ObservableProperty] + private ObservableCollection cpuCores = new(); + + [ObservableProperty] + private ObservableCollection affinityPresets = new(); + + // Core Masks - Available masks from the service + [ObservableProperty] + private ObservableCollection availableCoreMasks = new(); + + [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; + + [ObservableProperty] + private string topologyStatus = "Detecting CPU topology..."; + + [ObservableProperty] + private bool areAdvancedFeaturesAvailable = false; + + [ObservableProperty] + private PowerPlanModel? selectedPowerPlan; + + [ObservableProperty] + private ObservableCollection powerPlans = new(); + + // Note: EnableHyperThreading property removed - now using read-only status indicator + + [ObservableProperty] + private bool showActiveApplicationsOnly = false; + + [ObservableProperty] + private bool hideSystemProcesses = false; + + [ObservableProperty] + private bool hideIdleProcesses = false; + + [ObservableProperty] + private string sortMode = "CpuUsage"; + + // Hyperthreading/SMT Status Properties + [ObservableProperty] + private string hyperThreadingStatusText = "Multi-Threading: Unknown"; + + [ObservableProperty] + private bool isHyperThreadingActive = false; + + // New feature properties + [ObservableProperty] + private bool isIdleServerDisabled = false; + + [ObservableProperty] + private bool isRegistryPriorityEnabled = false; + + [ObservableProperty] + private bool isProcessListLocked = false; + + // PERFORMANCE IMPROVEMENT: Progressive loading support + [ObservableProperty] + private double loadingProgress = 0.0; + + [ObservableProperty] + private string loadingStatusText = string.Empty; + + // VIRTUALIZATION ENHANCEMENT: Batch loading support + [ObservableProperty] + private int currentBatchIndex = 0; + + [ObservableProperty] + private int totalBatches = 0; + + [ObservableProperty] + private int totalProcessCount = 0; + + [ObservableProperty] + private bool hasMoreBatches = false; + + [ObservableProperty] + private bool isVirtualizationEnabled = true; + + public ProcessViewModel( + ILogger logger, + IProcessService processService, + ProcessFilterService processFilterService, + IVirtualizedProcessService virtualizedProcessService, + ICpuTopologyService cpuTopologyService, + IPowerPlanService powerPlanService, + INotificationService notificationService, + ISystemTrayService systemTrayService, + ICoreMaskService coreMaskService, + IProcessPowerPlanAssociationService associationService, + IGameModeService gameModeService, + IAffinityApplyService? affinityApplyService = null, + IProcessAffinityApplyCoordinator? processAffinityApplyCoordinator = null, + ICpuTopologyProvider? cpuTopologyProvider = null, + IEnhancedLoggingService? enhancedLoggingService = null, + IActivityAuditService? activityAuditService = null, + IProcessMemoryPriorityService? memoryPriorityService = null, + IPersistentProcessRuleStore? persistentRuleStore = null, + IPersistentProcessRuleMatcher? persistentRuleMatcher = null, + IProcessRuleCreationService? processRuleCreationService = null, + Action? clipboardSetter = null, + Action? executableLocationOpener = null, + ILocalizationService? localizationService = null) + : base(logger, enhancedLoggingService, activityAuditService) + { + this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); + this.processFilterService = processFilterService ?? throw new ArgumentNullException(nameof(processFilterService)); + this.virtualizedProcessService = virtualizedProcessService ?? throw new ArgumentNullException(nameof(virtualizedProcessService)); + this.cpuTopologyService = cpuTopologyService ?? throw new ArgumentNullException(nameof(cpuTopologyService)); + this.powerPlanService = powerPlanService ?? throw new ArgumentNullException(nameof(powerPlanService)); + this.notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService)); + this.systemTrayService = systemTrayService ?? throw new ArgumentNullException(nameof(systemTrayService)); + 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); + this.processAffinityApplyCoordinator = processAffinityApplyCoordinator ?? new ProcessAffinityApplyCoordinator( + this.affinityApplyService, + cpuTopologyProvider, + new CpuSelectionMigrationService(), + NullLogger.Instance); + this.memoryPriorityService = memoryPriorityService; + this.processRuleCreationService = processRuleCreationService ?? (persistentRuleStore == null + ? null + : new ProcessRuleCreationService( + persistentRuleStore, + cpuTopologyProvider, + new CpuSelectionMigrationService(), + NullLogger.Instance)); + this.clipboardSetter = clipboardSetter ?? System.Windows.Clipboard.SetText; + this.executableLocationOpener = executableLocationOpener ?? OpenExecutableLocationInExplorer; + this.SelectedProcessSummary = new SelectedProcessSummaryViewModel( + memoryPriorityService, + persistentRuleStore, + persistentRuleMatcher, + localizationService); + + // Subscribe to topology detection events + this.cpuTopologyService.TopologyDetected += this.OnTopologyDetected; + + // Subscribe to system tray events + this.systemTrayService.QuickApplyRequested += this.OnTrayQuickApplyRequested; + + this.searchRefreshCoordinator = new ThrottledRefreshCoordinator( + TimeSpan.FromMilliseconds(300), + this.ApplyFiltersOnUiAsync, + ex => this.Logger.LogWarning(ex, "Search filter refresh failed")); + + this.filterRefreshCoordinator = new ThrottledRefreshCoordinator( + TimeSpan.FromMilliseconds(100), + this.ApplyFiltersOnUiAsync, + ex => this.Logger.LogWarning(ex, "Filter refresh failed")); + + this.SetupRefreshTimer(); + this.SetupVirtualizedProcessService(); + // 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/ViewModels/SelectedProcessSummaryViewModel.cs b/ViewModels/SelectedProcessSummaryViewModel.cs index 2828231..2802061 100644 --- a/ViewModels/SelectedProcessSummaryViewModel.cs +++ b/ViewModels/SelectedProcessSummaryViewModel.cs @@ -1,365 +1,365 @@ -/* - * ThreadPilot - lightweight selected process summary view model. - */ -namespace ThreadPilot.ViewModels -{ - using System.Diagnostics; - using System.Threading; - using CommunityToolkit.Mvvm.ComponentModel; - using ThreadPilot.Models; - using ThreadPilot.Services; - - public sealed class SelectedProcessSummaryViewModel : ObservableObject - { - private readonly IProcessMemoryPriorityService? memoryPriorityService; - private readonly IPersistentProcessRuleStore? persistentRuleStore; - private readonly IPersistentProcessRuleMatcher? persistentRuleMatcher; - private readonly ILocalizationService? localizationService; - private bool hasSelection; - private int processId; - private string processName = string.Empty; - private string executablePath = string.Empty; - private double cpuUsage; - private long memoryUsage; - private ProcessPriorityClass cpuPriority; - private long processorAffinity; - private ProcessMemoryPriority? memoryPriority; - private string processTitle = "No process selected"; - private string currentProcessStatusText = "No process selected"; - private string cpuUsageText = "CPU: unavailable"; - private string memoryUsageText = "Memory: unavailable"; - private string cpuPriorityText = "CPU priority: unavailable"; - private string memoryPriorityText = "Memory priority unavailable"; - private string affinityText = "Affinity: unavailable"; - private string ruleStatusText = "No saved rule"; - private string lastOperationMessage = "No recent ThreadPilot action"; - private string lastOperationSeverity = "Information"; - private bool isProtectedOrAccessDenied; - private bool hasThreadPilotRule; - private int updateVersion; - - public SelectedProcessSummaryViewModel( - IProcessMemoryPriorityService? memoryPriorityService = null, - IPersistentProcessRuleStore? persistentRuleStore = null, - IPersistentProcessRuleMatcher? persistentRuleMatcher = null, - ILocalizationService? localizationService = null) - { - this.memoryPriorityService = memoryPriorityService; - this.persistentRuleStore = persistentRuleStore; - this.persistentRuleMatcher = persistentRuleMatcher; - this.localizationService = localizationService; - this.Clear(version: 0, lastOperationMessage: null, lastOperationIsError: false); - } - - public bool HasSelection - { - get => this.hasSelection; - private set => this.SetProperty(ref this.hasSelection, value); - } - - public int ProcessId - { - get => this.processId; - private set => this.SetProperty(ref this.processId, value); - } - - public string ProcessName - { - get => this.processName; - private set => this.SetProperty(ref this.processName, value); - } - - public string ExecutablePath - { - get => this.executablePath; - private set => this.SetProperty(ref this.executablePath, value); - } - - public double CpuUsage - { - get => this.cpuUsage; - private set => this.SetProperty(ref this.cpuUsage, value); - } - - public long MemoryUsage - { - get => this.memoryUsage; - private set => this.SetProperty(ref this.memoryUsage, value); - } - - public ProcessPriorityClass CpuPriority - { - get => this.cpuPriority; - private set => this.SetProperty(ref this.cpuPriority, value); - } - - public long ProcessorAffinity - { - get => this.processorAffinity; - private set => this.SetProperty(ref this.processorAffinity, value); - } - - public ProcessMemoryPriority? MemoryPriority - { - get => this.memoryPriority; - private set => this.SetProperty(ref this.memoryPriority, value); - } - - public string ProcessTitle - { - get => this.processTitle; - private set => this.SetProperty(ref this.processTitle, value); - } - - public string CurrentProcessStatusText - { - get => this.currentProcessStatusText; - private set => this.SetProperty(ref this.currentProcessStatusText, value); - } - - public string CpuUsageText - { - get => this.cpuUsageText; - private set => this.SetProperty(ref this.cpuUsageText, value); - } - - public string MemoryUsageText - { - get => this.memoryUsageText; - private set => this.SetProperty(ref this.memoryUsageText, value); - } - - public string CpuPriorityText - { - get => this.cpuPriorityText; - private set => this.SetProperty(ref this.cpuPriorityText, value); - } - - public string MemoryPriorityText - { - get => this.memoryPriorityText; - private set => this.SetProperty(ref this.memoryPriorityText, value); - } - - public string AffinityText - { - get => this.affinityText; - private set => this.SetProperty(ref this.affinityText, value); - } - - public string RuleStatusText - { - get => this.ruleStatusText; - private set => this.SetProperty(ref this.ruleStatusText, value); - } - - public string LastOperationMessage - { - get => this.lastOperationMessage; - private set => this.SetProperty(ref this.lastOperationMessage, value); - } - - public string LastOperationSeverity - { - get => this.lastOperationSeverity; - private set => this.SetProperty(ref this.lastOperationSeverity, value); - } - - public bool IsProtectedOrAccessDenied - { - get => this.isProtectedOrAccessDenied; - private set => this.SetProperty(ref this.isProtectedOrAccessDenied, value); - } - - public bool HasThreadPilotRule - { - get => this.hasThreadPilotRule; - private set => this.SetProperty(ref this.hasThreadPilotRule, value); - } - - public async Task UpdateAsync( - ProcessModel? process, - string? lastOperationMessage = null, - bool lastOperationIsError = false) - { - var version = Interlocked.Increment(ref this.updateVersion); - if (process == null) - { - this.Clear(version, lastOperationMessage, lastOperationIsError); - return; - } - - this.HasSelection = true; - this.ProcessId = process.ProcessId; - this.ProcessName = process.Name ?? string.Empty; - this.ExecutablePath = process.ExecutablePath ?? string.Empty; - this.CpuUsage = process.CpuUsage; - this.MemoryUsage = process.MemoryUsage; - this.CpuPriority = process.Priority; - this.ProcessorAffinity = process.ProcessorAffinity; - this.IsProtectedOrAccessDenied = process.Classification == ProcessClassification.ProtectedOrAccessDenied; - this.ProcessTitle = this.L("ProcessSummary_SelectedProcessFormat", "Selected process: {0} (PID {1})", this.ProcessName, this.ProcessId); - this.CurrentProcessStatusText = this.IsProtectedOrAccessDenied - ? this.L("ProcessSummary_StatusProtected", "Current process status: protected or access denied") - : this.L("ProcessSummary_StatusSelected", "Current process status: selected"); - this.CpuUsageText = this.L("ProcessSummary_CpuFormat", "CPU: {0:N1}%", this.CpuUsage); - this.MemoryUsageText = this.L("ProcessSummary_MemoryFormat", "Memory: {0}", FormatMemory(this.MemoryUsage)); - this.CpuPriorityText = this.L("ProcessSummary_CpuPriorityFormat", "CPU priority: {0}", this.CpuPriority); - this.AffinityText = this.L("ProcessSummary_AffinityLegacyFormat", "Affinity: legacy mask 0x{0:X}", this.ProcessorAffinity); - this.UpdateLastOperation(lastOperationMessage, lastOperationIsError); - - await this.UpdateMemoryPriorityAsync(process, version); - if (!this.IsCurrentVersion(version)) - { - return; - } - - await this.UpdateRuleStatusAsync(process, version); - } - - private static string FormatMemory(long bytes) - { - if (bytes <= 0) - { - return "0 MB"; - } - - var megabytes = bytes / 1024d / 1024d; - return $"{megabytes:N0} MB"; - } - - private void Clear(int version, string? lastOperationMessage, bool lastOperationIsError) - { - if (!this.IsCurrentVersion(version)) - { - return; - } - - this.HasSelection = false; - this.ProcessId = 0; - this.ProcessName = string.Empty; - this.ExecutablePath = string.Empty; - this.CpuUsage = 0; - this.MemoryUsage = 0; - this.CpuPriority = default; - this.ProcessorAffinity = 0; - this.MemoryPriority = null; - this.IsProtectedOrAccessDenied = false; - this.HasThreadPilotRule = false; - this.ProcessTitle = this.L("ProcessView_NoProcessSelected", "No process selected"); - this.CurrentProcessStatusText = this.L("ProcessView_NoProcessSelected", "No process selected"); - this.CpuUsageText = this.L("ProcessSummary_CpuUnavailable", "CPU: unavailable"); - this.MemoryUsageText = this.L("ProcessSummary_MemoryUnavailable", "Memory: unavailable"); - this.CpuPriorityText = this.L("ProcessSummary_CpuPriorityUnavailable", "CPU priority: unavailable"); - this.MemoryPriorityText = this.L("ProcessSummary_MemoryPriorityUnavailable", "Memory priority unavailable"); - this.AffinityText = this.L("ProcessSummary_AffinityUnavailable", "Affinity: unavailable"); - this.RuleStatusText = this.L("ProcessSummary_NoSavedRule", "No saved rule"); - this.UpdateLastOperation(lastOperationMessage, lastOperationIsError); - } - - private async Task UpdateMemoryPriorityAsync(ProcessModel process, int version) - { - this.MemoryPriority = null; - this.MemoryPriorityText = this.L("ProcessSummary_MemoryPriorityUnavailable", "Memory priority unavailable"); - - if (this.memoryPriorityService == null) - { - return; - } - - try - { - var priority = await this.memoryPriorityService.GetMemoryPriorityAsync(process); - if (!this.IsCurrentVersion(version)) - { - return; - } - - if (priority == null) - { - return; - } - - this.MemoryPriority = priority.Value; - this.MemoryPriorityText = this.L("ProcessSummary_MemoryPriorityFormat", "Memory priority: {0}", priority.Value); - } - catch (Exception) - { - if (!this.IsCurrentVersion(version)) - { - return; - } - - this.MemoryPriority = null; - this.MemoryPriorityText = this.L("ProcessSummary_MemoryPriorityUnavailable", "Memory priority unavailable"); - } - } - - private async Task UpdateRuleStatusAsync(ProcessModel process, int version) - { - this.HasThreadPilotRule = false; - this.RuleStatusText = this.L("ProcessSummary_NoSavedRule", "No saved rule"); - - if (this.persistentRuleStore == null || this.persistentRuleMatcher == null) - { - return; - } - - try - { - var rules = await this.persistentRuleStore.LoadAsync(); - if (!this.IsCurrentVersion(version)) - { - return; - } - - var matchingRule = rules.FirstOrDefault(rule => this.persistentRuleMatcher.IsMatch(rule, process)); - if (matchingRule == null) - { - return; - } - - this.HasThreadPilotRule = true; - var ruleName = string.IsNullOrWhiteSpace(matchingRule.Name) - ? this.L("ProcessSummary_SavedRuleFallback", "saved rule") - : matchingRule.Name.Trim(); - this.RuleStatusText = this.L("ProcessSummary_SavedRuleExistsFormat", "Saved rule exists: {0}", ruleName); - } - catch (Exception) - { - if (!this.IsCurrentVersion(version)) - { - return; - } - - this.HasThreadPilotRule = false; - this.RuleStatusText = this.L("ProcessSummary_NoSavedRule", "No saved rule"); - } - } - - private bool IsCurrentVersion(int version) => Volatile.Read(ref this.updateVersion) == version; - - private void UpdateLastOperation(string? message, bool isError) - { - if (string.IsNullOrWhiteSpace(message)) - { - this.LastOperationMessage = this.L("ProcessSummary_NoRecentAction", "No recent ThreadPilot action"); - this.LastOperationSeverity = "Information"; - return; - } - - this.LastOperationMessage = message.Trim(); - this.LastOperationSeverity = isError ? "Error" : "Information"; - } - - private string L(string key, string fallback, params object[] args) - { - var localized = this.localizationService?.GetString(key); - var format = string.IsNullOrWhiteSpace(localized) || string.Equals(localized, key, StringComparison.Ordinal) - ? fallback - : localized; - - return args.Length == 0 ? format : string.Format(format, args); - } - } -} +/* + * ThreadPilot - lightweight selected process summary view model. + */ +namespace ThreadPilot.ViewModels +{ + using System.Diagnostics; + using System.Threading; + using CommunityToolkit.Mvvm.ComponentModel; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class SelectedProcessSummaryViewModel : ObservableObject + { + private readonly IProcessMemoryPriorityService? memoryPriorityService; + private readonly IPersistentProcessRuleStore? persistentRuleStore; + private readonly IPersistentProcessRuleMatcher? persistentRuleMatcher; + private readonly ILocalizationService? localizationService; + private bool hasSelection; + private int processId; + private string processName = string.Empty; + private string executablePath = string.Empty; + private double cpuUsage; + private long memoryUsage; + private ProcessPriorityClass cpuPriority; + private long processorAffinity; + private ProcessMemoryPriority? memoryPriority; + private string processTitle = "No process selected"; + private string currentProcessStatusText = "No process selected"; + private string cpuUsageText = "CPU: unavailable"; + private string memoryUsageText = "Memory: unavailable"; + private string cpuPriorityText = "CPU priority: unavailable"; + private string memoryPriorityText = "Memory priority unavailable"; + private string affinityText = "Affinity: unavailable"; + private string ruleStatusText = "No saved rule"; + private string lastOperationMessage = "No recent ThreadPilot action"; + private string lastOperationSeverity = "Information"; + private bool isProtectedOrAccessDenied; + private bool hasThreadPilotRule; + private int updateVersion; + + public SelectedProcessSummaryViewModel( + IProcessMemoryPriorityService? memoryPriorityService = null, + IPersistentProcessRuleStore? persistentRuleStore = null, + IPersistentProcessRuleMatcher? persistentRuleMatcher = null, + ILocalizationService? localizationService = null) + { + this.memoryPriorityService = memoryPriorityService; + this.persistentRuleStore = persistentRuleStore; + this.persistentRuleMatcher = persistentRuleMatcher; + this.localizationService = localizationService; + this.Clear(version: 0, lastOperationMessage: null, lastOperationIsError: false); + } + + public bool HasSelection + { + get => this.hasSelection; + private set => this.SetProperty(ref this.hasSelection, value); + } + + public int ProcessId + { + get => this.processId; + private set => this.SetProperty(ref this.processId, value); + } + + public string ProcessName + { + get => this.processName; + private set => this.SetProperty(ref this.processName, value); + } + + public string ExecutablePath + { + get => this.executablePath; + private set => this.SetProperty(ref this.executablePath, value); + } + + public double CpuUsage + { + get => this.cpuUsage; + private set => this.SetProperty(ref this.cpuUsage, value); + } + + public long MemoryUsage + { + get => this.memoryUsage; + private set => this.SetProperty(ref this.memoryUsage, value); + } + + public ProcessPriorityClass CpuPriority + { + get => this.cpuPriority; + private set => this.SetProperty(ref this.cpuPriority, value); + } + + public long ProcessorAffinity + { + get => this.processorAffinity; + private set => this.SetProperty(ref this.processorAffinity, value); + } + + public ProcessMemoryPriority? MemoryPriority + { + get => this.memoryPriority; + private set => this.SetProperty(ref this.memoryPriority, value); + } + + public string ProcessTitle + { + get => this.processTitle; + private set => this.SetProperty(ref this.processTitle, value); + } + + public string CurrentProcessStatusText + { + get => this.currentProcessStatusText; + private set => this.SetProperty(ref this.currentProcessStatusText, value); + } + + public string CpuUsageText + { + get => this.cpuUsageText; + private set => this.SetProperty(ref this.cpuUsageText, value); + } + + public string MemoryUsageText + { + get => this.memoryUsageText; + private set => this.SetProperty(ref this.memoryUsageText, value); + } + + public string CpuPriorityText + { + get => this.cpuPriorityText; + private set => this.SetProperty(ref this.cpuPriorityText, value); + } + + public string MemoryPriorityText + { + get => this.memoryPriorityText; + private set => this.SetProperty(ref this.memoryPriorityText, value); + } + + public string AffinityText + { + get => this.affinityText; + private set => this.SetProperty(ref this.affinityText, value); + } + + public string RuleStatusText + { + get => this.ruleStatusText; + private set => this.SetProperty(ref this.ruleStatusText, value); + } + + public string LastOperationMessage + { + get => this.lastOperationMessage; + private set => this.SetProperty(ref this.lastOperationMessage, value); + } + + public string LastOperationSeverity + { + get => this.lastOperationSeverity; + private set => this.SetProperty(ref this.lastOperationSeverity, value); + } + + public bool IsProtectedOrAccessDenied + { + get => this.isProtectedOrAccessDenied; + private set => this.SetProperty(ref this.isProtectedOrAccessDenied, value); + } + + public bool HasThreadPilotRule + { + get => this.hasThreadPilotRule; + private set => this.SetProperty(ref this.hasThreadPilotRule, value); + } + + public async Task UpdateAsync( + ProcessModel? process, + string? lastOperationMessage = null, + bool lastOperationIsError = false) + { + var version = Interlocked.Increment(ref this.updateVersion); + if (process == null) + { + this.Clear(version, lastOperationMessage, lastOperationIsError); + return; + } + + this.HasSelection = true; + this.ProcessId = process.ProcessId; + this.ProcessName = process.Name ?? string.Empty; + this.ExecutablePath = process.ExecutablePath ?? string.Empty; + this.CpuUsage = process.CpuUsage; + this.MemoryUsage = process.MemoryUsage; + this.CpuPriority = process.Priority; + this.ProcessorAffinity = process.ProcessorAffinity; + this.IsProtectedOrAccessDenied = process.Classification == ProcessClassification.ProtectedOrAccessDenied; + this.ProcessTitle = this.L("ProcessSummary_SelectedProcessFormat", "Selected process: {0} (PID {1})", this.ProcessName, this.ProcessId); + this.CurrentProcessStatusText = this.IsProtectedOrAccessDenied + ? this.L("ProcessSummary_StatusProtected", "Current process status: protected or access denied") + : this.L("ProcessSummary_StatusSelected", "Current process status: selected"); + this.CpuUsageText = this.L("ProcessSummary_CpuFormat", "CPU: {0:N1}%", this.CpuUsage); + this.MemoryUsageText = this.L("ProcessSummary_MemoryFormat", "Memory: {0}", FormatMemory(this.MemoryUsage)); + this.CpuPriorityText = this.L("ProcessSummary_CpuPriorityFormat", "CPU priority: {0}", this.CpuPriority); + this.AffinityText = this.L("ProcessSummary_AffinityLegacyFormat", "Affinity: legacy mask 0x{0:X}", this.ProcessorAffinity); + this.UpdateLastOperation(lastOperationMessage, lastOperationIsError); + + await this.UpdateMemoryPriorityAsync(process, version); + if (!this.IsCurrentVersion(version)) + { + return; + } + + await this.UpdateRuleStatusAsync(process, version); + } + + private static string FormatMemory(long bytes) + { + if (bytes <= 0) + { + return "0 MB"; + } + + var megabytes = bytes / 1024d / 1024d; + return $"{megabytes:N0} MB"; + } + + private void Clear(int version, string? lastOperationMessage, bool lastOperationIsError) + { + if (!this.IsCurrentVersion(version)) + { + return; + } + + this.HasSelection = false; + this.ProcessId = 0; + this.ProcessName = string.Empty; + this.ExecutablePath = string.Empty; + this.CpuUsage = 0; + this.MemoryUsage = 0; + this.CpuPriority = default; + this.ProcessorAffinity = 0; + this.MemoryPriority = null; + this.IsProtectedOrAccessDenied = false; + this.HasThreadPilotRule = false; + this.ProcessTitle = this.L("ProcessView_NoProcessSelected", "No process selected"); + this.CurrentProcessStatusText = this.L("ProcessView_NoProcessSelected", "No process selected"); + this.CpuUsageText = this.L("ProcessSummary_CpuUnavailable", "CPU: unavailable"); + this.MemoryUsageText = this.L("ProcessSummary_MemoryUnavailable", "Memory: unavailable"); + this.CpuPriorityText = this.L("ProcessSummary_CpuPriorityUnavailable", "CPU priority: unavailable"); + this.MemoryPriorityText = this.L("ProcessSummary_MemoryPriorityUnavailable", "Memory priority unavailable"); + this.AffinityText = this.L("ProcessSummary_AffinityUnavailable", "Affinity: unavailable"); + this.RuleStatusText = this.L("ProcessSummary_NoSavedRule", "No saved rule"); + this.UpdateLastOperation(lastOperationMessage, lastOperationIsError); + } + + private async Task UpdateMemoryPriorityAsync(ProcessModel process, int version) + { + this.MemoryPriority = null; + this.MemoryPriorityText = this.L("ProcessSummary_MemoryPriorityUnavailable", "Memory priority unavailable"); + + if (this.memoryPriorityService == null) + { + return; + } + + try + { + var priority = await this.memoryPriorityService.GetMemoryPriorityAsync(process); + if (!this.IsCurrentVersion(version)) + { + return; + } + + if (priority == null) + { + return; + } + + this.MemoryPriority = priority.Value; + this.MemoryPriorityText = this.L("ProcessSummary_MemoryPriorityFormat", "Memory priority: {0}", priority.Value); + } + catch (Exception) + { + if (!this.IsCurrentVersion(version)) + { + return; + } + + this.MemoryPriority = null; + this.MemoryPriorityText = this.L("ProcessSummary_MemoryPriorityUnavailable", "Memory priority unavailable"); + } + } + + private async Task UpdateRuleStatusAsync(ProcessModel process, int version) + { + this.HasThreadPilotRule = false; + this.RuleStatusText = this.L("ProcessSummary_NoSavedRule", "No saved rule"); + + if (this.persistentRuleStore == null || this.persistentRuleMatcher == null) + { + return; + } + + try + { + var rules = await this.persistentRuleStore.LoadAsync(); + if (!this.IsCurrentVersion(version)) + { + return; + } + + var matchingRule = rules.FirstOrDefault(rule => this.persistentRuleMatcher.IsMatch(rule, process)); + if (matchingRule == null) + { + return; + } + + this.HasThreadPilotRule = true; + var ruleName = string.IsNullOrWhiteSpace(matchingRule.Name) + ? this.L("ProcessSummary_SavedRuleFallback", "saved rule") + : matchingRule.Name.Trim(); + this.RuleStatusText = this.L("ProcessSummary_SavedRuleExistsFormat", "Saved rule exists: {0}", ruleName); + } + catch (Exception) + { + if (!this.IsCurrentVersion(version)) + { + return; + } + + this.HasThreadPilotRule = false; + this.RuleStatusText = this.L("ProcessSummary_NoSavedRule", "No saved rule"); + } + } + + private bool IsCurrentVersion(int version) => Volatile.Read(ref this.updateVersion) == version; + + private void UpdateLastOperation(string? message, bool isError) + { + if (string.IsNullOrWhiteSpace(message)) + { + this.LastOperationMessage = this.L("ProcessSummary_NoRecentAction", "No recent ThreadPilot action"); + this.LastOperationSeverity = "Information"; + return; + } + + this.LastOperationMessage = message.Trim(); + this.LastOperationSeverity = isError ? "Error" : "Information"; + } + + private string L(string key, string fallback, params object[] args) + { + var localized = this.localizationService?.GetString(key); + var format = string.IsNullOrWhiteSpace(localized) || string.Equals(localized, key, StringComparison.Ordinal) + ? fallback + : localized; + + return args.Length == 0 ? format : string.Format(format, args); + } + } +} diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs index fdbcde9..901c61e 100644 --- a/ViewModels/SettingsViewModel.cs +++ b/ViewModels/SettingsViewModel.cs @@ -1,925 +1,900 @@ -/* - * 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.ViewModels -{ - using System; - using System.Collections.Generic; - using System.Collections.ObjectModel; - using System.IO; - using System.Linq; - using System.Reflection; - using System.Text; - using System.Text.Json; - using System.Threading.Tasks; - using System.Windows; - using System.Windows.Input; - using CommunityToolkit.Mvvm.ComponentModel; - using CommunityToolkit.Mvvm.Input; - using Microsoft.Extensions.Logging; - using Microsoft.Win32; - using ThreadPilot.Models; - using ThreadPilot.Services; - - /// - /// ViewModel for application settings. - /// - public partial class SettingsViewModel : BaseViewModel - { - private readonly IApplicationSettingsService settingsService; - private readonly INotificationService notificationService; - private readonly IAutostartService autostartService; - private readonly IPowerPlanService powerPlanService; - private readonly IProcessPowerPlanAssociationService associationService; - private readonly IProcessMonitorManagerService processMonitorManagerService; - private readonly IThemeService themeService; - private readonly ISystemTrayService systemTrayService; - private readonly IUpdateService updateService; - private readonly IApplicationVersionProvider versionProvider; - private readonly ILocalizationService localizationService; - private ApplicationSettingsModel savedSettingsSnapshot; - private bool isSyncingFromService = false; - private bool? appliedThemePreference; - private string cachedDefaultPowerPlanGuid = string.Empty; - private string cachedDefaultPowerPlanName = string.Empty; - private UpdateReleaseInfo? availableUpdate; - private static readonly JsonSerializerOptions ImportExportJsonOptions = new() - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - ReadCommentHandling = JsonCommentHandling.Skip, - AllowTrailingCommas = true, - }; - - [ObservableProperty] - private ApplicationSettingsModel settings; - - [ObservableProperty] - private bool hasUnsavedChanges = false; - - [ObservableProperty] - private bool isLoading = false; - - public bool CanSaveSettings => this.HasUnsavedChanges && !this.IsLoading; - - public bool HasPendingChanges => this.HasUnsavedChanges; - - [ObservableProperty] - private ObservableCollection availablePowerPlans = new(); - - public string ApplicationVersion { get; } - - public ICommand SaveSettingsCommand { get; } - - public ICommand ResetToDefaultsCommand { get; } - - public ICommand ExportSettingsCommand { get; } - - public ICommand ImportSettingsCommand { get; } - - public ICommand TestNotificationCommand { get; } - - public ICommand RefreshPowerPlansCommand { get; } - - public ICommand CheckUpdatesCommand { get; } - - public IAsyncRelayCommand DownloadAndInstallUpdateCommand { get; } - - [ObservableProperty] - private string latestUpdateVersion = string.Empty; - - [ObservableProperty] - private string lastUpdateCheckText = string.Empty; - - [ObservableProperty] - private bool isUpdateAvailable = false; - - public bool CanDownloadAndInstallUpdate => this.IsUpdateAvailable && !this.IsLoading; - - public SettingsViewModel( - ILogger logger, - IApplicationSettingsService settingsService, - INotificationService notificationService, - IAutostartService autostartService, - IPowerPlanService powerPlanService, - IProcessPowerPlanAssociationService associationService, - IProcessMonitorManagerService processMonitorManagerService, - IThemeService themeService, - ISystemTrayService systemTrayService, - IUpdateService updateService, - IApplicationVersionProvider versionProvider, - ILocalizationService localizationService, - IEnhancedLoggingService? enhancedLoggingService = null, - IActivityAuditService? activityAuditService = null) - : base(logger, enhancedLoggingService, activityAuditService) - { - this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); - this.notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService)); - this.autostartService = autostartService ?? throw new ArgumentNullException(nameof(autostartService)); - this.powerPlanService = powerPlanService ?? throw new ArgumentNullException(nameof(powerPlanService)); - this.associationService = associationService ?? throw new ArgumentNullException(nameof(associationService)); - this.processMonitorManagerService = processMonitorManagerService ?? throw new ArgumentNullException(nameof(processMonitorManagerService)); - this.themeService = themeService ?? throw new ArgumentNullException(nameof(themeService)); - this.systemTrayService = systemTrayService ?? throw new ArgumentNullException(nameof(systemTrayService)); - this.updateService = updateService ?? throw new ArgumentNullException(nameof(updateService)); - this.versionProvider = versionProvider ?? throw new ArgumentNullException(nameof(versionProvider)); - this.localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); - - this.ApplicationVersion = this.versionProvider.DisplayVersion; - - // Initialize with current settings - this.settings = (ApplicationSettingsModel)this.settingsService.Settings.Clone(); - this.savedSettingsSnapshot = (ApplicationSettingsModel)this.settings.Clone(); - this.appliedThemePreference = this.settings.UseDarkTheme; - this.LatestUpdateVersion = this.GetLocalizedString("Settings_UpdateNotChecked", "Not checked"); - this.UpdateLastCheckedText(); - - // Initialize commands - this.SaveSettingsCommand = new AsyncRelayCommand(this.SaveSettingsAsync); - this.ResetToDefaultsCommand = new AsyncRelayCommand(this.ResetToDefaultsAsync); - this.ExportSettingsCommand = new AsyncRelayCommand(this.ExportSettingsAsync); - this.ImportSettingsCommand = new AsyncRelayCommand(this.ImportSettingsAsync); - this.TestNotificationCommand = new AsyncRelayCommand(this.TestNotificationAsync); - this.RefreshPowerPlansCommand = new AsyncRelayCommand(this.RefreshPowerPlansAsync); - this.CheckUpdatesCommand = new AsyncRelayCommand(this.CheckUpdatesAsync); - this.DownloadAndInstallUpdateCommand = new AsyncRelayCommand( - this.DownloadAndInstallUpdateAsync, - () => this.CanDownloadAndInstallUpdate); - - // Subscribe to property changes to track unsaved changes - this.Settings.PropertyChanged += this.OnSettingsPropertyChanged; - - // Keep viewmodel in sync with persisted settings - this.settingsService.SettingsChanged += this.OnSettingsServiceSettingsChanged; - - var dispatcher = System.Windows.Application.Current?.Dispatcher; - if (dispatcher != null) - { - // Ensure we load the latest persisted settings on startup. - _ = dispatcher.InvokeAsync(async () => await this.RefreshSettingsAsync()); - - // Initialize data - marshal to UI thread to prevent cross-thread access exceptions. - _ = dispatcher.InvokeAsync(async () => await this.RefreshPowerPlansAsync()); - } - - this.Logger.LogInformation("Settings ViewModel initialized"); - } - - private void OnSettingsPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) - { - if (this.isSyncingFromService) - { - return; - } - - if (string.Equals(e.PropertyName, nameof(ApplicationSettingsModel.UseDarkTheme), StringComparison.Ordinal)) - { - this.Settings.HasUserThemePreference = true; - this.UpdatePendingChangesState(); - this.ApplyThemePreference(this.Settings.UseDarkTheme, logUserAction: true); - return; - } - - if (string.Equals(e.PropertyName, nameof(ApplicationSettingsModel.Language), StringComparison.Ordinal)) - { - this.UpdatePendingChangesState(); - this.ApplyLanguagePreference(this.Settings.Language, logUserAction: true); - return; - } - - if (string.Equals(e.PropertyName, nameof(ApplicationSettingsModel.ApplyPersistentRulesOnProcessStart), StringComparison.Ordinal)) - { - this.UpdatePendingChangesState(); - var state = this.Settings.ApplyPersistentRulesOnProcessStart ? "enabled" : "disabled"; - _ = this.LogUserActionAsync( - "SettingsChanged", - $"[Settings] Apply saved rules at process start {state}."); - return; - } - - this.UpdatePendingChangesState(); - } - - private void ApplyThemePreference(bool useDarkTheme, bool logUserAction) - { - if (this.appliedThemePreference == useDarkTheme) - { - return; - } - - var themeName = useDarkTheme - ? this.GetLocalizedString("Settings_ThemeDark", "Dark") - : this.GetLocalizedString("Settings_ThemeLight", "Light"); - try - { - this.themeService.ApplyTheme(useDarkTheme); - this.systemTrayService.ApplyTheme(useDarkTheme); - this.appliedThemePreference = useDarkTheme; - this.StatusMessage = this.GetLocalizedString("Settings_StatusThemeChangedFormat", "Theme changed to {0}.", themeName); - - if (logUserAction) - { - _ = this.LogUserActionAsync("ThemeChanged", $"Theme changed to {themeName}"); - } - } - catch (Exception ex) - { - this.StatusMessage = this.GetLocalizedString("Settings_StatusThemeChangeFailedFormat", "Failed to change theme to {0}.", themeName); - this.Logger.LogError(ex, "Failed to apply theme preference {ThemeName}", themeName); - _ = this.LogUserActionAsync("ThemeChangeFailed", $"Failed to change theme to {themeName}: {ex.Message}"); - } - } - - private void ApplyLanguagePreference(string language, bool logUserAction) - { - var normalizedLanguage = LocalizationService.NormalizeLanguage(language); - try - { - this.localizationService.ApplyLanguage(normalizedLanguage); - this.Settings.Language = normalizedLanguage; - var languageName = normalizedLanguage == LocalizationService.SimplifiedChineseLanguage - ? this.GetLocalizedString("Settings_LanguageSimplifiedChinese", "Simplified Chinese") - : this.GetLocalizedString("Settings_LanguageEnglish", "English"); - this.StatusMessage = this.GetLocalizedString("Settings_StatusLanguageChangedFormat", "Language changed to {0}.", languageName); - - if (logUserAction) - { - _ = this.LogUserActionAsync("LanguageChanged", $"Language changed to {languageName}"); - } - } - catch (Exception ex) - { - this.StatusMessage = this.GetLocalizedString("Settings_StatusLanguageChangeFailed", "Failed to change language."); - this.Logger.LogError(ex, "Failed to apply language preference {Language}", normalizedLanguage); - _ = this.LogUserActionAsync("LanguageChangeFailed", $"Failed to change language to {normalizedLanguage}: {ex.Message}"); - } - } - - partial void OnHasUnsavedChangesChanged(bool value) - { - OnPropertyChanged(nameof(CanSaveSettings)); - } - - partial void OnIsLoadingChanged(bool value) - { - OnPropertyChanged(nameof(CanSaveSettings)); - OnPropertyChanged(nameof(CanDownloadAndInstallUpdate)); - this.DownloadAndInstallUpdateCommand.NotifyCanExecuteChanged(); - } - - partial void OnIsUpdateAvailableChanged(bool value) - { - OnPropertyChanged(nameof(CanDownloadAndInstallUpdate)); - this.DownloadAndInstallUpdateCommand.NotifyCanExecuteChanged(); - } - - private async Task SaveSettingsAsync() - { - string previousDefaultPowerPlanGuid = string.Empty; - string previousDefaultPowerPlanName = string.Empty; - - try - { - this.IsLoading = true; - this.StatusMessage = this.GetLocalizedString("Settings_StatusSaving", "Saving settings..."); - var warnings = new List(); - - previousDefaultPowerPlanGuid = this.Settings.DefaultPowerPlanId; - previousDefaultPowerPlanName = this.Settings.DefaultPowerPlanName; - - // Handle autostart setting - var currentAutostartState = await this.autostartService.CheckAutostartStatusAsync(); - if (this.Settings.AutostartWithWindows != currentAutostartState) - { - bool autostartUpdated; - if (this.Settings.AutostartWithWindows) - { - autostartUpdated = await this.autostartService.EnableAutostartAsync(this.Settings.StartMinimized); - } - else - { - autostartUpdated = await this.autostartService.DisableAutostartAsync(); - } - - if (!autostartUpdated) - { - warnings.Add(this.GetLocalizedString( - "Settings_WarningAutostartFailed", - "Failed to update Windows autostart. Keeping previous autostart state.")); - this.Settings.AutostartWithWindows = currentAutostartState; - } - else - { - this.Settings.AutostartWithWindows = await this.autostartService.CheckAutostartStatusAsync(); - } - } - - await this.settingsService.UpdateSettingsAsync(this.Settings); - - var useDarkTheme = this.Settings.HasUserThemePreference - ? this.Settings.UseDarkTheme - : this.themeService.GetSystemUsesDarkTheme(); - - this.isSyncingFromService = true; - this.Settings.UseDarkTheme = useDarkTheme; - this.isSyncingFromService = false; - this.ApplyThemePreference(useDarkTheme, logUserAction: false); - this.ApplyLanguagePreference(this.Settings.Language, logUserAction: false); - - // Update monitoring services with new settings - this.processMonitorManagerService.UpdateSettings(); - - this.SetSavedSettingsSnapshot(this.Settings); - if (warnings.Count > 0) - { - this.StatusMessage = this.GetLocalizedString( - "Settings_StatusSavedWarningsFormat", - "Settings saved with warnings: {0}", - string.Join(" ", warnings)); - await this.notificationService.ShowNotificationAsync( - "Settings Saved with Warnings", - string.Join(" ", warnings), - NotificationType.Warning); - } - else - { - this.StatusMessage = this.GetLocalizedString("Settings_StatusSavedApplied", "Settings saved and applied successfully."); - await this.notificationService.ShowSuccessNotificationAsync( - "Settings Saved", - "Application settings have been saved successfully"); - } - - await this.LogUserActionAsync("SettingsChanged", "Settings saved and applied"); - this.Logger.LogInformation("Settings saved successfully"); - } - catch (Exception ex) - { - this.Settings.DefaultPowerPlanId = previousDefaultPowerPlanGuid; - this.Settings.DefaultPowerPlanName = previousDefaultPowerPlanName; - - this.StatusMessage = this.GetLocalizedString("Settings_StatusErrorSavingFormat", "Error saving settings: {0}", ex.Message); - this.Logger.LogError(ex, "Error saving settings"); - - await this.notificationService.ShowErrorNotificationAsync( - "Settings Error", - "Failed to save settings", - ex); - await this.LogUserActionAsync("SettingsChangeFailed", $"Failed to save settings: {ex.Message}"); - } - finally - { - this.IsLoading = false; - } - } - - private async Task ResetToDefaultsAsync() - { - try - { - this.IsLoading = true; - this.StatusMessage = this.GetLocalizedString("Settings_StatusResetting", "Resetting to defaults..."); - - var defaultSettings = new ApplicationSettingsModel(); - this.Settings.CopyFrom(defaultSettings); - - this.UpdatePendingChangesState(); - this.StatusMessage = this.GetLocalizedString("Settings_StatusResetPending", "Settings reset to defaults (not saved yet)"); - - await this.LogUserActionAsync("SettingsChanged", "Settings reset to defaults pending save"); - this.Logger.LogInformation("Settings reset to defaults"); - } - catch (Exception ex) - { - this.StatusMessage = this.GetLocalizedString("Settings_StatusErrorResettingFormat", "Error resetting settings: {0}", ex.Message); - this.Logger.LogError(ex, "Error resetting settings"); - await this.LogUserActionAsync("SettingsChangeFailed", $"Failed to reset settings: {ex.Message}"); - } - finally - { - this.IsLoading = false; - } - } - - private async Task ExportSettingsAsync() - { - try - { - this.IsLoading = true; - this.StatusMessage = this.GetLocalizedString("Settings_StatusExporting", "Exporting configuration bundle..."); - - var saveDialog = new SaveFileDialog - { - Title = this.GetLocalizedString("Settings_DialogExportTitle", "Export ThreadPilot Configuration"), - Filter = "ThreadPilot configuration (*.json)|*.json|All files (*.*)|*.*", - DefaultExt = ".json", - FileName = $"ThreadPilot_Configuration_{DateTime.Now:yyyyMMdd_HHmmss}.json", - OverwritePrompt = true, - AddExtension = true, - }; - - if (saveDialog.ShowDialog() != true) - { - this.StatusMessage = this.GetLocalizedString("Settings_StatusExportCanceled", "Export canceled"); - return; - } - - var settingsSnapshot = (ApplicationSettingsModel)this.Settings.Clone(); - var rulesSnapshot = CloneConfiguration(this.associationService.Configuration); - - var bundle = new ConfigurationBundle - { - SchemaVersion = "2.0", - ExportedAtUtc = DateTime.UtcNow, - Settings = settingsSnapshot, - ProcessMonitorConfiguration = rulesSnapshot, - }; - - var json = JsonSerializer.Serialize(bundle, ImportExportJsonOptions); - await AtomicFileWriter.WriteAllTextAsync(saveDialog.FileName, json, Encoding.UTF8); - - this.StatusMessage = this.GetLocalizedString("Settings_StatusExportedFormat", "Configuration exported to: {0}", saveDialog.FileName); - - await this.notificationService.ShowSuccessNotificationAsync( - "Configuration Exported", - $"Settings and rules exported to {Path.GetFileName(saveDialog.FileName)}"); - - this.Logger.LogInformation("Configuration bundle exported to {Path}", saveDialog.FileName); - await this.LogUserActionAsync("SettingsChanged", "Configuration exported", Path.GetFileName(saveDialog.FileName)); - } - catch (Exception ex) - { - this.StatusMessage = this.GetLocalizedString("Settings_StatusErrorExportingFormat", "Error exporting settings: {0}", ex.Message); - this.Logger.LogError(ex, "Error exporting settings"); - - await this.notificationService.ShowErrorNotificationAsync( - "Export Error", - "Failed to export settings", - ex); - await this.LogUserActionAsync("SettingsChangeFailed", $"Failed to export settings: {ex.Message}"); - } - finally - { - this.IsLoading = false; - } - } - - private async Task ImportSettingsAsync() - { - try - { - this.IsLoading = true; - this.StatusMessage = this.GetLocalizedString("Settings_StatusImporting", "Importing configuration..."); - - var openDialog = new OpenFileDialog - { - Title = this.GetLocalizedString("Settings_DialogImportTitle", "Import ThreadPilot Configuration"), - Filter = "JSON files (*.json)|*.json|All files (*.*)|*.*", - Multiselect = false, - CheckFileExists = true, - }; - - if (openDialog.ShowDialog() != true) - { - this.StatusMessage = this.GetLocalizedString("Settings_StatusImportCanceled", "Import canceled"); - return; - } - - var importPath = openDialog.FileName; - var json = await File.ReadAllTextAsync(importPath); - - if (TryParseBundle(json, out var bundle)) - { - await this.settingsService.UpdateSettingsAsync(bundle.Settings); - - var importedConfiguration = bundle.ProcessMonitorConfiguration ?? new ProcessMonitorConfiguration(); - var replaced = await this.associationService.ReplaceConfigurationAsync(importedConfiguration); - if (!replaced) - { - throw new InvalidOperationException("Failed to apply imported rules configuration"); - } - - await this.processMonitorManagerService.RefreshConfigurationAsync(); - this.processMonitorManagerService.UpdateSettings(); - await this.RefreshSettingsAsync(); - this.HasUnsavedChanges = false; - - this.StatusMessage = this.GetLocalizedString("Settings_StatusImportedApplied", "Configuration bundle imported and applied"); - await this.notificationService.ShowSuccessNotificationAsync( - "Configuration Imported", - $"Settings and rules imported from {Path.GetFileName(importPath)}"); - - this.Logger.LogInformation("Configuration bundle imported from {Path}", importPath); - await this.LogUserActionAsync("SettingsChanged", "Configuration bundle imported", Path.GetFileName(importPath)); - return; - } - - await this.settingsService.ImportSettingsAsync(importPath); - this.processMonitorManagerService.UpdateSettings(); - await this.RefreshSettingsAsync(); - this.HasUnsavedChanges = false; - - this.StatusMessage = this.GetLocalizedString("Settings_StatusLegacyImported", "Legacy settings imported (rules unchanged)"); - await this.notificationService.ShowNotificationAsync( - "Legacy Import Completed", - $"Imported settings from {Path.GetFileName(importPath)}. Rules were not modified.", - NotificationType.Information); - - this.Logger.LogInformation("Legacy settings imported from {Path}", importPath); - await this.LogUserActionAsync("SettingsChanged", "Legacy settings imported", Path.GetFileName(importPath)); - } - catch (Exception ex) - { - this.StatusMessage = this.GetLocalizedString("Settings_StatusErrorImportingFormat", "Error importing settings: {0}", ex.Message); - this.Logger.LogError(ex, "Error importing settings"); - - await this.notificationService.ShowErrorNotificationAsync( - "Import Error", - "Failed to import configuration", - ex); - await this.LogUserActionAsync("SettingsChangeFailed", $"Failed to import settings: {ex.Message}"); - } - finally - { - this.IsLoading = false; - } - } - - private async Task TestNotificationAsync() - { - try - { - await this.notificationService.ShowNotificationAsync( - "Test Notification", - "This is a test notification to verify your settings are working correctly.", - NotificationType.Information); - - this.StatusMessage = this.GetLocalizedString("Settings_StatusTestSent", "Test notification sent"); - this.Logger.LogInformation("Test notification sent"); - } - catch (Exception ex) - { - this.StatusMessage = this.GetLocalizedString("Settings_StatusErrorTestFormat", "Error sending test notification: {0}", ex.Message); - this.Logger.LogError(ex, "Error sending test notification"); - } - } - - /// - /// Refreshes settings from the service. - /// - public async Task RefreshSettingsAsync() - { - try - { - this.IsLoading = true; - this.StatusMessage = this.GetLocalizedString("Settings_StatusLoading", "Loading settings..."); - - await this.settingsService.LoadSettingsAsync(); - await this.associationService.LoadConfigurationAsync(); - - var settingsSnapshot = this.settingsService.Settings; - var (defaultPowerPlanGuid, defaultPowerPlanName) = await this.associationService.GetDefaultPowerPlanAsync(); - this.cachedDefaultPowerPlanGuid = defaultPowerPlanGuid; - this.cachedDefaultPowerPlanName = defaultPowerPlanName; - if (!string.IsNullOrWhiteSpace(defaultPowerPlanGuid)) - { - settingsSnapshot.DefaultPowerPlanId = defaultPowerPlanGuid; - settingsSnapshot.DefaultPowerPlanName = defaultPowerPlanName; - } - - this.isSyncingFromService = true; - this.Settings.CopyFrom(settingsSnapshot); - this.isSyncingFromService = false; - - var useDarkTheme = this.Settings.HasUserThemePreference - ? this.Settings.UseDarkTheme - : this.themeService.GetSystemUsesDarkTheme(); - - this.isSyncingFromService = true; - this.Settings.UseDarkTheme = useDarkTheme; - this.isSyncingFromService = false; - this.ApplyThemePreference(useDarkTheme, logUserAction: false); - this.ApplyLanguagePreference(this.Settings.Language, logUserAction: false); - - this.SetSavedSettingsSnapshot(this.Settings); - this.StatusMessage = this.GetLocalizedString("Settings_StatusLoaded", "Settings loaded"); - - this.Logger.LogInformation("Settings refreshed"); - } - catch (Exception ex) - { - this.StatusMessage = this.GetLocalizedString("Settings_StatusErrorLoadingFormat", "Error loading settings: {0}", ex.Message); - this.Logger.LogError(ex, "Error loading settings"); - } - finally - { - this.isSyncingFromService = false; - this.IsLoading = false; - } - } - - /// - /// Checks if there are unsaved changes. - /// - public bool CanClose() - { - return !this.HasUnsavedChanges; - } - - private void OnSettingsServiceSettingsChanged(object? sender, ApplicationSettingsChangedEventArgs e) - { - // Marshal to UI thread to avoid cross-thread property change issues - System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.isSyncingFromService = true; - try - { - this.Settings.CopyFrom(e.NewSettings); - if (!string.IsNullOrWhiteSpace(this.cachedDefaultPowerPlanGuid)) - { - this.Settings.DefaultPowerPlanId = this.cachedDefaultPowerPlanGuid; - this.Settings.DefaultPowerPlanName = this.cachedDefaultPowerPlanName; - } - this.SetSavedSettingsSnapshot(this.Settings); - this.ApplyLanguagePreference(this.Settings.Language, logUserAction: false); - this.StatusMessage = this.GetLocalizedString("Settings_StatusSynchronized", "Settings synchronized"); - } - finally - { - this.isSyncingFromService = false; - } - }); - } - - private async Task RefreshPowerPlansAsync() - { - try - { - var powerPlans = await this.powerPlanService.GetPowerPlansAsync(); - - this.AvailablePowerPlans.Clear(); - foreach (var plan in powerPlans) - { - this.AvailablePowerPlans.Add(plan); - } - - this.Logger.LogDebug("Refreshed {Count} power plans", this.AvailablePowerPlans.Count); - } - catch (Exception ex) - { - this.Logger.LogError(ex, "Failed to refresh power plans"); - } - } - - private async Task CheckUpdatesAsync() - { - try - { - this.IsLoading = true; - this.StatusMessage = this.GetLocalizedString("Settings_StatusCheckingUpdates", "Checking for updates..."); - - var result = await this.updateService.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Manual)); - this.UpdateLastCheckedText(); - - if (result.Status == UpdateCheckStatus.Failed) - { - this.StatusMessage = this.GetLocalizedString("Settings_StatusLatestUnknown", "Unable to determine the latest version."); - await this.notificationService.ShowErrorNotificationAsync( - "Update Check", - result.Message); - return; - } - - if (result.IsUpdateAvailable && result.Release != null) - { - this.availableUpdate = result.Release; - this.LatestUpdateVersion = $"v{result.Release.Version}"; - this.IsUpdateAvailable = true; - this.StatusMessage = this.GetLocalizedString("Settings_StatusNewVersionFormat", "New version available: {0}", result.Release.Version); - await this.notificationService.ShowNotificationAsync( - "Update available", - $"ThreadPilot {result.Release.Version} is available.", - NotificationType.Information); - } - else - { - this.availableUpdate = null; - this.LatestUpdateVersion = result.Release != null ? $"v{result.Release.Version}" : this.GetLocalizedString("Settings_UpdateLatestUnknown", "Unknown"); - this.IsUpdateAvailable = false; - this.StatusMessage = this.GetLocalizedString("Settings_StatusUpToDateFormat", "Application is up to date. Installed version: {0}", this.ApplicationVersion); - await this.notificationService.ShowSuccessNotificationAsync( - "Application up to date", - $"Installed version: {this.ApplicationVersion}"); - } - } - catch (Exception ex) - { - this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateErrorFormat", "Error while checking updates: {0}", ex.Message); - this.Logger.LogError(ex, "Error checking for updates"); - - await this.notificationService.ShowErrorNotificationAsync( - "Update check error", - "Unable to verify updates", - ex); - } - finally - { - this.IsLoading = false; - } - } - - private async Task DownloadAndInstallUpdateAsync() - { - if (this.availableUpdate == null) - { - this.StatusMessage = this.GetLocalizedString("Settings_StatusLatestUnknown", "Unable to determine the latest version."); - return; - } - - var message = this.GetLocalizedString( - "Settings_UpdateConfirmMessageFormat", - "ThreadPilot will download and verify version {0}, then ask Windows for permission to run the installer. Continue?", - this.availableUpdate.Version); - var confirmation = System.Windows.MessageBox.Show( - message, - this.GetLocalizedString("Settings_UpdateConfirmTitle", "Install ThreadPilot update"), - MessageBoxButton.YesNo, - MessageBoxImage.Information); - - if (confirmation != MessageBoxResult.Yes) - { - this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateCanceled", "Update canceled."); - return; - } - - try - { - this.IsLoading = true; - this.StatusMessage = this.GetLocalizedString("Settings_StatusDownloadingUpdate", "Downloading and verifying update..."); - - var result = await this.updateService.DownloadAndInstallAsync(this.availableUpdate); - if (result.Status == UpdateInstallStatus.Started) - { - this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateInstallerStarted", "Update installer started."); - await this.notificationService.ShowNotificationAsync( - "Update installer started", - "ThreadPilot will close while the installer runs.", - NotificationType.Information); - } - else - { - this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateInstallFailedFormat", "Update install failed: {0}", result.Message); - await this.notificationService.ShowErrorNotificationAsync( - "Update install failed", - result.Message); - } - } - catch (Exception ex) - { - this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateInstallFailedFormat", "Update install failed: {0}", ex.Message); - this.Logger.LogError(ex, "Error downloading or installing update"); - await this.notificationService.ShowErrorNotificationAsync( - "Update install failed", - "Unable to download or start the update installer", - ex); - } - finally - { - this.IsLoading = false; - } - } - - private void UpdateLastCheckedText() - { - var lastCheck = this.settingsService.Settings.LastUpdateCheckUtc; - this.LastUpdateCheckText = lastCheck.HasValue - ? lastCheck.Value.LocalDateTime.ToString("g", System.Globalization.CultureInfo.CurrentCulture) - : this.GetLocalizedString("Settings_UpdateLastCheckedNever", "Never"); - } - - private string GetLocalizedString(string key, string fallback, params object[] args) - { - var localized = this.localizationService.GetString(key); - var format = string.IsNullOrWhiteSpace(localized) || string.Equals(localized, key, StringComparison.Ordinal) - ? fallback - : localized; - - return args.Length == 0 ? format : string.Format(format, args); - } - - public async Task SaveIfDirtyAsync() - { - if (!this.HasUnsavedChanges) - { - return true; - } - - await this.SaveSettingsAsync(); - return !this.HasUnsavedChanges; - } - - public async Task DiscardPendingChangesAsync() - { - if (!this.HasUnsavedChanges) - { - return; - } - - await this.RefreshSettingsAsync(); - } - - private void UpdatePendingChangesState() - { - this.HasUnsavedChanges = !this.Settings.HasSameUserSettingsAs(this.savedSettingsSnapshot); - this.StatusMessage = this.HasUnsavedChanges - ? this.GetLocalizedString("Settings_StatusModified", "Settings have been modified") - : this.GetLocalizedString("Settings_StatusMatchSaved", "Settings match the saved configuration"); - } - - private void SetSavedSettingsSnapshot(ApplicationSettingsModel settingsSnapshot) - { - this.savedSettingsSnapshot = (ApplicationSettingsModel)settingsSnapshot.Clone(); - this.HasUnsavedChanges = false; - } - - private static bool TryParseBundle(string json, out ConfigurationBundle bundle) - { - bundle = new ConfigurationBundle(); - - try - { - using var document = JsonDocument.Parse(json); - if (document.RootElement.ValueKind != JsonValueKind.Object) - { - return false; - } - - if (!document.RootElement.TryGetProperty("settings", out var settingsElement)) - { - return false; - } - - if (!document.RootElement.TryGetProperty("processMonitorConfiguration", out var rulesElement) && - !document.RootElement.TryGetProperty("rulesConfiguration", out rulesElement)) - { - return false; - } - - var parsedBundle = JsonSerializer.Deserialize(json, ImportExportJsonOptions); - if (parsedBundle?.Settings == null) - { - return false; - } - - parsedBundle.ProcessMonitorConfiguration = - parsedBundle.ProcessMonitorConfiguration - ?? parsedBundle.RulesConfiguration - ?? JsonSerializer.Deserialize(rulesElement.GetRawText(), ImportExportJsonOptions) - ?? new ProcessMonitorConfiguration(); - - parsedBundle.ProcessMonitorConfiguration.Associations ??= new List(); - bundle = parsedBundle; - return true; - } - catch (JsonException) - { - return false; - } - } - - private static ProcessMonitorConfiguration CloneConfiguration(ProcessMonitorConfiguration source) - { - var serialized = JsonSerializer.Serialize(source, ImportExportJsonOptions); - var clone = JsonSerializer.Deserialize(serialized, ImportExportJsonOptions) - ?? new ProcessMonitorConfiguration(); - clone.Associations ??= new List(); - return clone; - } - - private sealed class ConfigurationBundle - { - public string SchemaVersion { get; set; } = "2.0"; - - public DateTime ExportedAtUtc { get; set; } = DateTime.UtcNow; - - public ApplicationSettingsModel Settings { get; set; } = new ApplicationSettingsModel(); - - public ProcessMonitorConfiguration? ProcessMonitorConfiguration { get; set; } - - public ProcessMonitorConfiguration? RulesConfiguration { get; set; } - } - } -} +namespace ThreadPilot.ViewModels +{ + using System; + using System.Collections.Generic; + using System.Collections.ObjectModel; + using System.IO; + using System.Linq; + using System.Reflection; + using System.Text; + using System.Text.Json; + using System.Threading.Tasks; + using System.Windows; + using System.Windows.Input; + using CommunityToolkit.Mvvm.ComponentModel; + using CommunityToolkit.Mvvm.Input; + using Microsoft.Extensions.Logging; + using Microsoft.Win32; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public partial class SettingsViewModel : BaseViewModel + { + private readonly IApplicationSettingsService settingsService; + private readonly INotificationService notificationService; + private readonly IAutostartService autostartService; + private readonly IPowerPlanService powerPlanService; + private readonly IProcessPowerPlanAssociationService associationService; + private readonly IProcessMonitorManagerService processMonitorManagerService; + private readonly IThemeService themeService; + private readonly ISystemTrayService systemTrayService; + private readonly IUpdateService updateService; + private readonly IApplicationVersionProvider versionProvider; + private readonly ILocalizationService localizationService; + private ApplicationSettingsModel savedSettingsSnapshot; + private bool isSyncingFromService = false; + private bool? appliedThemePreference; + private string cachedDefaultPowerPlanGuid = string.Empty; + private string cachedDefaultPowerPlanName = string.Empty; + private UpdateReleaseInfo? availableUpdate; + private static readonly JsonSerializerOptions ImportExportJsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + [ObservableProperty] + private ApplicationSettingsModel settings; + + [ObservableProperty] + private bool hasUnsavedChanges = false; + + [ObservableProperty] + private bool isLoading = false; + + public bool CanSaveSettings => this.HasUnsavedChanges && !this.IsLoading; + + public bool HasPendingChanges => this.HasUnsavedChanges; + + [ObservableProperty] + private ObservableCollection availablePowerPlans = new(); + + public string ApplicationVersion { get; } + + public ICommand SaveSettingsCommand { get; } + + public ICommand ResetToDefaultsCommand { get; } + + public ICommand ExportSettingsCommand { get; } + + public ICommand ImportSettingsCommand { get; } + + public ICommand TestNotificationCommand { get; } + + public ICommand RefreshPowerPlansCommand { get; } + + public ICommand CheckUpdatesCommand { get; } + + public IAsyncRelayCommand DownloadAndInstallUpdateCommand { get; } + + [ObservableProperty] + private string latestUpdateVersion = string.Empty; + + [ObservableProperty] + private string lastUpdateCheckText = string.Empty; + + [ObservableProperty] + private bool isUpdateAvailable = false; + + public bool CanDownloadAndInstallUpdate => this.IsUpdateAvailable && !this.IsLoading; + + public SettingsViewModel( + ILogger logger, + IApplicationSettingsService settingsService, + INotificationService notificationService, + IAutostartService autostartService, + IPowerPlanService powerPlanService, + IProcessPowerPlanAssociationService associationService, + IProcessMonitorManagerService processMonitorManagerService, + IThemeService themeService, + ISystemTrayService systemTrayService, + IUpdateService updateService, + IApplicationVersionProvider versionProvider, + ILocalizationService localizationService, + IEnhancedLoggingService? enhancedLoggingService = null, + IActivityAuditService? activityAuditService = null) + : base(logger, enhancedLoggingService, activityAuditService) + { + this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + this.notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService)); + this.autostartService = autostartService ?? throw new ArgumentNullException(nameof(autostartService)); + this.powerPlanService = powerPlanService ?? throw new ArgumentNullException(nameof(powerPlanService)); + this.associationService = associationService ?? throw new ArgumentNullException(nameof(associationService)); + this.processMonitorManagerService = processMonitorManagerService ?? throw new ArgumentNullException(nameof(processMonitorManagerService)); + this.themeService = themeService ?? throw new ArgumentNullException(nameof(themeService)); + this.systemTrayService = systemTrayService ?? throw new ArgumentNullException(nameof(systemTrayService)); + this.updateService = updateService ?? throw new ArgumentNullException(nameof(updateService)); + this.versionProvider = versionProvider ?? throw new ArgumentNullException(nameof(versionProvider)); + this.localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); + + this.ApplicationVersion = this.versionProvider.DisplayVersion; + + // Initialize with current settings + this.settings = (ApplicationSettingsModel)this.settingsService.Settings.Clone(); + this.savedSettingsSnapshot = (ApplicationSettingsModel)this.settings.Clone(); + this.appliedThemePreference = this.settings.UseDarkTheme; + this.LatestUpdateVersion = this.GetLocalizedString("Settings_UpdateNotChecked", "Not checked"); + this.UpdateLastCheckedText(); + + // Initialize commands + this.SaveSettingsCommand = new AsyncRelayCommand(this.SaveSettingsAsync); + this.ResetToDefaultsCommand = new AsyncRelayCommand(this.ResetToDefaultsAsync); + this.ExportSettingsCommand = new AsyncRelayCommand(this.ExportSettingsAsync); + this.ImportSettingsCommand = new AsyncRelayCommand(this.ImportSettingsAsync); + this.TestNotificationCommand = new AsyncRelayCommand(this.TestNotificationAsync); + this.RefreshPowerPlansCommand = new AsyncRelayCommand(this.RefreshPowerPlansAsync); + this.CheckUpdatesCommand = new AsyncRelayCommand(this.CheckUpdatesAsync); + this.DownloadAndInstallUpdateCommand = new AsyncRelayCommand( + this.DownloadAndInstallUpdateAsync, + () => this.CanDownloadAndInstallUpdate); + + // Subscribe to property changes to track unsaved changes + this.Settings.PropertyChanged += this.OnSettingsPropertyChanged; + + // Keep viewmodel in sync with persisted settings + this.settingsService.SettingsChanged += this.OnSettingsServiceSettingsChanged; + + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher != null) + { + // Ensure we load the latest persisted settings on startup. + _ = dispatcher.InvokeAsync(async () => await this.RefreshSettingsAsync()); + + // Initialize data - marshal to UI thread to prevent cross-thread access exceptions. + _ = dispatcher.InvokeAsync(async () => await this.RefreshPowerPlansAsync()); + } + + this.Logger.LogInformation("Settings ViewModel initialized"); + } + + private void OnSettingsPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (this.isSyncingFromService) + { + return; + } + + if (string.Equals(e.PropertyName, nameof(ApplicationSettingsModel.UseDarkTheme), StringComparison.Ordinal)) + { + this.Settings.HasUserThemePreference = true; + this.UpdatePendingChangesState(); + this.ApplyThemePreference(this.Settings.UseDarkTheme, logUserAction: true); + return; + } + + if (string.Equals(e.PropertyName, nameof(ApplicationSettingsModel.Language), StringComparison.Ordinal)) + { + this.UpdatePendingChangesState(); + this.ApplyLanguagePreference(this.Settings.Language, logUserAction: true); + return; + } + + if (string.Equals(e.PropertyName, nameof(ApplicationSettingsModel.ApplyPersistentRulesOnProcessStart), StringComparison.Ordinal)) + { + this.UpdatePendingChangesState(); + var state = this.Settings.ApplyPersistentRulesOnProcessStart ? "enabled" : "disabled"; + _ = this.LogUserActionAsync( + "SettingsChanged", + $"[Settings] Apply saved rules at process start {state}."); + return; + } + + this.UpdatePendingChangesState(); + } + + private void ApplyThemePreference(bool useDarkTheme, bool logUserAction) + { + if (this.appliedThemePreference == useDarkTheme) + { + return; + } + + var themeName = useDarkTheme + ? this.GetLocalizedString("Settings_ThemeDark", "Dark") + : this.GetLocalizedString("Settings_ThemeLight", "Light"); + try + { + this.themeService.ApplyTheme(useDarkTheme); + this.systemTrayService.ApplyTheme(useDarkTheme); + this.appliedThemePreference = useDarkTheme; + this.StatusMessage = this.GetLocalizedString("Settings_StatusThemeChangedFormat", "Theme changed to {0}.", themeName); + + if (logUserAction) + { + _ = this.LogUserActionAsync("ThemeChanged", $"Theme changed to {themeName}"); + } + } + catch (Exception ex) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusThemeChangeFailedFormat", "Failed to change theme to {0}.", themeName); + this.Logger.LogError(ex, "Failed to apply theme preference {ThemeName}", themeName); + _ = this.LogUserActionAsync("ThemeChangeFailed", $"Failed to change theme to {themeName}: {ex.Message}"); + } + } + + private void ApplyLanguagePreference(string language, bool logUserAction) + { + var normalizedLanguage = LocalizationService.NormalizeLanguage(language); + try + { + this.localizationService.ApplyLanguage(normalizedLanguage); + this.Settings.Language = normalizedLanguage; + var languageName = normalizedLanguage == LocalizationService.SimplifiedChineseLanguage + ? this.GetLocalizedString("Settings_LanguageSimplifiedChinese", "Simplified Chinese") + : this.GetLocalizedString("Settings_LanguageEnglish", "English"); + this.StatusMessage = this.GetLocalizedString("Settings_StatusLanguageChangedFormat", "Language changed to {0}.", languageName); + + if (logUserAction) + { + _ = this.LogUserActionAsync("LanguageChanged", $"Language changed to {languageName}"); + } + } + catch (Exception ex) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusLanguageChangeFailed", "Failed to change language."); + this.Logger.LogError(ex, "Failed to apply language preference {Language}", normalizedLanguage); + _ = this.LogUserActionAsync("LanguageChangeFailed", $"Failed to change language to {normalizedLanguage}: {ex.Message}"); + } + } + + partial void OnHasUnsavedChangesChanged(bool value) + { + OnPropertyChanged(nameof(CanSaveSettings)); + } + + partial void OnIsLoadingChanged(bool value) + { + OnPropertyChanged(nameof(CanSaveSettings)); + OnPropertyChanged(nameof(CanDownloadAndInstallUpdate)); + this.DownloadAndInstallUpdateCommand.NotifyCanExecuteChanged(); + } + + partial void OnIsUpdateAvailableChanged(bool value) + { + OnPropertyChanged(nameof(CanDownloadAndInstallUpdate)); + this.DownloadAndInstallUpdateCommand.NotifyCanExecuteChanged(); + } + + private async Task SaveSettingsAsync() + { + string previousDefaultPowerPlanGuid = string.Empty; + string previousDefaultPowerPlanName = string.Empty; + + try + { + this.IsLoading = true; + this.StatusMessage = this.GetLocalizedString("Settings_StatusSaving", "Saving settings..."); + var warnings = new List(); + + previousDefaultPowerPlanGuid = this.Settings.DefaultPowerPlanId; + previousDefaultPowerPlanName = this.Settings.DefaultPowerPlanName; + + // Handle autostart setting + var currentAutostartState = await this.autostartService.CheckAutostartStatusAsync(); + if (this.Settings.AutostartWithWindows != currentAutostartState) + { + bool autostartUpdated; + if (this.Settings.AutostartWithWindows) + { + autostartUpdated = await this.autostartService.EnableAutostartAsync(this.Settings.StartMinimized); + } + else + { + autostartUpdated = await this.autostartService.DisableAutostartAsync(); + } + + if (!autostartUpdated) + { + warnings.Add(this.GetLocalizedString( + "Settings_WarningAutostartFailed", + "Failed to update Windows autostart. Keeping previous autostart state.")); + this.Settings.AutostartWithWindows = currentAutostartState; + } + else + { + this.Settings.AutostartWithWindows = await this.autostartService.CheckAutostartStatusAsync(); + } + } + + await this.settingsService.UpdateSettingsAsync(this.Settings); + + var useDarkTheme = this.Settings.HasUserThemePreference + ? this.Settings.UseDarkTheme + : this.themeService.GetSystemUsesDarkTheme(); + + this.isSyncingFromService = true; + this.Settings.UseDarkTheme = useDarkTheme; + this.isSyncingFromService = false; + this.ApplyThemePreference(useDarkTheme, logUserAction: false); + this.ApplyLanguagePreference(this.Settings.Language, logUserAction: false); + + // Update monitoring services with new settings + this.processMonitorManagerService.UpdateSettings(); + + this.SetSavedSettingsSnapshot(this.Settings); + if (warnings.Count > 0) + { + this.StatusMessage = this.GetLocalizedString( + "Settings_StatusSavedWarningsFormat", + "Settings saved with warnings: {0}", + string.Join(" ", warnings)); + await this.notificationService.ShowNotificationAsync( + "Settings Saved with Warnings", + string.Join(" ", warnings), + NotificationType.Warning); + } + else + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusSavedApplied", "Settings saved and applied successfully."); + await this.notificationService.ShowSuccessNotificationAsync( + "Settings Saved", + "Application settings have been saved successfully"); + } + + await this.LogUserActionAsync("SettingsChanged", "Settings saved and applied"); + this.Logger.LogInformation("Settings saved successfully"); + } + catch (Exception ex) + { + this.Settings.DefaultPowerPlanId = previousDefaultPowerPlanGuid; + this.Settings.DefaultPowerPlanName = previousDefaultPowerPlanName; + + this.StatusMessage = this.GetLocalizedString("Settings_StatusErrorSavingFormat", "Error saving settings: {0}", ex.Message); + this.Logger.LogError(ex, "Error saving settings"); + + await this.notificationService.ShowErrorNotificationAsync( + "Settings Error", + "Failed to save settings", + ex); + await this.LogUserActionAsync("SettingsChangeFailed", $"Failed to save settings: {ex.Message}"); + } + finally + { + this.IsLoading = false; + } + } + + private async Task ResetToDefaultsAsync() + { + try + { + this.IsLoading = true; + this.StatusMessage = this.GetLocalizedString("Settings_StatusResetting", "Resetting to defaults..."); + + var defaultSettings = new ApplicationSettingsModel(); + this.Settings.CopyFrom(defaultSettings); + + this.UpdatePendingChangesState(); + this.StatusMessage = this.GetLocalizedString("Settings_StatusResetPending", "Settings reset to defaults (not saved yet)"); + + await this.LogUserActionAsync("SettingsChanged", "Settings reset to defaults pending save"); + this.Logger.LogInformation("Settings reset to defaults"); + } + catch (Exception ex) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusErrorResettingFormat", "Error resetting settings: {0}", ex.Message); + this.Logger.LogError(ex, "Error resetting settings"); + await this.LogUserActionAsync("SettingsChangeFailed", $"Failed to reset settings: {ex.Message}"); + } + finally + { + this.IsLoading = false; + } + } + + private async Task ExportSettingsAsync() + { + try + { + this.IsLoading = true; + this.StatusMessage = this.GetLocalizedString("Settings_StatusExporting", "Exporting configuration bundle..."); + + var saveDialog = new SaveFileDialog + { + Title = this.GetLocalizedString("Settings_DialogExportTitle", "Export ThreadPilot Configuration"), + Filter = "ThreadPilot configuration (*.json)|*.json|All files (*.*)|*.*", + DefaultExt = ".json", + FileName = $"ThreadPilot_Configuration_{DateTime.Now:yyyyMMdd_HHmmss}.json", + OverwritePrompt = true, + AddExtension = true, + }; + + if (saveDialog.ShowDialog() != true) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusExportCanceled", "Export canceled"); + return; + } + + var settingsSnapshot = (ApplicationSettingsModel)this.Settings.Clone(); + var rulesSnapshot = CloneConfiguration(this.associationService.Configuration); + + var bundle = new ConfigurationBundle + { + SchemaVersion = "2.0", + ExportedAtUtc = DateTime.UtcNow, + Settings = settingsSnapshot, + ProcessMonitorConfiguration = rulesSnapshot, + }; + + var json = JsonSerializer.Serialize(bundle, ImportExportJsonOptions); + await AtomicFileWriter.WriteAllTextAsync(saveDialog.FileName, json, Encoding.UTF8); + + this.StatusMessage = this.GetLocalizedString("Settings_StatusExportedFormat", "Configuration exported to: {0}", saveDialog.FileName); + + await this.notificationService.ShowSuccessNotificationAsync( + "Configuration Exported", + $"Settings and rules exported to {Path.GetFileName(saveDialog.FileName)}"); + + this.Logger.LogInformation("Configuration bundle exported to {Path}", saveDialog.FileName); + await this.LogUserActionAsync("SettingsChanged", "Configuration exported", Path.GetFileName(saveDialog.FileName)); + } + catch (Exception ex) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusErrorExportingFormat", "Error exporting settings: {0}", ex.Message); + this.Logger.LogError(ex, "Error exporting settings"); + + await this.notificationService.ShowErrorNotificationAsync( + "Export Error", + "Failed to export settings", + ex); + await this.LogUserActionAsync("SettingsChangeFailed", $"Failed to export settings: {ex.Message}"); + } + finally + { + this.IsLoading = false; + } + } + + private async Task ImportSettingsAsync() + { + try + { + this.IsLoading = true; + this.StatusMessage = this.GetLocalizedString("Settings_StatusImporting", "Importing configuration..."); + + var openDialog = new OpenFileDialog + { + Title = this.GetLocalizedString("Settings_DialogImportTitle", "Import ThreadPilot Configuration"), + Filter = "JSON files (*.json)|*.json|All files (*.*)|*.*", + Multiselect = false, + CheckFileExists = true, + }; + + if (openDialog.ShowDialog() != true) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusImportCanceled", "Import canceled"); + return; + } + + var importPath = openDialog.FileName; + var json = await File.ReadAllTextAsync(importPath); + + if (TryParseBundle(json, out var bundle)) + { + await this.settingsService.UpdateSettingsAsync(bundle.Settings); + + var importedConfiguration = bundle.ProcessMonitorConfiguration ?? new ProcessMonitorConfiguration(); + var replaced = await this.associationService.ReplaceConfigurationAsync(importedConfiguration); + if (!replaced) + { + throw new InvalidOperationException("Failed to apply imported rules configuration"); + } + + await this.processMonitorManagerService.RefreshConfigurationAsync(); + this.processMonitorManagerService.UpdateSettings(); + await this.RefreshSettingsAsync(); + this.HasUnsavedChanges = false; + + this.StatusMessage = this.GetLocalizedString("Settings_StatusImportedApplied", "Configuration bundle imported and applied"); + await this.notificationService.ShowSuccessNotificationAsync( + "Configuration Imported", + $"Settings and rules imported from {Path.GetFileName(importPath)}"); + + this.Logger.LogInformation("Configuration bundle imported from {Path}", importPath); + await this.LogUserActionAsync("SettingsChanged", "Configuration bundle imported", Path.GetFileName(importPath)); + return; + } + + await this.settingsService.ImportSettingsAsync(importPath); + this.processMonitorManagerService.UpdateSettings(); + await this.RefreshSettingsAsync(); + this.HasUnsavedChanges = false; + + this.StatusMessage = this.GetLocalizedString("Settings_StatusLegacyImported", "Legacy settings imported (rules unchanged)"); + await this.notificationService.ShowNotificationAsync( + "Legacy Import Completed", + $"Imported settings from {Path.GetFileName(importPath)}. Rules were not modified.", + NotificationType.Information); + + this.Logger.LogInformation("Legacy settings imported from {Path}", importPath); + await this.LogUserActionAsync("SettingsChanged", "Legacy settings imported", Path.GetFileName(importPath)); + } + catch (Exception ex) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusErrorImportingFormat", "Error importing settings: {0}", ex.Message); + this.Logger.LogError(ex, "Error importing settings"); + + await this.notificationService.ShowErrorNotificationAsync( + "Import Error", + "Failed to import configuration", + ex); + await this.LogUserActionAsync("SettingsChangeFailed", $"Failed to import settings: {ex.Message}"); + } + finally + { + this.IsLoading = false; + } + } + + private async Task TestNotificationAsync() + { + try + { + await this.notificationService.ShowNotificationAsync( + "Test Notification", + "This is a test notification to verify your settings are working correctly.", + NotificationType.Information); + + this.StatusMessage = this.GetLocalizedString("Settings_StatusTestSent", "Test notification sent"); + this.Logger.LogInformation("Test notification sent"); + } + catch (Exception ex) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusErrorTestFormat", "Error sending test notification: {0}", ex.Message); + this.Logger.LogError(ex, "Error sending test notification"); + } + } + + public async Task RefreshSettingsAsync() + { + try + { + this.IsLoading = true; + this.StatusMessage = this.GetLocalizedString("Settings_StatusLoading", "Loading settings..."); + + await this.settingsService.LoadSettingsAsync(); + await this.associationService.LoadConfigurationAsync(); + + var settingsSnapshot = this.settingsService.Settings; + var (defaultPowerPlanGuid, defaultPowerPlanName) = await this.associationService.GetDefaultPowerPlanAsync(); + this.cachedDefaultPowerPlanGuid = defaultPowerPlanGuid; + this.cachedDefaultPowerPlanName = defaultPowerPlanName; + if (!string.IsNullOrWhiteSpace(defaultPowerPlanGuid)) + { + settingsSnapshot.DefaultPowerPlanId = defaultPowerPlanGuid; + settingsSnapshot.DefaultPowerPlanName = defaultPowerPlanName; + } + + this.isSyncingFromService = true; + this.Settings.CopyFrom(settingsSnapshot); + this.isSyncingFromService = false; + + var useDarkTheme = this.Settings.HasUserThemePreference + ? this.Settings.UseDarkTheme + : this.themeService.GetSystemUsesDarkTheme(); + + this.isSyncingFromService = true; + this.Settings.UseDarkTheme = useDarkTheme; + this.isSyncingFromService = false; + this.ApplyThemePreference(useDarkTheme, logUserAction: false); + this.ApplyLanguagePreference(this.Settings.Language, logUserAction: false); + + this.SetSavedSettingsSnapshot(this.Settings); + this.StatusMessage = this.GetLocalizedString("Settings_StatusLoaded", "Settings loaded"); + + this.Logger.LogInformation("Settings refreshed"); + } + catch (Exception ex) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusErrorLoadingFormat", "Error loading settings: {0}", ex.Message); + this.Logger.LogError(ex, "Error loading settings"); + } + finally + { + this.isSyncingFromService = false; + this.IsLoading = false; + } + } + + public bool CanClose() + { + return !this.HasUnsavedChanges; + } + + private void OnSettingsServiceSettingsChanged(object? sender, ApplicationSettingsChangedEventArgs e) + { + // Marshal to UI thread to avoid cross-thread property change issues + System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.isSyncingFromService = true; + try + { + this.Settings.CopyFrom(e.NewSettings); + if (!string.IsNullOrWhiteSpace(this.cachedDefaultPowerPlanGuid)) + { + this.Settings.DefaultPowerPlanId = this.cachedDefaultPowerPlanGuid; + this.Settings.DefaultPowerPlanName = this.cachedDefaultPowerPlanName; + } + this.SetSavedSettingsSnapshot(this.Settings); + this.ApplyLanguagePreference(this.Settings.Language, logUserAction: false); + this.StatusMessage = this.GetLocalizedString("Settings_StatusSynchronized", "Settings synchronized"); + } + finally + { + this.isSyncingFromService = false; + } + }); + } + + private async Task RefreshPowerPlansAsync() + { + try + { + var powerPlans = await this.powerPlanService.GetPowerPlansAsync(); + + this.AvailablePowerPlans.Clear(); + foreach (var plan in powerPlans) + { + this.AvailablePowerPlans.Add(plan); + } + + this.Logger.LogDebug("Refreshed {Count} power plans", this.AvailablePowerPlans.Count); + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Failed to refresh power plans"); + } + } + + private async Task CheckUpdatesAsync() + { + try + { + this.IsLoading = true; + this.StatusMessage = this.GetLocalizedString("Settings_StatusCheckingUpdates", "Checking for updates..."); + + var result = await this.updateService.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Manual)); + this.UpdateLastCheckedText(); + + if (result.Status == UpdateCheckStatus.Failed) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusLatestUnknown", "Unable to determine the latest version."); + await this.notificationService.ShowErrorNotificationAsync( + "Update Check", + result.Message); + return; + } + + if (result.IsUpdateAvailable && result.Release != null) + { + this.availableUpdate = result.Release; + this.LatestUpdateVersion = $"v{result.Release.Version}"; + this.IsUpdateAvailable = true; + this.StatusMessage = this.GetLocalizedString("Settings_StatusNewVersionFormat", "New version available: {0}", result.Release.Version); + await this.notificationService.ShowNotificationAsync( + "Update available", + $"ThreadPilot {result.Release.Version} is available.", + NotificationType.Information); + } + else + { + this.availableUpdate = null; + this.LatestUpdateVersion = result.Release != null ? $"v{result.Release.Version}" : this.GetLocalizedString("Settings_UpdateLatestUnknown", "Unknown"); + this.IsUpdateAvailable = false; + this.StatusMessage = this.GetLocalizedString("Settings_StatusUpToDateFormat", "Application is up to date. Installed version: {0}", this.ApplicationVersion); + await this.notificationService.ShowSuccessNotificationAsync( + "Application up to date", + $"Installed version: {this.ApplicationVersion}"); + } + } + catch (Exception ex) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateErrorFormat", "Error while checking updates: {0}", ex.Message); + this.Logger.LogError(ex, "Error checking for updates"); + + await this.notificationService.ShowErrorNotificationAsync( + "Update check error", + "Unable to verify updates", + ex); + } + finally + { + this.IsLoading = false; + } + } + + private async Task DownloadAndInstallUpdateAsync() + { + if (this.availableUpdate == null) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusLatestUnknown", "Unable to determine the latest version."); + return; + } + + var message = this.GetLocalizedString( + "Settings_UpdateConfirmMessageFormat", + "ThreadPilot will download and verify version {0}, then ask Windows for permission to run the installer. Continue?", + this.availableUpdate.Version); + var confirmation = System.Windows.MessageBox.Show( + message, + this.GetLocalizedString("Settings_UpdateConfirmTitle", "Install ThreadPilot update"), + MessageBoxButton.YesNo, + MessageBoxImage.Information); + + if (confirmation != MessageBoxResult.Yes) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateCanceled", "Update canceled."); + return; + } + + try + { + this.IsLoading = true; + this.StatusMessage = this.GetLocalizedString("Settings_StatusDownloadingUpdate", "Downloading and verifying update..."); + + var result = await this.updateService.DownloadAndInstallAsync(this.availableUpdate); + if (result.Status == UpdateInstallStatus.Started) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateInstallerStarted", "Update installer started."); + await this.notificationService.ShowNotificationAsync( + "Update installer started", + "ThreadPilot will close while the installer runs.", + NotificationType.Information); + } + else + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateInstallFailedFormat", "Update install failed: {0}", result.Message); + await this.notificationService.ShowErrorNotificationAsync( + "Update install failed", + result.Message); + } + } + catch (Exception ex) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateInstallFailedFormat", "Update install failed: {0}", ex.Message); + this.Logger.LogError(ex, "Error downloading or installing update"); + await this.notificationService.ShowErrorNotificationAsync( + "Update install failed", + "Unable to download or start the update installer", + ex); + } + finally + { + this.IsLoading = false; + } + } + + private void UpdateLastCheckedText() + { + var lastCheck = this.settingsService.Settings.LastUpdateCheckUtc; + this.LastUpdateCheckText = lastCheck.HasValue + ? lastCheck.Value.LocalDateTime.ToString("g", System.Globalization.CultureInfo.CurrentCulture) + : this.GetLocalizedString("Settings_UpdateLastCheckedNever", "Never"); + } + + private string GetLocalizedString(string key, string fallback, params object[] args) + { + var localized = this.localizationService.GetString(key); + var format = string.IsNullOrWhiteSpace(localized) || string.Equals(localized, key, StringComparison.Ordinal) + ? fallback + : localized; + + return args.Length == 0 ? format : string.Format(format, args); + } + + public async Task SaveIfDirtyAsync() + { + if (!this.HasUnsavedChanges) + { + return true; + } + + await this.SaveSettingsAsync(); + return !this.HasUnsavedChanges; + } + + public async Task DiscardPendingChangesAsync() + { + if (!this.HasUnsavedChanges) + { + return; + } + + await this.RefreshSettingsAsync(); + } + + private void UpdatePendingChangesState() + { + this.HasUnsavedChanges = !this.Settings.HasSameUserSettingsAs(this.savedSettingsSnapshot); + this.StatusMessage = this.HasUnsavedChanges + ? this.GetLocalizedString("Settings_StatusModified", "Settings have been modified") + : this.GetLocalizedString("Settings_StatusMatchSaved", "Settings match the saved configuration"); + } + + private void SetSavedSettingsSnapshot(ApplicationSettingsModel settingsSnapshot) + { + this.savedSettingsSnapshot = (ApplicationSettingsModel)settingsSnapshot.Clone(); + this.HasUnsavedChanges = false; + } + + private static bool TryParseBundle(string json, out ConfigurationBundle bundle) + { + bundle = new ConfigurationBundle(); + + try + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (!document.RootElement.TryGetProperty("settings", out var settingsElement)) + { + return false; + } + + if (!document.RootElement.TryGetProperty("processMonitorConfiguration", out var rulesElement) && + !document.RootElement.TryGetProperty("rulesConfiguration", out rulesElement)) + { + return false; + } + + var parsedBundle = JsonSerializer.Deserialize(json, ImportExportJsonOptions); + if (parsedBundle?.Settings == null) + { + return false; + } + + parsedBundle.ProcessMonitorConfiguration = + parsedBundle.ProcessMonitorConfiguration + ?? parsedBundle.RulesConfiguration + ?? JsonSerializer.Deserialize(rulesElement.GetRawText(), ImportExportJsonOptions) + ?? new ProcessMonitorConfiguration(); + + parsedBundle.ProcessMonitorConfiguration.Associations ??= new List(); + bundle = parsedBundle; + return true; + } + catch (JsonException) + { + return false; + } + } + + private static ProcessMonitorConfiguration CloneConfiguration(ProcessMonitorConfiguration source) + { + var serialized = JsonSerializer.Serialize(source, ImportExportJsonOptions); + var clone = JsonSerializer.Deserialize(serialized, ImportExportJsonOptions) + ?? new ProcessMonitorConfiguration(); + clone.Associations ??= new List(); + return clone; + } + + private sealed class ConfigurationBundle + { + public string SchemaVersion { get; set; } = "2.0"; + + public DateTime ExportedAtUtc { get; set; } = DateTime.UtcNow; + + public ApplicationSettingsModel Settings { get; set; } = new ApplicationSettingsModel(); + + public ProcessMonitorConfiguration? ProcessMonitorConfiguration { get; set; } + + public ProcessMonitorConfiguration? RulesConfiguration { get; set; } + } + } +} diff --git a/ViewModels/SystemTweaksViewModel.cs b/ViewModels/SystemTweaksViewModel.cs index 5a86835..e7ebe54 100644 --- a/ViewModels/SystemTweaksViewModel.cs +++ b/ViewModels/SystemTweaksViewModel.cs @@ -1,364 +1,342 @@ -/* - * 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.ViewModels -{ - using System; - using System.Collections.ObjectModel; - using System.Linq; - using System.Threading.Tasks; - using CommunityToolkit.Mvvm.ComponentModel; - using CommunityToolkit.Mvvm.Input; - using Microsoft.Extensions.Logging; - using ThreadPilot.Services; - - /// - /// ViewModel for the System Tweaks tab. - /// - public partial class SystemTweaksViewModel : BaseViewModel - { - private readonly ISystemTweaksService systemTweaksService; - private readonly INotificationService notificationService; - - [ObservableProperty] - private ObservableCollection tweakItems = new(); - - [ObservableProperty] - private bool isRefreshing; - - [ObservableProperty] - private string refreshStatusText = "Ready"; - - public SystemTweaksViewModel( - ISystemTweaksService systemTweaksService, - INotificationService notificationService, - ILogger logger, - IEnhancedLoggingService? enhancedLoggingService = null, - IActivityAuditService? activityAuditService = null) - : base(logger, enhancedLoggingService, activityAuditService) - { - this.systemTweaksService = systemTweaksService; - this.notificationService = notificationService; - - // Subscribe to tweak status changes - this.systemTweaksService.TweakStatusChanged += this.OnTweakStatusChanged; - - this.InitializeTweakItems(); - } - - private void InitializeTweakItems() - { - this.TweakItems = new ObservableCollection - { - new SystemTweakItem - { - Name = "Core Parking", - Description = "Controls CPU core parking for power management", - TweakType = SystemTweak.CoreParking, - IsEnabled = false, - IsAvailable = true, - ToggleCommand = new AsyncRelayCommand(this.ToggleTweakAsync), - }, - new SystemTweakItem - { - Name = "C-States", - Description = "Controls CPU C-States for power management", - TweakType = SystemTweak.CStates, - IsEnabled = false, - IsAvailable = true, - ToggleCommand = new AsyncRelayCommand(this.ToggleTweakAsync), - }, - new SystemTweakItem - { - Name = "SysMain Service", - Description = "Windows Superfetch/SysMain service for memory management", - TweakType = SystemTweak.SysMain, - IsEnabled = false, - IsAvailable = true, - ToggleCommand = new AsyncRelayCommand(this.ToggleTweakAsync), - }, - new SystemTweakItem - { - Name = "Prefetch", - Description = "Windows Prefetch feature for faster application loading", - TweakType = SystemTweak.Prefetch, - IsEnabled = false, - IsAvailable = true, - ToggleCommand = new AsyncRelayCommand(this.ToggleTweakAsync), - }, - new SystemTweakItem - { - Name = "Power Throttling", - Description = "Windows Power Throttling for energy efficiency", - TweakType = SystemTweak.PowerThrottling, - IsEnabled = false, - IsAvailable = true, - ToggleCommand = new AsyncRelayCommand(this.ToggleTweakAsync), - }, - new SystemTweakItem - { - Name = "HPET", - Description = "High Precision Event Timer for system timing", - TweakType = SystemTweak.Hpet, - IsEnabled = false, - IsAvailable = true, - ToggleCommand = new AsyncRelayCommand(this.ToggleTweakAsync), - }, - new SystemTweakItem - { - Name = "High Scheduling Category", - Description = "High scheduling priority for gaming applications", - TweakType = SystemTweak.HighSchedulingCategory, - IsEnabled = false, - IsAvailable = true, - ToggleCommand = new AsyncRelayCommand(this.ToggleTweakAsync), - }, - new SystemTweakItem - { - Name = "Menu Show Delay", - Description = "Delay before showing context menus", - TweakType = SystemTweak.MenuShowDelay, - IsEnabled = false, - IsAvailable = true, - ToggleCommand = new AsyncRelayCommand(this.ToggleTweakAsync) - }, - }; - } - - [RelayCommand] - public async Task LoadAsync() - { - await this.ExecuteAsync( - async () => - { - await this.RefreshAllTweaksAsync(); - }, "Loading system tweaks...", "System tweaks loaded successfully"); - } - - [RelayCommand] - public async Task RefreshAllTweaksAsync() - { - try - { - // Marshal UI updates to the UI thread to prevent cross-thread access exceptions - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.IsRefreshing = true; - this.RefreshStatusText = "Refreshing system tweaks..."; - }); - - await this.systemTweaksService.RefreshAllStatusesAsync(); - - // Update each tweak item with current status - foreach (var item in this.TweakItems) - { - await this.UpdateTweakItemStatusAsync(item); - } - - // Marshal UI updates to the UI thread to prevent cross-thread access exceptions - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.RefreshStatusText = $"Last refreshed: {DateTime.Now:HH:mm:ss}"; - }); - } - catch (Exception ex) - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetError("Failed to refresh system tweaks", ex); - this.RefreshStatusText = "Refresh failed"; - }); - } - finally - { - // Marshal UI updates to the UI thread to prevent cross-thread access exceptions - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.IsRefreshing = false; - }); - } - } - - private async Task UpdateTweakItemStatusAsync(SystemTweakItem item) - { - try - { - TweakStatus status = item.TweakType switch - { - SystemTweak.CoreParking => await this.systemTweaksService.GetCoreParkingStatusAsync(), - SystemTweak.CStates => await this.systemTweaksService.GetCStatesStatusAsync(), - SystemTweak.SysMain => await this.systemTweaksService.GetSysMainStatusAsync(), - SystemTweak.Prefetch => await this.systemTweaksService.GetPrefetchStatusAsync(), - SystemTweak.PowerThrottling => await this.systemTweaksService.GetPowerThrottlingStatusAsync(), - SystemTweak.Hpet => await this.systemTweaksService.GetHpetStatusAsync(), - SystemTweak.HighSchedulingCategory => await this.systemTweaksService.GetHighSchedulingCategoryStatusAsync(), - SystemTweak.MenuShowDelay => await this.systemTweaksService.GetMenuShowDelayStatusAsync(), - _ => new TweakStatus { IsAvailable = false, ErrorMessage = "Unknown tweak type" }, - }; - - item.IsEnabled = status.IsEnabled; - item.IsAvailable = status.IsAvailable; - item.ErrorMessage = status.ErrorMessage; - if (!string.IsNullOrEmpty(status.Description)) - { - item.Description = status.Description; - } - } - catch (Exception ex) - { - this.Logger.LogError(ex, "Error updating status for tweak {TweakName}", item.Name); - item.IsAvailable = false; - item.ErrorMessage = ex.Message; - } - } - - private async Task ToggleTweakAsync(SystemTweakItem? item) - { - if (item == null) - { - return; - } - - try - { - await InvokeOnUiAsync(() => - { - this.SetStatus($"Toggling {item.Name}..."); - }); - - var newState = !item.IsEnabled; - bool success = item.TweakType switch - { - SystemTweak.CoreParking => await this.systemTweaksService.SetCoreParkingAsync(newState), - SystemTweak.CStates => await this.systemTweaksService.SetCStatesAsync(newState), - SystemTweak.SysMain => await this.systemTweaksService.SetSysMainAsync(newState), - SystemTweak.Prefetch => await this.systemTweaksService.SetPrefetchAsync(newState), - SystemTweak.PowerThrottling => await this.systemTweaksService.SetPowerThrottlingAsync(newState), - SystemTweak.Hpet => await this.systemTweaksService.SetHpetAsync(newState), - SystemTweak.HighSchedulingCategory => await this.systemTweaksService.SetHighSchedulingCategoryAsync(newState), - SystemTweak.MenuShowDelay => await this.systemTweaksService.SetMenuShowDelayAsync(newState), - _ => false, - }; - - if (success) - { - await this.UpdateTweakItemStatusAsync(item); - await InvokeOnUiAsync(() => - { - this.SetStatus($"{item.Name} {(newState ? "enabled" : "disabled")} successfully"); - }); - - await this.notificationService.ShowSuccessNotificationAsync( - "System Tweak Updated", - $"{item.Name} has been {(newState ? "enabled" : "disabled")}"); - await this.LogUserActionAsync( - "SystemTweakApplied", - $"{item.Name} {(newState ? "enabled" : "disabled")}", - item.TweakType.ToString()); - } - else - { - await InvokeOnUiAsync(() => - { - this.SetError($"Failed to toggle {item.Name}", null); - }); - - await this.notificationService.ShowErrorNotificationAsync( - "System Tweak Failed", - $"Failed to {(newState ? "enable" : "disable")} {item.Name}"); - await this.LogUserActionAsync( - "SystemTweakFailed", - $"Failed to {(newState ? "enable" : "disable")} {item.Name}", - item.TweakType.ToString()); - } - } - catch (Exception ex) - { - await InvokeOnUiAsync(() => - { - this.SetError($"Error toggling {item.Name}", ex); - }); - this.Logger.LogError(ex, "Error toggling tweak {TweakName}", item.Name); - await this.LogUserActionAsync( - "SystemTweakFailed", - $"Error toggling {item.Name}: {ex.Message}", - item.TweakType.ToString()); - } - } - - private static Task InvokeOnUiAsync(Action action) - { - var dispatcher = System.Windows.Application.Current?.Dispatcher; - if (dispatcher == null) - { - action(); - return Task.CompletedTask; - } - - return dispatcher.InvokeAsync(action).Task; - } - - private void OnTweakStatusChanged(object? sender, TweakStatusChangedEventArgs e) - { - try - { - var item = this.TweakItems.FirstOrDefault(t => t.TweakType.ToString() == e.TweakName); - if (item != null) - { - item.IsEnabled = e.Status.IsEnabled; - item.IsAvailable = e.Status.IsAvailable; - item.ErrorMessage = e.Status.ErrorMessage; - } - } - catch (Exception ex) - { - this.Logger.LogError(ex, "Error handling tweak status change for {TweakName}", e.TweakName); - } - } - - protected override void OnDispose() - { - this.systemTweaksService.TweakStatusChanged -= this.OnTweakStatusChanged; - base.OnDispose(); - } - } - - /// - /// Represents a system tweak item in the UI. - /// - public partial class SystemTweakItem : ObservableObject - { - [ObservableProperty] - private string name = string.Empty; - - [ObservableProperty] - private string description = string.Empty; - - [ObservableProperty] - private SystemTweak tweakType; - - [ObservableProperty] - private bool isEnabled; - - [ObservableProperty] - private bool isAvailable = true; - - [ObservableProperty] - private string? errorMessage; - - public IAsyncRelayCommand? ToggleCommand { get; set; } - } -} +namespace ThreadPilot.ViewModels +{ + using System; + using System.Collections.ObjectModel; + using System.Linq; + using System.Threading.Tasks; + using CommunityToolkit.Mvvm.ComponentModel; + using CommunityToolkit.Mvvm.Input; + using Microsoft.Extensions.Logging; + using ThreadPilot.Services; + + public partial class SystemTweaksViewModel : BaseViewModel + { + private readonly ISystemTweaksService systemTweaksService; + private readonly INotificationService notificationService; + + [ObservableProperty] + private ObservableCollection tweakItems = new(); + + [ObservableProperty] + private bool isRefreshing; + + [ObservableProperty] + private string refreshStatusText = "Ready"; + + public SystemTweaksViewModel( + ISystemTweaksService systemTweaksService, + INotificationService notificationService, + ILogger logger, + IEnhancedLoggingService? enhancedLoggingService = null, + IActivityAuditService? activityAuditService = null) + : base(logger, enhancedLoggingService, activityAuditService) + { + this.systemTweaksService = systemTweaksService; + this.notificationService = notificationService; + + // Subscribe to tweak status changes + this.systemTweaksService.TweakStatusChanged += this.OnTweakStatusChanged; + + this.InitializeTweakItems(); + } + + private void InitializeTweakItems() + { + this.TweakItems = new ObservableCollection + { + new SystemTweakItem + { + Name = "Core Parking", + Description = "Controls CPU core parking for power management", + TweakType = SystemTweak.CoreParking, + IsEnabled = false, + IsAvailable = true, + ToggleCommand = new AsyncRelayCommand(this.ToggleTweakAsync), + }, + new SystemTweakItem + { + Name = "C-States", + Description = "Controls CPU C-States for power management", + TweakType = SystemTweak.CStates, + IsEnabled = false, + IsAvailable = true, + ToggleCommand = new AsyncRelayCommand(this.ToggleTweakAsync), + }, + new SystemTweakItem + { + Name = "SysMain Service", + Description = "Windows Superfetch/SysMain service for memory management", + TweakType = SystemTweak.SysMain, + IsEnabled = false, + IsAvailable = true, + ToggleCommand = new AsyncRelayCommand(this.ToggleTweakAsync), + }, + new SystemTweakItem + { + Name = "Prefetch", + Description = "Windows Prefetch feature for faster application loading", + TweakType = SystemTweak.Prefetch, + IsEnabled = false, + IsAvailable = true, + ToggleCommand = new AsyncRelayCommand(this.ToggleTweakAsync), + }, + new SystemTweakItem + { + Name = "Power Throttling", + Description = "Windows Power Throttling for energy efficiency", + TweakType = SystemTweak.PowerThrottling, + IsEnabled = false, + IsAvailable = true, + ToggleCommand = new AsyncRelayCommand(this.ToggleTweakAsync), + }, + new SystemTweakItem + { + Name = "HPET", + Description = "High Precision Event Timer for system timing", + TweakType = SystemTweak.Hpet, + IsEnabled = false, + IsAvailable = true, + ToggleCommand = new AsyncRelayCommand(this.ToggleTweakAsync), + }, + new SystemTweakItem + { + Name = "High Scheduling Category", + Description = "High scheduling priority for gaming applications", + TweakType = SystemTweak.HighSchedulingCategory, + IsEnabled = false, + IsAvailable = true, + ToggleCommand = new AsyncRelayCommand(this.ToggleTweakAsync), + }, + new SystemTweakItem + { + Name = "Menu Show Delay", + Description = "Delay before showing context menus", + TweakType = SystemTweak.MenuShowDelay, + IsEnabled = false, + IsAvailable = true, + ToggleCommand = new AsyncRelayCommand(this.ToggleTweakAsync) + }, + }; + } + + [RelayCommand] + public async Task LoadAsync() + { + await this.ExecuteAsync( + async () => + { + await this.RefreshAllTweaksAsync(); + }, "Loading system tweaks...", "System tweaks loaded successfully"); + } + + [RelayCommand] + public async Task RefreshAllTweaksAsync() + { + try + { + // Marshal UI updates to the UI thread to prevent cross-thread access exceptions + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.IsRefreshing = true; + this.RefreshStatusText = "Refreshing system tweaks..."; + }); + + await this.systemTweaksService.RefreshAllStatusesAsync(); + + // Update each tweak item with current status + foreach (var item in this.TweakItems) + { + await this.UpdateTweakItemStatusAsync(item); + } + + // Marshal UI updates to the UI thread to prevent cross-thread access exceptions + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.RefreshStatusText = $"Last refreshed: {DateTime.Now:HH:mm:ss}"; + }); + } + catch (Exception ex) + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.SetError("Failed to refresh system tweaks", ex); + this.RefreshStatusText = "Refresh failed"; + }); + } + finally + { + // Marshal UI updates to the UI thread to prevent cross-thread access exceptions + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.IsRefreshing = false; + }); + } + } + + private async Task UpdateTweakItemStatusAsync(SystemTweakItem item) + { + try + { + TweakStatus status = item.TweakType switch + { + SystemTweak.CoreParking => await this.systemTweaksService.GetCoreParkingStatusAsync(), + SystemTweak.CStates => await this.systemTweaksService.GetCStatesStatusAsync(), + SystemTweak.SysMain => await this.systemTweaksService.GetSysMainStatusAsync(), + SystemTweak.Prefetch => await this.systemTweaksService.GetPrefetchStatusAsync(), + SystemTweak.PowerThrottling => await this.systemTweaksService.GetPowerThrottlingStatusAsync(), + SystemTweak.Hpet => await this.systemTweaksService.GetHpetStatusAsync(), + SystemTweak.HighSchedulingCategory => await this.systemTweaksService.GetHighSchedulingCategoryStatusAsync(), + SystemTweak.MenuShowDelay => await this.systemTweaksService.GetMenuShowDelayStatusAsync(), + _ => new TweakStatus { IsAvailable = false, ErrorMessage = "Unknown tweak type" }, + }; + + item.IsEnabled = status.IsEnabled; + item.IsAvailable = status.IsAvailable; + item.ErrorMessage = status.ErrorMessage; + if (!string.IsNullOrEmpty(status.Description)) + { + item.Description = status.Description; + } + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Error updating status for tweak {TweakName}", item.Name); + item.IsAvailable = false; + item.ErrorMessage = ex.Message; + } + } + + private async Task ToggleTweakAsync(SystemTweakItem? item) + { + if (item == null) + { + return; + } + + try + { + await InvokeOnUiAsync(() => + { + this.SetStatus($"Toggling {item.Name}..."); + }); + + var newState = !item.IsEnabled; + bool success = item.TweakType switch + { + SystemTweak.CoreParking => await this.systemTweaksService.SetCoreParkingAsync(newState), + SystemTweak.CStates => await this.systemTweaksService.SetCStatesAsync(newState), + SystemTweak.SysMain => await this.systemTweaksService.SetSysMainAsync(newState), + SystemTweak.Prefetch => await this.systemTweaksService.SetPrefetchAsync(newState), + SystemTweak.PowerThrottling => await this.systemTweaksService.SetPowerThrottlingAsync(newState), + SystemTweak.Hpet => await this.systemTweaksService.SetHpetAsync(newState), + SystemTweak.HighSchedulingCategory => await this.systemTweaksService.SetHighSchedulingCategoryAsync(newState), + SystemTweak.MenuShowDelay => await this.systemTweaksService.SetMenuShowDelayAsync(newState), + _ => false, + }; + + if (success) + { + await this.UpdateTweakItemStatusAsync(item); + await InvokeOnUiAsync(() => + { + this.SetStatus($"{item.Name} {(newState ? "enabled" : "disabled")} successfully"); + }); + + await this.notificationService.ShowSuccessNotificationAsync( + "System Tweak Updated", + $"{item.Name} has been {(newState ? "enabled" : "disabled")}"); + await this.LogUserActionAsync( + "SystemTweakApplied", + $"{item.Name} {(newState ? "enabled" : "disabled")}", + item.TweakType.ToString()); + } + else + { + await InvokeOnUiAsync(() => + { + this.SetError($"Failed to toggle {item.Name}", null); + }); + + await this.notificationService.ShowErrorNotificationAsync( + "System Tweak Failed", + $"Failed to {(newState ? "enable" : "disable")} {item.Name}"); + await this.LogUserActionAsync( + "SystemTweakFailed", + $"Failed to {(newState ? "enable" : "disable")} {item.Name}", + item.TweakType.ToString()); + } + } + catch (Exception ex) + { + await InvokeOnUiAsync(() => + { + this.SetError($"Error toggling {item.Name}", ex); + }); + this.Logger.LogError(ex, "Error toggling tweak {TweakName}", item.Name); + await this.LogUserActionAsync( + "SystemTweakFailed", + $"Error toggling {item.Name}: {ex.Message}", + item.TweakType.ToString()); + } + } + + private static Task InvokeOnUiAsync(Action action) + { + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher == null) + { + action(); + return Task.CompletedTask; + } + + return dispatcher.InvokeAsync(action).Task; + } + + private void OnTweakStatusChanged(object? sender, TweakStatusChangedEventArgs e) + { + try + { + var item = this.TweakItems.FirstOrDefault(t => t.TweakType.ToString() == e.TweakName); + if (item != null) + { + item.IsEnabled = e.Status.IsEnabled; + item.IsAvailable = e.Status.IsAvailable; + item.ErrorMessage = e.Status.ErrorMessage; + } + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Error handling tweak status change for {TweakName}", e.TweakName); + } + } + + protected override void OnDispose() + { + this.systemTweaksService.TweakStatusChanged -= this.OnTweakStatusChanged; + base.OnDispose(); + } + } + + public partial class SystemTweakItem : ObservableObject + { + [ObservableProperty] + private string name = string.Empty; + + [ObservableProperty] + private string description = string.Empty; + + [ObservableProperty] + private SystemTweak tweakType; + + [ObservableProperty] + private bool isEnabled; + + [ObservableProperty] + private bool isAvailable = true; + + [ObservableProperty] + private string? errorMessage; + + public IAsyncRelayCommand? ToggleCommand { get; set; } + } +} diff --git a/ViewModels/ViewModelFactory.cs b/ViewModels/ViewModelFactory.cs deleted file mode 100644 index 723d919..0000000 --- a/ViewModels/ViewModelFactory.cs +++ /dev/null @@ -1,150 +0,0 @@ -/* - * 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.ViewModels -{ - using System; - using System.Collections.Generic; - using Microsoft.Extensions.DependencyInjection; - using Microsoft.Extensions.Logging; - using ThreadPilot.Services; - - /// - /// Factory for creating and managing ViewModel instances. - /// - public interface IViewModelFactory - { - /// - /// Create a ViewModel instance of the specified type. - /// - T CreateViewModel() - where T : BaseViewModel; - - /// - /// Create a ViewModel instance with initialization. - /// - Task CreateAndInitializeViewModelAsync() - where T : BaseViewModel; - - /// - /// Dispose all managed ViewModels. - /// - void DisposeAllViewModels(); - } - - /// - /// Implementation of ViewModel factory with dependency injection support. - /// - public class ViewModelFactory : IViewModelFactory, IDisposable - { - private readonly IServiceProvider serviceProvider; - private readonly ILogger logger; - private readonly List managedViewModels = new(); - private bool disposed; - - public ViewModelFactory(IServiceProvider serviceProvider, ILogger logger) - { - this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public T CreateViewModel() - where T : BaseViewModel - { - try - { - var viewModel = this.serviceProvider.GetRequiredService(); - this.managedViewModels.Add(viewModel); - - this.logger.LogDebug("Created ViewModel of type {ViewModelType}", typeof(T).Name); - return viewModel; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to create ViewModel of type {ViewModelType}", typeof(T).Name); - throw; - } - } - - public async Task CreateAndInitializeViewModelAsync() - where T : BaseViewModel - { - var viewModel = this.CreateViewModel(); - - try - { - await viewModel.InitializeAsync(); - this.logger.LogDebug("Initialized ViewModel of type {ViewModelType}", typeof(T).Name); - return viewModel; - } - catch (Exception ex) - { - this.logger.LogError(ex, "Failed to initialize ViewModel of type {ViewModelType}", typeof(T).Name); - - // Dispose the failed ViewModel - viewModel.Dispose(); - this.managedViewModels.Remove(viewModel); - - throw; - } - } - - public void DisposeAllViewModels() - { - this.logger.LogInformation("Disposing all managed ViewModels"); - - foreach (var viewModel in this.managedViewModels) - { - try - { - viewModel.Dispose(); - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error disposing ViewModel {ViewModelType}", viewModel.GetType().Name); - } - } - - this.managedViewModels.Clear(); - this.logger.LogInformation("All managed ViewModels disposed"); - } - - public void Dispose() - { - if (!this.disposed) - { - this.DisposeAllViewModels(); - this.disposed = true; - } - } - } - - /// - /// Extension methods for ViewModel factory registration. - /// - public static class ViewModelFactoryExtensions - { - /// - /// Register ViewModel factory in dependency injection container. - /// - public static IServiceCollection AddViewModelFactory(this IServiceCollection services) - { - services.AddSingleton(); - return services; - } - } -} - diff --git a/Views/LogViewerView.xaml.cs b/Views/LogViewerView.xaml.cs index 80ce1da..70b28e5 100644 --- a/Views/LogViewerView.xaml.cs +++ b/Views/LogViewerView.xaml.cs @@ -1,19 +1,3 @@ -/* - * 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 . - */ using System; using System.Globalization; using System.Windows; @@ -24,9 +8,6 @@ namespace ThreadPilot.Views { - /// - /// Interaction logic for LogViewerView.xaml. - /// public partial class LogViewerView : System.Windows.Controls.UserControl { public LogViewerView() @@ -52,9 +33,6 @@ private async Task OnLoadedAsync() } } - /// - /// Converter to convert bytes to megabytes for display. - /// public class BytesToMegabytesConverter : IValueConverter { public static readonly BytesToMegabytesConverter Instance = new(); @@ -74,9 +52,6 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu } } - /// - /// Converter to invert boolean values. - /// public class InverseBooleanConverter : IValueConverter { public static readonly InverseBooleanConverter Instance = new(); @@ -100,9 +75,6 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu } } - /// - /// Converter to invert boolean values for visibility. - /// public class InverseBooleanToVisibilityConverter : IValueConverter { public static readonly InverseBooleanToVisibilityConverter Instance = new(); diff --git a/Views/MasksView.xaml.cs b/Views/MasksView.xaml.cs index 2a1a23c..4868fd0 100644 --- a/Views/MasksView.xaml.cs +++ b/Views/MasksView.xaml.cs @@ -1,35 +1,15 @@ -/* - * 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.Views -{ - using ThreadPilot.Helpers; - using ThreadPilot.ViewModels; - - /// - /// Interaction logic for MasksView.xaml - /// Based on CPUSetSetter's data-binding pattern. - /// - public partial class MasksView : System.Windows.Controls.UserControl - { - public MasksView() - { - this.InitializeComponent(); - this.DataContext = ServiceProviderExtensions.GetService(); - } - } -} - +namespace ThreadPilot.Views +{ + using ThreadPilot.Helpers; + using ThreadPilot.ViewModels; + + public partial class MasksView : System.Windows.Controls.UserControl + { + public MasksView() + { + this.InitializeComponent(); + this.DataContext = ServiceProviderExtensions.GetService(); + } + } +} + diff --git a/Views/PerformanceView.xaml.cs b/Views/PerformanceView.xaml.cs index f804f07..6efe630 100644 --- a/Views/PerformanceView.xaml.cs +++ b/Views/PerformanceView.xaml.cs @@ -1,39 +1,20 @@ -/* - * 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.Views -{ - using System.Windows.Controls; - using ThreadPilot.ViewModels; - - /// - /// Interaction logic for PerformanceView.xaml. - /// - public partial class PerformanceView : System.Windows.Controls.UserControl - { - public PerformanceView() - { - this.InitializeComponent(); - } - - public PerformanceView(PerformanceViewModel viewModel) - : this() - { - this.DataContext = viewModel; - } - } -} - +namespace ThreadPilot.Views +{ + using System.Windows.Controls; + using ThreadPilot.ViewModels; + + public partial class PerformanceView : System.Windows.Controls.UserControl + { + public PerformanceView() + { + this.InitializeComponent(); + } + + public PerformanceView(PerformanceViewModel viewModel) + : this() + { + this.DataContext = viewModel; + } + } +} + diff --git a/Views/PowerPlanView.xaml.cs b/Views/PowerPlanView.xaml.cs index 6492b5e..edad373 100644 --- a/Views/PowerPlanView.xaml.cs +++ b/Views/PowerPlanView.xaml.cs @@ -1,35 +1,19 @@ -/* - * 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.Views -{ - using System.Windows.Controls; - using ThreadPilot.ViewModels; - - public partial class PowerPlanView : System.Windows.Controls.UserControl - { - public PowerPlanView() - { - this.InitializeComponent(); - } - - public PowerPlanView(PowerPlanViewModel viewModel) - : this() - { - this.DataContext = viewModel; - } - } -} +namespace ThreadPilot.Views +{ + using System.Windows.Controls; + using ThreadPilot.ViewModels; + + public partial class PowerPlanView : System.Windows.Controls.UserControl + { + public PowerPlanView() + { + this.InitializeComponent(); + } + + public PowerPlanView(PowerPlanViewModel viewModel) + : this() + { + this.DataContext = viewModel; + } + } +} diff --git a/Views/ProcessPowerPlanAssociationView.xaml.cs b/Views/ProcessPowerPlanAssociationView.xaml.cs index 7706567..254f522 100644 --- a/Views/ProcessPowerPlanAssociationView.xaml.cs +++ b/Views/ProcessPowerPlanAssociationView.xaml.cs @@ -1,32 +1,13 @@ -/* - * 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.Views -{ - using WpfUserControl = System.Windows.Controls.UserControl; - - /// - /// Interaction logic for ProcessPowerPlanAssociationView.xaml. - /// - public partial class ProcessPowerPlanAssociationView : WpfUserControl - { - public ProcessPowerPlanAssociationView() - { - this.InitializeComponent(); - } - } -} - +namespace ThreadPilot.Views +{ + using WpfUserControl = System.Windows.Controls.UserControl; + + public partial class ProcessPowerPlanAssociationView : WpfUserControl + { + public ProcessPowerPlanAssociationView() + { + this.InitializeComponent(); + } + } +} + diff --git a/Views/ProcessView.xaml.cs b/Views/ProcessView.xaml.cs index 70d1767..c4f464d 100644 --- a/Views/ProcessView.xaml.cs +++ b/Views/ProcessView.xaml.cs @@ -1,43 +1,27 @@ -/* - * 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.Views -{ - using System.Windows.Controls; - using System.Windows.Input; - using ThreadPilot.Helpers; - using ThreadPilot.ViewModels; - - public partial class ProcessView : System.Windows.Controls.UserControl - { - 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(); - } - } -} +namespace ThreadPilot.Views +{ + using System.Windows.Controls; + using System.Windows.Input; + using ThreadPilot.Helpers; + using ThreadPilot.ViewModels; + + public partial class ProcessView : System.Windows.Controls.UserControl + { + 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(); + } + } +} diff --git a/Views/SettingsView.xaml.cs b/Views/SettingsView.xaml.cs index 3172f9a..b9f05e0 100644 --- a/Views/SettingsView.xaml.cs +++ b/Views/SettingsView.xaml.cs @@ -1,58 +1,39 @@ -/* - * 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.Views -{ - using System.Windows; - using System.Windows.Controls; - using ThreadPilot.Services; - using ThreadPilot.ViewModels; - - /// - /// Interaction logic for SettingsView.xaml. - /// - public partial class SettingsView : System.Windows.Controls.UserControl - { - public SettingsView() - { - this.InitializeComponent(); - this.Loaded += this.SettingsView_Loaded; - } - - public SettingsView(SettingsViewModel viewModel) - : this() - { - this.DataContext = viewModel; - } - - private void SettingsView_Loaded(object sender, RoutedEventArgs e) - { - TaskSafety.FireAndForget(this.SettingsView_LoadedAsync(), _ => - { - // Non-critical load refresh failures are handled by the view model. - }); - } - - private async Task SettingsView_LoadedAsync() - { - if (this.DataContext is SettingsViewModel viewModel) - { - await viewModel.RefreshSettingsAsync(); - } - } - } -} - +namespace ThreadPilot.Views +{ + using System.Windows; + using System.Windows.Controls; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public partial class SettingsView : System.Windows.Controls.UserControl + { + public SettingsView() + { + this.InitializeComponent(); + this.Loaded += this.SettingsView_Loaded; + } + + public SettingsView(SettingsViewModel viewModel) + : this() + { + this.DataContext = viewModel; + } + + private void SettingsView_Loaded(object sender, RoutedEventArgs e) + { + TaskSafety.FireAndForget(this.SettingsView_LoadedAsync(), _ => + { + // Non-critical load refresh failures are handled by the view model. + }); + } + + private async Task SettingsView_LoadedAsync() + { + if (this.DataContext is SettingsViewModel viewModel) + { + await viewModel.RefreshSettingsAsync(); + } + } + } +} + diff --git a/Views/SettingsWindow.xaml.cs b/Views/SettingsWindow.xaml.cs index 8632b0a..a3de744 100644 --- a/Views/SettingsWindow.xaml.cs +++ b/Views/SettingsWindow.xaml.cs @@ -1,81 +1,62 @@ -/* - * 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.Views -{ - using System; - using System.ComponentModel; - using System.Windows; - using ThreadPilot.ViewModels; - - /// - /// Interaction logic for SettingsWindow.xaml. - /// - public partial class SettingsWindow : Window - { - private readonly SettingsViewModel viewModel; - private bool isClosingAfterUnsavedPrompt; - - public SettingsWindow(SettingsViewModel viewModel) - { - this.InitializeComponent(); - - this.viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel)); - this.SettingsViewControl.DataContext = this.viewModel; - } - - protected override void OnClosing(CancelEventArgs e) - { - // Check for unsaved changes - if (!this.isClosingAfterUnsavedPrompt && !this.viewModel.CanClose()) - { - e.Cancel = true; - this.UnsavedSettingsOverlay.Visibility = Visibility.Visible; - return; - } - - base.OnClosing(e); - } - - private async void UnsavedSettingsSave_Click(object sender, RoutedEventArgs e) - { - var saved = await this.viewModel.SaveIfDirtyAsync(); - if (saved) - { - this.CloseAfterUnsavedPrompt(); - } - } - - private async void UnsavedSettingsDiscard_Click(object sender, RoutedEventArgs e) - { - await this.viewModel.DiscardPendingChangesAsync(); - this.CloseAfterUnsavedPrompt(); - } - - private void UnsavedSettingsCancel_Click(object sender, RoutedEventArgs e) - { - this.UnsavedSettingsOverlay.Visibility = Visibility.Collapsed; - } - - private void CloseAfterUnsavedPrompt() - { - this.isClosingAfterUnsavedPrompt = true; - this.UnsavedSettingsOverlay.Visibility = Visibility.Collapsed; - this.Close(); - } - } -} - +namespace ThreadPilot.Views +{ + using System; + using System.ComponentModel; + using System.Windows; + using ThreadPilot.ViewModels; + + public partial class SettingsWindow : Window + { + private readonly SettingsViewModel viewModel; + private bool isClosingAfterUnsavedPrompt; + + public SettingsWindow(SettingsViewModel viewModel) + { + this.InitializeComponent(); + + this.viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel)); + this.SettingsViewControl.DataContext = this.viewModel; + } + + protected override void OnClosing(CancelEventArgs e) + { + // Check for unsaved changes + if (!this.isClosingAfterUnsavedPrompt && !this.viewModel.CanClose()) + { + e.Cancel = true; + this.UnsavedSettingsOverlay.Visibility = Visibility.Visible; + return; + } + + base.OnClosing(e); + } + + private async void UnsavedSettingsSave_Click(object sender, RoutedEventArgs e) + { + var saved = await this.viewModel.SaveIfDirtyAsync(); + if (saved) + { + this.CloseAfterUnsavedPrompt(); + } + } + + private async void UnsavedSettingsDiscard_Click(object sender, RoutedEventArgs e) + { + await this.viewModel.DiscardPendingChangesAsync(); + this.CloseAfterUnsavedPrompt(); + } + + private void UnsavedSettingsCancel_Click(object sender, RoutedEventArgs e) + { + this.UnsavedSettingsOverlay.Visibility = Visibility.Collapsed; + } + + private void CloseAfterUnsavedPrompt() + { + this.isClosingAfterUnsavedPrompt = true; + this.UnsavedSettingsOverlay.Visibility = Visibility.Collapsed; + this.Close(); + } + } +} + diff --git a/Views/SystemTweaksView.xaml.cs b/Views/SystemTweaksView.xaml.cs index da2d6cf..4f7c25a 100644 --- a/Views/SystemTweaksView.xaml.cs +++ b/Views/SystemTweaksView.xaml.cs @@ -1,50 +1,31 @@ -/* - * 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.Views -{ - using System.Windows.Controls; - using ThreadPilot.Services; - using ThreadPilot.ViewModels; - - /// - /// Interaction logic for SystemTweaksView.xaml. - /// - public partial class SystemTweaksView : System.Windows.Controls.UserControl - { - public SystemTweaksView() - { - this.InitializeComponent(); - } - - private void UserControl_Loaded(object sender, System.Windows.RoutedEventArgs e) - { - TaskSafety.FireAndForget(this.UserControl_LoadedAsync(), _ => - { - // Ignore non-fatal loading errors to keep the view responsive. - }); - } - - private async Task UserControl_LoadedAsync() - { - if (this.DataContext is SystemTweaksViewModel viewModel) - { - await viewModel.LoadCommand.ExecuteAsync(null); - } - } - } -} - +namespace ThreadPilot.Views +{ + using System.Windows.Controls; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public partial class SystemTweaksView : System.Windows.Controls.UserControl + { + public SystemTweaksView() + { + this.InitializeComponent(); + } + + private void UserControl_Loaded(object sender, System.Windows.RoutedEventArgs e) + { + TaskSafety.FireAndForget(this.UserControl_LoadedAsync(), _ => + { + // Ignore non-fatal loading errors to keep the view responsive. + }); + } + + private async Task UserControl_LoadedAsync() + { + if (this.DataContext is SystemTweaksViewModel viewModel) + { + await viewModel.LoadCommand.ExecuteAsync(null); + } + } + } +} + diff --git a/chocolatey/threadpilot-1.1.2.1-fix.zip b/chocolatey/threadpilot-1.1.2.1-fix.zip deleted file mode 100644 index 882e152..0000000 Binary files a/chocolatey/threadpilot-1.1.2.1-fix.zip and /dev/null differ diff --git a/docs/archive/CLAUDE.md b/docs/archive/CLAUDE.md deleted file mode 100644 index 564908e..0000000 --- a/docs/archive/CLAUDE.md +++ /dev/null @@ -1,238 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -ThreadPilot is a professional Windows process and power plan manager built with WPF and .NET 8.0. It provides advanced process management, intelligent power plan automation, ML-based game detection, and system optimization tools for power users, gamers, and system administrators. - -## Build and Development Commands - -### Building -```bash -# Build the project -dotnet build --configuration Release - -# Build for debugging -dotnet build --configuration Debug -``` - -### Running -```bash -# Run the application -dotnet run --configuration Release - -# Run with command-line arguments -dotnet run --configuration Release -- --test # Run tests -dotnet run --configuration Release -- --start-minimized # Start minimized -dotnet run --configuration Release -- --autostart # Autostart mode -``` - -### Publishing -```bash -# Publish as self-contained portable executable -dotnet publish --configuration Release --runtime win-x64 --self-contained true -``` - -### Testing -The application includes an integrated test suite: -- Press `Ctrl+Shift+T` in the running application to execute Game Boost integration tests -- Run `dotnet run --configuration Release -- --test` to run tests in console mode -- Tests are located in the `Tests/` directory - -## Architecture Overview - -### MVVM Pattern with Dependency Injection -The application follows strict MVVM architecture using CommunityToolkit.Mvvm with centralized DI configuration: - -- **Models/** - Data models using ObservableObject base class -- **Views/** - WPF XAML views with code-behind -- **ViewModels/** - View models implementing INotifyPropertyChanged via source generators -- **Services/** - Business logic and system interaction layer - -### Service Configuration -All services are configured in `Services/ServiceConfiguration.cs` using extension methods organized by layer: -- `ConfigureServiceInfrastructure()` - Logging, caching, health monitoring, retry policies -- `ConfigureCoreSystemServices()` - OS interaction (ProcessService, PowerPlanService, CpuTopologyService) -- `ConfigureProcessManagementServices()` - Process monitoring, game detection, boost services -- `ConfigureApplicationLevelServices()` - Settings, notifications, system tray, security -- `ConfigurePresentationLayer()` - ViewModels and Views - -### Service Lifetime Strategy -- **Singletons**: Core services, ViewModels that share state (ProcessViewModel, MasksViewModel) -- **Transients**: UI-specific ViewModels and Views (PowerPlanViewModel, SettingsViewModel, MainWindow) - -### CPU Topology and Affinity Management -The application has sophisticated CPU topology awareness: -- **CpuTopologyService** - Detects P-cores/E-cores (Intel Hybrid), AMD CCD layout, NUMA nodes -- **CoreMaskService** - Manages CPU affinity masks for precise core assignment -- **ProcessCpuSetHandler** (Platforms/Windows/) - Uses Windows CPU Sets API for modern affinity control on Windows 11+ -- Fallback to traditional `ProcessorAffinity` for Windows 10 compatibility - -CPU Sets vs ProcessorAffinity: -- CPU Sets (Windows 11+): More granular control, respects system scheduling policies -- ProcessorAffinity (legacy): Direct affinity mask, used as fallback - -### Power Plan Management -- Integrates with Windows Power Plans via `powercfg` command-line tool -- Supports custom .pow power plan imports from hardcoded path (see PowerPlanService.cs:15) -- Process-based automatic power plan switching via `ProcessPowerPlanAssociationService` -- Real-time power plan monitoring with change event notifications - -### Game Detection and Boost -- **GameDetectionService** - ML-based game detection with 95% accuracy (heuristics-based) -- **GameBoostService** - Automatic performance optimization for detected games -- **PerformanceMonitoringService** - Real-time FPS estimation and resource tracking -- User override system with persistent manual classification - -### Notification System -- **NotificationService** - Basic Windows notifications -- **SmartNotificationService** - Intelligent throttling, deduplication, DND mode, priority queuing -- Category-based notification preferences -- Integrates with system tray for balloon tips - -### Process Monitoring -- **ProcessMonitorService** - Low-level WMI-based process event monitoring -- **ProcessMonitorManagerService** - Orchestrates monitoring, profile application, power plan switching -- **VirtualizedProcessService** - Handles 5000+ processes efficiently with UI virtualization -- Background refresh with intelligent throttling to reduce resource usage - -### System Tweaks -- Core parking control -- C-States management -- System service tweaks (SysMain, Prefetch, power throttling) -- HPET configuration -- High-priority scheduling category management - -### Security and Elevation -- **ElevationService** - Manages administrator privilege detection and elevation requests -- **SecurityService** - Security-related functionality -- Application can run in limited mode without admin privileges -- Prompts for elevation when needed for specific features - -## Key Implementation Details - -### Async Initialization Pattern -MainWindow uses a sophisticated async initialization pattern with loading overlay: -1. Loading overlay displayed during startup -2. ViewModels initialized with timeout protection (prevents hanging) -3. Services initialized in specific order with fallback strategies -4. System tray and monitoring started last -5. Graceful degradation if components fail (e.g., basic tray mode if full init times out) - -### Cross-Thread Marshaling -UI updates from background threads must be marshaled via `Dispatcher.InvokeAsync()`: -```csharp -Dispatcher.InvokeAsync(() => { - // UI updates here -}); -``` - -### Memory Management -- `IServiceHealthMonitor` and `IServiceDisposalCoordinator` for lifecycle management -- `IRetryPolicyService` for automatic retry with exponential backoff -- Memory cache (Microsoft.Extensions.Caching.Memory) for performance optimization -- Proper cleanup of timers, event handlers, and WMI watchers - -### Logging -- Uses Microsoft.Extensions.Logging with console output -- **EnhancedLoggingService** provides correlation IDs and structured logging -- Debug logging to temp file during initialization (see MainWindow.xaml.cs:45) - -### Settings Persistence -- Settings stored via `ApplicationSettingsService` (JSON-based) -- Profiles stored in ApplicationData\ThreadPilot\Profiles as JSON files -- Power plans exported as .pow files in hardcoded directory - -### Keyboard Shortcuts -- **KeyboardShortcutService** manages global hotkeys via Win32 API -- RegisterHotKey/UnregisterHotKey integration -- Actions: ShowMainWindow, ToggleMonitoring, GameBoostToggle, OpenTweaks, OpenSettings, etc. -- Shortcuts loaded from settings - -### System Tray Integration -- **SystemTrayService** with context menu for quick actions -- Power plan switching directly from tray -- Monitoring status display (CPU/Memory usage) -- Game Boost status indicator -- Periodic updates every 10 seconds (performance-optimized) - -### Platform-Specific Code -- Windows-specific functionality isolated in `Platforms/Windows/` -- P/Invoke calls for CPU Sets API (CpuSetNativeMethods.cs) -- Requires `AllowUnsafeBlocks` for native interop - -## Common Development Patterns - -### Creating New Services -1. Define interface in `Services/I*.cs` -2. Implement in `Services/*.cs` with constructor injection -3. Register in `ServiceConfiguration.cs` in appropriate layer method -4. Inject into ViewModels or other services as needed - -### Adding ViewModels -1. Inherit from `BaseViewModel` or `ObservableObject` -2. Use `[ObservableProperty]` source generators for properties -3. Use `[RelayCommand]` for commands -4. Register in `ServiceConfiguration.ConfigurePresentationLayer()` -5. Choose Singleton (shared state) or Transient (per-instance) lifetime - -### Working with Processes -- Use `IProcessService` for basic process operations -- Use `IVirtualizedProcessService` for large process lists in UI -- Update ProcessModel properties to trigger UI updates automatically -- CPU usage calculation requires two samples (see ProcessService.CalculateCpuUsage) - -### Adding System Tweaks -- Implement tweak logic in `SystemTweaksService` -- Add corresponding properties/commands to `SystemTweaksViewModel` -- Update UI in `Views/SystemTweaksView.xaml` -- Most tweaks require administrator privileges - -## Project Structure Notes - -- **Converters/** - WPF value converters (e.g., ItemIndexConverter for list numbering) -- **Models/** - Core data models (ProcessModel, PowerPlanModel, ProcessPowerPlanAssociation, CoreMask) -- **app.manifest** - Defines UAC elevation requirements and compatibility -- **ico.ico** - Application icon used in loading overlay and system tray -- **ThreadPilot.csproj** - Configured for single-file publish, self-contained, win-x64 only - -## Important Implementation Notes - -### Startup Sequence -1. App.xaml.cs configures DI container via ServiceConfiguration -2. Validates core service resolution -3. Checks elevation status (shows warning if not admin) -4. Parses command-line arguments (--test, --start-minimized, --autostart) -5. MainWindow constructor initializes loading overlay -6. Async initialization loads ViewModels, Services, starts monitoring -7. Loading overlay hidden when complete (with timeout protection) - -### Error Handling Strategy -- Global exception handlers in App.xaml.cs (domain + dispatcher) -- Timeout protection on async operations (typically 5-8 seconds) -- Fallback strategies for failed initializations (e.g., basic system tray) -- User-friendly error dialogs with retry options -- Detailed logging with correlation IDs - -### Performance Considerations -- Process list refresh paused when window minimized -- System tray updates throttled to 10-second intervals -- Virtualized UI for large datasets (process lists) -- CPU usage calculations cached per process -- Intelligent notification deduplication and throttling - -### Windows Version Compatibility -- Targets .NET 8.0 Windows only (UseWPF + UseWindowsForms) -- CPU Sets API used on Windows 11+, falls back to ProcessorAffinity on Windows 10 -- Requires Windows 10/11 for full functionality -- Some features require administrator privileges - -## Gotchas and Known Issues - -- PowerPlanService.cs:15 has hardcoded path `C:\Users\Administrator\Desktop\Project\ThreadPilot_1\Powerplans` -- WMI monitoring may fail on some systems (graceful degradation implemented) -- Process CPU Sets require Windows 11 - application auto-detects and falls back -- System tray context menu updates can timeout if performance metrics take too long (2s timeout) -- Loading overlay initialization has 15-second timeout with retry option -- Elevation dialogs can be suppressed during autostart to avoid interrupting user diff --git a/docs/audits/COMPLIANCE_AUDIT.md b/docs/audits/COMPLIANCE_AUDIT.md deleted file mode 100644 index 2b6502e..0000000 --- a/docs/audits/COMPLIANCE_AUDIT.md +++ /dev/null @@ -1,137 +0,0 @@ -# Compliance and Quality Audit - -This document captures the current compliance posture of ThreadPilot and the concrete actions required to align with requested standards. - -## Scope - -- Product: ThreadPilot (`net8.0-windows`, WPF desktop app) -- Baseline date: 2026-04-11 -- Audit method: static repository review + architecture inspection - -## Executive Summary - -- The application has solid architecture foundations (MVVM, DI, structured services, logging, error handling) but lacks formal governance artifacts required for enterprise compliance. -- Most requested standards are process- and evidence-heavy and require dedicated lifecycle artifacts, traceability, and automated pipeline enforcement. -- Immediate technical risk addressed in this pass: UI re-entrancy and collection concurrency faults in fast tab switching scenarios. - -## ISO/Standards Mapping - -### ISO 25010 (Product Quality) - -Current strengths: -- Functional suitability: rich process/power-plan management feature set -- Usability: themed UI with tray integration and status signaling -- Maintainability: modular services + DI configuration (`Services/ServiceConfiguration.cs`) - -Gaps: -- No formal quality model matrix with measurable criteria -- No non-functional acceptance thresholds documented per release - -Required artifacts: -- `docs/quality/iso25010-quality-model.md` -- Quality gates (performance, reliability, security, maintainability) - -### ISO 12207 (Software Lifecycle) - -Current strengths: -- Defined architecture and service layering in docs and code - -Gaps: -- No lifecycle process assets (requirements baseline, verification plan, transition evidence) -- No formal traceability from requirement to tests and releases - -Required artifacts: -- Lifecycle plan -- Requirements traceability matrix -- Release readiness checklist - -### OWASP + ISO 27001 (Security) - -Current strengths: -- Elevated privilege checks and guarded admin operations -- Some defensive error handling and service-level logging - -Gaps: -- Missing threat model and secure coding policy document -- No automated SAST/dependency/secrets scanning pipeline -- No formal control mapping to ISO 27001 Annex A controls - -Required controls: -- SAST + dependency + secrets scan in CI -- Security issue triage SLA -- Signed release and artifact integrity checks - -### CI/CD + DevSecOps - -Current state: -- No repository CI workflows detected - -Required: -- Build, test, and security workflow on pull requests and main -- Release pipeline with immutable artifacts and provenance metadata - -### ISTQB / ISO 29119 (Testing) - -Current strengths: -- Integrated runtime tests in `Tests/` - -Gaps: -- No standardized test specification set (plan/design/cases/procedure) -- Not integrated with `dotnet test` framework and CI coverage reporting - -Required: -- Test strategy and test design docs -- Structured test execution reports in CI - -### Microsoft + HLK Guidance - -Current strengths: -- Windows-targeted app with manifest and platform-specific integration - -Gaps: -- No formal HLK-aligned validation checklist or records -- No packaging/signing verification evidence in pipeline - -Required: -- Windows compatibility checklist -- Release validation records for startup, tray, privilege modes, and resiliency - -### Secure Coding (CERT / MISRA) - -Applicability note: -- CERT secure coding is applicable to C#/.NET development practices. -- MISRA is primarily aimed at C/C++ in safety-critical embedded domains; use only for native interop boundaries if mandated by policy. - -Required: -- Secure coding standard tailored for C# + interop -- Static analysis ruleset and coding exceptions register - -## Immediate Technical Remediation Applied - -- Added re-entrancy guards for fast tab switching in `MainWindow.xaml.cs` -- Hardened collection update patterns in `ViewModels/ProcessViewModel.cs` and `ViewModels/PerformanceViewModel.cs` -- Added UI exception dialog throttling in `App.xaml.cs` to prevent dialog storms -- Removed problematic gear glyph usage and normalized section labels -- Restored denser content spacing while preserving modern visual style in theme dictionaries - -## Recommended Next Implementation Sprint - -1. Add CI workflow: build + smoke tests + static analysis. -2. Add security workflow: dependency audit + CodeQL/SAST + secret scanning. -3. Introduce test project for `dotnet test` and keep integrated runtime tests for operational checks. -4. Publish quality and security governance docs under `docs/` with ownership and review cadence. -5. Add release checklist with HLK-aligned Windows validation evidence. - -## Corrective Actions (2026-04-21) - -- Replaced Docker-based changelog generation in release workflow with runner-native `git-cliff` binary installation using pinned version and checksum verification. -- Removed vendored `gitleaks-bin` artifacts from repository tracking to eliminate recurring secret-scanning false positives from upstream scanner sample content. -- Added repository guardrails in `.gitignore` to prevent recommitting scanner binaries and archives. -- Added operational documentation updates in release and security checklists to keep the remediation stable across future release cycles. - -## Acceptance Criteria For Compliance Baseline - -- CI required on all pull requests and main branch merges. -- Security scans block merges on high/critical findings. -- Traceability matrix links features -> tests -> release notes. -- Documented quality metrics and release thresholds are versioned. diff --git a/docs/audits/DEPENDENCY_AUDIT_2026-04-15.csv b/docs/audits/DEPENDENCY_AUDIT_2026-04-15.csv deleted file mode 100644 index 8b6046a..0000000 --- a/docs/audits/DEPENDENCY_AUDIT_2026-04-15.csv +++ /dev/null @@ -1,31 +0,0 @@ -"PackageType","PackageId","RequestedVersion","ResolvedVersion","LatestVersion","AutoReferenced" -"TopLevel","CommunityToolkit.Mvvm","8.4.0","8.4.0","8.4.2", -"TopLevel","Microsoft.Extensions.Caching.Memory","9.0.0","9.0.0","10.0.6", -"TopLevel","Microsoft.Extensions.DependencyInjection","9.0.0","9.0.0","10.0.6", -"TopLevel","Microsoft.Extensions.Logging","9.0.0","9.0.0","10.0.6", -"TopLevel","Microsoft.Extensions.Logging.Console","9.0.0","9.0.0","10.0.6", -"TopLevel","Microsoft.NET.ILLink.Tasks","[8.0.25, )","8.0.25",,"true" -"TopLevel","StyleCop.Analyzers","1.2.0-beta.556","1.2.0-beta.556",, -"TopLevel","System.Drawing.Common","9.0.7","9.0.7","10.0.6", -"TopLevel","System.Management","8.0.0","8.0.0","10.0.6", -"TopLevel","System.ServiceProcess.ServiceController","9.0.7","9.0.7","10.0.6", -"TopLevel","WPF-UI","4.2.0","4.2.0",, -"Transitive","Microsoft.Extensions.Caching.Abstractions","","9.0.0","10.0.6","" -"Transitive","Microsoft.Extensions.Configuration","","9.0.0","10.0.6","" -"Transitive","Microsoft.Extensions.Configuration.Abstractions","","9.0.0","10.0.6","" -"Transitive","Microsoft.Extensions.Configuration.Binder","","9.0.0","10.0.6","" -"Transitive","Microsoft.Extensions.DependencyInjection.Abstractions","","9.0.0","10.0.6","" -"Transitive","Microsoft.Extensions.Logging.Abstractions","","9.0.0","10.0.6","" -"Transitive","Microsoft.Extensions.Logging.Configuration","","9.0.0","10.0.6","" -"Transitive","Microsoft.Extensions.Options","","9.0.0","10.0.6","" -"Transitive","Microsoft.Extensions.Options.ConfigurationExtensions","","9.0.0","10.0.6","" -"Transitive","Microsoft.Extensions.Primitives","","9.0.0","10.0.6","" -"Transitive","Microsoft.Win32.SystemEvents","","9.0.7","10.0.6","" -"Transitive","StyleCop.Analyzers.Unstable","","1.2.0.556",,"" -"Transitive","System.CodeDom","","8.0.0","10.0.6","" -"Transitive","System.Diagnostics.DiagnosticSource","","9.0.0","10.0.6","" -"Transitive","System.Diagnostics.EventLog","","9.0.7","10.0.6","" -"Transitive","System.IO.Pipelines","","9.0.0","10.0.6","" -"Transitive","System.Text.Encodings.Web","","9.0.0","10.0.6","" -"Transitive","System.Text.Json","","9.0.0","10.0.6","" -"Transitive","WPF-UI.Abstractions","","4.2.0",,"" diff --git a/docs/audits/DEPENDENCY_INVENTORY_2026-04-15.json b/docs/audits/DEPENDENCY_INVENTORY_2026-04-15.json deleted file mode 100644 index a37b1af..0000000 --- a/docs/audits/DEPENDENCY_INVENTORY_2026-04-15.json +++ /dev/null @@ -1,150 +0,0 @@ -{ - "version": 1, - "parameters": "--include-transitive", - "projects": [ - { - "path": "C:/Users/Lorenzo/Documents/Projects/ThreadPilot/ThreadPilot.csproj", - "frameworks": [ - { - "framework": "net8.0-windows10.0.22000", - "topLevelPackages": [ - { - "id": "CommunityToolkit.Mvvm", - "requestedVersion": "8.4.0", - "resolvedVersion": "8.4.0" - }, - { - "id": "Microsoft.Extensions.Caching.Memory", - "requestedVersion": "9.0.0", - "resolvedVersion": "9.0.0" - }, - { - "id": "Microsoft.Extensions.DependencyInjection", - "requestedVersion": "9.0.0", - "resolvedVersion": "9.0.0" - }, - { - "id": "Microsoft.Extensions.Logging", - "requestedVersion": "9.0.0", - "resolvedVersion": "9.0.0" - }, - { - "id": "Microsoft.Extensions.Logging.Console", - "requestedVersion": "9.0.0", - "resolvedVersion": "9.0.0" - }, - { - "id": "Microsoft.NET.ILLink.Tasks", - "requestedVersion": "[8.0.25, )", - "resolvedVersion": "8.0.25", - "autoReferenced": "true" - }, - { - "id": "StyleCop.Analyzers", - "requestedVersion": "1.2.0-beta.556", - "resolvedVersion": "1.2.0-beta.556" - }, - { - "id": "System.Drawing.Common", - "requestedVersion": "9.0.7", - "resolvedVersion": "9.0.7" - }, - { - "id": "System.Management", - "requestedVersion": "8.0.0", - "resolvedVersion": "8.0.0" - }, - { - "id": "System.ServiceProcess.ServiceController", - "requestedVersion": "9.0.7", - "resolvedVersion": "9.0.7" - }, - { - "id": "WPF-UI", - "requestedVersion": "4.2.0", - "resolvedVersion": "4.2.0" - } - ], - "transitivePackages": [ - { - "id": "Microsoft.Extensions.Caching.Abstractions", - "resolvedVersion": "9.0.0" - }, - { - "id": "Microsoft.Extensions.Configuration", - "resolvedVersion": "9.0.0" - }, - { - "id": "Microsoft.Extensions.Configuration.Abstractions", - "resolvedVersion": "9.0.0" - }, - { - "id": "Microsoft.Extensions.Configuration.Binder", - "resolvedVersion": "9.0.0" - }, - { - "id": "Microsoft.Extensions.DependencyInjection.Abstractions", - "resolvedVersion": "9.0.0" - }, - { - "id": "Microsoft.Extensions.Logging.Abstractions", - "resolvedVersion": "9.0.0" - }, - { - "id": "Microsoft.Extensions.Logging.Configuration", - "resolvedVersion": "9.0.0" - }, - { - "id": "Microsoft.Extensions.Options", - "resolvedVersion": "9.0.0" - }, - { - "id": "Microsoft.Extensions.Options.ConfigurationExtensions", - "resolvedVersion": "9.0.0" - }, - { - "id": "Microsoft.Extensions.Primitives", - "resolvedVersion": "9.0.0" - }, - { - "id": "Microsoft.Win32.SystemEvents", - "resolvedVersion": "9.0.7" - }, - { - "id": "StyleCop.Analyzers.Unstable", - "resolvedVersion": "1.2.0.556" - }, - { - "id": "System.CodeDom", - "resolvedVersion": "8.0.0" - }, - { - "id": "System.Diagnostics.DiagnosticSource", - "resolvedVersion": "9.0.0" - }, - { - "id": "System.Diagnostics.EventLog", - "resolvedVersion": "9.0.7" - }, - { - "id": "System.IO.Pipelines", - "resolvedVersion": "9.0.0" - }, - { - "id": "System.Text.Encodings.Web", - "resolvedVersion": "9.0.0" - }, - { - "id": "System.Text.Json", - "resolvedVersion": "9.0.0" - }, - { - "id": "WPF-UI.Abstractions", - "resolvedVersion": "4.2.0" - } - ] - } - ] - } - ] -} diff --git a/docs/audits/DEPENDENCY_REMEDIATION_PLAN_2026-04-15.md b/docs/audits/DEPENDENCY_REMEDIATION_PLAN_2026-04-15.md deleted file mode 100644 index 209a178..0000000 --- a/docs/audits/DEPENDENCY_REMEDIATION_PLAN_2026-04-15.md +++ /dev/null @@ -1,48 +0,0 @@ -# Dependency Remediation Plan - -Date: 2026-04-15 -Reference Artifacts: - -- docs/audits/OUTDATED_PACKAGES_2026-04-15.json -- docs/audits/DEPENDENCY_AUDIT_2026-04-15.csv - -## Current Risk Posture - -- Known vulnerable packages: none detected. -- Update debt: moderate (major-version drift on Microsoft.Extensions and System.* packages). - -## Priority Matrix - -| Priority | Package Group | Current | Latest | Action | -|---|---|---|---|---| -| P0 | Security hotfixes (if any CVE appears) | varies | varies | Immediate patch and release | -| P1 | Microsoft.Extensions.* | 9.0.0 | 10.0.6 | Plan controlled upgrade branch | -| P1 | System.* runtime libraries | 8/9 | 10.0.6 | Upgrade with compatibility test sweep | -| P2 | CommunityToolkit.Mvvm | 8.4.0 | 8.4.2 | Minor update in maintenance window | - -## Remediation Execution Plan - -1. Create branch `chore/dependency-upgrade-wave1`. -2. Upgrade low-risk minor updates first (CommunityToolkit.Mvvm). -3. Run full build/test + manual smoke on Windows 10/11. -4. Upgrade Microsoft.Extensions and System.* as a second wave. -5. Validate: - - startup and tray lifecycle - - process monitoring and affinity flows - - power plan switch and persistence -6. Re-run vulnerability and outdated scans and compare diffs. - -## Validation Gates for Each Wave - -```powershell -dotnet restore ThreadPilot_1.sln -dotnet build ThreadPilot_1.sln --configuration Release --no-restore -dotnet test ThreadPilot_1.sln --configuration Release --no-build -dotnet list ThreadPilot.csproj package --vulnerable --include-transitive -dotnet list ThreadPilot.csproj package --outdated --include-transitive -``` - -## Rollback Strategy - -- Keep each wave in separate commits for quick revert. -- If regressions appear in process control paths, revert affected package group and retain scan artifacts for incident review. diff --git a/docs/audits/GITLEAKS_REPORT_2026-04-15.json b/docs/audits/GITLEAKS_REPORT_2026-04-15.json deleted file mode 100644 index fe51488..0000000 --- a/docs/audits/GITLEAKS_REPORT_2026-04-15.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/docs/audits/OUTDATED_PACKAGES_2026-04-15.json b/docs/audits/OUTDATED_PACKAGES_2026-04-15.json deleted file mode 100644 index 3d15d16..0000000 --- a/docs/audits/OUTDATED_PACKAGES_2026-04-15.json +++ /dev/null @@ -1,155 +0,0 @@ -{ - "version": 1, - "parameters": "--outdated --include-transitive", - "sources": [ - "https://api.nuget.org/v3/index.json", - "C:/Program Files (x86)/Microsoft SDKs/NuGetPackages/" - ], - "projects": [ - { - "path": "C:/Users/Lorenzo/Documents/Projects/ThreadPilot/ThreadPilot.csproj", - "frameworks": [ - { - "framework": "net8.0-windows10.0.22000", - "topLevelPackages": [ - { - "id": "CommunityToolkit.Mvvm", - "requestedVersion": "8.4.0", - "resolvedVersion": "8.4.0", - "latestVersion": "8.4.2" - }, - { - "id": "Microsoft.Extensions.Caching.Memory", - "requestedVersion": "9.0.0", - "resolvedVersion": "9.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "Microsoft.Extensions.DependencyInjection", - "requestedVersion": "9.0.0", - "resolvedVersion": "9.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "Microsoft.Extensions.Logging", - "requestedVersion": "9.0.0", - "resolvedVersion": "9.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "Microsoft.Extensions.Logging.Console", - "requestedVersion": "9.0.0", - "resolvedVersion": "9.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "System.Drawing.Common", - "requestedVersion": "9.0.7", - "resolvedVersion": "9.0.7", - "latestVersion": "10.0.6" - }, - { - "id": "System.Management", - "requestedVersion": "8.0.0", - "resolvedVersion": "8.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "System.ServiceProcess.ServiceController", - "requestedVersion": "9.0.7", - "resolvedVersion": "9.0.7", - "latestVersion": "10.0.6" - } - ], - "transitivePackages": [ - { - "id": "Microsoft.Extensions.Caching.Abstractions", - "resolvedVersion": "9.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "Microsoft.Extensions.Configuration", - "resolvedVersion": "9.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "Microsoft.Extensions.Configuration.Abstractions", - "resolvedVersion": "9.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "Microsoft.Extensions.Configuration.Binder", - "resolvedVersion": "9.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "Microsoft.Extensions.DependencyInjection.Abstractions", - "resolvedVersion": "9.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "Microsoft.Extensions.Logging.Abstractions", - "resolvedVersion": "9.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "Microsoft.Extensions.Logging.Configuration", - "resolvedVersion": "9.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "Microsoft.Extensions.Options", - "resolvedVersion": "9.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "Microsoft.Extensions.Options.ConfigurationExtensions", - "resolvedVersion": "9.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "Microsoft.Extensions.Primitives", - "resolvedVersion": "9.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "Microsoft.Win32.SystemEvents", - "resolvedVersion": "9.0.7", - "latestVersion": "10.0.6" - }, - { - "id": "System.CodeDom", - "resolvedVersion": "8.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "System.Diagnostics.DiagnosticSource", - "resolvedVersion": "9.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "System.Diagnostics.EventLog", - "resolvedVersion": "9.0.7", - "latestVersion": "10.0.6" - }, - { - "id": "System.IO.Pipelines", - "resolvedVersion": "9.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "System.Text.Encodings.Web", - "resolvedVersion": "9.0.0", - "latestVersion": "10.0.6" - }, - { - "id": "System.Text.Json", - "resolvedVersion": "9.0.0", - "latestVersion": "10.0.6" - } - ] - } - ] - } - ] -} diff --git a/docs/audits/PHASE1_1_MEMORY_CPU_BASELINE.md b/docs/audits/PHASE1_1_MEMORY_CPU_BASELINE.md deleted file mode 100644 index 8fdc90a..0000000 --- a/docs/audits/PHASE1_1_MEMORY_CPU_BASELINE.md +++ /dev/null @@ -1,66 +0,0 @@ -# Phase 1.1 Memory and CPU Baseline Audit - -Date: 2026-04-15 -Scope: `RELEASE_PLAN.md` -> Phase 1.1 (memory/CPU footprint, long-running monitoring readiness) - -## Current Status - -- Baseline collection automation added: `build/collect-process-footprint.ps1`. -- Output format aligned with requested deliverable (CSV + summary JSON). -- Initial static audit completed for timer, polling, and process-monitoring paths. - -## Baseline Collection Procedure - -1. Start ThreadPilot in minimized mode and leave it idle in tray. -2. Collect a 30-minute baseline: - -```powershell -pwsh -NoProfile -ExecutionPolicy Bypass -File "build/collect-process-footprint.ps1" ` - -ProcessName "ThreadPilot" ` - -DurationMinutes 30 ` - -SampleIntervalSeconds 5 ` - -WaitForProcess -``` - -3. Optional GC telemetry in parallel (same PID): - -```powershell -dotnet-counters monitor --process-id --refresh-interval 5 --counters System.Runtime -``` - -4. Artifacts produced under `artifacts/perf/`: - - `-footprint-.csv` - - `-footprint-.summary.json` - -CSV fields: -- `WorkingSetMB` -- `PrivateMemoryMB` -- `HandleCount` -- `CpuPercentSingleCore` -- `CpuPercentOverall` -- `ThreadCount` - -## Static Audit Findings (Initial) - -| Priority | Finding | Evidence | Impact | -|---|---|---|---| -| P1 | Tray status refresh marshals full update path to UI dispatcher every cycle | `MainWindow.xaml.cs:1189`, `MainWindow.xaml.cs:1235` | Potential UI-thread stalls during expensive metrics reads | -| P1 | Fallback polling allocates dictionary/list snapshots every iteration | `Services/ProcessMonitorService.cs:452`, `Services/ProcessMonitorService.cs:473` | Increased allocation pressure in prolonged fallback mode | -| P1 | No explicit GC telemetry/alerts for Gen2 duration and pressure | repository-wide search (no `GC.CollectionCount` diagnostics path) | Harder to detect long-run memory regressions before release | -| P2 | Performance monitor uses WMI for physical memory lookup path | `Services/PerformanceMonitoringService.cs:374` | WMI latency risk, partially mitigated by 5-minute cache | -| P2 | Startup and tray paths use multiple timed async operations with timeouts | `MainWindow.xaml.cs:429`, `MainWindow.xaml.cs:1156`, `MainWindow.xaml.cs:1193` | Operationally safe, but requires baseline validation under load | - -## Positive Baseline Readiness Signals - -- Process monitor is already event-first with WMI start/stop watchers and adaptive fallback polling. - - `Services/ProcessMonitorService.cs:241` - - `Services/ProcessMonitorService.cs:491` -- Overlap protection exists for fallback polling and tray refresh loops. - - `Services/ProcessMonitorService.cs:431` - - `MainWindow.xaml.cs:1202` - -## Next Implementation Slice (Phase 1.1 continuation) - -1. Capture real ThreadPilot 30-minute idle baseline in tray mode and archive CSV/summary. -2. Add lightweight runtime GC counters to diagnostics/logging path (`Gen0/1/2 collections`, allocated bytes). -3. Compare baseline after any polling refactor to validate CPU wake-up and memory trend improvements. diff --git a/docs/audits/PINVOKE_AUDIT_REPORT_2026-04-15.md b/docs/audits/PINVOKE_AUDIT_REPORT_2026-04-15.md deleted file mode 100644 index 2749376..0000000 --- a/docs/audits/PINVOKE_AUDIT_REPORT_2026-04-15.md +++ /dev/null @@ -1,27 +0,0 @@ -# P/Invoke Audit Report - -Date: 2026-04-15 -Scope: Native interop declarations and safety posture. - -## Inventory Snapshot - -| File | API/Pattern | Current State | Recommendation | -|---|---|---|---| -| App.xaml.cs | DllImport(kernel32) | Minimal use (debug console) | Keep debug-only guard | -| MainWindow.xaml.cs | DllImport(dwmapi) | UI theme attribute call | Validate return codes and graceful fallback | -| Platforms/Windows/CpuSetNativeMethods.cs | LibraryImport + SafeProcessHandle | Strong pattern | Keep as reference implementation | -| Services/CpuTopologyService.cs | DllImport(kernel32, SetLastError=true) | Acceptable | Add explicit error logging on Win32 failures | -| Services/KeyboardShortcutService.cs | DllImport(user32) | Needs review | Ensure unregister and handle lifecycle coverage | -| Services/ProcessService.cs | DllImport(kernel32 SetThreadExecutionState) | Acceptable | Keep exception boundaries and explicit result checks | - -## Findings - -1. SafeHandle usage is already present in CPU set path and should be preferred for any new handle-based interop. -2. Some legacy DllImport declarations can be gradually migrated to LibraryImport for source-generated marshalling where supported. -3. Interop calls should consistently capture and log Win32 error codes for post-mortem diagnostics. - -## Action Items - -- Add interop-focused analyzer review in release gate checklist. -- Add targeted tests around keyboard shortcut registration/unregistration lifecycle. -- Document safe interop patterns for contributors. diff --git a/docs/audits/README_AUDIT_REPORT_2026-04-15.md b/docs/audits/README_AUDIT_REPORT_2026-04-15.md deleted file mode 100644 index 011d5ee..0000000 --- a/docs/audits/README_AUDIT_REPORT_2026-04-15.md +++ /dev/null @@ -1,25 +0,0 @@ -# README Audit Report - -Date: 2026-04-15 -Scope: Phase 5.3 README/documentation alignment in RELEASE_PLAN.md. - -## Checklist Results - -- Installation requirements and package naming: pass. -- Privilege model text: aligned to least-privilege (`asInvoker`) behavior. -- Release artifact naming and links: pass. -- Build/test commands: pass. -- Repository docs references: pass. -- Security/quality sections: pass. - -## Notes - -- README now describes elevation on-demand for privileged actions, not mandatory startup elevation. -- Packaging details are consistent with `docs/release/PACKAGING.md` and release note templates. - -## Evidence - -- README: `README.md` -- Docs index: `docs/README.md` -- Packaging guide: `docs/release/PACKAGING.md` -- Release notes template: `docs/release/RELEASE_NOTES_TEMPLATE.md` diff --git a/docs/audits/REPOSITORY_ARTIFACT_SCAN_2026-04-15.txt b/docs/audits/REPOSITORY_ARTIFACT_SCAN_2026-04-15.txt deleted file mode 100644 index 55d0962..0000000 --- a/docs/audits/REPOSITORY_ARTIFACT_SCAN_2026-04-15.txt +++ /dev/null @@ -1 +0,0 @@ -No AI/temp artifact directories found for configured patterns. diff --git a/docs/audits/SECURITY_CHECKLIST.md b/docs/audits/SECURITY_CHECKLIST.md deleted file mode 100644 index a2efd37..0000000 --- a/docs/audits/SECURITY_CHECKLIST.md +++ /dev/null @@ -1,47 +0,0 @@ -# Security Checklist and Risk Matrix - -Date: 2026-04-15 -Scope: Release readiness hardening (privileges, interop, process manipulation, configuration safety). - -## Checklist - -- [x] Validate privilege model behavior against product policy (requireAdministrator vs asInvoker). -- [x] Verify all process handle APIs use safe cleanup patterns. -- [x] Verify forbidden critical process list cannot be optimized (System, csrss, lsass, wininit). -- [x] Confirm configuration file writes stay in user-space writable paths. -- [x] Confirm no secrets are persisted in plaintext configuration. -- [x] Confirm structured logging sanitizes user-provided strings. -- [x] Confirm dependency vulnerability scan is green in CI. -- [x] Confirm secret scanning is green in CI. -- [x] Confirm security scanner binaries are downloaded at runtime in CI and not vendored in repository history. - -## Validation Evidence - -- User-space configuration path verified via `StoragePaths.AppDataRoot` (`%AppData%\\ThreadPilot`) and `ApplicationSettingsService` persistence path usage. -- Secret scan evidence: `docs/audits/GITLEAKS_REPORT_2026-04-15.json` (no leaks found). -- Dependency scan evidence: `docs/audits/VULNERABILITY_SCAN_2026-04-15.json` (no vulnerable packages). -- Scanner runtime policy: `.github/workflows/ci-devsecops.yml` downloads Gitleaks to `$RUNNER_TEMP` and does not require vendored scanner artifacts. - -## P/Invoke Audit Snapshot - -| API/Pattern | Location | Current status | Notes | -|---|---|---|---| -| OpenProcess (SafeProcessHandle) | Platforms/Windows/CpuSetNativeMethods.cs | Good | Uses SafeHandle wrapper | -| Process handle lifecycle | Platforms/Windows/ProcessCpuSetHandler.cs | Good | Dispose paths present | -| DllImport declarations | App.xaml.cs, MainWindow.xaml.cs, Services/* | Review required | Validate SetLastError and marshaling consistency | -| Keyboard hooks hotkeys | Services/KeyboardShortcutService.cs | Review required | Ensure unregister on dispose | - -## Privilege Escalation Risk Matrix - -| Risk | Severity | Likelihood | Mitigation | -|---|---|---|---| -| Over-broad elevated runtime | High | Medium | Keep privileged operations minimal and audited | -| Unauthorized process manipulation | High | Low-Medium | Enforce critical-process denylist and validation | -| Handle/resource leak in native interop | Medium | Medium | Continue SafeHandle usage + disposal tests | -| Logging injection via external input | Medium | Medium | Sanitize and structure user-provided strings | - -## Immediate Next Actions - -1. Add automated tests for critical-process denylist enforcement. -2. Add targeted interop analyzer rules for DllImport signatures. -3. Add release gate requiring successful vulnerability + secret scans. diff --git a/docs/audits/SECURITY_REMEDIATION_PLAN_2026-04-15.md b/docs/audits/SECURITY_REMEDIATION_PLAN_2026-04-15.md deleted file mode 100644 index 338606c..0000000 --- a/docs/audits/SECURITY_REMEDIATION_PLAN_2026-04-15.md +++ /dev/null @@ -1,31 +0,0 @@ -# Security Remediation Plan - -Date: 2026-04-15 - -## Inputs - -- docs/audits/VULNERABILITY_SCAN_REPORT_2026-04-15.md -- docs/audits/PINVOKE_AUDIT_REPORT_2026-04-15.md -- docs/audits/SECURITY_CHECKLIST.md - -## Immediate Completed Items - -- Added runtime enforcement for process-protection checks in process mutation paths. -- Added sanitization in security logging paths to reduce log injection risk. -- Added unit tests for protected-process validation logic. -- Adopted least-privilege startup model (`asInvoker`) and removed mandatory elevation gate at startup. - -## Open Remediation Backlog - -| ID | Area | Risk | Priority | Planned Action | -|---|---|---|---|---| -| SR-01 | Interop diagnostics | Medium | P1 | Add explicit Win32 error code logging in all native failure paths | -| SR-02 | Keyboard hook lifecycle | Medium | P1 | Add tests for register/unregister symmetry and dispose safety | -| SR-03 | Dependency update wave | Medium | P1 | Execute controlled upgrade plan from dependency remediation doc | - -## Exit Criteria - -- Zero known vulnerable packages in CI. -- Protected-process denylist enforcement covered by tests. -- Interop audit action items tracked and scheduled. -- Security checklist reviewed and marked for release. diff --git a/docs/audits/VULNERABILITY_SCAN_2026-04-15.json b/docs/audits/VULNERABILITY_SCAN_2026-04-15.json deleted file mode 100644 index 91ae336..0000000 --- a/docs/audits/VULNERABILITY_SCAN_2026-04-15.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": 1, - "parameters": "--vulnerable --include-transitive", - "sources": [ - "https://api.nuget.org/v3/index.json", - "C:/Program Files (x86)/Microsoft SDKs/NuGetPackages/" - ], - "projects": [ - { - "path": "C:/Users/Lorenzo/Documents/Projects/ThreadPilot/ThreadPilot.csproj" - } - ] -} diff --git a/docs/audits/VULNERABILITY_SCAN_REPORT_2026-04-15.md b/docs/audits/VULNERABILITY_SCAN_REPORT_2026-04-15.md deleted file mode 100644 index bf3bf39..0000000 --- a/docs/audits/VULNERABILITY_SCAN_REPORT_2026-04-15.md +++ /dev/null @@ -1,36 +0,0 @@ -# Vulnerability Scan Report - -Date: 2026-04-15 -Scope: ThreadPilot dependency vulnerability assessment (direct + transitive NuGet packages). - -## Commands Executed - -```powershell -dotnet list ThreadPilot.csproj package --vulnerable --include-transitive -dotnet list ThreadPilot.csproj package --vulnerable --include-transitive --format json > docs/audits/VULNERABILITY_SCAN_2026-04-15.json -``` - -## Data Sources - -- https://api.nuget.org/v3/index.json -- C:/Program Files (x86)/Microsoft SDKs/NuGetPackages/ - -## Result Summary - -- Critical vulnerabilities: 0 -- High vulnerabilities: 0 -- Medium vulnerabilities: 0 -- Low vulnerabilities: 0 - -The scan output reported no vulnerable packages for the current package graph. - -## Artifacts - -- Machine-readable scan: docs/audits/VULNERABILITY_SCAN_2026-04-15.json -- Dependency inventory: docs/audits/DEPENDENCY_INVENTORY_2026-04-15.json -- Dependency spreadsheet: docs/audits/DEPENDENCY_AUDIT_2026-04-15.csv - -## Notes - -- A clean vulnerability result does not eliminate operational security risk. -- Keep this scan mandatory in CI and pre-release gates. diff --git a/docs/plans/DOCUMENTATION_AUDIT_CHECKLIST.md b/docs/plans/DOCUMENTATION_AUDIT_CHECKLIST.md deleted file mode 100644 index 72ab1b2..0000000 --- a/docs/plans/DOCUMENTATION_AUDIT_CHECKLIST.md +++ /dev/null @@ -1,12 +0,0 @@ -# Documentation Audit Checklist - -- [x] README installation and package names are current. -- [x] CHANGELOG contains the latest release entry. -- [x] SECURITY policy links and disclosure workflow are valid. -- [x] PACKAGING guide matches current scripts/workflows. -- [x] Release notes template reflects current distribution formats. -- [x] Test plan references existing test projects and commands. -- [x] Quality gates match CI pipeline checks. -- [x] All newly added docs are indexed in docs/README.md. - -Audit date: 2026-04-15. diff --git a/docs/plans/DOCUMENTATION_TEMPLATES.md b/docs/plans/DOCUMENTATION_TEMPLATES.md deleted file mode 100644 index dad8287..0000000 --- a/docs/plans/DOCUMENTATION_TEMPLATES.md +++ /dev/null @@ -1,55 +0,0 @@ -# Documentation Templates - -Use these templates for release-readiness documents. - -## Audit Report Template - -```markdown -# -Date: YYYY-MM-DD -Scope: <area> - -## Method -- Command/Tool 1 -- Command/Tool 2 - -## Findings -| Severity | Finding | Evidence | Action | -|---|---|---|---| - -## Conclusion -- Summary -``` - -## Runbook Template - -```markdown -# <Runbook Name> - -## Preconditions -- Item - -## Steps -1. Step -2. Step - -## Validation -- Command/output check - -## Rollback -- Rollback step -``` - -## Remediation Plan Template - -```markdown -# <Plan Name> - -## Risks -| ID | Risk | Priority | Owner | Due | -|---|---|---|---|---| - -## Actions -1. Action with acceptance criteria -2. Action with acceptance criteria -``` diff --git a/docs/plans/PRE_TAG_RELEASE_CHECKLIST.md b/docs/plans/PRE_TAG_RELEASE_CHECKLIST.md deleted file mode 100644 index 9c92f2d..0000000 --- a/docs/plans/PRE_TAG_RELEASE_CHECKLIST.md +++ /dev/null @@ -1,123 +0,0 @@ -# ThreadPilot Pre-Tag Release Checklist - -Use this checklist before creating a release tag. It is aligned with local build scripts and the GitHub release workflow. - -## Release Meta - -- [ ] Target version: 1.1.1 -- [ ] Target tag: v1.1.1 -- [ ] Branch is correct for release -- [ ] Working tree is clean (except intentionally ignored artifact files) - -## 1) Artifact Cleanup (Local) - -Goal: keep only 1.1.1 artifacts in artifacts/release. - -- [ ] Delete old installer: artifacts/release/installer/ThreadPilot_v1.1.0_Setup.exe -- [ ] Delete old zips: artifacts/release/ThreadPilot_v1.1.0_Installer.zip, artifacts/release/ThreadPilot_v1.1.0_Portable.zip -- [ ] Delete temporary staging dirs: artifacts/release/package-stage, artifacts/release/_stage_installer, artifacts/release/_stage_portable - -Expected state: - -- [ ] artifacts/release/installer/ThreadPilot_v1.1.1_Setup.exe -- [ ] artifacts/release/packages/ThreadPilot_v1.1.1_Installer.zip -- [ ] artifacts/release/packages/ThreadPilot_v1.1.1_Portable.zip - -## 2) Build Gates (Must Pass) - -Run from repository root. - -Commands: - - dotnet restore "ThreadPilot_1.sln" - dotnet build "ThreadPilot_1.sln" --configuration Release --no-restore - dotnet test "ThreadPilot_1.sln" --configuration Release --no-build - -- [ ] Restore passed -- [ ] Build passed -- [ ] Tests passed - -## 3) Packaging Gates (Local) - -Commands: - - ./build/build-installer.ps1 -Version "1.1.1" - ./build/package-release-zips.ps1 -Version "1.1.1" - -- [ ] Installer generated in artifacts/release/installer -- [ ] Zip packages generated in artifacts/release/packages -- [ ] Inno Setup warnings target is zero (artifacts/release/installer_iscc_setup.log) - -## 4) Naming Gate (Local vs CI) - -Local script naming (package-release-zips.ps1): - -- [ ] ThreadPilot_v1.1.1_Installer.zip -- [ ] ThreadPilot_v1.1.1_Portable.zip - -GitHub workflow naming (release.yml package step): - -- [ ] ThreadPilot_v1.1.1_singlefile_win-x64.zip -- [ ] ThreadPilot_v1.1.1_readytorun_win-x64.zip - -Note: naming differs by channel (local script vs CI workflow). Validate the expected set for the channel you are releasing from. - -## 5) Hash + Signature Gate - -Generate and verify SHA256 hashes: - -Commands: - - $hashFile = "artifacts/release/SHA256SUMS.txt" - if (Test-Path $hashFile) { Remove-Item $hashFile -Force } - - $releaseFiles = @() - $releaseFiles += Get-ChildItem "artifacts/release/packages" -File -ErrorAction SilentlyContinue - $releaseFiles += Get-ChildItem "artifacts/release/installer/*.exe" -File -ErrorAction SilentlyContinue - - $releaseFiles | ForEach-Object { - $hash = Get-FileHash $_.FullName -Algorithm SHA256 - "$($hash.Hash) $($_.Name)" | Out-File -FilePath $hashFile -Append -Encoding utf8 - } - -- [ ] SHA256SUMS.txt generated -- [ ] Every shipped artifact has one hash row - -If signing is enabled: - -- [ ] Authenticode signature is valid (Get-AuthenticodeSignature) -- [ ] Timestamp present (DigiCert or equivalent) - -## 6) Smoke Test Installation - -Portable smoke test: - -Commands: - - $tmp = "$env:TEMP/ThreadPilot_Smoke_$(Get-Random)" - Expand-Archive "artifacts/release/packages/ThreadPilot_v1.1.1_Portable.zip" -DestinationPath $tmp -Force - Test-Path "$tmp/ThreadPilot.exe" - -- [ ] Portable archive extracts correctly -- [ ] ThreadPilot.exe exists and starts - -Installer smoke test: - -- [ ] Run artifacts/release/installer/ThreadPilot_v1.1.1_Setup.exe -- [ ] Install completes without errors -- [ ] Start menu and/or desktop shortcut launches app -- [ ] Uninstall path works -- [ ] Theme is coherent on first launch (system dark -> dark UI + checked setting) - -## 7) Pre-Tag Final Gate - -- [ ] All gates above are green -- [ ] Release notes prepared -- [ ] Optional signing decision documented (signed/unsigned) - -Create tag only after all checks pass: - -Commands: - - git tag -a v1.1.1 -m "Release ThreadPilot v1.1.1" - git push origin v1.1.1 diff --git a/docs/plans/RELEASE_PLAN_IMPLEMENTATION_STATUS.md b/docs/plans/RELEASE_PLAN_IMPLEMENTATION_STATUS.md deleted file mode 100644 index f53a38c..0000000 --- a/docs/plans/RELEASE_PLAN_IMPLEMENTATION_STATUS.md +++ /dev/null @@ -1,99 +0,0 @@ -# Release Plan Implementation Status - -Date: 2026-04-15 -Reference: RELEASE_PLAN.md - -## Phase 1 - Background and Performance - -- [x] 1.1 Baseline collection automation and initial audit - - build/collect-process-footprint.ps1 - - docs/audits/PHASE1_1_MEMORY_CPU_BASELINE.md -- [x] 1.2 Timer resilience improvements (adaptive backoff on tray updates) - - MainWindow.xaml.cs -- [x] 1.3 GC diagnostics telemetry hooks - - Services/PerformanceMonitoringService.cs - - Services/IPerformanceMonitoringService.cs - - Models/LogEventTypes.cs - -## Phase 2 - Robustness and Testing - -- [x] 2.1 Test plan and additional unit tests - - docs/plans/TEST_PLAN_v1.1.1.md - - Tests/ThreadPilot.Core.Tests/RetryPolicyServiceTests.cs -- [x] 2.2 Global error handling hardening - - App.xaml.cs - - Models/ThreadPilotException.cs - - docs/reference/EXCEPTION_HANDLING_POLICY.md - -## Phase 3 - Security - -- [x] 3.1 Security checklist and risk matrix - - docs/audits/SECURITY_CHECKLIST.md -- [x] 3.2 Full static-analysis remediation backlog - - docs/audits/VULNERABILITY_SCAN_REPORT_2026-04-15.md - - docs/audits/DEPENDENCY_REMEDIATION_PLAN_2026-04-15.md - - docs/audits/PINVOKE_AUDIT_REPORT_2026-04-15.md - - docs/audits/SECURITY_REMEDIATION_PLAN_2026-04-15.md - - docs/audits/DEPENDENCY_AUDIT_2026-04-15.csv - - docs/audits/DEPENDENCY_INVENTORY_2026-04-15.json - - docs/audits/VULNERABILITY_SCAN_2026-04-15.json - - docs/audits/OUTDATED_PACKAGES_2026-04-15.json - - docs/reference/SAFE_WIN32_INTEROP_EXAMPLES.md - - Services/SecurityService.cs - - Services/ProcessService.cs - - Tests/ThreadPilot.Core.Tests/SecurityServiceTests.cs - - Tests/ThreadPilot.Core.Tests/ProcessServiceSecurityTests.cs - - App.xaml.cs - - app.manifest - - sonar-project.properties - - docs/audits/GITLEAKS_REPORT_2026-04-15.json - -## Phase 4 - Repository Cleanup - -- [x] 4.1 .gitignore hardening for local artifacts - - .gitignore -- [x] 4.2 Contributor guidance for artifact cleanup - - docs/CONTRIBUTING.md - - build/install-git-hooks.ps1 - - .githooks/pre-commit.ps1 - - build/repo-cleanup.ps1 - -## Phase 5 - Packaging and Distribution - -- [x] 5.1 Packaging runbook/checksum process - - docs/release/RELEASE_RUNBOOK.md - - docs/release/RELEASE_EXECUTION_LOG_2026-04-15.md - - artifacts/release/SHA256SUMS.txt -- [x] 5.2 GitHub release automation helper - - build/create-github-release.ps1 - - docs/release/RELEASE_NOTES_TEMPLATE.md - - docs/release/RELEASE_NOTES.md -- [x] 5.3 README/docs alignment (index updates) - - docs/README.md - - docs/audits/README_AUDIT_REPORT_2026-04-15.md - -## Phase 6 - Quality Gates - -- [x] 6.1 Quality gate definition - - docs/QUALITY_GATES.md -- [x] 6.2 Pre-tag/runbook alignment - - docs/plans/PRE_TAG_RELEASE_CHECKLIST.md - - docs/release/RELEASE_RUNBOOK.md - - docs/release/GO_NO_GO_MATRIX.md - -## Phase 7 - Documentation Deliverables - -- [x] CHANGELOG baseline added - - docs/CHANGELOG.md -- [x] Additional technical docs introduced - - docs/reference/EXCEPTION_HANDLING_POLICY.md - - docs/reference/runtimeconfig.template.json - - docs/SECURITY.md -- [x] Performance and development operational references - - docs/reference/PERFORMANCE.md - - docs/reference/DEVELOPMENT.md -- [x] Documentation templates and audit checklist - - docs/plans/DOCUMENTATION_TEMPLATES.md - - docs/plans/DOCUMENTATION_AUDIT_CHECKLIST.md -- [x] Release decision governance artifact - - docs/release/GO_NO_GO_MATRIX.md diff --git a/docs/plans/TEST_PLAN_v1.1.1.md b/docs/plans/TEST_PLAN_v1.1.1.md deleted file mode 100644 index 4ba1f63..0000000 --- a/docs/plans/TEST_PLAN_v1.1.1.md +++ /dev/null @@ -1,48 +0,0 @@ -# ThreadPilot Test Plan v1.1.1 - -Date: 2026-04-15 -Scope: Critical-path unit and integration validation aligned to RELEASE_PLAN phases. - -## Coverage Targets - -- Critical services (P0): target >= 80% line coverage. -- Supporting services (P1/P2): target >= 60% line coverage. - -## Coverage Matrix - -| Component | Priority | Current test type | Required additions | -|---|---|---|---| -| ProcessMonitorManagerService | P0 | Integration-lite | Add conflict and recovery scenarios | -| ProcessMonitorService | P0 | Integration-lite | Add fallback polling behavior tests | -| RetryPolicyService | P0 | Unit | Added transient/non-retriable tests | -| PowerPlanService | P0 | Unit (security) | Extend with denied-access and fallback tests | -| ElevationService | P0 | Unit | Add UAC failure-path tests | -| SystemTrayService | P1 | None | Add context menu state tests | -| ApplicationSettingsService | P1 | None | Add persistence corruption tests | - -## Implemented Test Additions (This Cycle) - -- RetryPolicyServiceTests: - - retries transient faults until success - - does not retry when predicate blocks retries - -## Next Test Additions - -1. ProcessMonitorService adaptive polling interval transitions. -2. ProcessMonitorService WMI recovery fallback path. -3. App-level unobserved task exception handler behavior (non-crashing path). -4. Settings import validation for malformed payloads. - -## CI Commands - -```powershell -dotnet restore ThreadPilot_1.sln -dotnet build ThreadPilot_1.sln --configuration Release --no-restore -dotnet test ThreadPilot_1.sln --configuration Release --no-build --collect:"XPlat Code Coverage" -``` - -## Exit Criteria - -- No failing tests on Release configuration. -- No flaky test observed in 3 consecutive CI runs. -- Critical-path coverage trend is non-decreasing. diff --git a/docs/plans/implementation_plan.md b/docs/plans/implementation_plan.md deleted file mode 100644 index e773941..0000000 --- a/docs/plans/implementation_plan.md +++ /dev/null @@ -1,74 +0,0 @@ -# ๐ŸŒŠ Fluent Design Migration Plan (Safe & Incremental) - -This revised plan adopts `Wpf.Ui` to natively implement Windows 11 Fluent Design while strictly avoiding a destructive rewrite. We will use a staged deprecation model to gracefully phase out legacy custom styles without causing UI collapses or broken resource references. - -## 1. Staged Deprecation Plan - -**Rule:** We will *not* delete all custom styles at once. - -| Category | Action | Reasoning | -| :--- | :--- | :--- | -| **Control Templates** (TabControl, TabItem, ComboBox, Button, TextBox) | **Remove Immediately** (in Phase 1) | `Wpf.Ui`'s global `ControlsDictionary` implicitly and instantly styles these when custom styles are cleared. Keeping custom styles causes collisions. | -| **Structural Styles** (Grid, Border layout snaps) | **Preserve Temporarily** | These don't conflict with Fluent Design and prevent immediate layout shifts. | -| **Color Brushes** (SurfaceBrush, AccentBrush, etc.) | **Preserve via Aliasing** | Explicitly mapping our legacy brush names to `Wpf.Ui` keys ensures un-migrated Views do not crash or turn transparent. | -| **State Brushes** (SuccessBackgroundBrush, etc.) | **Preserve Permanently** | `Wpf.Ui` focuses on structural elements; maintaining our custom success/error semantic tokens is necessary for valid status bars. | - -## 2. Brush Migration Strategy & Mapping - -To prevent broken `DynamicResource` bindings during the transition, we will transform `Themes/FluentDark.xaml` into an **Alias Dictionary**. - -| Current Custom Brush | `Wpf.Ui` Equivalent Target | -| :--- | :--- | -| `AppBackgroundBrush` | `ApplicationBackgroundBrush` | -| `SurfaceBrush` | `CardBackgroundFillColorDefaultBrush` (or `ControlFillColorDefaultBrush`) | -| `SurfaceAltBrush` | `CardBackgroundFillColorSecondaryBrush` | -| `TextPrimaryBrush` | `TextFillColorPrimaryBrush` | -| `TextSecondaryBrush` | `TextFillColorSecondaryBrush` | -| `TextDisabledBrush` | `TextFillColorDisabledBrush` | -| `AccentBrush` | `SystemAccentColorPrimaryBrush` | -| `AccentAltBrush` | `SystemAccentColorSecondaryBrush` | -| `BorderBrush` | `CardStrokeColorDefaultBrush` | -| `InputBorderBrush` | `ControlStrokeColorDefaultBrush` | - -*Fallback strategy:* If a specific legacy brush requires an exact opacity/hex that Fluent lacks, we will retain the exact `<SolidColorBrush>` entry rather than mapping it. - -## 3. Control Migration Scope (Phase 1) - -Only the following core interactive elements will be yielded to Fluent styling during the infrastructure phase: - -- **TabControl / TabItem**: Will switch to Fluent tabs. Wpf.Ui native tabs use underline selection effects instead of filled block backgrounds. -- **ComboBox**: Will transition to modern, rounded popup cards. -- **Buttons**: Will adopt Fluent background states and rounded corners (`CornerRadius="4"` default). -- **ListView / DataGrid**: Will adopt Fluent bordered-row styles natively. - -**Known Structural Risks in Scope**: -- Fluent `DataGrid` rows have larger innate vertical padding. This may cause scrollbars to appear earlier in constrained `Heights`. -- Fluent `ComboBox` dropdowns render outside the parent visual tree constraints (Popup), which usually resolves Z-index issues perfectly but may alter perceived alignment if wrapped tightly in a canvas. - -## 4. Risk Mitigation Strategies - -1. **Preventing UI Regressions**: We will explicitly map `System.Windows.Controls` styles prior to deleting any custom XAML headers. Un-migrated tabs will still visually resolve their `Background="{DynamicResource SurfaceBrush}"` to a Fluent native shade. -2. **Preventing Broken Bindings**: No C# `DataContext` or `Binding` logic will be altered during XAML structural updates. -3. **Preventing Layout Collapses**: As elements transition to Fluent geometries, we will systematically strip fixed `Height="XXX"` or `MaxLength` restraints globally from elements, instead leveraging `Grid.RowDefinitions="Auto,*"` to allow fluent elements to define their own organic sizing needs. - -## 5. Incremental Execution Plan - -We divide Phase 2 into strict atomic units to test visually before advancing. - -### Step 1: Framework Base & Aliasing (The Infrastructure) -- `dotnet add package Wpf.Ui` -- Update `App.xaml` with core `.MergedDictionaries`. -- Strip `Themes/FluentDark.xaml` of all `<Style TargetType="Button">`, `<Style TargetType="ComboBox">`, etc. -- Rewrite remaining `SolidColorBrush` definitions in `FluentDark.xaml` to `DynamicResource` aliases to Wpf.Ui. - -### Step 2: MainWindow Integration -- Transform the root `<Window>` into `<ui:FluentWindow>` to unlock Mica backdrop effects and custom titlebars. -- Replace the legacy `<TabControl>` with Wpf.Ui native navigation (or clean native TabControl). Verify it renders precisely. - -### Step 3: View Anchor Migration (LogViewer) -- Target `LogViewerView.xaml`. -- Cleanse it of rigid layout blocks. Implement responsive `<Grid>` expansions to distribute columns evenly. -- Swap explicit legacy Brush calls (e.g. `Foreground="{DynamicResource TextPrimaryBrush}"`) for direct `Wpf.Ui` brush keys. - -### Step 4: Iterative Rollout -Target the remaining views (`ProcessManagementView`, `MasksView`, `PowerPlanView`) sequentially, verifying their respective grids, slider components, and checkboxes adopt Fluent visuals flawlessly. diff --git a/docs/reference/ADVANCED_CPU_FEATURES.md b/docs/reference/ADVANCED_CPU_FEATURES.md deleted file mode 100644 index a73b271..0000000 --- a/docs/reference/ADVANCED_CPU_FEATURES.md +++ /dev/null @@ -1,128 +0,0 @@ -# Advanced CPU Core Detection and Affinity Selection - -## Overview - -ThreadPilot now includes advanced CPU topology detection and dynamic core selection capabilities, supporting modern CPU architectures including multi-socket systems, AMD CCD (Core Complex Die), Intel Hybrid (P-core/E-core), and SMT/HyperThreading. - -## Features - -### ๐Ÿ” CPU Topology Detection -- **Automatic Detection**: Uses WMI (Windows Management Instrumentation) to detect CPU topology -- **Fallback Support**: Graceful degradation when advanced topology information is unavailable -- **Real-time Updates**: Event-driven topology detection with UI updates -- **Architecture Support**: - - Intel Hybrid (Performance + Efficiency cores) - - AMD CCD (Core Complex Die) detection - - SMT/HyperThreading identification - - Multi-socket systems - -### ๐ŸŽฏ Quick Selection Controls -- **All Cores**: Select all available CPU cores -- **Physical Cores Only**: Select only physical cores (excludes hyperthreaded siblings) -- **Performance Cores**: Select Intel P-cores only (Intel Hybrid) -- **Efficiency Cores**: Select Intel E-cores only (Intel Hybrid) -- **CCD Selection**: Select cores from specific CCDs (AMD processors) - -### ๐Ÿ–ฅ๏ธ Dynamic UI -- **Adaptive Interface**: CPU core grid adapts to detected topology -- **Visual Indicators**: Color-coded core types and status -- **Topology Status**: Real-time display of detection success/failure -- **Scrollable Grid**: Supports systems with many CPU cores - -### โšก Power Plan Integration -- **Combined Application**: Apply both CPU affinity and power plan in one action -- **Quick Apply**: Fast reapplication of settings to selected processes -- **System Tray**: Context menu for quick access without opening main window - -## Usage - -### Basic Usage -1. **Select a Process**: Choose a process from the process list -2. **View Topology**: CPU topology is automatically detected and displayed -3. **Select Cores**: Use quick selection buttons or manually select individual cores -4. **Apply Settings**: Use "Apply Affinity" or "Quick Apply Affinity & Power Plan" - -### Quick Selection Buttons -- **All Cores**: Selects all available CPU cores -- **Physical Only**: Selects only physical cores (useful for avoiding HT conflicts) -- **P-Cores**: Selects Intel Performance cores (Intel 12th gen+) -- **E-Cores**: Selects Intel Efficiency cores (Intel 12th gen+) -- **CCD 0/1/2...**: Selects cores from specific AMD CCDs - -### System Tray Integration -- **Quick Apply**: Right-click tray icon โ†’ "Quick Apply to [ProcessName]" -- **Show Window**: Double-click tray icon or right-click โ†’ "Show ThreadPilot" -- **Minimize to Tray**: Window minimizes to system tray instead of taskbar - -## Technical Details - -### CPU Topology Models -- **CpuTopologyModel**: Main topology container with architecture detection -- **CpuCoreModel**: Individual core representation with type and topology info -- **CpuAffinityPreset**: Pre-configured affinity selections for quick access - -### Services -- **ICpuTopologyService**: Interface for CPU topology detection and management -- **CpuTopologyService**: WMI-based implementation with fallback mechanisms -- **ISystemTrayService**: System tray icon and context menu management - -### Architecture Detection -- **Intel Hybrid**: Detects P-cores and E-cores using WMI processor information -- **AMD CCD**: Identifies Core Complex Dies for NUMA-aware core selection -- **SMT/HyperThreading**: Distinguishes physical cores from logical threads -- **Multi-socket**: Supports systems with multiple CPU sockets - -## Testing - -### Test Mode -Run the application with `--test` parameter to execute CPU topology detection tests: -``` -ThreadPilot.exe --test -``` - -### Manual Testing -1. Open ThreadPilot -2. Navigate to Process Management tab -3. Select any process -4. Verify CPU topology detection status -5. Test quick selection buttons -6. Apply affinity settings and verify they take effect - -## Troubleshooting - -### Topology Detection Failed -- **Cause**: WMI access restrictions or unsupported hardware -- **Solution**: Run as Administrator or use fallback mode -- **Fallback**: Basic core enumeration without advanced features - -### Quick Selection Not Working -- **Cause**: No process selected or topology detection failed -- **Solution**: Select a process and ensure topology detection succeeded -- **Check**: Topology status indicator in the UI - -### System Tray Not Visible -- **Cause**: Windows Forms package not available or permissions issue -- **Solution**: Ensure System.Windows.Forms package is installed -- **Alternative**: Use main window controls - -## Performance Considerations - -- **WMI Queries**: Topology detection uses cached results to minimize overhead -- **UI Updates**: Core selection changes are batched for better performance -- **Memory Usage**: Topology models are lightweight and reused across processes -- **Background Detection**: Topology detection runs asynchronously without blocking UI - -## Compatibility - -- **Windows Version**: Windows 7 or later (WMI support required) -- **CPU Support**: Intel and AMD processors with standard WMI interfaces -- **Architecture**: x64 and ARM64 (where WMI topology data is available) -- **.NET Version**: .NET 9.0 or later - -## Future Enhancements - -- **NUMA Awareness**: Automatic NUMA node detection and optimization -- **Performance Monitoring**: Real-time CPU usage per core type -- **Profile Management**: Save/load CPU affinity profiles -- **Scheduler Integration**: Windows scheduler hint optimization -- **Advanced Presets**: Custom affinity patterns for specific workloads diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md deleted file mode 100644 index 4df0029..0000000 --- a/docs/reference/API_REFERENCE.md +++ /dev/null @@ -1,318 +0,0 @@ -# ThreadPilot API Reference - -## Core Interfaces - -### ISystemService -Base interface for all system services providing lifecycle management. - -```csharp -public interface ISystemService -{ - bool IsAvailable { get; } - event EventHandler<ServiceAvailabilityChangedEventArgs>? AvailabilityChanged; - Task InitializeAsync(); - Task DisposeAsync(); -} -``` - -**Key Methods:** -- `InitializeAsync()`: Initialize service resources -- `DisposeAsync()`: Clean up service resources -- `IsAvailable`: Indicates if service is operational - -### IRepository<T> -Generic repository interface for data access operations. - -```csharp -public interface IRepository<T> : IRepository<T, string> where T : class -{ - Task<T?> GetByIdAsync(string id); - Task<IEnumerable<T>> GetAllAsync(); - Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate); - Task<T> AddAsync(T entity); - Task<T> UpdateAsync(T entity); - Task<bool> DeleteAsync(string id); - Task<bool> ExistsAsync(string id); - Task<int> CountAsync(); -} -``` - -## Core Services - -### IProcessService -Manages process enumeration and manipulation. - -```csharp -public interface IProcessService -{ - Task<IEnumerable<ProcessModel>> GetRunningProcessesAsync(); - Task<bool> SetProcessPriorityAsync(int processId, ProcessPriorityClass priority); - Task<bool> SetProcessAffinityAsync(int processId, long affinityMask); - Task<ProcessModel?> GetProcessByIdAsync(int processId); - bool IsProcessRunning(string processName); -} -``` - -**Key Features:** -- Async process enumeration -- Priority and affinity management -- Process existence checking - -### IPowerPlanService -Manages Windows power plans. - -```csharp -public interface IPowerPlanService -{ - Task<IEnumerable<PowerPlanModel>> GetAvailablePowerPlansAsync(); - Task<PowerPlanModel?> GetActivePowerPlanAsync(); - Task<bool> SetActivePowerPlanAsync(string powerPlanGuid); - Task<PowerPlanModel?> GetPowerPlanByGuidAsync(string guid); - event EventHandler<PowerPlanChangedEventArgs>? PowerPlanChanged; -} -``` - -**Key Features:** -- Power plan enumeration and activation -- Change event notifications -- GUID-based plan identification - -### ICpuTopologyService -Provides CPU topology detection and affinity management. - -```csharp -public interface ICpuTopologyService -{ - Task<CpuTopologyModel> DetectTopologyAsync(); - Task<bool> SetProcessAffinityAsync(int processId, IEnumerable<int> coreIds); - Task<long> GetProcessAffinityAsync(int processId); - event EventHandler<CpuTopologyDetectedEventArgs>? TopologyDetected; -} -``` - -**Key Features:** -- Modern CPU architecture detection -- Core affinity management -- Support for hybrid architectures - -## Business Logic Services - -### IProcessMonitorManagerService -Orchestrates process monitoring and power plan switching. - -```csharp -public interface IProcessMonitorManagerService -{ - bool IsMonitoringActive { get; } - Task StartMonitoringAsync(); - Task StopMonitoringAsync(); - event EventHandler<ProcessEventArgs>? ProcessStarted; - event EventHandler<ProcessEventArgs>? ProcessStopped; - event EventHandler? MonitoringStarted; - event EventHandler? MonitoringStopped; -} -``` - -**Key Features:** -- WMI-based process monitoring -- Automatic power plan switching -- Fallback polling mechanism - -### IGameBoostService -Manages game detection and performance optimization. - -```csharp -public interface IGameBoostService -{ - bool IsGameBoostActive { get; } - string? CurrentGameName { get; } - Task<bool> ActivateGameBoostAsync(ProcessModel gameProcess); - Task DeactivateGameBoostAsync(); - Task<bool> AddKnownGameAsync(string executableName); - Task<bool> RemoveKnownGameAsync(string executableName); - event EventHandler<GameBoostEventArgs>? GameBoostActivated; - event EventHandler<GameBoostEventArgs>? GameBoostDeactivated; -} -``` - -**Key Features:** -- Automatic game detection -- Performance optimization -- Configurable game database - -## Application Services - -### IApplicationSettingsService -Manages application configuration and persistence. - -```csharp -public interface IApplicationSettingsService -{ - ApplicationSettingsModel Settings { get; } - Task LoadSettingsAsync(); - Task SaveSettingsAsync(); - Task ResetToDefaultsAsync(); - Task<bool> ExportSettingsAsync(string filePath); - Task<bool> ImportSettingsAsync(string filePath); - event EventHandler<SettingsChangedEventArgs>? SettingsChanged; -} -``` - -**Key Features:** -- Automatic settings persistence -- Import/export functionality -- Change notifications - -### INotificationService -Handles user notifications and system tray integration. - -```csharp -public interface INotificationService -{ - Task ShowNotificationAsync(string title, string message, NotificationType type); - Task ShowBalloonNotificationAsync(string title, string message, int duration); - Task ShowToastNotificationAsync(string title, string message); - Task ClearAllNotificationsAsync(); -} -``` - -**Key Features:** -- Multiple notification types -- System tray balloon tips -- Windows toast notifications - -### IEnhancedLoggingService -Provides structured logging with file persistence. - -```csharp -public interface IEnhancedLoggingService -{ - Task LogPowerPlanChangeAsync(string fromPlan, string toPlan, string reason); - Task LogProcessEventAsync(string processName, string eventType, string details); - Task LogGameBoostEventAsync(string gameName, string action, string details); - Task LogUserActionAsync(string action, string details, string? context = null); - Task LogErrorAsync(string source, string error, string? stackTrace = null); - Task<IEnumerable<LogEntry>> GetLogEntriesAsync(DateTime? fromDate = null, DateTime? toDate = null); -} -``` - -**Key Features:** -- Structured event logging -- File rotation and management -- Query capabilities - -## Data Models - -### ProcessModel -Represents a system process with extended information. - -```csharp -public partial class ProcessModel : ObservableObject -{ - public int ProcessId { get; set; } - public string Name { get; set; } - public string ExecutablePath { get; set; } - public ProcessPriorityClass Priority { get; set; } - public long ProcessorAffinity { get; set; } - public double CpuUsage { get; set; } - public long WorkingSet { get; set; } -} -``` - -### PowerPlanModel -Represents a Windows power plan. - -```csharp -public partial class PowerPlanModel : ObservableObject -{ - public string Guid { get; set; } - public string Name { get; set; } - public string Description { get; set; } - public bool IsActive { get; set; } -} -``` - -### CpuTopologyModel -Represents CPU topology information. - -```csharp -public partial class CpuTopologyModel : ObservableObject -{ - public int TotalCores { get; set; } - public int PhysicalCores { get; set; } - public int LogicalCores { get; set; } - public bool HasHybridArchitecture { get; set; } - public ObservableCollection<CpuCoreModel> Cores { get; set; } -} -``` - -## Event Arguments - -### ProcessEventArgs -Provides data for process-related events. - -```csharp -public class ProcessEventArgs : EventArgs -{ - public ProcessModel Process { get; } - public DateTime Timestamp { get; } - public string EventType { get; } -} -``` - -### PowerPlanChangedEventArgs -Provides data for power plan change events. - -```csharp -public class PowerPlanChangedEventArgs : EventArgs -{ - public PowerPlanModel? PreviousPlan { get; } - public PowerPlanModel CurrentPlan { get; } - public string Reason { get; } - public DateTime Timestamp { get; } -} -``` - -### GameBoostEventArgs -Provides data for game boost events. - -```csharp -public class GameBoostEventArgs : EventArgs -{ - public ProcessModel? GameProcess { get; } - public bool IsActivated { get; } - public DateTime Timestamp { get; } -} -``` - -## Configuration Enums - -### NotificationType -Defines notification types for the notification service. - -```csharp -public enum NotificationType -{ - Information, - Warning, - Error, - Success -} -``` - -### ProcessPriorityClass -Standard .NET process priority enumeration. - -```csharp -public enum ProcessPriorityClass -{ - Idle, - BelowNormal, - Normal, - AboveNormal, - High, - RealTime -} -``` - -This API reference provides comprehensive documentation for all public interfaces and key classes in ThreadPilot. diff --git a/docs/reference/ARCHITECTURE_GUIDE.md b/docs/reference/ARCHITECTURE_GUIDE.md deleted file mode 100644 index cce5c37..0000000 --- a/docs/reference/ARCHITECTURE_GUIDE.md +++ /dev/null @@ -1,199 +0,0 @@ -# ThreadPilot Architecture Guide - -## Overview -This document describes the modular architecture of ThreadPilot, designed for maintainability, testability, and future extensibility. - -## Architecture Principles - -### 1. Separation of Concerns -- **Core Services**: Direct OS interaction (Process, Power Plan, CPU Topology) -- **Process Management**: Business logic for process monitoring and management -- **Application Services**: UI and application-level functionality -- **Presentation Layer**: ViewModels and Views with MVVM pattern - -### 2. Dependency Injection -- Centralized service configuration in `ServiceConfiguration.cs` -- Service factory pattern for advanced service management -- Proper lifecycle management for all services - -### 3. Event-Driven Architecture -- Services communicate through well-defined events -- Loose coupling between components -- Reactive UI updates through observable patterns - -## Project Structure - -``` -ThreadPilot/ -โ”œโ”€โ”€ Services/ -โ”‚ โ”œโ”€โ”€ Core/ # Base interfaces and implementations -โ”‚ โ”‚ โ”œโ”€โ”€ ISystemService.cs # Base interface for system services -โ”‚ โ”‚ โ””โ”€โ”€ BaseSystemService.cs # Base implementation with common functionality -โ”‚ โ”œโ”€โ”€ ProcessManagement/ # Process-related services -โ”‚ โ”‚ โ””โ”€โ”€ IProcessManagementService.cs -โ”‚ โ”œโ”€โ”€ ServiceConfiguration.cs # Centralized DI configuration -โ”‚ โ””โ”€โ”€ ServiceFactory.cs # Service factory for advanced management -โ”œโ”€โ”€ Models/ -โ”‚ โ”œโ”€โ”€ Core/ # Base model interfaces and implementations -โ”‚ โ”‚ โ””โ”€โ”€ IModel.cs # Base model interface and validation -โ”‚ โ”œโ”€โ”€ Process/ # Process-related models -โ”‚ โ”œโ”€โ”€ PowerPlan/ # Power plan models -โ”‚ โ””โ”€โ”€ Configuration/ # Configuration models -โ”œโ”€โ”€ ViewModels/ -โ”‚ โ”œโ”€โ”€ BaseViewModel.cs # Enhanced base ViewModel with error handling -โ”‚ โ””โ”€โ”€ ViewModelFactory.cs # Factory for ViewModel creation and management -โ”œโ”€โ”€ Views/ # XAML views and code-behind -โ”œโ”€โ”€ Converters/ # Value converters for data binding -โ”œโ”€โ”€ Helpers/ # Utility classes and extension methods -โ””โ”€โ”€ Tests/ # Unit and integration tests -``` - -## Service Layer Architecture - -### Core Services (`Services/Core/`) -Base interfaces and implementations for all services: - -- **ISystemService**: Base interface with availability tracking and lifecycle management -- **BaseSystemService**: Common functionality for initialization, disposal, and error handling - -### Service Categories - -#### 1. Core System Services -Direct interaction with the operating system: -- `IProcessService`: Process enumeration and manipulation -- `IPowerPlanService`: Windows power plan management -- `ICpuTopologyService`: CPU topology detection and affinity management - -#### 2. Process Management Services -Business logic for process monitoring: -- `IProcessMonitorService`: WMI-based process event monitoring -- `IProcessPowerPlanAssociationService`: Process-to-power plan associations -- `IProcessMonitorManagerService`: Orchestrates process monitoring -- `IGameBoostService`: Game detection and performance optimization - -#### 3. Application Services -Application-level functionality: -- `IApplicationSettingsService`: Configuration persistence -- `INotificationService`: User notifications and system tray -- `IAutostartService`: Windows startup integration -- `IEnhancedLoggingService`: Structured logging with file persistence - -### Service Factory Pattern -The `ServiceFactory` class provides: -- Centralized service creation with dependency resolution -- Lifecycle management for `ISystemService` implementations -- Automatic initialization and disposal of managed services -- Error handling and logging for service operations - -## Presentation Layer Architecture - -### Enhanced BaseViewModel -The `BaseViewModel` class provides: -- **Error Handling**: Centralized error management with logging -- **Status Management**: Busy states and status messages -- **Async Operations**: Helper methods for async operations with error handling -- **Logging Integration**: Automatic user action logging -- **Lifecycle Management**: Proper initialization and disposal - -### ViewModel Factory -The `ViewModelFactory` class provides: -- Dependency injection for ViewModels -- Automatic initialization of ViewModels -- Lifecycle management and disposal -- Error handling during creation and initialization - -## Model Layer Architecture - -### Base Model Interface -The `IModel` interface provides: -- **Identity**: Unique ID and timestamps -- **Validation**: Built-in validation framework -- **Change Tracking**: Property change notifications -- **Cloning**: Deep copy support - -### Domain-Specific Models -Models are organized by domain: -- **Process Models**: Process information and metadata -- **Power Plan Models**: Power plan configurations -- **Configuration Models**: Application settings and associations - -## Configuration and Dependency Injection - -### ServiceConfiguration Class -Centralized configuration with methods for each service category: -- `ConfigureServiceInfrastructure()`: Logging and factories -- `ConfigureCoreSystemServices()`: OS interaction services -- `ConfigureProcessManagementServices()`: Business logic services -- `ConfigureApplicationLevelServices()`: UI and application services -- `ConfigurePresentationLayer()`: ViewModels and Views - -### Service Validation -Automatic validation of service configuration at startup: -- Ensures all required services can be resolved -- Validates service dependencies -- Provides clear error messages for configuration issues - -## Error Handling and Logging - -### Structured Logging -- **Enhanced Logging Service**: File-based logging with rotation -- **Structured Events**: Predefined event types for different operations -- **User Action Logging**: Audit trail for user interactions -- **Error Correlation**: Consistent error tracking across services - -### Error Handling Patterns -- **Service Level**: Try-catch with logging and graceful degradation -- **ViewModel Level**: User-friendly error messages with technical logging -- **Application Level**: Global exception handling with recovery - -## Testing Strategy - -### Unit Testing -- Service interfaces enable easy mocking -- BaseViewModel provides testable error handling -- Model validation can be tested independently - -### Integration Testing -- Service factory enables testing of service interactions -- Event-driven architecture allows testing of component communication -- Configuration validation ensures proper setup - -## Future Extensibility - -### Adding New Services -1. Implement appropriate base interface (`ISystemService` for system services) -2. Add to relevant service category in `ServiceConfiguration` -3. Follow established patterns for error handling and logging - -### Adding New Features -1. Create domain-specific models with validation -2. Implement services following the established patterns -3. Create ViewModels inheriting from `BaseViewModel` -4. Use dependency injection for all dependencies - -### Performance Optimization -- Service factory enables lazy loading of services -- Event-driven architecture allows for efficient updates -- Structured logging provides performance monitoring data - -## Best Practices - -### Service Development -- Always implement proper disposal for resources -- Use structured logging for all operations -- Implement graceful degradation for optional features -- Follow async/await patterns consistently - -### ViewModel Development -- Inherit from `BaseViewModel` for consistent functionality -- Use `ExecuteAsync` methods for error handling -- Implement proper disposal for event subscriptions -- Log user actions for audit purposes - -### Model Development -- Implement validation for all business rules -- Use property change notifications for UI binding -- Provide meaningful error messages -- Support cloning for undo/redo functionality - -This architecture provides a solid foundation for maintainable, testable, and extensible code while following established patterns and best practices. diff --git a/docs/reference/DEVELOPER_GUIDE.md b/docs/reference/DEVELOPER_GUIDE.md deleted file mode 100644 index 5a3f70b..0000000 --- a/docs/reference/DEVELOPER_GUIDE.md +++ /dev/null @@ -1,322 +0,0 @@ -# ThreadPilot Developer Guide - -## Quick Start for New Developers - -### Prerequisites -- .NET 8.0 SDK -- Visual Studio 2022 or VS Code with C# extension -- Windows 10/11 (for WMI and power plan features) - -### Building the Project -```bash -git clone <repository-url> -cd ThreadPilot -dotnet restore -dotnet build -``` - -### Running the Application -```bash -dotnet run --project ThreadPilot -``` - -## Key Architectural Concepts - -### 1. Service-Oriented Architecture -ThreadPilot uses a layered service architecture with dependency injection: - -- **Core Services**: Direct OS interaction (Process, Power Plan, CPU) -- **Business Services**: Application logic (Process Monitoring, Game Boost) -- **Application Services**: UI and configuration (Settings, Notifications) - -### 2. MVVM Pattern with Enhanced BaseViewModel -All ViewModels inherit from `BaseViewModel` which provides: -- Centralized error handling with logging -- Async operation helpers with status management -- User action logging for audit trails -- Proper disposal and lifecycle management - -### 3. Repository Pattern for Data Access -Data persistence uses the repository pattern with: -- Generic `IRepository<T>` interface for CRUD operations -- JSON file-based implementation with thread-safe operations -- Centralized data access through `IDataAccessService` -- Model validation and integrity checking - -## Adding New Features - -### 1. Adding a New Service - -**Step 1**: Create the interface in appropriate folder: -```csharp -// Services/YourDomain/IYourService.cs -public interface IYourService : ISystemService -{ - Task<bool> DoSomethingAsync(); - event EventHandler<YourEventArgs>? SomethingHappened; -} -``` - -**Step 2**: Implement the service: -```csharp -// Services/YourDomain/YourService.cs -public class YourService : BaseSystemService, IYourService -{ - public YourService(ILogger<YourService> logger) : base(logger) { } - - public async Task<bool> DoSomethingAsync() - { - // Implementation - } -} -``` - -**Step 3**: Register in `ServiceConfiguration.cs`: -```csharp -services.AddSingleton<IYourService, YourService>(); -``` - -### 2. Adding a New ViewModel - -**Step 1**: Create ViewModel inheriting from BaseViewModel: -```csharp -public partial class YourViewModel : BaseViewModel -{ - private readonly IYourService _yourService; - - public YourViewModel( - ILogger<YourViewModel> logger, - IYourService yourService, - IEnhancedLoggingService? enhancedLoggingService = null) - : base(logger, enhancedLoggingService) - { - _yourService = yourService; - } - - [RelayCommand] - private async Task DoSomethingAsync() - { - await ExecuteAsync(async () => - { - await _yourService.DoSomethingAsync(); - await LogUserActionAsync("YourAction", "Did something", "User interaction"); - }, "Doing something...", "Operation completed successfully"); - } -} -``` - -**Step 2**: Register in `ServiceConfiguration.cs`: -```csharp -services.AddTransient<YourViewModel>(); -``` - -### 3. Adding a New Model - -**Step 1**: Create model implementing IModel: -```csharp -public partial class YourModel : ObservableObject, IModel -{ - [ObservableProperty] - private string id = Guid.NewGuid().ToString(); - - [ObservableProperty] - private DateTime createdAt = DateTime.UtcNow; - - [ObservableProperty] - private DateTime updatedAt = DateTime.UtcNow; - - [ObservableProperty] - private string name = string.Empty; - - // IModel implementation - public string Id => id; - public DateTime CreatedAt => createdAt; - public DateTime UpdatedAt => updatedAt; - - public ValidationResult Validate() - { - var errors = new List<string>(); - if (string.IsNullOrWhiteSpace(Name)) - errors.Add("Name is required"); - return errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors.ToArray()); - } - - public IModel Clone() - { - return new YourModel - { - id = Guid.NewGuid().ToString(), - Name = this.Name, - createdAt = DateTime.UtcNow, - updatedAt = DateTime.UtcNow - }; - } -} -``` - -## Common Patterns and Best Practices - -### Error Handling -Always use the BaseViewModel's error handling methods: -```csharp -// For async operations with user feedback -await ExecuteAsync(async () => -{ - // Your operation -}, "Loading...", "Completed successfully"); - -// For setting errors manually -SetError("Something went wrong", exception); - -// For clearing errors -ClearError(); -``` - -### Logging -Use structured logging throughout the application: -```csharp -// In services -Logger.LogInformation("Operation completed for {EntityId}", entityId); - -// In ViewModels (for user actions) -await LogUserActionAsync("ActionName", "Description", "Context"); -``` - -### Event Handling -Follow the established event pattern: -```csharp -public event EventHandler<YourEventArgs>? YourEvent; - -protected virtual void OnYourEvent(YourEventArgs args) -{ - YourEvent?.Invoke(this, args); -} -``` - -### Async Operations -Always use proper async/await patterns: -```csharp -// Good -public async Task<bool> DoSomethingAsync() -{ - await SomeAsyncOperation(); - return true; -} - -// Avoid blocking calls -public bool DoSomething() -{ - DoSomethingAsync().Wait(); // DON'T DO THIS - return true; -} -``` - -## Testing Guidelines - -### Unit Testing Services -```csharp -[Test] -public async Task YourService_DoSomething_ReturnsExpectedResult() -{ - // Arrange - var logger = Mock.Of<ILogger<YourService>>(); - var service = new YourService(logger); - - // Act - var result = await service.DoSomethingAsync(); - - // Assert - Assert.IsTrue(result); -} -``` - -### Testing ViewModels -```csharp -[Test] -public async Task YourViewModel_DoSomething_UpdatesStatusCorrectly() -{ - // Arrange - var logger = Mock.Of<ILogger<YourViewModel>>(); - var service = Mock.Of<IYourService>(); - var viewModel = new YourViewModel(logger, service); - - // Act - await viewModel.DoSomethingCommand.ExecuteAsync(null); - - // Assert - Assert.IsFalse(viewModel.HasError); -} -``` - -## Performance Considerations - -### Memory Management -- Always dispose of services that implement IDisposable -- Unsubscribe from events in ViewModel disposal -- Use weak event patterns for long-lived subscriptions - -### Threading -- UI operations must be on the UI thread -- Use ConfigureAwait(false) for non-UI async operations -- Protect shared resources with appropriate synchronization - -### Data Access -- Repository operations are thread-safe -- Use batch operations when possible -- Implement proper caching for frequently accessed data - -## Troubleshooting Common Issues - -### Service Resolution Errors -Check `ServiceConfiguration.cs` to ensure all dependencies are registered with correct lifetimes. - -### ViewModel Constructor Issues -Ensure all ViewModels call the base constructor with required logger parameter. - -### Data Persistence Issues -Check file permissions and ensure data directory exists. Use logging to trace repository operations. - -### Event Subscription Leaks -Always unsubscribe from events in the ViewModel's OnDispose method. - -## Configuration and Settings - -### Application Settings -Settings are managed through `IApplicationSettingsService` and persisted automatically: -```csharp -// Access current settings -var settings = _settingsService.Settings; - -// Modify settings -settings.EnableNotifications = true; - -// Save changes (automatic on property change) -await _settingsService.SaveSettingsAsync(); -``` - -### Logging Configuration -Enhanced logging is configured in `ServiceConfiguration.cs`: -- Console logging for development -- File-based logging with rotation for production -- Structured events for different operation types - -### Data Storage Locations -- **Settings**: `%AppData%\ThreadPilot\settings.json` -- **Associations**: `%AppData%\ThreadPilot\Data\ProcessAssociations.json` -- **Profiles**: `%AppData%\ThreadPilot\Data\ProcessProfiles.json` -- **Logs**: `%AppData%\ThreadPilot\Logs\` - -## Deployment and Distribution - -### Release Build -```bash -dotnet publish -c Release -r win-x64 --self-contained true -``` - -### Installer Considerations -- Register for Windows autostart if enabled -- Create application data directories -- Set appropriate file permissions -- Register WMI event handlers - -This guide provides the foundation for extending ThreadPilot while maintaining code quality and architectural consistency. diff --git a/docs/reference/DEVELOPMENT.md b/docs/reference/DEVELOPMENT.md deleted file mode 100644 index d9cbd4d..0000000 --- a/docs/reference/DEVELOPMENT.md +++ /dev/null @@ -1,43 +0,0 @@ -# Development Guide (Operational) - -This file provides a concise operational development workflow. - -## Build - -```powershell -dotnet restore ThreadPilot_1.sln -dotnet build ThreadPilot_1.sln --configuration Release --no-restore -``` - -## Test - -```powershell -dotnet test ThreadPilot_1.sln --configuration Release --no-build -``` - -## Debug Notes - -- Startup/unhandled exception handling is centralized in App.xaml.cs. -- Process monitoring orchestration is in ProcessMonitorManagerService. -- Tray lifecycle and UI startup flow are in MainWindow.xaml.cs. - -## Common Tasks - -- Install local git hooks: - -```powershell -./build/install-git-hooks.ps1 -``` - -- Package release artifacts: - -```powershell -./build/build-installer.ps1 -Version "1.1.1" -./build/package-release-zips.ps1 -Version "1.1.1" -``` - -- Publish GitHub release (after artifacts are built): - -```powershell -./build/create-github-release.ps1 -Version "1.1.1" -NotesFile "docs/release/RELEASE_NOTES.md" -``` diff --git a/docs/reference/EXCEPTION_HANDLING_POLICY.md b/docs/reference/EXCEPTION_HANDLING_POLICY.md deleted file mode 100644 index f917272..0000000 --- a/docs/reference/EXCEPTION_HANDLING_POLICY.md +++ /dev/null @@ -1,62 +0,0 @@ -# Exception Handling Policy - -This policy defines how ThreadPilot handles, classifies, and reports runtime failures. - -## Goals - -- Prevent silent failures in background tasks. -- Keep UI responsive when recoverable faults occur. -- Preserve actionable diagnostics for post-mortem analysis. - -## Exception Hierarchy - -Implemented domain exception tree: - -- ThreadPilotException -- ProcessManagementException -- PrivilegeException -- RuleEngineException -- ResourceOptimizationException -- PersistenceException - -Each domain exception carries an ErrorCode value for structured logging. - -## Global Handlers - -Global safety net handlers are registered in application startup: - -- AppDomain.CurrentDomain.UnhandledException -- Application.DispatcherUnhandledException -- TaskScheduler.UnobservedTaskException - -Behavior summary: - -- AppDomain unhandled: critical log + blocking error dialog. -- Dispatcher unhandled: error log + user choice to continue or terminate. -- Unobserved task: logged and marked observed to avoid process termination from finalizer escalation. - -## Logging and Correlation - -Unhandled exceptions are routed to: - -- ILogger<App> for immediate diagnostic visibility. -- IEnhancedLoggingService for persisted structured telemetry. - -Structured context includes: - -- Source handler -- ErrorCode (domain code when available) -- CorrelationId (if available) -- Termination-level hint - -## Recovery Guidelines - -- Prefer local try/catch + retry policies for known transient operations. -- Use RetryPolicyService with operation-specific predicates. -- Reserve global handlers for truly unhandled paths only. - -## Guard Clause Guidelines - -- Validate external inputs at entry points. -- Throw ArgumentException/ArgumentNullException early for invalid arguments. -- Wrap domain failures in ThreadPilotException-derived types when crossing service boundaries. diff --git a/docs/reference/GAME_BOOST_VALIDATION.md b/docs/reference/GAME_BOOST_VALIDATION.md deleted file mode 100644 index 730c612..0000000 --- a/docs/reference/GAME_BOOST_VALIDATION.md +++ /dev/null @@ -1,153 +0,0 @@ -# Game Boost Integration - Validation and Testing Guide - -## Overview -This document provides comprehensive validation and testing instructions for the Game Boost Mode feature that has been successfully integrated into ThreadPilot. - -## Feature Summary - -### โœ… **Completed Implementation** -The Game Boost Mode feature is **fully implemented** and includes: - -1. **Automatic Game Detection** - Detects games using advanced heuristics and a comprehensive known games database (150+ games) -2. **Process Priority Management** - Automatically sets high priority for detected games -3. **Power Plan Switching** - Switches to high-performance power plan when games are active -4. **CPU Affinity Optimization** - Optimizes CPU core allocation for better gaming performance -5. **System Tray Integration** - Shows Game Boost status in system tray with shield icon -6. **Main Window Status Display** - Visual indicators in the main UI showing active Game Boost status -7. **User Notifications** - Toast notifications when Game Boost activates/deactivates -8. **Manual Game Management** - UI for adding/removing games from the known games list - -### ๐Ÿ”ง **Key Components** - -#### Services -- **GameBoostService** - Core game boost functionality -- **ProcessMonitorManagerService** - Integrates game detection with process monitoring -- **SystemTrayService** - Enhanced with Game Boost status display -- **NotificationService** - Provides user feedback - -#### UI Components -- **SettingsView** - Game management interface -- **MainWindow** - Status display with visual indicators -- **System Tray** - Context menu and icon state management - -#### Models -- **ApplicationSettingsModel** - Game Boost configuration options -- **ProcessModel** - Enhanced for game detection -- **Event Args** - GameBoostActivatedEventArgs, GameBoostDeactivatedEventArgs - -## Testing Instructions - -### ๐Ÿงช **Automated Testing** -The application includes built-in integration tests that can be run using: - -**Keyboard Shortcut: `Ctrl+Shift+T`** - -This will execute comprehensive tests including: -1. Service resolution validation -2. Game detection logic testing -3. Known games management testing -4. System tray integration testing - -### ๐ŸŽฎ **Manual Testing Workflow** - -#### 1. **Enable Game Boost Mode** -1. Open ThreadPilot -2. Go to Settings tab -3. Enable "Game Boost Mode" -4. Configure desired settings: - - Set high priority for games - - Optimize CPU affinity - - Select Game Boost power plan -5. Save settings - -#### 2. **Test Automatic Game Detection** -1. Start a known game (e.g., Steam, any game from the known games list) -2. Verify Game Boost activates automatically: - - System tray icon changes to shield - - Context menu shows "Game Boost: Active (GameName)" - - Main window status bar shows green "Game Boost: Active" with shield emoji - - Notification appears: "Game Boost Activated" - -#### 3. **Test Game Boost Deactivation** -1. Close the game -2. Verify Game Boost deactivates: - - System tray icon returns to normal - - Context menu shows "Game Boost: Inactive" - - Main window status shows gray "Game Boost: Inactive" - - Notification appears: "Game Boost Deactivated" - -#### 4. **Test Manual Game Management** -1. Go to Settings โ†’ Game Management section -2. Add a custom game executable (e.g., "mygame.exe") -3. Verify it appears in the known games list -4. Test removing games from the list -5. Verify changes are persisted after restart - -#### 5. **Test System Integration** -1. Verify power plan switching works correctly -2. Check process priority is set to High for games -3. Confirm CPU affinity optimization (if enabled) -4. Test with multiple games running simultaneously - -### ๐Ÿ“Š **Expected Behavior** - -#### Game Detection -- **Known Games**: 150+ popular games are pre-configured -- **Auto-Detection**: Advanced heuristics detect likely game processes -- **Manual Addition**: Users can add custom games via UI - -#### Performance Optimization -- **Process Priority**: Games get High priority class -- **Power Plan**: Switches to high-performance plan -- **CPU Affinity**: Optimizes core allocation for better performance -- **Restoration**: All settings restored when games close - -#### User Interface -- **System Tray**: Shield icon when active, context menu status -- **Main Window**: StatusBar with color-coded Game Boost status -- **Notifications**: Toast notifications for activation/deactivation -- **Settings**: Comprehensive configuration options - -### ๐Ÿ” **Validation Checklist** - -#### Core Functionality -- [ ] Game Boost activates when known games start -- [ ] Game Boost deactivates when games close -- [ ] Process priority is set correctly -- [ ] Power plan switching works -- [ ] CPU affinity optimization functions (if enabled) - -#### User Interface -- [ ] System tray shows correct Game Boost status -- [ ] Main window StatusBar displays Game Boost state -- [ ] Shield icons appear when Game Boost is active -- [ ] Context menu shows current game name -- [ ] Settings UI allows game management - -#### Integration -- [ ] Works with existing process monitoring -- [ ] Notifications appear at appropriate times -- [ ] Settings are persisted correctly -- [ ] Multiple games handled properly -- [ ] Error handling works gracefully - -#### Performance -- [ ] No significant performance impact when inactive -- [ ] Quick activation/deactivation response times -- [ ] Stable operation during extended gaming sessions -- [ ] Proper cleanup when application closes - -### ๐Ÿšจ **Known Limitations** -1. Requires administrator privileges for process priority changes -2. Some games may not be detected automatically (can be added manually) -3. Power plan switching requires appropriate Windows permissions -4. CPU affinity optimization depends on system configuration - -### ๐Ÿ“ **Troubleshooting** -- **Game not detected**: Add manually via Settings โ†’ Game Management -- **Priority not set**: Ensure application runs with sufficient privileges -- **Power plan not switching**: Check Windows power management permissions -- **Tests failing**: Check logs for detailed error information - -## Conclusion -The Game Boost Mode feature is fully implemented and ready for production use. The comprehensive testing framework ensures reliability, and the user-friendly interface makes it accessible to all users. diff --git a/docs/reference/PERFORMANCE.md b/docs/reference/PERFORMANCE.md deleted file mode 100644 index d6d303b..0000000 --- a/docs/reference/PERFORMANCE.md +++ /dev/null @@ -1,34 +0,0 @@ -# Performance Guide - -This document consolidates performance diagnostics and optimization guidance for ThreadPilot. - -## Profiling Instructions - -1. Collect process footprint baseline: - -```powershell -pwsh -NoProfile -ExecutionPolicy Bypass -File "build/collect-process-footprint.ps1" -ProcessName "ThreadPilot" -DurationMinutes 30 -SampleIntervalSeconds 5 -WaitForProcess -``` - -2. Optional runtime counters: - -```powershell -dotnet-counters monitor --process-id <PID> --refresh-interval 5 --counters System.Runtime -``` - -## Benchmarks and Targets - -- Idle tray CPU: target < 1% single-core equivalent -- Idle memory footprint: target < 100MB working set -- No sustained Gen2 pause > 100ms without investigation - -## Optimization Notes - -- Prefer event-driven process detection via WMI watchers. -- Use fallback polling with adaptive intervals and overlap protection. -- Keep UI-thread work minimal in periodic timers. - -## Related Documents - -- docs/audits/PHASE1_1_MEMORY_CPU_BASELINE.md -- docs/reference/runtimeconfig.template.json diff --git a/docs/reference/PROJECT_STRUCTURE.md b/docs/reference/PROJECT_STRUCTURE.md deleted file mode 100644 index 6662419..0000000 --- a/docs/reference/PROJECT_STRUCTURE.md +++ /dev/null @@ -1,92 +0,0 @@ -# ThreadPilot Project Structure - -## Overview - -This document reflects the repository as it exists today. It intentionally distinguishes: - -- production application code -- xUnit coverage-driving tests -- legacy runtime smoke harnesses under `Tests/` - -## Root Layout - -```text -ThreadPilot/ -โ”œโ”€โ”€ .github/ # CI, release, and security workflows -โ”œโ”€โ”€ assets/ # Static assets bundled into releases -โ”œโ”€โ”€ build/ # Release/package automation scripts -โ”œโ”€โ”€ chocolatey/ # Chocolatey package template and install scripts -โ”œโ”€โ”€ Converters/ # WPF binding converters -โ”œโ”€โ”€ docs/ # Reference, release, audit, and contributor docs -โ”œโ”€โ”€ Helpers/ # Shared helper utilities -โ”œโ”€โ”€ Installer/ # Inno Setup installer definition -โ”œโ”€โ”€ Models/ # Domain/data models -โ”œโ”€โ”€ Platforms/ # Windows-specific interop helpers -โ”œโ”€โ”€ Properties/ # Publish profiles and app properties -โ”œโ”€โ”€ Services/ # Application and OS-integration services -โ”œโ”€โ”€ Tests/ -โ”‚ โ”œโ”€โ”€ ThreadPilot.Core.Tests/ # Real xUnit suite used by CI and Codecov -โ”‚ โ””โ”€โ”€ *.cs # Legacy runtime smoke/integration harnesses -โ”œโ”€โ”€ Themes/ # WPF theme resources -โ”œโ”€โ”€ ViewModels/ # MVVM presentation logic -โ”œโ”€โ”€ Views/ # XAML views and code-behind -โ”œโ”€โ”€ winget/ # Submission scripts/reference manifests -โ”œโ”€โ”€ App.xaml / App.xaml.cs # Application bootstrap and DI entry -โ”œโ”€โ”€ ThreadPilot.csproj # Main WPF application project -โ””โ”€โ”€ ThreadPilot_1.sln # Solution file -``` - -## Services - -`Services/` contains the highest-value business and orchestration logic in the repo. - -Key areas: - -- `Abstractions/`: injectable seams such as settings storage, GitHub release client, and process runner -- `Core/`, `ProcessManagement/`: older organizational subfolders still present in the repo -- application services such as `ApplicationSettingsService`, `AutostartService`, `PowerPlanService`, `ProcessMonitorService`, `ProcessMonitorManagerService` -- infrastructure services such as `ServiceConfiguration`, `ServiceFactory`, `ServiceDisposalCoordinator` - -## Tests - -### Coverage-driving suite - -`Tests/ThreadPilot.Core.Tests/` is the real automated test project. - -- This is the suite executed by CI. -- This is the suite used for Cobertura/Codecov reporting. -- Coverage runsettings live in `Tests/ThreadPilot.Core.Tests/coverlet.runsettings`. - -### Legacy harnesses - -The other files directly under `Tests/` are not the xUnit suite. - -They are retained as ad-hoc runtime smoke/integration harnesses used by debug-only `--test` mode in `App.xaml.cs`. - -Current examples: - -- `CpuTopologyServiceTests.cs` -- `ProcessSelectionTest.cs` -- `ExecutableBrowseTest.cs` -- `GameBoostIntegrationTest.cs` -- `ActiveApplicationsTest.cs` -- `TestRunner.cs` - -These harnesses are useful for exploratory/manual checks, but they are not part of CI coverage expectations. - -## Release and Packaging - -Release automation is split across: - -- `.github/workflows/release.yml` -- `build/` -- `Installer/` -- `chocolatey/` -- `winget/` - -Generated release artifacts are expected under `artifacts/release/` during packaging runs. - -## Notes - -- `bin/`, `obj/`, and `TestResults*` directories are local/generated outputs and not part of the intended source structure. -- `docs/superpowers/plans/` contains implementation plans and is intentionally excluded from normal source-control expectations for GitHub publication. diff --git a/docs/reference/SAFE_WIN32_INTEROP_EXAMPLES.md b/docs/reference/SAFE_WIN32_INTEROP_EXAMPLES.md deleted file mode 100644 index 164413c..0000000 --- a/docs/reference/SAFE_WIN32_INTEROP_EXAMPLES.md +++ /dev/null @@ -1,44 +0,0 @@ -# Safe Win32 Interop Examples - -This guide provides safe patterns for native interop in ThreadPilot. - -## 1) Prefer SafeHandle for Owned Handles - -- Use SafeHandle-derived types for APIs that return handles. -- Dispose handles deterministically (`using` or explicit dispose paths). - -## 2) Enable Error Propagation - -- Use `SetLastError = true` in declarations where Win32 sets an error code. -- On failure, inspect `Marshal.GetLastWin32Error()` and log actionable context. - -## 3) Validate Inputs Before Native Calls - -- Guard pointers, lengths, and IDs. -- Reject invalid process IDs and empty operation payloads early. - -## 4) Isolate Native Call Boundaries - -- Keep P/Invoke calls inside dedicated helper/service classes. -- Wrap with typed exceptions at service boundary (`ThreadPilotException` hierarchy). - -## 5) Example Pattern (Pseudo) - -```csharp -if (processId <= 0) -{ - throw new ArgumentOutOfRangeException(nameof(processId)); -} - -using SafeProcessHandle handle = NativeMethods.OpenProcess(flags, false, (uint)processId); -if (handle.IsInvalid) -{ - int win32 = Marshal.GetLastWin32Error(); - throw new InvalidOperationException($"OpenProcess failed with Win32 error {win32}."); -} -``` - -## 6) Logging Hygiene - -- Sanitize user-controlled strings before logging. -- Keep logs structured (event type + process + operation + result). diff --git a/docs/reference/UI_STYLE_GUIDE.md b/docs/reference/UI_STYLE_GUIDE.md deleted file mode 100644 index ba38d5f..0000000 --- a/docs/reference/UI_STYLE_GUIDE.md +++ /dev/null @@ -1,201 +0,0 @@ -# ThreadPilot UI Style Guide - -## Overview -This document defines the UI standards, terminology, and style guidelines for the ThreadPilot application to ensure consistency across all components and future development. - -## Application Structure - -### Main Window Layout -- **Title**: "ThreadPilot - Process & Power Plan Manager" -- **Layout**: Grid with TabControl for main content and StatusBar at bottom -- **Tabs**: Organized with emoji icons for visual clarity - - ๐Ÿ”ง Process Management - - โšก Power Plans - - ๐Ÿ”— Process Associations - - โš™๏ธ Settings - -### Status Bar -- Left: General status messages -- Right: Game Boost status with visual indicators (๐Ÿ›ก๏ธ when active) - -## UI Component Standards - -### Buttons -- **Standard Padding**: `10,5` for main action buttons -- **Small Buttons**: `5,2` for utility buttons with `FontSize="10"` -- **Icons**: Use emoji prefixes for visual clarity (๐Ÿ”„ Refresh, โš™๏ธ Settings) -- **Colors**: - - Update/Save: `#007ACC` background, white foreground - - Remove/Delete: `#D13438` background, white foreground - - Default: System colors - -### GroupBox Headers -- Use emoji prefixes for visual organization -- Examples: - - ๐Ÿ” Process Search & Control - - โšก Available Power Plans - - ๐Ÿ“‹ Current Associations - - โš™๏ธ Configuration - -### Text Input Controls -- **Search boxes**: Width="200" with descriptive tooltips -- **Background**: `#3C3C3C` for dark theme areas -- **Foreground**: White for dark theme areas -- **Border**: `#404040` for dark theme areas - -### Data Grids -- **Selection**: Single selection mode -- **Headers**: Column headers visible -- **Grid Lines**: Horizontal only -- **Resizing**: Allow column resizing and sorting - -## Color Scheme - -### Light Theme (Default) -- **Background**: System default (white/light gray) -- **Foreground**: System default (black/dark gray) -- **Accent**: `#007ACC` (blue) -- **Error**: `#D13438` (red) -- **Success**: System green - -### Dark Theme Areas -- **Background**: `#1E1E1E` (main), `#3C3C3C` (controls) -- **Foreground**: White, `#CCCCCC` (secondary text) -- **Border**: `#404040` - -## Typography - -### Font Sizes -- **Default**: System default -- **Small Controls**: `FontSize="10"` for utility buttons -- **Secondary Text**: `FontSize="12"` for descriptions -- **Headers**: Default with bold weight when active - -### Font Weights -- **Active Items**: Bold (using BoolToFontWeightConverter) -- **Normal Items**: Normal weight -- **Secondary Text**: Normal weight - -## Icons and Visual Indicators - -### Emoji Usage -- ๐Ÿ”ง Process/System Management -- โšก Power/Energy related -- ๐Ÿ”— Connections/Associations -- ๐Ÿ“‹ Lists/Logs/Data -- โš™๏ธ Settings/Configuration -- ๐Ÿ” Search functionality -- ๐Ÿ”„ Refresh/Reload -- ๐Ÿ›ก๏ธ Game Boost/Protection -- โœ… Success/Enabled -- โŒ Error/Disabled - -### Status Indicators -- **Game Boost Active**: Bold text + ๐Ÿ›ก๏ธ icon -- **Monitoring Active**: System tray icon changes -- **Error States**: Red text/background -- **Success States**: Green text/background - -## Tooltips and Help Text - -### Tooltip Standards -- **Buttons**: Describe the action clearly - - "Refresh the process list" - - "Apply the selected CPU core affinity to the process" -- **Input Fields**: Describe expected input - - "Search processes by name" - - "Enter the executable name (e.g., game.exe)" -- **Checkboxes**: Explain the behavior - - "Match processes by full path instead of just executable name" - -### Help Text Format -- Use clear, concise language -- Avoid technical jargon where possible -- Provide examples when helpful -- Use consistent terminology (see Terminology section) - -## Layout Guidelines - -### Spacing and Margins -- **Main Container**: `Margin="10"` -- **GroupBox Content**: `Margin="5"` -- **Control Spacing**: `Margin="0,0,10,0"` for horizontal spacing -- **Section Spacing**: `Margin="0,0,0,10"` for vertical spacing - -### Grid Organization -- Use GroupBox for logical sections -- Separate related controls visually -- Maintain consistent spacing between elements -- Use separators in context menus - -### Responsive Design -- Allow column resizing in data grids -- Use appropriate width constraints -- Ensure controls scale properly - -## Terminology Standards - -### Process Management -- **Process**: Running application/executable -- **Executable**: The .exe file name -- **Process Name**: Display name of the process -- **CPU Affinity**: Which CPU cores a process can use -- **Priority**: Process execution priority level - -### Power Management -- **Power Plan**: Windows power configuration scheme -- **Active Power Plan**: Currently selected power plan -- **Default Power Plan**: Fallback power plan when no processes are running -- **Power Plan Association**: Link between process and power plan - -### Game Boost -- **Game Boost**: High-performance mode for games -- **Game Detection**: Automatic identification of game processes -- **Known Games**: Pre-configured list of game executables - -### Monitoring -- **Event-based Monitoring**: Real-time WMI process monitoring -- **Fallback Polling**: Backup monitoring method -- **Process Association**: Configured process-to-power plan mapping - -## Accessibility Guidelines - -### Keyboard Navigation -- Ensure all controls are keyboard accessible -- Provide logical tab order -- Support standard keyboard shortcuts - -### Screen Reader Support -- Use descriptive control names -- Provide appropriate ARIA labels where needed -- Ensure status information is announced - -### Visual Accessibility -- Maintain sufficient color contrast -- Don't rely solely on color for information -- Provide text alternatives for visual indicators - -## Future Development Guidelines - -### Adding New Features -1. Follow existing naming conventions -2. Use consistent spacing and layout patterns -3. Add appropriate tooltips and help text -4. Update this style guide with new patterns -5. Test with both light and dark theme areas - -### Code Organization -- Keep XAML clean and well-commented -- Use consistent indentation (4 spaces) -- Group related controls logically -- Use meaningful names for controls that need code-behind access - -### Testing Considerations -- Test all UI changes with different screen sizes -- Verify tooltip text is helpful and accurate -- Ensure consistent behavior across all tabs -- Test keyboard navigation paths - -## Version History -- **v1.0** (2025-01-28): Initial style guide creation -- Covers current UI implementation with logging, game boost, and process monitoring features diff --git a/docs/reference/runtimeconfig.template.json b/docs/reference/runtimeconfig.template.json deleted file mode 100644 index 27b6e8f..0000000 --- a/docs/reference/runtimeconfig.template.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "runtimeOptions": { - "configProperties": { - "System.GC.Server": true, - "System.GC.Concurrent": true, - "System.GC.HeapHardLimitPercent": 0, - "System.GC.RetainVM": false - } - } -} diff --git a/gitleaks.zip b/gitleaks.zip deleted file mode 100644 index a63e2a8..0000000 Binary files a/gitleaks.zip and /dev/null differ