diff --git a/WinHome.sln b/WinHome.sln index 1fe4d53d..c21a9269 100644 --- a/WinHome.sln +++ b/WinHome.sln @@ -7,6 +7,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WinHome", "src\WinHome.cspr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WinHome.Tests", "tests\WinHome.Tests\WinHome.Tests.csproj", "{27C541F4-7BBF-45A6-ABE4-590A5656D143}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wdem.Core", "src\Wdem.Core\Wdem.Core.csproj", "{0CE4C7A1-BA49-4AAD-88CC-928FDA049B81}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wdem.Core.Tests", "tests\Wdem.Core.Tests\Wdem.Core.Tests.csproj", "{88FE3E69-860D-4F11-BCA9-6E1F6A1D11BB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,10 +49,38 @@ Global {27C541F4-7BBF-45A6-ABE4-590A5656D143}.Release|x64.Build.0 = Release|Any CPU {27C541F4-7BBF-45A6-ABE4-590A5656D143}.Release|x86.ActiveCfg = Release|Any CPU {27C541F4-7BBF-45A6-ABE4-590A5656D143}.Release|x86.Build.0 = Release|Any CPU + {0CE4C7A1-BA49-4AAD-88CC-928FDA049B81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0CE4C7A1-BA49-4AAD-88CC-928FDA049B81}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0CE4C7A1-BA49-4AAD-88CC-928FDA049B81}.Debug|x64.ActiveCfg = Debug|Any CPU + {0CE4C7A1-BA49-4AAD-88CC-928FDA049B81}.Debug|x64.Build.0 = Debug|Any CPU + {0CE4C7A1-BA49-4AAD-88CC-928FDA049B81}.Debug|x86.ActiveCfg = Debug|Any CPU + {0CE4C7A1-BA49-4AAD-88CC-928FDA049B81}.Debug|x86.Build.0 = Debug|Any CPU + {0CE4C7A1-BA49-4AAD-88CC-928FDA049B81}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0CE4C7A1-BA49-4AAD-88CC-928FDA049B81}.Release|Any CPU.Build.0 = Release|Any CPU + {0CE4C7A1-BA49-4AAD-88CC-928FDA049B81}.Release|x64.ActiveCfg = Release|Any CPU + {0CE4C7A1-BA49-4AAD-88CC-928FDA049B81}.Release|x64.Build.0 = Release|Any CPU + {0CE4C7A1-BA49-4AAD-88CC-928FDA049B81}.Release|x86.ActiveCfg = Release|Any CPU + {0CE4C7A1-BA49-4AAD-88CC-928FDA049B81}.Release|x86.Build.0 = Release|Any CPU + {88FE3E69-860D-4F11-BCA9-6E1F6A1D11BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {88FE3E69-860D-4F11-BCA9-6E1F6A1D11BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {88FE3E69-860D-4F11-BCA9-6E1F6A1D11BB}.Debug|x64.ActiveCfg = Debug|Any CPU + {88FE3E69-860D-4F11-BCA9-6E1F6A1D11BB}.Debug|x64.Build.0 = Debug|Any CPU + {88FE3E69-860D-4F11-BCA9-6E1F6A1D11BB}.Debug|x86.ActiveCfg = Debug|Any CPU + {88FE3E69-860D-4F11-BCA9-6E1F6A1D11BB}.Debug|x86.Build.0 = Debug|Any CPU + {88FE3E69-860D-4F11-BCA9-6E1F6A1D11BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {88FE3E69-860D-4F11-BCA9-6E1F6A1D11BB}.Release|Any CPU.Build.0 = Release|Any CPU + {88FE3E69-860D-4F11-BCA9-6E1F6A1D11BB}.Release|x64.ActiveCfg = Release|Any CPU + {88FE3E69-860D-4F11-BCA9-6E1F6A1D11BB}.Release|x64.Build.0 = Release|Any CPU + {88FE3E69-860D-4F11-BCA9-6E1F6A1D11BB}.Release|x86.ActiveCfg = Release|Any CPU + {88FE3E69-860D-4F11-BCA9-6E1F6A1D11BB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {0CE4C7A1-BA49-4AAD-88CC-928FDA049B81} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {88FE3E69-860D-4F11-BCA9-6E1F6A1D11BB} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {CB6C2E14-E236-475B-992E-4BBD8822BE6E} EndGlobalSection diff --git a/src/Engine.cs b/src/Engine.cs index 6a9a1240..46358f25 100644 --- a/src/Engine.cs +++ b/src/Engine.cs @@ -2,7 +2,10 @@ using System.Net.Http; using WinHome.Interfaces; using WinHome.Models; +using WinHome.Providers; using WinHome.Services; +using Wdem.Core.Providers; +using Wdem.Core.Resources; namespace WinHome { @@ -23,6 +26,7 @@ public class Engine : IEngine private readonly IPluginRunner _pluginRunner; private readonly IStateService _stateService; private readonly IRuntimeResolver _runtimeResolver; + private readonly IResourceProviderRegistry? _resourceProviders; private readonly StateWriter _stateWriter; private static readonly HttpClient _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(3) }; private static readonly Uri ConnectivityCheckUri = new("http://www.msftconnecttest.com/connecttest.txt"); @@ -42,7 +46,8 @@ public Engine( IPluginRunner pluginRunner, IStateService stateService, ILogger logger, - IRuntimeResolver runtimeResolver) + IRuntimeResolver runtimeResolver, + IResourceProviderRegistry? resourceProviders = null) { _managers = managers; _dotfiles = dotfiles; @@ -58,6 +63,7 @@ public Engine( _stateService = stateService; _logger = logger; _runtimeResolver = runtimeResolver; + _resourceProviders = resourceProviders; _stateWriter = new StateWriter(); } @@ -77,8 +83,9 @@ public Engine( IStateService stateService, ILogger logger, IRuntimeResolver runtimeResolver, - StateWriter? stateWriter) - : this(managers, dotfiles, registry, systemSettings, wsl, git, env, serviceManager, scheduledTaskService, pluginManager, pluginRunner, stateService, logger, runtimeResolver) + StateWriter? stateWriter, + IResourceProviderRegistry? resourceProviders = null) + : this(managers, dotfiles, registry, systemSettings, wsl, git, env, serviceManager, scheduledTaskService, pluginManager, pluginRunner, stateService, logger, runtimeResolver, resourceProviders) { _stateWriter = stateWriter ?? new StateWriter(); } @@ -92,8 +99,19 @@ public Engine( /// If true, reapplies steps even if previously succeeded. /// If true, continues with remaining steps when a step fails. /// If true, automatically installs missing plugin prerequisite applications. - public async Task RunAsync(Configuration config, bool dryRun, string? profileName = null, bool debug = false, bool diff = false, bool forceReapply = false, bool continueOnError = false, bool autoInstallApps = false) + /// Cancels the active configuration run. + public async Task RunAsync( + Configuration config, + bool dryRun, + string? profileName = null, + bool debug = false, + bool diff = false, + bool forceReapply = false, + bool continueOnError = false, + bool autoInstallApps = false, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); _logger.LogInfo($"--- WinHome v{config.Version} ---"); // Ensure all configured plugins are downloaded/available locally @@ -105,6 +123,7 @@ public async Task RunAsync(Configuration config, bool dryRun, string? profileNam .ToList(); await _pluginManager.EnsurePluginsInstalledAsync(configuredPluginNames); + cancellationToken.ThrowIfCancellationRequested(); // Load Plugins var plugins = _pluginManager.DiscoverPlugins().ToList(); @@ -112,6 +131,7 @@ public async Task RunAsync(Configuration config, bool dryRun, string? profileNam foreach (var plugin in plugins) { + cancellationToken.ThrowIfCancellationRequested(); if (plugin.Capabilities.Contains("package_manager")) { if (!_managers.ContainsKey(plugin.Name)) @@ -145,13 +165,14 @@ public async Task RunAsync(Configuration config, bool dryRun, string? profileNam // Check network if we have apps to install or WSL update enabled if ((config.Apps.Any() || (config.Wsl != null && config.Wsl.Update)) && !dryRun) { - if (!await WaitForNetwork()) + if (!await WaitForNetwork(cancellationToken: cancellationToken)) { _logger.LogWarning("[Warning] No internet connection detected. Package manager operations may fail."); } } var currentState = await BuildStateFromConfig(config); + cancellationToken.ThrowIfCancellationRequested(); var previousState = _stateService.LoadState(); currentState.SystemSettingOriginals = new Dictionary(previousState.SystemSettingOriginals); @@ -168,34 +189,36 @@ public async Task RunAsync(Configuration config, bool dryRun, string? profileNam { _logger.LogInfo("\n--- Cleaning Up ---"); var removedItems = new ConcurrentBag(); - await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => - { - if (uniqueId.StartsWith("reg:")) - { - var parts = uniqueId.Substring(4).Split('|', 2); - if (parts.Length == 2 && _registry.Revert(parts[0], parts[1], dryRun) && !dryRun) - { - removedItems.Add(uniqueId); - confirmedApplied.TryRemove(uniqueId, out _); - } - } - else + var parallelOptions = new ParallelOptions { CancellationToken = cancellationToken }; + await Task.Run(() => Parallel.ForEach(itemsToRemove, parallelOptions, uniqueId => { - var parts = uniqueId.Split(':', 2); - if (parts.Length == 2 && _managers.TryGetValue(parts[0], out var mgr)) + if (uniqueId.StartsWith("reg:")) { - mgr.Uninstall(parts[1], dryRun); - if (!dryRun) + var parts = uniqueId.Substring(4).Split('|', 2); + if (parts.Length == 2 && _registry.Revert(parts[0], parts[1], dryRun) && !dryRun) { removedItems.Add(uniqueId); confirmedApplied.TryRemove(uniqueId, out _); } } - } - })); + else + { + var parts = uniqueId.Split(':', 2); + if (parts.Length == 2 && _managers.TryGetValue(parts[0], out var mgr)) + { + mgr.Uninstall(parts[1], dryRun); + if (!dryRun) + { + removedItems.Add(uniqueId); + confirmedApplied.TryRemove(uniqueId, out _); + } + } + } + }), cancellationToken); foreach (var item in removedItems) { + cancellationToken.ThrowIfCancellationRequested(); _stateService.RemoveApplied(item); try { @@ -220,8 +243,10 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => _logger.LogInfo("\n--- Reverting Removed System Settings ---"); foreach (var settingKey in removedSystemSettings) { + cancellationToken.ThrowIfCancellationRequested(); var originalValue = previousState.SystemSettingOriginals[settingKey]; await _systemSettings.RevertSystemSettingAsync(settingKey, originalValue, dryRun); + cancellationToken.ThrowIfCancellationRequested(); if (!dryRun) { _stateService.RemoveSystemSettingOriginal(settingKey); @@ -234,13 +259,14 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => // 1. Ensure System Managers (Scoop) are ready if needed by plugins if (plugins.Any(p => p.Type.ToLower() == "python" || p.Type.ToLower() == "javascript" || p.Type.ToLower() == "typescript")) { + cancellationToken.ThrowIfCancellationRequested(); if (_managers.TryGetValue("scoop", out var scoopMgr)) { if (!scoopMgr.IsAvailable()) { _logger.LogInfo("\n--- Bootstrapping System Managers ---"); _logger.LogInfo("[Engine] Bootstrapping Scoop for plugin runtimes..."); - scoopMgr.Bootstrapper.Install(dryRun); + await scoopMgr.Bootstrapper.InstallAsync(dryRun, cancellationToken); } } } @@ -261,7 +287,9 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => _logger.LogInfo("\n--- Reconciling Plugin Runtimes ---"); foreach (var plugin in usedPluginsNeedingRuntime) { + cancellationToken.ThrowIfCancellationRequested(); await _pluginManager.EnsureRuntimeAsync(plugin); + cancellationToken.ThrowIfCancellationRequested(); } _env.RefreshPath(); } @@ -287,28 +315,47 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => var sortedApps = DependencyResolver.Sort(config.Apps, globalResourceIds); foreach (var app in sortedApps) { + cancellationToken.ThrowIfCancellationRequested(); var stepId = $"{app.Manager}:{app.Id}"; _logger.LogInfo($"[Engine] Processing {stepId}..."); - if (!forceReapply && !dryRun && applyState.TryGetValue(stepId, out var previous) && previous.Status == StepStatus.Succeeded) - { - _logger.LogInfo($"[Engine] Skipping previously applied {stepId}."); - var skippedResult = new StepResult - { - StepId = stepId, - StepType = "app", - StepName = app.Id, - Status = StepStatus.Skipped, - AppliedAt = previous.AppliedAt - }; - applyState[stepId] = skippedResult; - continue; - } - if (_managers.TryGetValue(app.Manager, out var mgr)) { try { + IResourceProvider? resourceProvider = null; + if (_resourceProviders?.TryGet("package", app.Manager, out var resolvedProvider) == true && + resolvedProvider is not null) + { + resourceProvider = resolvedProvider; + var validation = await resourceProvider.ValidateAsync( + CreatePackageResourceDefinition(app), + cancellationToken); + if (!validation.IsValid) + { + throw new InvalidOperationException(string.Join(" ", validation.Errors)); + } + } + + if (!forceReapply && + !dryRun && + confirmedApplied.ContainsKey(stepId) && + applyState.TryGetValue(stepId, out var previous) && + previous.Status == StepStatus.Succeeded) + { + _logger.LogInfo($"[Engine] Skipping previously applied {stepId}."); + var skippedResult = new StepResult + { + StepId = stepId, + StepType = "app", + StepName = app.Id, + Status = StepStatus.Skipped, + AppliedAt = previous.AppliedAt + }; + applyState[stepId] = skippedResult; + continue; + } + if (mgr is WinHome.Services.Plugins.PluginPackageManagerAdapter adapter) { if (loggedPlugins.Add(app.Manager)) @@ -320,7 +367,7 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => if (!mgr.IsAvailable()) { _logger.LogInfo($"[Engine] Manager '{app.Manager}' not available. Bootstrapping..."); - mgr.Bootstrapper.Install(dryRun); + await mgr.Bootstrapper.InstallAsync(dryRun, cancellationToken); if (!mgr.IsAvailable()) { _logger.LogError($"[Error] Manager '{app.Manager}' not found after attempting to install it."); @@ -328,7 +375,19 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => } } - mgr.Install(app, dryRun); + if (resourceProvider is not null) + { + await ExecutePackageProviderAsync( + resourceProvider, + CreatePackageResourceDefinition(app), + dryRun, + cancellationToken); + } + else + { + mgr.Install(app, dryRun); + cancellationToken.ThrowIfCancellationRequested(); + } if (!dryRun) { @@ -349,6 +408,24 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => _env.RefreshPath(); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + var cancelledResult = new StepResult + { + StepId = stepId, + StepType = "app", + StepName = app.Id, + Status = StepStatus.Cancelled, + ErrorMessage = "Cancelled by the user.", + AppliedAt = DateTime.UtcNow + }; + RecordCancelledStep( + cancelledResult, + applyState, + currentState, + confirmedApplied); + throw; + } catch (Exception ex) { var original = System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex); @@ -383,12 +460,19 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => } } - if (config.Git != null) _git.Configure(config.Git, dryRun); + cancellationToken.ThrowIfCancellationRequested(); + if (config.Git != null) + { + _git.Configure(config.Git, dryRun); + cancellationToken.ThrowIfCancellationRequested(); + } + cancellationToken.ThrowIfCancellationRequested(); if (config.Wsl != null) { _logger.LogInfo("\n--- Configuring WSL ---"); _wsl.Configure(config.Wsl, dryRun); + cancellationToken.ThrowIfCancellationRequested(); } if (config.EnvVars.Any()) @@ -397,7 +481,9 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => var sortedEnvVars = DependencyResolver.Sort(config.EnvVars, globalResourceIds); foreach (var env in sortedEnvVars) { + cancellationToken.ThrowIfCancellationRequested(); _env.Apply(env, dryRun); + cancellationToken.ThrowIfCancellationRequested(); } } @@ -413,6 +499,7 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => _logger.LogInfo("\n--- Running Plugin Extensions ---"); foreach (var ext in allExtensions) { + cancellationToken.ThrowIfCancellationRequested(); var pluginName = ext.Key; var pluginConfig = ext.Value; @@ -426,6 +513,7 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => } await _pluginManager.EnsureRuntimeAsync(plugin); + cancellationToken.ThrowIfCancellationRequested(); bool isInstalled = true; if (autoInstallApps) @@ -496,6 +584,7 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => foreach (var mgrName in managersToTry) { + cancellationToken.ThrowIfCancellationRequested(); if (_managers.TryGetValue(mgrName, out var mgr)) { if (!mgr.IsAvailable()) @@ -503,7 +592,11 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => _logger.LogInfo($"[Engine] Manager '{mgrName}' not available. Attempting to bootstrap..."); try { - mgr.Bootstrapper.Install(dryRun); + await mgr.Bootstrapper.InstallAsync(dryRun, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } catch (Exception bootEx) { @@ -525,7 +618,15 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => { _logger.LogInfo($"[Plugin] Prerequisite app for '{pluginName}' is not installed. Attempting auto-installation using manager '{selectedMgrName}' (Package: '{packageId}')..."); var appConfig = new AppConfig { Id = packageId, Manager = selectedMgrName }; - selectedMgr.Install(appConfig, dryRun); + if (!dryRun && selectedMgr is ICancellablePackageManager cancellableManager) + { + await cancellableManager.InstallAsync(appConfig, null, cancellationToken); + } + else + { + selectedMgr.Install(appConfig, dryRun); + } + cancellationToken.ThrowIfCancellationRequested(); _logger.LogSuccess($"[Plugin] Prerequisite app '{packageId}' installed successfully."); _env.RefreshPath(); } @@ -538,6 +639,7 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => _logger.LogInfo($"[Plugin] Applying configuration for '{pluginName}'..."); var result = await _pluginRunner.ExecuteAsync(plugin, "apply", pluginConfig, new { dryRun = dryRun }); + cancellationToken.ThrowIfCancellationRequested(); if (!result.Success) { @@ -556,6 +658,7 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => } var presetTweaks = await _systemSettings.GetTweaksAsync(config.SystemSettings); + cancellationToken.ThrowIfCancellationRequested(); var allTweaks = config.RegistryTweaks.Concat(presetTweaks).ToList(); if (allTweaks.Any() && OperatingSystem.IsWindows()) @@ -564,9 +667,14 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => var applyState = _stateWriter.Load(); foreach (var tweak in allTweaks) { + cancellationToken.ThrowIfCancellationRequested(); var stepId = $"reg:{tweak.Path}|{tweak.Name}"; - if (!forceReapply && !dryRun && applyState.TryGetValue(stepId, out var previous) && previous.Status == StepStatus.Succeeded) + if (!forceReapply && + !dryRun && + confirmedApplied.ContainsKey(stepId) && + applyState.TryGetValue(stepId, out var previous) && + previous.Status == StepStatus.Succeeded) { _logger.LogInfo($"[Engine] Skipping previously applied registry tweak {tweak.Path}|{tweak.Name}."); var skippedResult = new StepResult @@ -584,6 +692,7 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => try { var applied = _registry.Apply(tweak, dryRun); + cancellationToken.ThrowIfCancellationRequested(); if (!applied) { throw new Exception($"Failed to apply registry tweak {tweak.Path}|{tweak.Name}."); @@ -606,6 +715,25 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => hadSuccessfulApply = true; } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + var cancelledResult = new StepResult + { + StepId = stepId, + StepType = "registry", + StepName = tweak.Name, + Status = StepStatus.Cancelled, + ErrorMessage = "Cancelled by the user.", + AppliedAt = DateTime.UtcNow + }; + + RecordCancelledStep( + cancelledResult, + applyState, + currentState, + confirmedApplied); + throw; + } catch (Exception ex) { var original = System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex); @@ -642,8 +770,10 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => if (!dryRun) { var originals = await _systemSettings.CaptureOriginalSettingsAsync(config.SystemSettings); + cancellationToken.ThrowIfCancellationRequested(); foreach (var kvp in originals) { + cancellationToken.ThrowIfCancellationRequested(); if (!currentState.SystemSettingOriginals.ContainsKey(kvp.Key)) { currentState.SystemSettingOriginals[kvp.Key] = kvp.Value; @@ -653,6 +783,7 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => } await _systemSettings.ApplyNonRegistrySettingsAsync(config.SystemSettings, dryRun); + cancellationToken.ThrowIfCancellationRequested(); } if (config.Dotfiles.Any()) @@ -663,11 +794,25 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => if (hasDotfileDeps) { foreach (var dotfile in sortedDotfiles) + { + cancellationToken.ThrowIfCancellationRequested(); _dotfiles.Apply(dotfile, dryRun); + cancellationToken.ThrowIfCancellationRequested(); + } } else { - await Task.Run(() => Parallel.ForEach(sortedDotfiles, dotfile => _dotfiles.Apply(dotfile, dryRun))); + var parallelOptions = new ParallelOptions { CancellationToken = cancellationToken }; + await Task.Run( + () => Parallel.ForEach( + sortedDotfiles, + parallelOptions, + dotfile => + { + _dotfiles.Apply(dotfile, dryRun); + parallelOptions.CancellationToken.ThrowIfCancellationRequested(); + }), + cancellationToken); } } @@ -679,11 +824,25 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => if (hasServiceDeps) { foreach (var service in sortedServices) + { + cancellationToken.ThrowIfCancellationRequested(); _serviceManager.Apply(service, dryRun); + cancellationToken.ThrowIfCancellationRequested(); + } } else { - await Task.Run(() => Parallel.ForEach(sortedServices, service => _serviceManager.Apply(service, dryRun))); + var parallelOptions = new ParallelOptions { CancellationToken = cancellationToken }; + await Task.Run( + () => Parallel.ForEach( + sortedServices, + parallelOptions, + service => + { + _serviceManager.Apply(service, dryRun); + parallelOptions.CancellationToken.ThrowIfCancellationRequested(); + }), + cancellationToken); } } @@ -695,14 +854,29 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => if (hasTaskDeps) { foreach (var task in sortedTasks) + { + cancellationToken.ThrowIfCancellationRequested(); _scheduledTaskService.Apply(task, dryRun); + cancellationToken.ThrowIfCancellationRequested(); + } } else { - await Task.Run(() => Parallel.ForEach(sortedTasks, task => _scheduledTaskService.Apply(task, dryRun))); + var parallelOptions = new ParallelOptions { CancellationToken = cancellationToken }; + await Task.Run( + () => Parallel.ForEach( + sortedTasks, + parallelOptions, + task => + { + _scheduledTaskService.Apply(task, dryRun); + parallelOptions.CancellationToken.ThrowIfCancellationRequested(); + }), + cancellationToken); } } + cancellationToken.ThrowIfCancellationRequested(); if (!dryRun) { if (hadSuccessfulApply) @@ -719,6 +893,104 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => } } + private static ResourceDefinition CreatePackageResourceDefinition(AppConfig app) + { + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [LegacyPackageManagerProviderAdapter.PackageIdParameter] = app.Id + }; + + if (!string.IsNullOrWhiteSpace(app.Source)) + { + parameters[LegacyPackageManagerProviderAdapter.SourceParameter] = app.Source; + } + + if (!string.IsNullOrWhiteSpace(app.Params)) + { + parameters[LegacyPackageManagerProviderAdapter.InstallerParametersParameter] = app.Params; + } + + return new ResourceDefinition + { + Id = app.ResourceId ?? $"{app.Manager}:{app.Id}", + Type = "package", + Provider = app.Manager, + DisplayName = app.Id, + PreferredVersion = app.Version, + Dependencies = app.DependsOn is null ? Array.Empty() : app.DependsOn, + Parameters = parameters, + PrivilegeRequirement = PrivilegeRequirement.Administrator + }; + } + + private async Task ExecutePackageProviderAsync( + IResourceProvider provider, + ResourceDefinition resource, + bool dryRun, + CancellationToken cancellationToken) + { + var detectedState = await provider.DetectAsync(resource, cancellationToken); + var plan = await provider.PlanAsync(resource, detectedState, cancellationToken); + if (!plan.IsExecutable) + { + throw new InvalidOperationException( + $"Resource '{resource.Id}' cannot be applied: {plan.Error}"); + } + + if (dryRun || !plan.RequiresApply) + { + return; + } + + var result = await provider.ApplyAsync(resource, plan, null, cancellationToken); + if (result.Outcome == ApplyOutcome.Cancelled) + { + throw new OperationCanceledException(cancellationToken); + } + + var verification = await provider.VerifyAsync(resource, cancellationToken); + if (verification.Compliance != ComplianceStatus.Satisfied) + { + throw new InvalidOperationException( + verification.Message ?? + $"Resource '{resource.Id}' failed verification with status '{verification.Compliance}'."); + } + } + + private void RecordCancelledStep( + StepResult cancelledResult, + Dictionary applyState, + StateData currentState, + ConcurrentDictionary confirmedApplied) + { + try + { + _stateWriter.RecordStep(cancelledResult); + applyState[cancelledResult.StepId] = cancelledResult; + } + catch (Exception stateError) + { + _logger.LogWarning( + $"[Engine] Failed to write cancelled step status for {cancelledResult.StepId}: " + + stateError.Message); + } + + try + { + currentState.AppliedItems = new HashSet( + confirmedApplied.Keys, + StringComparer.OrdinalIgnoreCase); + currentState.StepHistory[cancelledResult.StepId] = cancelledResult; + _stateService.SaveState(currentState); + } + catch (Exception stateError) + { + _logger.LogWarning( + $"[Engine] Failed to synchronize cancelled step status for {cancelledResult.StepId}: " + + stateError.Message); + } + } + /// Prints a diff of what will change compared to the previously applied state. public async Task PrintDiffAsync(Configuration config) { diff --git a/src/Infrastructure/AppHost.cs b/src/Infrastructure/AppHost.cs index 282b04b0..7543ce40 100644 --- a/src/Infrastructure/AppHost.cs +++ b/src/Infrastructure/AppHost.cs @@ -11,6 +11,8 @@ using WinHome.Services.Managers; using WinHome.Services.Plugins; using WinHome.Services.System; +using Wdem.Core.Providers; +using WinHome.Providers; using WinHome.Services; namespace WinHome.Infrastructure; @@ -129,6 +131,21 @@ public static void ConfigureServices(IConfiguration configuration, IServiceColle { "scoop", sp.GetRequiredService() } }); + services.AddSingleton(sp => + new ResourceProviderRegistry( + [ + new LegacyPackageManagerProviderAdapter( + "winget", + sp.GetRequiredService(), + supportsSource: true), + new LegacyPackageManagerProviderAdapter( + "choco", + sp.GetRequiredService()), + new LegacyPackageManagerProviderAdapter( + "scoop", + sp.GetRequiredService()) + ])); + services.AddSingleton(sp => new Engine( sp.GetRequiredService>(), sp.GetRequiredService(), @@ -143,7 +160,8 @@ public static void ConfigureServices(IConfiguration configuration, IServiceColle sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new AppRunner( sp.GetRequiredService(), diff --git a/src/Infrastructure/AppRunner.cs b/src/Infrastructure/AppRunner.cs index 6526dcc4..1671f046 100644 --- a/src/Infrastructure/AppRunner.cs +++ b/src/Infrastructure/AppRunner.cs @@ -34,8 +34,19 @@ public AppRunner(IEngine engine, IConfigValidator validator, ISecretResolver sec /// If true, reapplies steps even if previously succeeded. /// If true, continues applying remaining steps on failure. /// If true, automatically installs missing plugin prerequisite applications. + /// Cancels configuration loading and execution. /// Exit code (0 for success). - public async Task RunAsync(FileInfo configFile, bool dryRun, string? profile, bool debug, bool diff, bool json, bool force = false, bool continueOnError = false, bool autoInstallApps = false) + public async Task RunAsync( + FileInfo configFile, + bool dryRun, + string? profile, + bool debug, + bool diff, + bool json, + bool force = false, + bool continueOnError = false, + bool autoInstallApps = false, + CancellationToken cancellationToken = default) { try { @@ -47,7 +58,7 @@ public async Task RunAsync(FileInfo configFile, bool dryRun, string? profil return 1; } - var yamlContent = await File.ReadAllTextAsync(configFile.FullName); + var yamlContent = await File.ReadAllTextAsync(configFile.FullName, cancellationToken); var validation = _validator.Validate(yamlContent); if (!validation.IsValid) @@ -67,9 +78,23 @@ public async Task RunAsync(FileInfo configFile, bool dryRun, string? profil // Resolve Secrets _secretResolver.ResolveObject(config); - await _engine.RunAsync(config, dryRun, profile, debug, diff, force, continueOnError, autoInstallApps); + await _engine.RunAsync( + config, + dryRun, + profile, + debug, + diff, + force, + continueOnError, + autoInstallApps, + cancellationToken); return 0; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _logger.LogWarning("[Cancelled] Configuration was cancelled by the user."); + return 130; + } catch (Exception ex) { _logger.LogError($"[Fatal] An unexpected error occurred: {ex.Message}"); diff --git a/src/Infrastructure/StatusCommand.cs b/src/Infrastructure/StatusCommand.cs index 4b7d6f89..7a8ec0b5 100644 --- a/src/Infrastructure/StatusCommand.cs +++ b/src/Infrastructure/StatusCommand.cs @@ -34,7 +34,7 @@ public static void Register(RootCommand root, IServiceProvider services) return 0; } - int succeeded = 0, failed = 0, skipped = 0; + int succeeded = 0, failed = 0, cancelled = 0, skipped = 0; foreach (var kv in state.OrderBy(k => k.Key)) { var r = kv.Value; @@ -42,6 +42,7 @@ public static void Register(RootCommand root, IServiceProvider services) { StepStatus.Succeeded => "[OK]", StepStatus.Failed => "[FAIL]", + StepStatus.Cancelled => "[CANCELLED]", StepStatus.Skipped => "[SKIP]", _ => "[?]" }; @@ -53,11 +54,13 @@ public static void Register(RootCommand root, IServiceProvider services) if (r.Status == StepStatus.Succeeded) succeeded++; else if (r.Status == StepStatus.Failed) failed++; + else if (r.Status == StepStatus.Cancelled) cancelled++; else if (r.Status == StepStatus.Skipped) skipped++; } Console.WriteLine(); - Console.WriteLine($"{succeeded} succeeded · {failed} failed · {skipped} skipped"); + Console.WriteLine( + $"{succeeded} succeeded · {failed} failed · {cancelled} cancelled · {skipped} skipped"); return 0; }); diff --git a/src/Interfaces/ICancellablePackageManager.cs b/src/Interfaces/ICancellablePackageManager.cs new file mode 100644 index 00000000..39cd7897 --- /dev/null +++ b/src/Interfaces/ICancellablePackageManager.cs @@ -0,0 +1,11 @@ +using WinHome.Models; + +namespace WinHome.Interfaces; + +public interface ICancellablePackageManager : IPackageManager +{ + Task InstallAsync( + AppConfig app, + IProgress? progress, + CancellationToken cancellationToken); +} diff --git a/src/Interfaces/IEngine.cs b/src/Interfaces/IEngine.cs index cf70df5c..c593ef4e 100644 --- a/src/Interfaces/IEngine.cs +++ b/src/Interfaces/IEngine.cs @@ -6,6 +6,15 @@ namespace WinHome.Interfaces /// Main orchestrator for WinHome configuration application. public interface IEngine { - Task RunAsync(Configuration config, bool dryRun, string? profileName = null, bool debug = false, bool diff = false, bool forceReapply = false, bool continueOnError = false, bool autoInstallApps = false); + Task RunAsync( + Configuration config, + bool dryRun, + string? profileName = null, + bool debug = false, + bool diff = false, + bool forceReapply = false, + bool continueOnError = false, + bool autoInstallApps = false, + CancellationToken cancellationToken = default); } } diff --git a/src/Interfaces/IPackageManagerBootstrapper.cs b/src/Interfaces/IPackageManagerBootstrapper.cs index c1134504..71caba96 100644 --- a/src/Interfaces/IPackageManagerBootstrapper.cs +++ b/src/Interfaces/IPackageManagerBootstrapper.cs @@ -10,5 +10,14 @@ public interface IPackageManagerBootstrapper /// Installs the package manager. /// If true, simulates the installation without making changes. void Install(bool dryRun); + + /// Installs the package manager and observes cancellation when supported. + Task InstallAsync(bool dryRun, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Install(dryRun); + cancellationToken.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } } } diff --git a/src/Interfaces/IProcessRunner.cs b/src/Interfaces/IProcessRunner.cs index 56de7c50..9f7b36e0 100644 --- a/src/Interfaces/IProcessRunner.cs +++ b/src/Interfaces/IProcessRunner.cs @@ -14,6 +14,14 @@ public interface IProcessRunner /// Runs a command with individual argument tokens to prevent injection. bool RunCommand(string fileName, IEnumerable arguments, bool dryRun, Action? onOutput = null); + /// Runs a command asynchronously and terminates its process tree when cancelled. + Task RunCommandAsync( + string fileName, + IEnumerable arguments, + bool dryRun, + Action? onOutput, + CancellationToken cancellationToken); + /// Runs a command and captures output using a raw argument string (deprecated). [Obsolete("Use the IEnumerable overload instead to prevent command injection.")] string RunCommandWithOutput(string fileName, string args); diff --git a/src/Models/ApplyManifest.cs b/src/Models/ApplyManifest.cs index c177b859..30043f38 100644 --- a/src/Models/ApplyManifest.cs +++ b/src/Models/ApplyManifest.cs @@ -9,6 +9,8 @@ public enum StepStatus Succeeded, /// The step encountered an error. Failed, + /// The step was cancelled before completion. + Cancelled, /// The step was skipped (e.g. dry run or condition not met). Skipped } diff --git a/src/Program.cs b/src/Program.cs index 3d085db6..b9d3c3bb 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -36,8 +36,17 @@ static async Task Main(string[] args) } using IHost host = AppHost.CreateHost(args); + using var cancellation = new CancellationTokenSource(); + ConsoleCancelEventHandler cancelHandler = (_, eventArgs) => + { + eventArgs.Cancel = true; + cancellation.Cancel(); + }; + Console.CancelKeyPress += cancelHandler; - var rootCommand = CliBuilder.BuildRootCommand( + try + { + var rootCommand = CliBuilder.BuildRootCommand( // Run Action async (file, dryRun, profile, debug, diff, json, update, force, continueOnError, autoInstallApps, minLogLevel) => { @@ -57,7 +66,17 @@ static async Task Main(string[] args) var runner = host.Services.GetRequiredService(); - var exitCode = await runner.RunAsync(file, dryRun, profile, debug, diff, json, force, continueOnError, autoInstallApps); + var exitCode = await runner.RunAsync( + file, + dryRun, + profile, + debug, + diff, + json, + force, + continueOnError, + autoInstallApps, + cancellation.Token); if (logger is JsonLogger jsonLogger) { @@ -272,10 +291,15 @@ await backupService.BackupAsync( } ); - // Register status command (reads .winhome-state.json) - StatusCommand.Register(rootCommand, host.Services); + // Register status command (reads .winhome-state.json) + StatusCommand.Register(rootCommand, host.Services); - return await rootCommand.Parse(args).InvokeAsync(); + return await rootCommand.Parse(args).InvokeAsync(); + } + finally + { + Console.CancelKeyPress -= cancelHandler; + } } catch (Exception ex) { diff --git a/src/Providers/LegacyPackageManagerProviderAdapter.cs b/src/Providers/LegacyPackageManagerProviderAdapter.cs new file mode 100644 index 00000000..254798b4 --- /dev/null +++ b/src/Providers/LegacyPackageManagerProviderAdapter.cs @@ -0,0 +1,364 @@ +using Wdem.Core.Providers; +using Wdem.Core.Resources; +using WinHome.Interfaces; +using WinHome.Models; + +namespace WinHome.Providers; + +public sealed class LegacyPackageManagerProviderAdapter : IResourceProvider +{ + public const string PackageIdParameter = "packageId"; + public const string SourceParameter = "source"; + public const string InstallerParametersParameter = "installerParameters"; + + private readonly ICancellablePackageManager _packageManager; + + public LegacyPackageManagerProviderAdapter( + string providerName, + ICancellablePackageManager packageManager, + bool supportsSource = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(providerName); + ArgumentNullException.ThrowIfNull(packageManager); + + ProviderName = providerName; + _packageManager = packageManager; + Capabilities = new ProviderCapabilities + { + SupportsSource = supportsSource, + SupportsInProgressCancellation = true + }; + } + + public string ResourceType => "package"; + + public string ProviderName { get; } + + public ProviderCapabilities Capabilities { get; } + + public ValueTask ValidateAsync( + ResourceDefinition resource, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(resource); + + var errors = new List(); + + if (!string.Equals(resource.Type, ResourceType, StringComparison.OrdinalIgnoreCase)) + { + errors.Add($"Resource type '{resource.Type}' is not supported by the package provider."); + } + + if (!string.Equals(resource.Provider, ProviderName, StringComparison.OrdinalIgnoreCase)) + { + errors.Add($"Resource provider '{resource.Provider}' does not match '{ProviderName}'."); + } + + if (!TryGetRequiredParameter(resource, PackageIdParameter, out _)) + { + errors.Add($"Parameter '{PackageIdParameter}' is required."); + } + + if (!string.IsNullOrWhiteSpace(resource.VersionConstraint) || + !string.IsNullOrWhiteSpace(resource.PreferredVersion)) + { + errors.Add( + $"Legacy package provider '{ProviderName}' cannot reliably detect or enforce package versions."); + } + + if (!Capabilities.SupportsSource && + !string.IsNullOrWhiteSpace(GetOptionalParameter(resource, SourceParameter))) + { + errors.Add($"Legacy package provider '{ProviderName}' does not support a package source."); + } + + if (!string.IsNullOrWhiteSpace(GetOptionalParameter(resource, InstallerParametersParameter))) + { + errors.Add( + $"Legacy package provider '{ProviderName}' does not safely support custom installer parameters."); + } + + var knownParameters = new HashSet(StringComparer.OrdinalIgnoreCase) + { + PackageIdParameter, + SourceParameter, + InstallerParametersParameter + }; + var unknownParameters = resource.Parameters.Keys + .Where(parameter => !knownParameters.Contains(parameter)) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (unknownParameters.Length > 0) + { + errors.Add( + $"Legacy package provider '{ProviderName}' does not support parameters: " + + $"{string.Join(", ", unknownParameters)}."); + } + + return ValueTask.FromResult( + errors.Count == 0 + ? ProviderValidationResult.Valid + : ProviderValidationResult.Invalid(errors.ToArray())); + } + + public async ValueTask DetectAsync( + ResourceDefinition resource, + CancellationToken cancellationToken) + { + var validation = await ValidateAsync(resource, cancellationToken); + if (!validation.IsValid) + { + return new DetectedState + { + ResourceId = resource.Id, + Outcome = DetectionOutcome.Failed, + Error = string.Join(" ", validation.Errors) + }; + } + + if (!_packageManager.IsAvailable()) + { + return new DetectedState + { + ResourceId = resource.Id, + Outcome = DetectionOutcome.Failed, + Error = $"Package manager '{ProviderName}' is not available." + }; + } + + var packageId = resource.Parameters[PackageIdParameter]!; + var installed = _packageManager.IsInstalled(packageId); + + return new DetectedState + { + ResourceId = resource.Id, + Outcome = DetectionOutcome.Succeeded, + Exists = installed, + Evidence = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["provider"] = ProviderName, + [PackageIdParameter] = packageId + } + }; + } + + public async ValueTask PlanAsync( + ResourceDefinition resource, + DetectedState currentState, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(currentState); + + var validation = await ValidateAsync(resource, cancellationToken); + if (!validation.IsValid) + { + return CreateBlockedPlan(resource, string.Join(" ", validation.Errors)); + } + + if (!string.Equals(currentState.ResourceId, resource.Id, StringComparison.OrdinalIgnoreCase)) + { + return CreateBlockedPlan( + resource, + $"Detected state for '{currentState.ResourceId}' cannot be used to plan resource '{resource.Id}'."); + } + + if (currentState.Outcome != DetectionOutcome.Succeeded) + { + return new ResourcePlan + { + ResourceId = resource.Id, + ResourceType = resource.Type, + ProviderName = resource.Provider, + DesiredStateFingerprint = ResourceDefinitionFingerprint.Create(resource), + Compliance = currentState.Outcome == DetectionOutcome.Unsupported + ? ComplianceStatus.Unsupported + : ComplianceStatus.DetectionFailed, + IsExecutable = false, + Error = currentState.Error ?? "The resource could not be detected." + }; + } + + if (currentState.Exists) + { + return new ResourcePlan + { + ResourceId = resource.Id, + ResourceType = resource.Type, + ProviderName = resource.Provider, + DesiredStateFingerprint = ResourceDefinitionFingerprint.Create(resource), + Compliance = ComplianceStatus.Satisfied, + IsExecutable = true + }; + } + + var displayName = resource.DisplayName ?? resource.Id; + return new ResourcePlan + { + ResourceId = resource.Id, + ResourceType = resource.Type, + ProviderName = resource.Provider, + DesiredStateFingerprint = ResourceDefinitionFingerprint.Create(resource), + Compliance = ComplianceStatus.Missing, + IsExecutable = true, + Steps = + [ + new PlanStep + { + Id = $"{resource.Id}:install", + Description = $"Install {displayName} with {ProviderName}.", + Action = PlanAction.Install, + PrivilegeRequirement = resource.PrivilegeRequirement, + RestartPolicy = resource.RestartPolicy + } + ] + }; + } + + public async ValueTask ApplyAsync( + ResourceDefinition resource, + ResourcePlan plan, + IProgress? progress, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(plan); + + var validation = await ValidateAsync(resource, cancellationToken); + if (!validation.IsValid) + { + throw new InvalidOperationException( + $"Resource '{resource.Id}' is invalid: {string.Join(" ", validation.Errors)}"); + } + + if (!string.Equals(plan.ResourceId, resource.Id, StringComparison.OrdinalIgnoreCase) || + !string.Equals(plan.ResourceType, resource.Type, StringComparison.OrdinalIgnoreCase) || + !string.Equals(plan.ProviderName, resource.Provider, StringComparison.OrdinalIgnoreCase) || + !string.Equals( + plan.DesiredStateFingerprint, + ResourceDefinitionFingerprint.Create(resource), + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"The execution plan does not match resource '{resource.Id}' and provider '{resource.Provider}'."); + } + + if (!plan.IsExecutable) + { + throw new InvalidOperationException( + $"The execution plan for resource '{resource.Id}' is not executable: {plan.Error}"); + } + + if (!plan.RequiresApply) + { + return new ResourceApplyResult + { + ResourceId = resource.Id, + Outcome = ApplyOutcome.NotRequired + }; + } + + if (plan.Steps.Count != 1 || + plan.Steps[0].Action != PlanAction.Install || + !string.Equals(plan.Steps[0].Id, $"{resource.Id}:install", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The execution plan for resource '{resource.Id}' does not contain the expected install step."); + } + + var packageId = resource.Parameters[PackageIdParameter]!; + progress?.Report(new ProviderProgress("Apply", 0, $"Installing {packageId} with {ProviderName}.")); + + var package = new AppConfig + { + Id = packageId, + Manager = ProviderName, + Source = GetOptionalParameter(resource, SourceParameter), + ResourceId = resource.Id, + DependsOn = resource.Dependencies.ToList() + }; + var packageProgress = progress is null + ? null + : new Progress(message => + progress.Report(new ProviderProgress("Apply", 0.5, message))); + + try + { + await _packageManager.InstallAsync(package, packageProgress, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return new ResourceApplyResult + { + ResourceId = resource.Id, + Outcome = ApplyOutcome.Cancelled + }; + } + + progress?.Report(new ProviderProgress("Apply", 1, $"Installed {packageId} with {ProviderName}.")); + + return new ResourceApplyResult + { + ResourceId = resource.Id, + Outcome = ApplyOutcome.Succeeded + }; + } + + public async ValueTask VerifyAsync( + ResourceDefinition resource, + CancellationToken cancellationToken) + { + var detectedState = await DetectAsync(resource, cancellationToken); + var compliance = detectedState.Outcome switch + { + DetectionOutcome.Unsupported => ComplianceStatus.Unsupported, + DetectionOutcome.Failed => ComplianceStatus.DetectionFailed, + _ when detectedState.Exists => ComplianceStatus.Satisfied, + _ => ComplianceStatus.Missing + }; + + return new VerificationResult + { + ResourceId = resource.Id, + Compliance = compliance, + DetectedState = detectedState, + Message = compliance == ComplianceStatus.Satisfied + ? null + : $"Resource '{resource.Id}' did not reach its desired state." + }; + } + + private static bool TryGetRequiredParameter( + ResourceDefinition resource, + string name, + out string value) + { + if (resource.Parameters.TryGetValue(name, out var candidate) && + !string.IsNullOrWhiteSpace(candidate)) + { + value = candidate; + return true; + } + + value = string.Empty; + return false; + } + + private static string? GetOptionalParameter(ResourceDefinition resource, string name) => + resource.Parameters.TryGetValue(name, out var value) ? value : null; + + private static ResourcePlan CreateBlockedPlan(ResourceDefinition resource, string error) => + new() + { + ResourceId = resource.Id, + ResourceType = resource.Type, + ProviderName = resource.Provider, + DesiredStateFingerprint = ResourceDefinitionFingerprint.Create(resource), + Compliance = ComplianceStatus.DetectionFailed, + IsExecutable = false, + Error = error + }; +} diff --git a/src/Services/Bootstrappers/ChocolateyBootstrapper.cs b/src/Services/Bootstrappers/ChocolateyBootstrapper.cs index 56dc81fc..bb31b16d 100644 --- a/src/Services/Bootstrappers/ChocolateyBootstrapper.cs +++ b/src/Services/Bootstrappers/ChocolateyBootstrapper.cs @@ -93,5 +93,63 @@ public void Install(bool dryRun) } } + + public async Task InstallAsync(bool dryRun, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (dryRun) + { + Console.WriteLine($"[DryRun] Would install {Name}"); + return; + } + + string command = "[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; " + + "irm https://community.chocolatey.org/install.ps1 -outfile choco_install.ps1; " + + "(Get-Content choco_install.ps1).Replace('Get-ExecutionPolicy', '\"$([char]39)RemoteSigned$([char]39)\"') | Set-Content choco_install.ps1; " + + ".\\choco_install.ps1; " + + "if (Test-Path .\\choco_install.ps1) { Remove-Item .\\choco_install.ps1 }"; + + for (int attempt = 0; attempt < MaxRetries; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + Console.WriteLine($"[Bootstrapper] Installing {Name} (attempt {attempt + 1}/{MaxRetries})..."); + var output = new List(); + var success = await _processRunner.RunCommandAsync( + "powershell.exe", + new[] { "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command }, + false, + output.Add, + cancellationToken); + if (success) + { + RefreshPath(); + Console.WriteLine($"[Bootstrapper] {Name} installed successfully."); + return; + } + + if (attempt < MaxRetries - 1) + { + Console.WriteLine($"[Bootstrapper] Failed to install {Name}. Retrying in 10 seconds..."); + await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); + } + else + { + throw new Exception( + $"Failed to install {Name} after {MaxRetries} attempts: {string.Join(Environment.NewLine, output)}"); + } + } + } + + private static void RefreshPath() + { + if (!OperatingSystem.IsWindows()) return; + + string userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? ""; + string machinePath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine) ?? ""; + Environment.SetEnvironmentVariable( + "PATH", + $"{machinePath};{userPath}", + EnvironmentVariableTarget.Process); + } } } diff --git a/src/Services/Bootstrappers/ScoopBootstrapper.cs b/src/Services/Bootstrappers/ScoopBootstrapper.cs index 5a1a7b6c..e823f07a 100644 --- a/src/Services/Bootstrappers/ScoopBootstrapper.cs +++ b/src/Services/Bootstrappers/ScoopBootstrapper.cs @@ -99,5 +99,62 @@ public void Install(bool dryRun) Environment.SetEnvironmentVariable("PATH", newPath, EnvironmentVariableTarget.Process); } } + + public async Task InstallAsync(bool dryRun, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (dryRun) + { + Console.WriteLine($"[DryRun] Would install {Name}"); + return; + } + + string command = "irm get.scoop.sh -outfile install.ps1; " + + "(Get-Content install.ps1).Replace('(Get-ExecutionPolicy).ToString()', '\"$([char]39)RemoteSigned$([char]39)\"') | Set-Content install.ps1; " + + ".\\install.ps1 -RunAsAdmin; " + + "if (Test-Path .\\install.ps1) { Remove-Item .\\install.ps1 }"; + + for (int attempt = 0; attempt < MaxRetries; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + Console.WriteLine($"[Bootstrapper] Installing {Name} (attempt {attempt + 1}/{MaxRetries})..."); + var output = new List(); + var success = await _processRunner.RunCommandAsync( + "powershell.exe", + new[] { "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command }, + false, + output.Add, + cancellationToken); + if (success) + { + RefreshPath(); + Console.WriteLine($"[Bootstrapper] {Name} installed successfully."); + return; + } + + if (attempt < MaxRetries - 1) + { + Console.WriteLine($"[Bootstrapper] Failed to install {Name}. Retrying in 10 seconds..."); + await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); + } + else + { + throw new Exception( + $"Failed to install {Name} after {MaxRetries} attempts: {string.Join(Environment.NewLine, output)}"); + } + } + } + + private static void RefreshPath() + { + if (!OperatingSystem.IsWindows()) return; + + string userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? ""; + string machinePath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine) ?? ""; + Environment.SetEnvironmentVariable( + "PATH", + $"{machinePath};{userPath}", + EnvironmentVariableTarget.Process); + } } } diff --git a/src/Services/Bootstrappers/WingetBootstrapper.cs b/src/Services/Bootstrappers/WingetBootstrapper.cs index d4dcac43..e9e6c84a 100644 --- a/src/Services/Bootstrappers/WingetBootstrapper.cs +++ b/src/Services/Bootstrappers/WingetBootstrapper.cs @@ -137,6 +137,75 @@ public void Install(bool dryRun) } } + public async Task InstallAsync(bool dryRun, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (dryRun) + { + _logger.LogWarning($"[DryRun] Would install {Name} by downloading from GitHub."); + return; + } + + _logger.LogInfo($"[Bootstrapper] Installing {Name}..."); + string tempDir = Path.Combine(Path.GetTempPath(), "WinHome_" + Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + + try + { + string version = await GetLatestVersionAsync(cancellationToken); + _logger.LogInfo($"[Bootstrapper] Latest Winget version detected: {version}"); + + string dependenciesUrl = $"https://github.com/microsoft/winget-cli/releases/download/{version}/DesktopAppInstaller_Dependencies.zip"; + string msixBundleUrl = $"https://github.com/microsoft/winget-cli/releases/download/{version}/Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle"; + string dependenciesZip = Path.Combine(tempDir, "dependencies.zip"); + string msixBundle = Path.Combine(tempDir, "Microsoft.DesktopAppInstaller.msixbundle"); + + await DownloadFile(dependenciesUrl, dependenciesZip, cancellationToken); + await DownloadFile(msixBundleUrl, msixBundle, cancellationToken); + + string extractPath = Path.Combine(tempDir, "dependencies"); + ZipFile.ExtractToDirectory(dependenciesZip, extractPath); + string arch = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(); + + foreach (string file in Directory.GetFiles(extractPath, "*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + string fileName = Path.GetFileName(file).ToLowerInvariant(); + if (!IsAppPackage(fileName) || !MatchesArchitecture(fileName, arch)) + { + continue; + } + + _logger.LogInfo($"[Bootstrapper] Installing dependency: {Path.GetFileName(file)}"); + await InstallAppPackageAsync(file, cancellationToken); + } + + _logger.LogInfo("[Bootstrapper] Installing Winget msixbundle..."); + await InstallAppPackageAsync(msixBundle, cancellationToken); + _logger.LogSuccess($"[Bootstrapper] {Name} installation completed."); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + throw new Exception($"Failed to install {Name}: {ex.Message}", ex); + } + finally + { + try + { + Directory.Delete(tempDir, true); + } + catch (Exception ex) + { + _logger.LogWarning( + $"[Bootstrapper] Warning: Could not clean up temp directory: {ex.Message}"); + } + } + } + /// Gets the latest winget-cli version tag from GitHub Releases API. private string GetLatestVersion() { @@ -150,15 +219,21 @@ private string GetLatestVersion() } /// Downloads a file from a URL to the specified path with a long timeout for large files. - private async Task DownloadFile(string url, string path) + private async Task DownloadFile( + string url, + string path, + CancellationToken cancellationToken = default) { _logger.LogInfo($"[Bootstrapper] Downloading {url}..."); using var client = new HttpClient(); client.Timeout = TimeSpan.FromMinutes(10); // Increase timeout for large files - var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); + var response = await client.GetAsync( + url, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); response.EnsureSuccessStatusCode(); await using var fs = new FileStream(path, FileMode.Create); - await response.Content.CopyToAsync(fs); + await response.Content.CopyToAsync(fs, cancellationToken); } /// Installs an .appx/.msix package via PowerShell's Add-AppxPackage. @@ -176,5 +251,54 @@ private void InstallAppPackage(string path) } } } + + private async Task GetLatestVersionAsync(CancellationToken cancellationToken) + { + using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + client.DefaultRequestHeaders.Add("User-Agent", "WinHome-Bootstrapper"); + using var response = await client.GetAsync( + "https://api.github.com/repos/microsoft/winget-cli/releases/latest", + cancellationToken); + response.EnsureSuccessStatusCode(); + using var json = JsonDocument.Parse( + await response.Content.ReadAsStringAsync(cancellationToken)); + return json.RootElement.GetProperty("tag_name").GetString() ?? "v1.12.460"; + } + + private async Task InstallAppPackageAsync( + string path, + CancellationToken cancellationToken) + { + string command = $"Add-AppxPackage -Path \"{path}\""; + var output = new List(); + var success = await _processRunner.RunCommandAsync( + "powershell.exe", + new[] { "-NoProfile", "-NonInteractive", "-Command", command }, + false, + output.Add, + cancellationToken); + if (!success) + { + throw new Exception( + $"Package {Path.GetFileName(path)} failed to install: " + + string.Join(Environment.NewLine, output)); + } + } + + private static bool IsAppPackage(string fileName) => + fileName.EndsWith(".appx", StringComparison.Ordinal) || + fileName.EndsWith(".msix", StringComparison.Ordinal) || + fileName.EndsWith(".appxbundle", StringComparison.Ordinal) || + fileName.EndsWith(".msixbundle", StringComparison.Ordinal); + + private static bool MatchesArchitecture(string fileName, string architecture) + { + if (fileName.Contains("arm64", StringComparison.Ordinal) && architecture != "arm64") return false; + if (fileName.Contains("x64", StringComparison.Ordinal) && architecture != "x64") return false; + if (fileName.Contains("x86", StringComparison.Ordinal) && + architecture != "x86" && + architecture != "x64") return false; + return true; + } } } diff --git a/src/Services/Managers/ChocolateyService.cs b/src/Services/Managers/ChocolateyService.cs index 42173b1f..97052f8c 100644 --- a/src/Services/Managers/ChocolateyService.cs +++ b/src/Services/Managers/ChocolateyService.cs @@ -5,7 +5,7 @@ namespace WinHome.Services.Managers { /// Manages package operations via Chocolatey (choco). - public class ChocolateyService : IPackageManager + public class ChocolateyService : ICancellablePackageManager { private readonly IProcessRunner _processRunner; private readonly ILogger _logger; @@ -83,6 +83,46 @@ public void Install(AppConfig app, bool dryRun) _logger.LogSuccess($"[Success] Installed {app.Id}"); } + public async Task InstallAsync( + AppConfig app, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(app); + cancellationToken.ThrowIfCancellationRequested(); + + var executable = GetChocoExecutable(); + progress?.Report($"Installing {app.Id} with Chocolatey."); + _logger.LogInfo($"[Choco] Installing {app.Id}..."); + + var alreadyInstalled = false; + var success = await _processRunner.RunCommandAsync( + executable, + new[] { "install", app.Id, "-y" }, + false, + line => + { + LogFiltered(line, "Install"); + progress?.Report(line); + if (line.Contains("already installed", StringComparison.OrdinalIgnoreCase) || + line.Contains("packages installed currently", StringComparison.OrdinalIgnoreCase)) + { + alreadyInstalled = true; + } + }, + cancellationToken); + + if (!success && !alreadyInstalled) + { + throw new Exception($"Failed to install {app.Id} using Chocolatey."); + } + + _logger.LogSuccess( + alreadyInstalled + ? $"[Success] {app.Id} is already installed (detected during install attempt)." + : $"[Success] Installed {app.Id}"); + } + /// Uninstalls a package via Chocolatey. public void Uninstall(string appId, bool dryRun) { diff --git a/src/Services/Managers/ScoopService.cs b/src/Services/Managers/ScoopService.cs index 4e7fbcb8..65a8fc7d 100644 --- a/src/Services/Managers/ScoopService.cs +++ b/src/Services/Managers/ScoopService.cs @@ -5,7 +5,7 @@ namespace WinHome.Services.Managers { /// Manages package operations via Scoop. - public class ScoopService : IPackageManager + public class ScoopService : ICancellablePackageManager { private readonly IProcessRunner _processRunner; private readonly ILogger _logger; @@ -79,6 +79,52 @@ public void Install(AppConfig app, bool dryRun) _logger.LogSuccess($"[Success] Installed {app.Id}"); } + public async Task InstallAsync( + AppConfig app, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(app); + cancellationToken.ThrowIfCancellationRequested(); + + var executable = GetScoopExecutable(); + progress?.Report($"Installing {app.Id} with Scoop."); + _logger.LogInfo($"[Scoop] Installing {app.Id}..."); + + var alreadyInstalled = false; + var manifestNotFound = false; + var success = await _processRunner.RunCommandAsync( + executable, + new[] { "install", app.Id }, + false, + line => + { + _logger.LogInfo($"[Scoop:Install] {line}"); + progress?.Report(line); + if (line.Contains($"'{app.Id}' is already installed", StringComparison.OrdinalIgnoreCase)) + { + alreadyInstalled = true; + } + if (line.Contains("Couldn't find manifest", StringComparison.OrdinalIgnoreCase)) + { + manifestNotFound = true; + } + }, + cancellationToken); + + if ((!success || manifestNotFound) && !alreadyInstalled) + { + throw new Exception( + $"Failed to install {app.Id} using Scoop." + + (manifestNotFound ? " Manifest not found." : string.Empty)); + } + + _logger.LogSuccess( + alreadyInstalled + ? $"[Success] {app.Id} is already installed (detected during install attempt)." + : $"[Success] Installed {app.Id}"); + } + /// Uninstalls a package via Scoop. public void Uninstall(string appId, bool dryRun) { diff --git a/src/Services/Managers/WingetService.cs b/src/Services/Managers/WingetService.cs index 77cca29b..42ce2d42 100644 --- a/src/Services/Managers/WingetService.cs +++ b/src/Services/Managers/WingetService.cs @@ -5,7 +5,7 @@ namespace WinHome.Services.Managers { /// Manages package operations via Windows Package Manager (winget). - public class WingetService : IPackageManager + public class WingetService : ICancellablePackageManager { private string _wingetPath = "winget"; private bool _pathResolved = false; @@ -60,6 +60,25 @@ private void UpdateSource(bool dryRun) _sourceUpdated = true; } + private async Task UpdateSourceAsync(CancellationToken cancellationToken) + { + if (_sourceUpdated) return; + + _logger.LogInfo("[Winget] Updating package sources..."); + var success = await _processRunner.RunCommandAsync( + _wingetPath, + new[] { "source", "update", "--accept-source-agreements" }, + false, + line => LogFiltered(line, "SourceUpdate"), + cancellationToken); + if (!success) + { + throw new Exception("Failed to update Winget package sources."); + } + + _sourceUpdated = true; + } + /// Returns true if winget is available on the system. public bool IsAvailable() { @@ -116,6 +135,65 @@ public void Install(AppConfig app, bool dryRun) _logger.LogSuccess($"[Success] Installed {app.Id}"); } + public async Task InstallAsync( + AppConfig app, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(app); + cancellationToken.ThrowIfCancellationRequested(); + ResolveWingetPath(); + await UpdateSourceAsync(cancellationToken); + + progress?.Report($"Installing {app.Id} with Winget."); + _logger.LogInfo($"[Winget] Installing {app.Id}..."); + var args = new List + { + "install", + "--id", + app.Id, + "-e", + "--silent", + "--accept-package-agreements", + "--accept-source-agreements", + "--disable-interactivity", + "--no-upgrade" + }; + if (!string.IsNullOrEmpty(app.Source)) + { + args.Add("--source"); + args.Add(app.Source); + } + + var alreadyInstalled = false; + var success = await _processRunner.RunCommandAsync( + _wingetPath, + args, + false, + line => + { + LogFiltered(line, "Install"); + progress?.Report(line); + if (line.Contains( + "A package version is already installed", + StringComparison.OrdinalIgnoreCase)) + { + alreadyInstalled = true; + } + }, + cancellationToken); + + if (!success && !alreadyInstalled) + { + throw new Exception($"Failed to install {app.Id} using Winget."); + } + + _logger.LogSuccess( + alreadyInstalled + ? $"[Success] {app.Id} is already installed (detected during install attempt)." + : $"[Success] Installed {app.Id}"); + } + /// Uninstalls a package via winget. public void Uninstall(string appId, bool dryRun) { diff --git a/src/Services/System/DefaultProcessRunner.cs b/src/Services/System/DefaultProcessRunner.cs index 629dda1c..6df33b94 100644 --- a/src/Services/System/DefaultProcessRunner.cs +++ b/src/Services/System/DefaultProcessRunner.cs @@ -42,6 +42,200 @@ public bool RunCommand(string fileName, IEnumerable args, bool dryRun, A return RunProcessInternal(startInfo, fileName, onOutput); } + public async Task RunCommandAsync( + string fileName, + IEnumerable args, + bool dryRun, + Action? onOutput, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (dryRun) return true; + + var startInfo = new ProcessStartInfo + { + FileName = fileName, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + foreach (var arg in args) + { + startInfo.ArgumentList.Add(arg); + } + + try + { + if (OperatingSystem.IsWindows()) + { + return await RunWindowsJobCommandAsync( + fileName, + args, + onOutput, + cancellationToken); + } + + using var process = new Process { StartInfo = startInfo }; + var outputClosed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var errorClosed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + process.OutputDataReceived += (_, eventArgs) => + { + if (eventArgs.Data is null) + { + outputClosed.TrySetResult(); + } + else + { + onOutput?.Invoke(eventArgs.Data); + } + }; + process.ErrorDataReceived += (_, eventArgs) => + { + if (eventArgs.Data is null) + { + errorClosed.TrySetResult(); + } + else + { + onOutput?.Invoke(eventArgs.Data); + } + }; + + if (!process.Start()) + { + return false; + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(10)); + using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeout.Token); + + try + { + await process.WaitForExitAsync(linkedCancellation.Token); + try + { + await Task.WhenAll(outputClosed.Task, errorClosed.Task) + .WaitAsync(TimeSpan.FromSeconds(5), linkedCancellation.Token); + } + catch (TimeoutException) + { + global::System.Diagnostics.Trace.WriteLine( + $"[ProcessRunner] Timed out draining output for {fileName}."); + return false; + } + + return process.ExitCode == 0; + } + catch (OperationCanceledException) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + + catch (InvalidOperationException) + { + // The process exited between HasExited and Kill. + } + catch (Exception cleanupError) + { + global::System.Diagnostics.Trace.WriteLine( + $"[ProcessRunner] Failed to terminate {fileName}: {cleanupError.Message}"); + } + + try + { + await process.WaitForExitAsync(CancellationToken.None) + .WaitAsync(TimeSpan.FromSeconds(5)); + await Task.WhenAll(outputClosed.Task, errorClosed.Task) + .WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (Exception cleanupError) + { + global::System.Diagnostics.Trace.WriteLine( + $"[ProcessRunner] Failed while waiting for {fileName} cleanup: {cleanupError.Message}"); + } + + cancellationToken.ThrowIfCancellationRequested(); + return false; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + onOutput?.Invoke($"[ProcessRunner] Error starting {fileName}: {ex.Message}"); + global::System.Diagnostics.Trace.WriteLine( + $"[ProcessRunner] Error starting {fileName}: {ex.Message}"); + return false; + } + } + + private static async Task RunWindowsJobCommandAsync( + string fileName, + IEnumerable arguments, + Action? onOutput, + CancellationToken cancellationToken) + { + using var processJob = WindowsProcessJob.Start(fileName, arguments); + var outputTask = DrainOutputAsync(processJob.StandardOutput, onOutput); + var errorTask = DrainOutputAsync(processJob.StandardError, onOutput); + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(10)); + using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeout.Token); + + try + { + await processJob.Process.WaitForExitAsync(linkedCancellation.Token); + int exitCode = processJob.Process.ExitCode; + await processJob.WaitForEmptyAsync(linkedCancellation.Token); + await Task.WhenAll(outputTask, errorTask) + .WaitAsync(TimeSpan.FromSeconds(5), linkedCancellation.Token); + return exitCode == 0; + } + catch (OperationCanceledException) + { + processJob.Terminate(); + try + { + await Task.WhenAll(outputTask, errorTask) + .WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (Exception cleanupError) + { + global::System.Diagnostics.Trace.WriteLine( + $"[ProcessRunner] Failed while draining cancelled process output: {cleanupError.Message}"); + } + + cancellationToken.ThrowIfCancellationRequested(); + return false; + } + } + + private static async Task DrainOutputAsync( + StreamReader reader, + Action? onOutput) + { + while (await reader.ReadLineAsync() is { } line) + { + onOutput?.Invoke(line); + } + } + private bool RunProcessInternal(ProcessStartInfo startInfo, string fileName, Action? onOutput) { diff --git a/src/Services/System/WindowsProcessJob.cs b/src/Services/System/WindowsProcessJob.cs new file mode 100644 index 00000000..f8c09fa0 --- /dev/null +++ b/src/Services/System/WindowsProcessJob.cs @@ -0,0 +1,573 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace WinHome.Services.System; + +internal sealed class WindowsProcessJob : IDisposable +{ + private const uint JobObjectLimitKillOnJobClose = 0x00002000; + private const uint ExtendedStartupInfoPresent = 0x00080000; + private const uint CreateNoWindow = 0x08000000; + private const uint StartfUseStdHandles = 0x00000100; + private const uint HandleFlagInherit = 0x00000001; + private const nuint ProcThreadAttributeHandleList = 0x00020002; + private const nuint ProcThreadAttributeJobList = 0x0002000D; + + private SafeFileHandle? _jobHandle; + + private WindowsProcessJob( + SafeFileHandle jobHandle, + Process process, + StreamReader standardOutput, + StreamReader standardError) + { + _jobHandle = jobHandle; + Process = process; + StandardOutput = standardOutput; + StandardError = standardError; + } + + public Process Process { get; } + + public StreamReader StandardOutput { get; } + + public StreamReader StandardError { get; } + + public static WindowsProcessJob Start( + string fileName, + IEnumerable arguments) + { + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException(); + } + + using var standardInput = OpenNullInput(); + CreateOutputPipe(out var outputRead, out var outputWrite); + CreateOutputPipe(out var errorRead, out var errorWrite); + var outputReadTransferred = false; + var errorReadTransferred = false; + var jobHandle = CreateConfiguredJob(); + IntPtr attributeList = IntPtr.Zero; + IntPtr jobHandleList = IntPtr.Zero; + IntPtr inheritedHandleList = IntPtr.Zero; + ProcessInformation processInformation = default; + Process? managedProcess = null; + StreamReader? outputReader = null; + StreamReader? errorReader = null; + + try + { + attributeList = CreateAttributeList(2); + jobHandleList = Marshal.AllocHGlobal(IntPtr.Size); + Marshal.WriteIntPtr(jobHandleList, jobHandle.DangerousGetHandle()); + UpdateAttribute( + attributeList, + ProcThreadAttributeJobList, + jobHandleList, + (nuint)IntPtr.Size); + + inheritedHandleList = Marshal.AllocHGlobal(IntPtr.Size * 3); + Marshal.WriteIntPtr(inheritedHandleList, 0, standardInput.DangerousGetHandle()); + Marshal.WriteIntPtr(inheritedHandleList, IntPtr.Size, outputWrite.DangerousGetHandle()); + Marshal.WriteIntPtr(inheritedHandleList, IntPtr.Size * 2, errorWrite.DangerousGetHandle()); + UpdateAttribute( + attributeList, + ProcThreadAttributeHandleList, + inheritedHandleList, + (nuint)(IntPtr.Size * 3)); + + var startupInfo = new StartupInfoEx + { + StartupInfo = new StartupInfo + { + Size = Marshal.SizeOf(), + Flags = StartfUseStdHandles, + StandardInput = standardInput.DangerousGetHandle(), + StandardOutput = outputWrite.DangerousGetHandle(), + StandardError = errorWrite.DangerousGetHandle() + }, + AttributeList = attributeList + }; + var commandLine = new StringBuilder(BuildCommandLine(fileName, arguments)); + + if (!CreateProcess( + null, + commandLine, + IntPtr.Zero, + IntPtr.Zero, + inheritHandles: true, + ExtendedStartupInfoPresent | CreateNoWindow, + IntPtr.Zero, + null, + ref startupInfo, + out processInformation)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + outputWrite.Dispose(); + errorWrite.Dispose(); + + managedProcess = Process.GetProcessById((int)processInformation.ProcessId); + outputReader = new StreamReader(new FileStream(outputRead, FileAccess.Read)); + outputReadTransferred = true; + errorReader = new StreamReader(new FileStream(errorRead, FileAccess.Read)); + errorReadTransferred = true; + + return new WindowsProcessJob(jobHandle, managedProcess, outputReader, errorReader); + } + catch + { + outputReader?.Dispose(); + errorReader?.Dispose(); + managedProcess?.Dispose(); + jobHandle.Dispose(); + throw; + } + finally + { + if (!outputReadTransferred) outputRead.Dispose(); + outputWrite.Dispose(); + if (!errorReadTransferred) errorRead.Dispose(); + errorWrite.Dispose(); + if (processInformation.ProcessHandle != IntPtr.Zero) + { + CloseHandle(processInformation.ProcessHandle); + } + if (processInformation.ThreadHandle != IntPtr.Zero) + { + CloseHandle(processInformation.ThreadHandle); + } + if (attributeList != IntPtr.Zero) + { + DeleteProcThreadAttributeList(attributeList); + Marshal.FreeHGlobal(attributeList); + } + if (jobHandleList != IntPtr.Zero) + { + Marshal.FreeHGlobal(jobHandleList); + } + if (inheritedHandleList != IntPtr.Zero) + { + Marshal.FreeHGlobal(inheritedHandleList); + } + } + } + + public async Task WaitForEmptyAsync(CancellationToken cancellationToken) + { + while (GetActiveProcessCount() > 0) + { + await Task.Delay(50, cancellationToken); + } + } + + public void Terminate() + { + _jobHandle?.Dispose(); + _jobHandle = null; + } + + public void Dispose() + { + Terminate(); + StandardOutput.Dispose(); + StandardError.Dispose(); + Process.Dispose(); + } + + private static SafeFileHandle CreateConfiguredJob() + { + var handle = CreateJobObject(IntPtr.Zero, null); + if (handle.IsInvalid) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + var limits = new JobObjectExtendedLimitInformation + { + BasicLimitInformation = new JobObjectBasicLimitInformation + { + LimitFlags = JobObjectLimitKillOnJobClose + } + }; + var length = Marshal.SizeOf(); + var pointer = Marshal.AllocHGlobal(length); + try + { + Marshal.StructureToPtr(limits, pointer, false); + if (!SetInformationJobObject( + handle, + JobObjectInformationClass.ExtendedLimitInformation, + pointer, + (uint)length)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return handle; + } + catch + { + handle.Dispose(); + throw; + } + finally + { + Marshal.FreeHGlobal(pointer); + } + } + + private static SafeFileHandle OpenNullInput() + { + var securityAttributes = CreateInheritableSecurityAttributes(); + var handle = CreateFile( + "NUL", + 0x80000000, + 0x00000001 | 0x00000002, + ref securityAttributes, + 3, + 0, + IntPtr.Zero); + if (handle.IsInvalid) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return handle; + } + + private static void CreateOutputPipe( + out SafeFileHandle readHandle, + out SafeFileHandle writeHandle) + { + var securityAttributes = CreateInheritableSecurityAttributes(); + if (!CreatePipe(out var read, out writeHandle, ref securityAttributes, 0)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + if (!SetHandleInformation(read, HandleFlagInherit, 0)) + { + read.Dispose(); + writeHandle.Dispose(); + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + readHandle = read; + } + + private static SecurityAttributes CreateInheritableSecurityAttributes() => + new() + { + Length = Marshal.SizeOf(), + InheritHandle = true + }; + + private static IntPtr CreateAttributeList(int attributeCount) + { + nuint size = 0; + InitializeProcThreadAttributeList( + IntPtr.Zero, + attributeCount, + 0, + ref size); + var attributeList = Marshal.AllocHGlobal((nint)size); + if (!InitializeProcThreadAttributeList( + attributeList, + attributeCount, + 0, + ref size)) + { + Marshal.FreeHGlobal(attributeList); + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return attributeList; + } + + private static void UpdateAttribute( + IntPtr attributeList, + nuint attribute, + IntPtr value, + nuint size) + { + if (!UpdateProcThreadAttribute( + attributeList, + 0, + attribute, + value, + size, + IntPtr.Zero, + IntPtr.Zero)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + + private static string BuildCommandLine( + string fileName, + IEnumerable arguments) => + string.Join( + " ", + new[] { QuoteArgument(fileName) }.Concat(arguments.Select(QuoteArgument))); + + private static string QuoteArgument(string argument) + { + if (argument.Length > 0 && + !argument.Any(character => char.IsWhiteSpace(character) || character == '"')) + { + return argument; + } + + var quoted = new StringBuilder("\""); + var backslashes = 0; + foreach (var character in argument) + { + if (character == '\\') + { + backslashes++; + continue; + } + + if (character == '"') + { + quoted.Append('\\', backslashes * 2 + 1); + quoted.Append('"'); + backslashes = 0; + continue; + } + + quoted.Append('\\', backslashes); + backslashes = 0; + quoted.Append(character); + } + + quoted.Append('\\', backslashes * 2); + quoted.Append('"'); + return quoted.ToString(); + } + + private uint GetActiveProcessCount() + { + var handle = _jobHandle; + if (handle is null || handle.IsInvalid || handle.IsClosed) + { + return 0; + } + + var length = Marshal.SizeOf(); + var pointer = Marshal.AllocHGlobal(length); + try + { + if (!QueryInformationJobObject( + handle, + JobObjectInformationClass.BasicAccountingInformation, + pointer, + (uint)length, + out _)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return Marshal.PtrToStructure(pointer) + .ActiveProcesses; + } + finally + { + Marshal.FreeHGlobal(pointer); + } + } + + private enum JobObjectInformationClass + { + BasicAccountingInformation = 1, + ExtendedLimitInformation = 9 + } + + [StructLayout(LayoutKind.Sequential)] + private struct SecurityAttributes + { + public int Length; + public IntPtr SecurityDescriptor; + [MarshalAs(UnmanagedType.Bool)] + public bool InheritHandle; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo + { + public int Size; + public string? Reserved; + public string? Desktop; + public string? Title; + public int X; + public int Y; + public int XSize; + public int YSize; + public int XCountChars; + public int YCountChars; + public int FillAttribute; + public uint Flags; + public short ShowWindow; + public short Reserved2Size; + public IntPtr Reserved2; + public IntPtr StandardInput; + public IntPtr StandardOutput; + public IntPtr StandardError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct StartupInfoEx + { + public StartupInfo StartupInfo; + public IntPtr AttributeList; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation + { + public IntPtr ProcessHandle; + public IntPtr ThreadHandle; + public uint ProcessId; + public uint ThreadId; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IoCounters + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JobObjectBasicLimitInformation + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JobObjectExtendedLimitInformation + { + public JobObjectBasicLimitInformation BasicLimitInformation; + public IoCounters IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JobObjectBasicAccountingInformation + { + public long TotalUserTime; + public long TotalKernelTime; + public long ThisPeriodTotalUserTime; + public long ThisPeriodTotalKernelTime; + public uint TotalPageFaultCount; + public uint TotalProcesses; + public uint ActiveProcesses; + public uint TotalTerminatedProcesses; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateJobObject(IntPtr jobAttributes, string? name); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetInformationJobObject( + SafeFileHandle job, + JobObjectInformationClass informationClass, + IntPtr information, + uint informationLength); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool QueryInformationJobObject( + SafeFileHandle job, + JobObjectInformationClass informationClass, + IntPtr information, + uint informationLength, + out uint returnLength); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreatePipe( + out SafeFileHandle readPipe, + out SafeFileHandle writePipe, + ref SecurityAttributes pipeAttributes, + uint size); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetHandleInformation( + SafeFileHandle handle, + uint mask, + uint flags); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + uint shareMode, + ref SecurityAttributes securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool InitializeProcThreadAttributeList( + IntPtr attributeList, + int attributeCount, + int flags, + ref nuint size); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool UpdateProcThreadAttribute( + IntPtr attributeList, + uint flags, + nuint attribute, + IntPtr value, + nuint size, + IntPtr previousValue, + IntPtr returnSize); + + [DllImport("kernel32.dll")] + private static extern void DeleteProcThreadAttributeList(IntPtr attributeList); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateProcess( + string? applicationName, + StringBuilder commandLine, + IntPtr processAttributes, + IntPtr threadAttributes, + [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, + uint creationFlags, + IntPtr environment, + string? currentDirectory, + ref StartupInfoEx startupInfo, + out ProcessInformation processInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); +} diff --git a/src/Wdem.Core/Providers/IResourceProvider.cs b/src/Wdem.Core/Providers/IResourceProvider.cs new file mode 100644 index 00000000..60a2c2fd --- /dev/null +++ b/src/Wdem.Core/Providers/IResourceProvider.cs @@ -0,0 +1,33 @@ +using Wdem.Core.Resources; + +namespace Wdem.Core.Providers; + +public interface IResourceProvider +{ + string ResourceType { get; } + string ProviderName { get; } + ProviderCapabilities Capabilities { get; } + + ValueTask ValidateAsync( + ResourceDefinition resource, + CancellationToken cancellationToken); + + ValueTask DetectAsync( + ResourceDefinition resource, + CancellationToken cancellationToken); + + ValueTask PlanAsync( + ResourceDefinition resource, + DetectedState currentState, + CancellationToken cancellationToken); + + ValueTask ApplyAsync( + ResourceDefinition resource, + ResourcePlan plan, + IProgress? progress, + CancellationToken cancellationToken); + + ValueTask VerifyAsync( + ResourceDefinition resource, + CancellationToken cancellationToken); +} diff --git a/src/Wdem.Core/Providers/ProviderModels.cs b/src/Wdem.Core/Providers/ProviderModels.cs new file mode 100644 index 00000000..6a7f7963 --- /dev/null +++ b/src/Wdem.Core/Providers/ProviderModels.cs @@ -0,0 +1,104 @@ +using Wdem.Core.Resources; + +namespace Wdem.Core.Providers; + +public enum DetectionOutcome +{ + Succeeded, + Failed, + Unsupported +} + +public enum ComplianceStatus +{ + Satisfied, + Missing, + VersionMismatch, + ConfigurationMismatch, + DetectionFailed, + Unsupported +} + +public enum PlanAction +{ + None, + Install, + Configure, + Repair +} + +public enum ApplyOutcome +{ + Succeeded, + Cancelled, + NotRequired +} + +public sealed record ProviderCapabilities +{ + public bool SupportsSource { get; init; } + public bool SupportsVersionConstraints { get; init; } + public bool SupportsInstallerParameters { get; init; } + public bool SupportsInProgressCancellation { get; init; } +} + +public sealed record ProviderValidationResult +{ + public required IReadOnlyList Errors { get; init; } + public bool IsValid => Errors.Count == 0; + + public static ProviderValidationResult Valid { get; } = + new() { Errors = Array.Empty() }; + + public static ProviderValidationResult Invalid(params string[] errors) => + new() { Errors = errors }; +} + +public sealed record DetectedState +{ + public required string ResourceId { get; init; } + public required DetectionOutcome Outcome { get; init; } + public bool Exists { get; init; } + public string? Version { get; init; } + public IReadOnlyDictionary Evidence { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + public string? Error { get; init; } +} + +public sealed record PlanStep +{ + public required string Id { get; init; } + public required string Description { get; init; } + public required PlanAction Action { get; init; } + public required PrivilegeRequirement PrivilegeRequirement { get; init; } + public required RestartPolicy RestartPolicy { get; init; } +} + +public sealed record ResourcePlan +{ + public required string ResourceId { get; init; } + public required string ResourceType { get; init; } + public required string ProviderName { get; init; } + public required string DesiredStateFingerprint { get; init; } + public required ComplianceStatus Compliance { get; init; } + public required bool IsExecutable { get; init; } + public IReadOnlyList Steps { get; init; } = Array.Empty(); + public string? Error { get; init; } + public bool RequiresApply => Steps.Count > 0; +} + +public sealed record ProviderProgress(string Stage, double Percent, string Message); + +public sealed record ResourceApplyResult +{ + public required string ResourceId { get; init; } + public required ApplyOutcome Outcome { get; init; } +} + +public sealed record VerificationResult +{ + public required string ResourceId { get; init; } + public required ComplianceStatus Compliance { get; init; } + public required DetectedState DetectedState { get; init; } + public string? Message { get; init; } +} diff --git a/src/Wdem.Core/Providers/ResourceProviderRegistry.cs b/src/Wdem.Core/Providers/ResourceProviderRegistry.cs new file mode 100644 index 00000000..6ed8f7c7 --- /dev/null +++ b/src/Wdem.Core/Providers/ResourceProviderRegistry.cs @@ -0,0 +1,56 @@ +namespace Wdem.Core.Providers; + +public interface IResourceProviderRegistry +{ + IReadOnlyCollection Providers { get; } + + bool TryGet(string resourceType, string providerName, out IResourceProvider? provider); + + IResourceProvider GetRequired(string resourceType, string providerName); +} + +public sealed class ResourceProviderRegistry : IResourceProviderRegistry +{ + private readonly Dictionary _providers; + + public ResourceProviderRegistry(IEnumerable providers) + { + ArgumentNullException.ThrowIfNull(providers); + + _providers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var provider in providers) + { + ArgumentNullException.ThrowIfNull(provider); + + var key = CreateKey(provider.ResourceType, provider.ProviderName); + if (!_providers.TryAdd(key, provider)) + { + throw new InvalidOperationException( + $"Provider '{provider.ProviderName}' is already registered for resource type '{provider.ResourceType}'."); + } + } + } + + public IReadOnlyCollection Providers => _providers.Values; + + public bool TryGet(string resourceType, string providerName, out IResourceProvider? provider) => + _providers.TryGetValue(CreateKey(resourceType, providerName), out provider); + + public IResourceProvider GetRequired(string resourceType, string providerName) + { + if (TryGet(resourceType, providerName, out var provider) && provider is not null) + { + return provider; + } + + throw new KeyNotFoundException( + $"No provider named '{providerName}' is registered for resource type '{resourceType}'."); + } + + private static string CreateKey(string resourceType, string providerName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(resourceType); + ArgumentException.ThrowIfNullOrWhiteSpace(providerName); + return $"{resourceType}\0{providerName}"; + } +} diff --git a/src/Wdem.Core/Resources/ResourceDefinition.cs b/src/Wdem.Core/Resources/ResourceDefinition.cs new file mode 100644 index 00000000..d472a33c --- /dev/null +++ b/src/Wdem.Core/Resources/ResourceDefinition.cs @@ -0,0 +1,29 @@ +namespace Wdem.Core.Resources; + +public enum PrivilegeRequirement +{ + CurrentUser, + Administrator +} + +public enum RestartPolicy +{ + NoRestart, + RestartRecommended, + RestartRequired +} + +public sealed record ResourceDefinition +{ + public required string Id { get; init; } + public required string Type { get; init; } + public required string Provider { get; init; } + public string? DisplayName { get; init; } + public string? VersionConstraint { get; init; } + public string? PreferredVersion { get; init; } + public IReadOnlyList Dependencies { get; init; } = Array.Empty(); + public IReadOnlyDictionary Parameters { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + public PrivilegeRequirement PrivilegeRequirement { get; init; } + public RestartPolicy RestartPolicy { get; init; } +} diff --git a/src/Wdem.Core/Resources/ResourceDefinitionFingerprint.cs b/src/Wdem.Core/Resources/ResourceDefinitionFingerprint.cs new file mode 100644 index 00000000..36c4271a --- /dev/null +++ b/src/Wdem.Core/Resources/ResourceDefinitionFingerprint.cs @@ -0,0 +1,48 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Wdem.Core.Resources; + +public static class ResourceDefinitionFingerprint +{ + public static string Create(ResourceDefinition resource) + { + ArgumentNullException.ThrowIfNull(resource); + + var canonical = new StringBuilder(); + Append(canonical, resource.Id); + Append(canonical, resource.Type); + Append(canonical, resource.Provider); + Append(canonical, resource.DisplayName); + Append(canonical, resource.VersionConstraint); + Append(canonical, resource.PreferredVersion); + Append(canonical, resource.PrivilegeRequirement.ToString()); + Append(canonical, resource.RestartPolicy.ToString()); + + foreach (var dependency in resource.Dependencies.Order(StringComparer.OrdinalIgnoreCase)) + { + Append(canonical, dependency); + } + + foreach (var parameter in resource.Parameters.OrderBy( + pair => pair.Key, + StringComparer.OrdinalIgnoreCase)) + { + Append(canonical, parameter.Key); + Append(canonical, parameter.Value); + } + + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString()))); + } + + private static void Append(StringBuilder builder, string? value) + { + if (value is null) + { + builder.Append("-1:"); + return; + } + + builder.Append(value.Length).Append(':').Append(value); + } +} diff --git a/src/Wdem.Core/Wdem.Core.csproj b/src/Wdem.Core/Wdem.Core.csproj new file mode 100644 index 00000000..246c557d --- /dev/null +++ b/src/Wdem.Core/Wdem.Core.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + enable + enable + true + $(NoWarn);CS1591 + + + diff --git a/src/WinHome.csproj b/src/WinHome.csproj index 2b4f6593..c7b832b0 100644 --- a/src/WinHome.csproj +++ b/src/WinHome.csproj @@ -8,7 +8,7 @@ true true win-x64 - $(DefaultItemExcludes);WinHome.Tests\** + $(DefaultItemExcludes);WinHome.Tests\**;Wdem.Core\** true $(NoWarn);CS1591 1.2.0 @@ -26,4 +26,8 @@ + + + + diff --git a/tests/Wdem.Core.Tests/ResourceDefinitionTests.cs b/tests/Wdem.Core.Tests/ResourceDefinitionTests.cs new file mode 100644 index 00000000..2fcc62b1 --- /dev/null +++ b/tests/Wdem.Core.Tests/ResourceDefinitionTests.cs @@ -0,0 +1,110 @@ +using Wdem.Core.Resources; +using Wdem.Core.Providers; +using Xunit; + +namespace Wdem.Core.Tests; + +public sealed class ResourceDefinitionTests +{ + [Fact] + public void Defaults_AreBehaviorSafe() + { + var resource = new ResourceDefinition + { + Id = "git", + Type = "package", + Provider = "winget" + }; + + Assert.Empty(resource.Dependencies); + Assert.Empty(resource.Parameters); + Assert.Equal(PrivilegeRequirement.CurrentUser, resource.PrivilegeRequirement); + Assert.Equal(RestartPolicy.NoRestart, resource.RestartPolicy); + } + + [Fact] + public void ProviderRegistry_ResolvesByResourceTypeAndProviderName() + { + var winget = new StubProvider("package", "winget"); + var registry = new ResourceProviderRegistry([winget]); + + var resolved = registry.GetRequired("PACKAGE", "WINGET"); + + Assert.Same(winget, resolved); + } + + [Fact] + public void ProviderRegistry_RejectsDuplicateProviderKeys() + { + var providers = new[] + { + new StubProvider("package", "winget"), + new StubProvider("PACKAGE", "WINGET") + }; + + Assert.Throws(() => new ResourceProviderRegistry(providers)); + } + + [Fact] + public void Fingerprint_IsStableAcrossDictionaryInsertionOrder() + { + var first = new ResourceDefinition + { + Id = "git", + Type = "package", + Provider = "winget", + Parameters = new Dictionary + { + ["packageId"] = "Git.Git", + ["source"] = "winget" + } + }; + var second = first with + { + Parameters = new Dictionary + { + ["source"] = "winget", + ["packageId"] = "Git.Git" + } + }; + + Assert.Equal( + ResourceDefinitionFingerprint.Create(first), + ResourceDefinitionFingerprint.Create(second)); + } + + private sealed class StubProvider(string resourceType, string providerName) : IResourceProvider + { + public string ResourceType { get; } = resourceType; + public string ProviderName { get; } = providerName; + public ProviderCapabilities Capabilities { get; } = new(); + + public ValueTask ValidateAsync( + ResourceDefinition resource, + CancellationToken cancellationToken) => + ValueTask.FromResult(ProviderValidationResult.Valid); + + public ValueTask DetectAsync( + ResourceDefinition resource, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public ValueTask PlanAsync( + ResourceDefinition resource, + DetectedState currentState, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public ValueTask ApplyAsync( + ResourceDefinition resource, + ResourcePlan plan, + IProgress? progress, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public ValueTask VerifyAsync( + ResourceDefinition resource, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } +} diff --git a/tests/Wdem.Core.Tests/Wdem.Core.Tests.csproj b/tests/Wdem.Core.Tests/Wdem.Core.Tests.csproj new file mode 100644 index 00000000..964d4543 --- /dev/null +++ b/tests/Wdem.Core.Tests/Wdem.Core.Tests.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + diff --git a/tests/WinHome.Tests/ChocolateyBootstrapperTests.cs b/tests/WinHome.Tests/ChocolateyBootstrapperTests.cs index e7d3c786..061fba1c 100644 --- a/tests/WinHome.Tests/ChocolateyBootstrapperTests.cs +++ b/tests/WinHome.Tests/ChocolateyBootstrapperTests.cs @@ -92,6 +92,28 @@ public void Name_ReturnsChocolatey() Assert.Equal("Chocolatey", name); } + [Fact] + public async Task InstallAsync_PropagatesCancellation() + { + using var cancellation = new CancellationTokenSource(); + _mockProcessRunner + .Setup(runner => runner.RunCommandAsync( + "powershell.exe", + It.IsAny>(), + false, + It.IsAny?>(), + cancellation.Token)) + .Returns(async () => + { + cancellation.Cancel(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellation.Token); + return true; + }); + + await Assert.ThrowsAnyAsync(() => + _bootstrapper.InstallAsync(false, cancellation.Token)); + } + [Fact] public void Install_RetryExhausted_ThrowsAfterMaxAttempts() { diff --git a/tests/WinHome.Tests/EngineTest.cs b/tests/WinHome.Tests/EngineTest.cs index 9e1ca4a2..44fc9500 100644 --- a/tests/WinHome.Tests/EngineTest.cs +++ b/tests/WinHome.Tests/EngineTest.cs @@ -2,13 +2,15 @@ using WinHome.Interfaces; using WinHome.Models; using WinHome.Models.Plugins; +using Wdem.Core.Providers; +using WinHome.Providers; using Xunit; namespace WinHome.Tests { public class EngineTests { - private readonly Mock _mockWinget; + private readonly Mock _mockWinget; private readonly Mock _mockDotfiles; private readonly Mock _mockRegistry; private readonly Mock _mockSystemSettings; @@ -26,7 +28,7 @@ public class EngineTests public EngineTests() { // 1. Create Mocks - _mockWinget = new Mock(); + _mockWinget = new Mock(); _mockDotfiles = new Mock(); _mockRegistry = new Mock(); _mockSystemSettings = new Mock(); @@ -395,6 +397,84 @@ public async Task RunAsync_ShouldNotPersistUnknownManagerAsApplied() mockLogger.Verify(l => l.LogError(It.Is(s => s.Contains("Unknown manager: unknown-manager"))), Times.Once); } + [Fact] + public async Task RunAsync_RejectsVersionIgnoredByLegacyPackageManager() + { + var config = new Configuration(); + config.Apps.Add(new AppConfig + { + Id = "Git.Git", + Manager = "winget", + Version = "2.51.0" + }); + var mockLogger = new Mock(); + var registry = new ResourceProviderRegistry( + [ + new LegacyPackageManagerProviderAdapter("winget", _mockWinget.Object) + ]); + var engine = CreateEngine(mockLogger, resourceProviders: registry); + + await Assert.ThrowsAsync(() => engine.RunAsync(config, false)); + + _mockWinget.Verify( + manager => manager.Install(It.IsAny(), It.IsAny()), + Times.Never); + mockLogger.Verify( + logger => logger.LogError(It.Is(message => + message.Contains("cannot reliably detect or enforce package versions"))), + Times.Once); + } + + [Fact] + public async Task RunAsync_UsesCancellableProviderAndPropagatesCancellation() + { + var config = new Configuration(); + config.Apps.Add(new AppConfig + { + Id = "Git.Git", + Manager = "winget", + ResourceId = "git" + }); + var mockLogger = new Mock(); + using var cancellation = new CancellationTokenSource(); + _mockWinget.Setup(manager => manager.IsAvailable()).Returns(true); + _mockWinget.Setup(manager => manager.IsInstalled("Git.Git")).Returns(false); + _mockWinget + .Setup(manager => manager.InstallAsync( + It.IsAny(), + It.IsAny?>(), + cancellation.Token)) + .Returns(async () => + { + cancellation.Cancel(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellation.Token); + }); + var registry = new ResourceProviderRegistry( + [ + new LegacyPackageManagerProviderAdapter("winget", _mockWinget.Object) + ]); + var engine = CreateEngine(mockLogger, resourceProviders: registry); + + await Assert.ThrowsAsync(() => + engine.RunAsync(config, false, cancellationToken: cancellation.Token)); + + _mockWinget.Verify( + manager => manager.InstallAsync( + It.Is(app => app.Id == "Git.Git"), + It.IsAny?>(), + cancellation.Token), + Times.Once); + _mockWinget.Verify( + manager => manager.Install(It.IsAny(), It.IsAny()), + Times.Never); + _mockStateService.Verify( + state => state.SaveState(It.Is(saved => + saved.AppliedItems.Count == 0 && + saved.StepHistory.ContainsKey("winget:Git.Git") && + saved.StepHistory["winget:Git.Git"].Status == StepStatus.Cancelled)), + Times.Once); + } + [Fact] public async Task RunAsync_ShouldNotMarkFailedRegistryApplyAsApplied() { @@ -580,6 +660,9 @@ public async Task RunAsync_ShouldSkipPreviouslyAppliedSteps() Status = StepStatus.Succeeded, AppliedAt = DateTime.UtcNow }); + var previousState = new StateData(); + previousState.AppliedItems.Add("winget:SkipApp"); + _mockStateService.Setup(service => service.LoadState()).Returns(previousState); var engine = new Engine( _managers, _mockDotfiles.Object, _mockRegistry.Object, _mockSystemSettings.Object, @@ -599,6 +682,45 @@ public async Task RunAsync_ShouldSkipPreviouslyAppliedSteps() } } + [Fact] + public async Task RunAsync_RechecksStepMissingFromMainState() + { + var config = new Configuration(); + config.Apps.Add(new AppConfig { Id = "InterruptedApp", Manager = "winget" }); + var mockLogger = new Mock(); + var tmp = Path.Combine(Path.GetTempPath(), $"winhome_state_test_{Guid.NewGuid()}.json"); + try + { + var stateWriter = new WinHome.Services.StateWriter(tmp); + stateWriter.RecordStep(new StepResult + { + StepId = "winget:InterruptedApp", + StepType = "app", + StepName = "InterruptedApp", + Status = StepStatus.Succeeded, + AppliedAt = DateTime.UtcNow + }); + _mockStateService.Setup(service => service.LoadState()).Returns(new StateData()); + var engine = new Engine( + _managers, _mockDotfiles.Object, _mockRegistry.Object, _mockSystemSettings.Object, + _mockWsl.Object, _mockGit.Object, _mockEnv.Object, _mockServiceManager.Object, + _mockScheduledTaskService.Object, _mockPluginManager.Object, _mockPluginRunner.Object, + _mockStateService.Object, mockLogger.Object, _mockRuntimeResolver.Object, stateWriter); + + await engine.RunAsync(config, false); + + _mockWinget.Verify( + manager => manager.Install( + It.Is(app => app.Id == "InterruptedApp"), + false), + Times.Once); + } + finally + { + if (File.Exists(tmp)) File.Delete(tmp); + } + } + [Fact] public async Task RunAsync_ShouldForceReapply_WhenFlagIsSet() { @@ -638,7 +760,9 @@ public async Task RunAsync_ShouldForceReapply_WhenFlagIsSet() } } - private Engine CreateEngine(Mock logger) + private Engine CreateEngine( + Mock logger, + IResourceProviderRegistry? resourceProviders = null) { var tmp = Path.Combine(Path.GetTempPath(), $"winhome_state_test_{Guid.NewGuid()}.json"); var stateWriter = new WinHome.Services.StateWriter(tmp); @@ -657,7 +781,8 @@ private Engine CreateEngine(Mock logger) _mockStateService.Object, logger.Object, _mockRuntimeResolver.Object, - stateWriter + stateWriter, + resourceProviders ); } diff --git a/tests/WinHome.Tests/LegacyPackageManagerProviderAdapterTests.cs b/tests/WinHome.Tests/LegacyPackageManagerProviderAdapterTests.cs new file mode 100644 index 00000000..df7f5468 --- /dev/null +++ b/tests/WinHome.Tests/LegacyPackageManagerProviderAdapterTests.cs @@ -0,0 +1,244 @@ +using Moq; +using Wdem.Core.Providers; +using Wdem.Core.Resources; +using WinHome.Interfaces; +using WinHome.Models; +using WinHome.Providers; + +namespace WinHome.Tests; + +public sealed class LegacyPackageManagerProviderAdapterTests +{ + private readonly Mock _packageManager = new(); + + [Fact] + public async Task ValidateAsync_RejectsMissingPackageId() + { + var adapter = CreateAdapter(); + var resource = CreateResource(parameters: new Dictionary()); + + var result = await adapter.ValidateAsync(resource, CancellationToken.None); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, error => error.Contains( + LegacyPackageManagerProviderAdapter.PackageIdParameter, + StringComparison.Ordinal)); + } + + [Fact] + public async Task ValidateAsync_RejectsUnsupportedLegacyCapabilities() + { + var adapter = CreateAdapter(); + var resource = CreateResource() with + { + VersionConstraint = ">=2.50 <3.0", + PreferredVersion = "2.51.0", + Parameters = new Dictionary + { + [LegacyPackageManagerProviderAdapter.PackageIdParameter] = "Git.Git", + [LegacyPackageManagerProviderAdapter.SourceParameter] = "company", + [LegacyPackageManagerProviderAdapter.InstallerParametersParameter] = "--unsafe-raw-argument" + } + }; + + var result = await adapter.ValidateAsync(resource, CancellationToken.None); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, error => error.Contains("versions", StringComparison.Ordinal)); + Assert.Contains(result.Errors, error => error.Contains("source", StringComparison.Ordinal)); + Assert.Contains(result.Errors, error => error.Contains("installer parameters", StringComparison.Ordinal)); + } + + [Fact] + public async Task ValidateAsync_RejectsUnknownParameters() + { + var adapter = CreateAdapter(); + var resource = CreateResource() with + { + Parameters = new Dictionary + { + [LegacyPackageManagerProviderAdapter.PackageIdParameter] = "Git.Git", + ["unexpected"] = "value" + } + }; + + var result = await adapter.ValidateAsync(resource, CancellationToken.None); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, error => error.Contains("unexpected", StringComparison.Ordinal)); + } + + [Fact] + public async Task MissingPackage_CanBePlannedAppliedAndVerified() + { + var adapter = CreateAdapter(); + var resource = CreateResource(); + _packageManager.Setup(manager => manager.IsAvailable()).Returns(true); + _packageManager + .SetupSequence(manager => manager.IsInstalled("Git.Git")) + .Returns(false) + .Returns(true); + + var detected = await adapter.DetectAsync(resource, CancellationToken.None); + var plan = await adapter.PlanAsync(resource, detected, CancellationToken.None); + var applied = await adapter.ApplyAsync(resource, plan, null, CancellationToken.None); + var verified = await adapter.VerifyAsync(resource, CancellationToken.None); + + Assert.Equal(ComplianceStatus.Missing, plan.Compliance); + Assert.True(plan.IsExecutable); + Assert.Single(plan.Steps); + Assert.Equal(ApplyOutcome.Succeeded, applied.Outcome); + Assert.Equal(ComplianceStatus.Satisfied, verified.Compliance); + _packageManager.Verify(manager => manager.InstallAsync( + It.Is(app => + app.Id == "Git.Git" && + app.Manager == "winget" && + app.ResourceId == "git"), + It.IsAny?>(), + CancellationToken.None), Times.Once); + } + + [Fact] + public async Task SatisfiedPackage_ProducesNotRequiredPlan() + { + var adapter = CreateAdapter(); + var resource = CreateResource(); + _packageManager.Setup(manager => manager.IsAvailable()).Returns(true); + _packageManager.Setup(manager => manager.IsInstalled("Git.Git")).Returns(true); + + var detected = await adapter.DetectAsync(resource, CancellationToken.None); + var plan = await adapter.PlanAsync(resource, detected, CancellationToken.None); + var result = await adapter.ApplyAsync(resource, plan, null, CancellationToken.None); + + Assert.Equal(ComplianceStatus.Satisfied, plan.Compliance); + Assert.False(plan.RequiresApply); + Assert.Equal(ApplyOutcome.NotRequired, result.Outcome); + _packageManager.Verify( + manager => manager.InstallAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task UnavailableManager_BlocksExecutionPlan() + { + var adapter = CreateAdapter(); + var resource = CreateResource(); + _packageManager.Setup(manager => manager.IsAvailable()).Returns(false); + + var detected = await adapter.DetectAsync(resource, CancellationToken.None); + var plan = await adapter.PlanAsync(resource, detected, CancellationToken.None); + + Assert.Equal(DetectionOutcome.Failed, detected.Outcome); + Assert.Equal(ComplianceStatus.DetectionFailed, plan.Compliance); + Assert.False(plan.IsExecutable); + } + + [Fact] + public async Task ApplyAsync_RejectsPlanForDifferentResource() + { + var adapter = CreateAdapter(); + var resource = CreateResource(); + var plan = new ResourcePlan + { + ResourceId = "dotnet-sdk", + ResourceType = "package", + ProviderName = "winget", + DesiredStateFingerprint = ResourceDefinitionFingerprint.Create(resource), + Compliance = ComplianceStatus.Missing, + IsExecutable = true, + Steps = + [ + new PlanStep + { + Id = "dotnet-sdk:install", + Description = "Install .NET SDK.", + Action = PlanAction.Install, + PrivilegeRequirement = PrivilegeRequirement.CurrentUser, + RestartPolicy = RestartPolicy.NoRestart + } + ] + }; + + await Assert.ThrowsAsync(async () => + await adapter.ApplyAsync(resource, plan, null, CancellationToken.None)); + } + + [Fact] + public async Task ApplyAsync_RejectsChangedPackageAfterPlanApproval() + { + var adapter = CreateAdapter(); + var resource = CreateResource(); + var detected = new DetectedState + { + ResourceId = resource.Id, + Outcome = DetectionOutcome.Succeeded, + Exists = false + }; + var plan = await adapter.PlanAsync(resource, detected, CancellationToken.None); + var changedResource = resource with + { + Parameters = new Dictionary + { + [LegacyPackageManagerProviderAdapter.PackageIdParameter] = "Microsoft.VisualStudioCode" + } + }; + + await Assert.ThrowsAsync(async () => + await adapter.ApplyAsync(changedResource, plan, null, CancellationToken.None)); + _packageManager.Verify( + manager => manager.InstallAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ApplyAsync_ReturnsCancelledWhenInstallationIsCancelled() + { + var adapter = CreateAdapter(); + var resource = CreateResource(); + var detected = new DetectedState + { + ResourceId = resource.Id, + Outcome = DetectionOutcome.Succeeded, + Exists = false + }; + var plan = await adapter.PlanAsync(resource, detected, CancellationToken.None); + using var cancellation = new CancellationTokenSource(); + _packageManager + .Setup(manager => manager.InstallAsync( + It.IsAny(), + It.IsAny?>(), + cancellation.Token)) + .Returns(async () => + { + cancellation.Cancel(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellation.Token); + }); + + var result = await adapter.ApplyAsync(resource, plan, null, cancellation.Token); + + Assert.Equal(ApplyOutcome.Cancelled, result.Outcome); + } + + private LegacyPackageManagerProviderAdapter CreateAdapter() => + new("winget", _packageManager.Object); + + private static ResourceDefinition CreateResource( + IReadOnlyDictionary? parameters = null) => + new() + { + Id = "git", + Type = "package", + Provider = "winget", + DisplayName = "Git", + Parameters = parameters ?? new Dictionary + { + [LegacyPackageManagerProviderAdapter.PackageIdParameter] = "Git.Git" + } + }; +} diff --git a/tests/WinHome.Tests/ScoopBootstrapperTests.cs b/tests/WinHome.Tests/ScoopBootstrapperTests.cs index d09b0266..b5afabff 100644 --- a/tests/WinHome.Tests/ScoopBootstrapperTests.cs +++ b/tests/WinHome.Tests/ScoopBootstrapperTests.cs @@ -88,5 +88,27 @@ public void Name_ReturnsScoop() string name = _bootstrapper.Name; Assert.Equal("Scoop", name); } + + [Fact] + public async Task InstallAsync_PropagatesCancellation() + { + using var cancellation = new CancellationTokenSource(); + _mockProcessRunner + .Setup(runner => runner.RunCommandAsync( + "powershell.exe", + It.IsAny>(), + false, + It.IsAny?>(), + cancellation.Token)) + .Returns(async () => + { + cancellation.Cancel(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellation.Token); + return true; + }); + + await Assert.ThrowsAnyAsync(() => + _bootstrapper.InstallAsync(false, cancellation.Token)); + } } } diff --git a/tests/WinHome.Tests/Services/System/DefaultProcessRunnerTests.cs b/tests/WinHome.Tests/Services/System/DefaultProcessRunnerTests.cs index 0b39a66b..10f02bcc 100644 --- a/tests/WinHome.Tests/Services/System/DefaultProcessRunnerTests.cs +++ b/tests/WinHome.Tests/Services/System/DefaultProcessRunnerTests.cs @@ -136,6 +136,69 @@ public void RunCommand_OnOutputNull_DoesNotThrow() Assert.True(result); } + [Fact] + public async Task RunCommandAsync_CancellationTerminatesProcess() + { + var runner = new DefaultProcessRunner(); + using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(250)); + var stopwatch = global::System.Diagnostics.Stopwatch.StartNew(); + var executable = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd" : "sh"; + var arguments = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? new[] { "/c", "ping 127.0.0.1 -n 30 > nul" } + : new[] { "-c", "sleep 30" }; + + await Assert.ThrowsAsync(() => + runner.RunCommandAsync(executable, arguments, false, null, cancellation.Token)); + + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task RunCommandAsync_CancellationTerminatesChildAfterParentExits() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + + var runner = new DefaultProcessRunner(); + var testDirectory = Path.Combine( + Path.GetTempPath(), + $"winhome_job_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(testDirectory); + var markerPath = Path.Combine(testDirectory, "child-survived.txt"); + var childScript = Path.Combine(testDirectory, "child.cmd"); + var parentScript = Path.Combine(testDirectory, "parent.cmd"); + + try + { + File.WriteAllText( + childScript, + $"@ping 127.0.0.1 -n 4 >nul{Environment.NewLine}@echo survived>\"{markerPath}\""); + File.WriteAllText( + parentScript, + $"@start \"\" /b cmd /c \"\"{childScript}\"\""); + using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); + + await Assert.ThrowsAsync(() => + runner.RunCommandAsync( + "cmd", + new[] { "/c", parentScript }, + false, + null, + cancellation.Token)); + + await Task.Delay(TimeSpan.FromSeconds(4)); + Assert.False( + File.Exists(markerPath), + "A child process survived after the cancelled Job Object was closed."); + } + finally + { + if (Directory.Exists(testDirectory)) + { + Directory.Delete(testDirectory, recursive: true); + } + } + } + /// RunCommandWithOutput returns non-empty stdout for dotnet --version. [Fact] public void RunCommandWithOutput_ReturnsStdoutFromDotnetVersion() diff --git a/tests/WinHome.Tests/WingetBootstrapperTests.cs b/tests/WinHome.Tests/WingetBootstrapperTests.cs index 07ef7774..801bf753 100644 --- a/tests/WinHome.Tests/WingetBootstrapperTests.cs +++ b/tests/WinHome.Tests/WingetBootstrapperTests.cs @@ -52,5 +52,23 @@ public void Name_ReturnsWinget() string name = _bootstrapper.Name; Assert.Equal("Winget", name); } + + [Fact] + public async Task InstallAsync_RejectsPreCancelledOperation() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAsync(() => + _bootstrapper.InstallAsync(false, cancellation.Token)); + _mockProcessRunner.Verify( + runner => runner.RunCommandAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } } }