From 3daa770584fd1feb0fad4ff682f6aa36c14b24b9 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Wed, 10 Jun 2026 01:23:49 +0100 Subject: [PATCH] fix(apps): winget launch failure no longer crashes Apply On IoT LTSC winget is absent, so Process.Start('winget') throws Win32Exception ('cannot find the file specified') rather than returning non-zero. That throw propagated out of InstallAsync and failed the entire Apply ('Configuration failed'). AppInstaller is now fully exception-safe: a TryRunAsync wrapper converts launch throws into a failed run, winget is resolved defensively (PATH -> bootstrap+re-probe -> WindowsApps alias path) and when unavailable the installer skips apps and marks them not-installed instead of throwing. Per-app launch throws are isolated too. Two new tests cover probe-throws-skips and per-app-throw-isolated. Co-Authored-By: Claude Opus 4.8 --- .../Apps/AppInstaller.cs | 47 +++++++++++++++---- .../AppInstallerTests.cs | 43 ++++++++++++++++- 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs index 9e1d69b..0a11a60 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs @@ -10,7 +10,15 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn var results = new List(); if (apps.Count == 0) return results; - await EnsureWingetAsync(ct); + // App installs are non-critical: a missing/broken winget (e.g. offline IoT LTSC) must + // NEVER fail onboarding. Resolve winget defensively; if it can't be found, skip installs. + var winget = await ResolveWingetAsync(progress, ct); + if (winget is null) + { + progress.Report(new($"App installer unavailable - skipping {apps.Count} app(s)", 80)); + foreach (var app in apps) results.Add(new AppInstallResult(app.Id, false)); + return results; + } var i = 0; foreach (var app in apps) @@ -21,7 +29,7 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn var id = app.Source.Winget; if (!string.IsNullOrWhiteSpace(id)) { - var r = await runner.RunAsync("winget", + var r = await TryRunAsync(winget, $"install --id {id} --silent --accept-package-agreements --accept-source-agreements --disable-interactivity", ct); ok = r.ExitCode == 0; @@ -29,7 +37,7 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn { var script = Path.Combine(appsDir, "configure", app.Configure); // best-effort: configuration failure does not mark the install as failed - await runner.RunAsync("powershell.exe", + await TryRunAsync("powershell.exe", $"-NoProfile -ExecutionPolicy Bypass -File \"{script}\"", ct); } } @@ -38,17 +46,38 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn return results; } - // winget (App Installer) is absent from IoT Enterprise LTSC. - // Detect it; if missing, provision via the bundled bootstrap script or the registered package family name. - private async Task EnsureWingetAsync(CancellationToken ct) + // Find a usable winget. winget (App Installer) is absent from IoT Enterprise LTSC, and even + // when present it ships as a WindowsApps execution alias that Process.Start can't always launch + // by bare name. Returns the invocation target ("winget" or a full path), or null if unavailable. + private async Task ResolveWingetAsync(IProgress progress, CancellationToken ct) { - var probe = await runner.RunAsync("winget", "--version", ct); - if (probe.ExitCode == 0) return; + // 1) Already launchable by name (on PATH for this process)? + if ((await TryRunAsync("winget", "--version", ct)).ExitCode == 0) return "winget"; + // 2) Provision App Installer via the bundled bootstrap (or registered package), then re-probe. + progress.Report(new("Preparing app installer", 68)); var bootstrap = Path.Combine(appsDir, "bootstrap-winget.ps1"); - await runner.RunAsync("powershell.exe", + await TryRunAsync("powershell.exe", $"-NoProfile -ExecutionPolicy Bypass -Command \"if (Test-Path '{bootstrap}') {{ & '{bootstrap}' }} else {{ " + "Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe -EA SilentlyContinue }}\"", ct); + if ((await TryRunAsync("winget", "--version", ct)).ExitCode == 0) return "winget"; + + // 3) Fall back to the WindowsApps execution-alias path (bare-name launch can fail under + // UseShellExecute=false even when winget is installed). + var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var aliased = Path.Combine(local, "Microsoft", "WindowsApps", "winget.exe"); + if (File.Exists(aliased) && (await TryRunAsync(aliased, "--version", ct)).ExitCode == 0) return aliased; + + return null; + } + + // Launching a missing executable throws Win32Exception ("cannot find the file specified") + // rather than returning a non-zero code. Treat any launch failure as a failed run so the + // installer's continue-on-failure logic covers it and onboarding never crashes. + private async Task TryRunAsync(string file, string args, CancellationToken ct) + { + try { return await runner.RunAsync(file, args, ct); } + catch (Exception ex) { return new ProcessResult(-1, "", ex.Message); } } } diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/AppInstallerTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/AppInstallerTests.cs index 47b3c8e..70f2809 100644 --- a/windows/welcome/tests/SilverOS.Welcome.Tests/AppInstallerTests.cs +++ b/windows/welcome/tests/SilverOS.Welcome.Tests/AppInstallerTests.cs @@ -38,7 +38,8 @@ public class AppInstallerTests public async Task Bootstraps_winget_when_absent() { var run = Runner(); - run.Setup(r => r.RunAsync("winget", It.Is(s => s.Contains("--version")), It.IsAny())) + // Any winget probe (bare name OR the WindowsApps alias path) reports absent. + run.Setup(r => r.RunAsync(It.IsAny(), It.Is(s => s.Contains("--version")), It.IsAny())) .ReturnsAsync(new ProcessResult(1, "", "not found")); var sut = new AppInstaller(run.Object, "C:\\apps"); await sut.InstallAsync(new[] { App("tb", "Mozilla.Thunderbird") }, new Progress(_ => { })); @@ -62,4 +63,44 @@ public class AppInstallerTests Assert.False(res.First(r => r.Id == "bad").Installed); Assert.True(res.First(r => r.Id == "good").Installed); } + + [Fact] + public async Task Winget_launch_exception_does_not_crash_apply_and_skips_installs() + { + // On IoT LTSC winget is absent, so Process.Start throws Win32Exception instead of + // returning a non-zero code. The installer must swallow it, skip installs, and NOT throw. + var run = new Mock(); + run.Setup(r => r.RunAsync(It.IsAny(), It.Is(s => s.Contains("--version")), It.IsAny())) + .ThrowsAsync(new System.ComponentModel.Win32Exception("The system cannot find the file specified.")); + run.Setup(r => r.RunAsync("powershell.exe", It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProcessResult(0, "", "")); + var sut = new AppInstaller(run.Object, "C:\\apps"); + + var res = await sut.InstallAsync(new[] { App("vscodium", "VSCodium.VSCodium"), App("git", "Git.Git") }, + new Progress(_ => { })); + + Assert.Equal(2, res.Count); + Assert.All(res, r => Assert.False(r.Installed)); + // never attempted an install once winget was unresolved + run.Verify(r => r.RunAsync(It.IsAny(), It.Is(s => s.Contains("install --id")), It.IsAny()), Times.Never); + } + + [Fact] + public async Task App_install_exception_is_isolated_from_the_rest() + { + var run = new Mock(); + run.Setup(r => r.RunAsync(It.IsAny(), It.Is(s => s.Contains("--version")), It.IsAny())) + .ReturnsAsync(new ProcessResult(0, "v1.8", "")); + run.Setup(r => r.RunAsync(It.IsAny(), It.Is(s => s.Contains("Bad.App")), It.IsAny())) + .ThrowsAsync(new System.ComponentModel.Win32Exception("boom")); + run.Setup(r => r.RunAsync(It.IsAny(), It.Is(s => s.Contains("Good.App")), It.IsAny())) + .ReturnsAsync(new ProcessResult(0, "", "")); + var sut = new AppInstaller(run.Object, "C:\\apps"); + + var res = await sut.InstallAsync(new[] { App("bad", "Bad.App"), App("good", "Good.App") }, + new Progress(_ => { })); + + Assert.False(res.First(r => r.Id == "bad").Installed); + Assert.True(res.First(r => r.Id == "good").Installed); + } }