feat(apps): winget install engine (bootstrap + per-app + configure, continue-on-failure)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<IReadOnlyList<AppInstallResult>> InstallAsync(
|
||||
IReadOnlyList<AppCatalogEntry> apps, IProgress<ApplyProgress> progress, CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<AppInstallResult>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<IReadOnlyList<AppInstallResult>> InstallAsync(
|
||||
IReadOnlyList<AppCatalogEntry> apps, IProgress<ApplyProgress> progress, CancellationToken ct = default);
|
||||
}
|
||||
@@ -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<IProcessRunner> Runner(int exit = 0)
|
||||
{
|
||||
var m = new Mock<IProcessRunner>();
|
||||
m.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.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<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
|
||||
.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<ApplyProgress>(_ => { }));
|
||||
run.Verify(r => r.RunAsync("winget", It.Is<string>(s =>
|
||||
s.Contains("install") && s.Contains("VSCodium.VSCodium") && s.Contains("--silent")),
|
||||
It.IsAny<CancellationToken>()), 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<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(1, "", "not found"));
|
||||
var sut = new AppInstaller(run.Object, "C:\\apps");
|
||||
await sut.InstallAsync(new[] { App("tb", "Mozilla.Thunderbird") }, new Progress<ApplyProgress>(_ => { }));
|
||||
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
|
||||
s.Contains("DesktopAppInstaller")), It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_app_failure_does_not_stop_the_rest()
|
||||
{
|
||||
var run = new Mock<IProcessRunner>();
|
||||
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(0, "v1.8", ""));
|
||||
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("Bad.App")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(1, "", "fail"));
|
||||
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("Good.App")), It.IsAny<CancellationToken>()))
|
||||
.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<ApplyProgress>(_ => { }));
|
||||
Assert.False(res.First(r => r.Id == "bad").Installed);
|
||||
Assert.True(res.First(r => r.Id == "good").Installed);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user