diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs new file mode 100644 index 0000000..9e1d69b --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs @@ -0,0 +1,54 @@ +using SilverOS.Welcome.Core.Apply; + +namespace SilverOS.Welcome.Core.Apps; + +public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppInstaller +{ + public async Task> InstallAsync( + IReadOnlyList apps, IProgress progress, CancellationToken ct = default) + { + var results = new List(); + if (apps.Count == 0) return results; + + await EnsureWingetAsync(ct); + + var i = 0; + foreach (var app in apps) + { + i++; + progress.Report(new($"Installing {app.Name} ({i}/{apps.Count})", 80)); + var ok = false; + var id = app.Source.Winget; + if (!string.IsNullOrWhiteSpace(id)) + { + var r = await runner.RunAsync("winget", + $"install --id {id} --silent --accept-package-agreements --accept-source-agreements --disable-interactivity", + ct); + ok = r.ExitCode == 0; + if (ok && !string.IsNullOrWhiteSpace(app.Configure)) + { + var script = Path.Combine(appsDir, "configure", app.Configure); + // best-effort: configuration failure does not mark the install as failed + await runner.RunAsync("powershell.exe", + $"-NoProfile -ExecutionPolicy Bypass -File \"{script}\"", ct); + } + } + results.Add(new AppInstallResult(app.Id, ok)); + } + 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) + { + var probe = await runner.RunAsync("winget", "--version", ct); + if (probe.ExitCode == 0) return; + + var bootstrap = Path.Combine(appsDir, "bootstrap-winget.ps1"); + await runner.RunAsync("powershell.exe", + $"-NoProfile -ExecutionPolicy Bypass -Command \"if (Test-Path '{bootstrap}') {{ & '{bootstrap}' }} else {{ " + + "Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe -EA SilentlyContinue }}\"", + ct); + } +} diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppInstaller.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppInstaller.cs new file mode 100644 index 0000000..b3f64b1 --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppInstaller.cs @@ -0,0 +1,11 @@ +using SilverOS.Welcome.Core.Apply; + +namespace SilverOS.Welcome.Core.Apps; + +public sealed record AppInstallResult(string Id, bool Installed); + +public interface IAppInstaller +{ + Task> InstallAsync( + IReadOnlyList apps, IProgress progress, CancellationToken ct = default); +} diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/AppInstallerTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/AppInstallerTests.cs new file mode 100644 index 0000000..47b3c8e --- /dev/null +++ b/windows/welcome/tests/SilverOS.Welcome.Tests/AppInstallerTests.cs @@ -0,0 +1,65 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using SilverOS.Welcome.Core.Apps; +using SilverOS.Welcome.Core.Apply; + +public class AppInstallerTests +{ + static AppCatalogEntry App(string id, string winget, string? cfg = null) => + new() { Id = id, Name = id, Source = new AppSource { Winget = winget }, Configure = cfg }; + + static Mock Runner(int exit = 0) + { + var m = new Mock(); + m.Setup(r => r.RunAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProcessResult(exit, "", "")); + return m; + } + + [Fact] + public async Task Installs_each_selected_app_via_winget() + { + var run = Runner(); + run.Setup(r => r.RunAsync("winget", It.Is(s => s.Contains("--version")), It.IsAny())) + .ReturnsAsync(new ProcessResult(0, "v1.8", "")); + var sut = new AppInstaller(run.Object, "C:\\apps"); + var res = await sut.InstallAsync(new[] { App("vscodium", "VSCodium.VSCodium") }, + new Progress(_ => { })); + run.Verify(r => r.RunAsync("winget", It.Is(s => + s.Contains("install") && s.Contains("VSCodium.VSCodium") && s.Contains("--silent")), + It.IsAny()), Times.Once); + Assert.True(res.Single().Installed); + } + + [Fact] + public async Task Bootstraps_winget_when_absent() + { + var run = Runner(); + run.Setup(r => r.RunAsync("winget", 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(_ => { })); + run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => + s.Contains("DesktopAppInstaller")), It.IsAny()), Times.AtLeastOnce); + } + + [Fact] + public async Task One_app_failure_does_not_stop_the_rest() + { + var run = new Mock(); + run.Setup(r => r.RunAsync("winget", It.Is(s => s.Contains("--version")), It.IsAny())) + .ReturnsAsync(new ProcessResult(0, "v1.8", "")); + run.Setup(r => r.RunAsync("winget", It.Is(s => s.Contains("Bad.App")), It.IsAny())) + .ReturnsAsync(new ProcessResult(1, "", "fail")); + run.Setup(r => r.RunAsync("winget", 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); + } +}