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);
+ }
}
}