feat(apps): install selected apps during Apply (after accounts, before BitLocker)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sysadmin
2026-06-10 00:36:54 +01:00
parent de18ee8dd2
commit 3c7654f128
8 changed files with 62 additions and 11 deletions

View File

@@ -39,11 +39,14 @@ public static class MauiProgram
builder.Services.AddSingleton<IAccountService, AccountService>();
builder.Services.AddSingleton<IBitLockerService, BitLockerService>();
builder.Services.AddSingleton<IBootstrapService, BootstrapService>();
var appsDir = Path.Combine(AppContext.BaseDirectory, "apps");
builder.Services.AddSingleton<IAppInstaller>(sp => new AppInstaller(sp.GetRequiredService<IProcessRunner>(), appsDir));
builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
sp.GetRequiredService<IProcessRunner>(),
sp.GetRequiredService<IAccountService>(),
sp.GetRequiredService<IBitLockerService>(),
sp.GetRequiredService<IBootstrapService>(),
sp.GetRequiredService<IAppInstaller>(),
hardeningDir));
builder.Services.AddScoped<WizardState>();

View File

@@ -1,4 +1,6 @@
using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Flavours;
namespace SilverOS.Welcome.Core.Apply;
public sealed record ApplyRequest(FlavourManifest Flavour, string Username, string Password,
string AdminPassword, string BitLockerPin, string BootstrapUser);
string AdminPassword, string BitLockerPin, string BootstrapUser,
IReadOnlyList<AppCatalogEntry> Apps);

View File

@@ -1,9 +1,11 @@
using System.Text.Json;
using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Flavours;
namespace SilverOS.Welcome.Core.Apply;
public sealed class ApplyService(IProcessRunner runner, IAccountService accounts,
IBitLockerService bitlocker, IBootstrapService bootstrap, string hardeningDir) : IApplyService
IBitLockerService bitlocker, IBootstrapService bootstrap, IAppInstaller installer,
string hardeningDir) : IApplyService
{
public async Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default)
{
@@ -29,6 +31,9 @@ public sealed class ApplyService(IProcessRunner runner, IAccountService accounts
progress.Report(new("Creating your account", 55));
await accounts.CreateAccountsAsync(req.Username, req.Password, req.AdminPassword, ct);
progress.Report(new("Installing apps", 70));
await installer.InstallAsync(req.Apps, progress, ct); // continue-on-failure; never throws
progress.Report(new("Encrypting the disk", 75));
await bitlocker.EnableAsync(req.BitLockerPin, ct);

View File

@@ -1,4 +1,6 @@
@using SilverOS.Welcome.Core.Apps
@inject IApplyService ApplyService
@inject IAppCatalog AppCatalog
@inject WizardState State
<div class="step apply-step">
@@ -81,13 +83,17 @@
StateHasChanged();
await OnRunningChanged.InvokeAsync(true);
var apps = AppCatalog.Load(Path.Combine(AppContext.BaseDirectory, "apps"))
.All.Where(a => State.SelectedApps.Contains(a.Id)).ToList();
var req = new ApplyRequest(
Flavour: State.Flavour!,
Username: State.Username,
Password: State.Password,
AdminPassword: State.AdminPassword,
BitLockerPin: State.BitLockerPin,
BootstrapUser: "sm-bootstrap");
BootstrapUser: "sm-bootstrap",
Apps: apps);
var progress = new Progress<ApplyProgress>(p =>
{

View File

@@ -1,5 +1,6 @@
using Moq;
using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Flavours;
using Xunit;
@@ -69,11 +70,18 @@ public class ApplyServiceHardeningIntegrationTests
boot.Setup(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var installer = new Mock<IAppInstaller>();
installer.Setup(i => i.InstallAsync(
It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<AppInstallResult>());
var sut = new ApplyService(
runner: new ProcessRunner(),
accounts: acct.Object,
bitlocker: bl.Object,
bootstrap: boot.Object,
installer: installer.Object,
hardeningDir: tmp);
// Flavour requests modules 00 and 05 only — 03 and 07 must be skipped.
@@ -82,7 +90,7 @@ public class ApplyServiceHardeningIntegrationTests
Id = "test",
Hardening = new HardeningSpec { Modules = new[] { "00", "05" } }
};
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap");
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap", Array.Empty<AppCatalogEntry>());
// ---- Act ----
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));

View File

@@ -1,10 +1,20 @@
using Moq;
using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Flavours;
using Xunit;
public class ApplyServiceTests
{
private static Mock<IAppInstaller> NoApps()
{
var installer = new Mock<IAppInstaller>();
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<AppInstallResult>());
return installer;
}
[Fact]
public async Task Runs_modules_then_accounts_then_bitlocker_then_bootstrap_last()
{
@@ -16,15 +26,16 @@ public class ApplyServiceTests
var acct = new Mock<IAccountService>(); acct.Setup(a => a.CreateAccountsAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("accounts")).Returns(Task.CompletedTask);
var bl = new Mock<IBitLockerService>(); bl.Setup(b => b.EnableAsync(It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
var boot = new Mock<IBootstrapService>(); boot.Setup(b => b.TearDownAsync(It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("bootstrap")).Returns(Task.CompletedTask);
var installer = NoApps(); installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),It.IsAny<IProgress<ApplyProgress>>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty<AppInstallResult>());
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, "C:\\hard");
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, installer.Object, "C:\\hard");
var flavour = new FlavourManifest { Id="daily-driver", Hardening = new HardeningSpec { Modules = new[]{"00"} } };
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap");
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap", System.Array.Empty<AppCatalogEntry>());
var progress = new List<string>();
await sut.RunAsync(req, new Progress<ApplyProgress>(p => progress.Add(p.Stage)));
Assert.Equal(new[]{"modules","accounts","bitlocker","bootstrap"}, order);
Assert.Equal(new[]{"modules","accounts","apps","bitlocker","bootstrap"}, order);
Assert.Contains("Applying hardening", progress);
}
@@ -34,8 +45,8 @@ public class ApplyServiceTests
var run = new Mock<IProcessRunner>(); run.Setup(r => r.RunAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).ReturnsAsync(new ProcessResult(0,"",""));
var acct = new Mock<IAccountService>(); acct.Setup(a => a.CreateAccountsAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("boom"));
var bl = new Mock<IBitLockerService>(); var boot = new Mock<IBootstrapService>();
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, "C:\\hard");
var req = new ApplyRequest(new FlavourManifest{ Hardening=new HardeningSpec{Modules=new[]{"00"}}}, "a","b","c","123456","sm-bootstrap");
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, NoApps().Object, "C:\\hard");
var req = new ApplyRequest(new FlavourManifest{ Hardening=new HardeningSpec{Modules=new[]{"00"}}}, "a","b","c","123456","sm-bootstrap", System.Array.Empty<AppCatalogEntry>());
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.RunAsync(req, new Progress<ApplyProgress>(_ => {})));
boot.Verify(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}

View File

@@ -5,11 +5,18 @@ using Microsoft.Extensions.DependencyInjection;
using SilverOS.Welcome.App.Components;
using SilverOS.Welcome.App.Components.Steps;
using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Flavours;
using Xunit;
public class ApplyStepTests : TestContext
{
// The component loads the catalog from AppContext.BaseDirectory/apps; no catalog.json
// is staged in the test bin, so the real AppCatalog degrades to an empty list — which is
// exactly what these tests want (no apps selected → empty Apps on the request).
private static void AddCatalog(IServiceCollection services) =>
services.AddSingleton<IAppCatalog>(new AppCatalog());
[Fact]
public async Task Calls_apply_with_the_wizard_selections()
{
@@ -24,6 +31,7 @@ public class ApplyStepTests : TestContext
};
Services.AddSingleton(state);
Services.AddSingleton(apply.Object);
AddCatalog(Services);
var cut = RenderComponent<ApplyStep>();
await cut.InvokeAsync(() => cut.Instance.StartAsync());
apply.Verify(a => a.RunAsync(
@@ -45,6 +53,7 @@ public class ApplyStepTests : TestContext
};
Services.AddSingleton(state);
Services.AddSingleton(apply.Object);
AddCatalog(Services);
var completed = false;
var cut = RenderComponent<ApplyStep>(p => p.Add(s => s.OnComplete, EventCallback.Factory.Create(this, () => { completed = true; })));
await cut.InvokeAsync(() => cut.Instance.StartAsync());
@@ -64,6 +73,7 @@ public class ApplyStepTests : TestContext
};
Services.AddSingleton(state);
Services.AddSingleton(apply.Object);
AddCatalog(Services);
var cut = RenderComponent<ApplyStep>();
await cut.InvokeAsync(() => cut.Instance.StartAsync());
Assert.Contains("Module 03 failed", cut.Markup);

View File

@@ -1,5 +1,6 @@
using Moq;
using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Apps;
public class BootstrapServiceRevertKioskTests
{
@@ -85,13 +86,18 @@ public class BootstrapServiceRevertKioskTests
.Callback(() => order.Add("teardown"))
.Returns(Task.CompletedTask);
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, "C:\\hard");
var installer = new Mock<IAppInstaller>();
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(System.Array.Empty<AppInstallResult>());
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, installer.Object, "C:\\hard");
var flavour = new SilverOS.Welcome.Core.Flavours.FlavourManifest
{
Id = "daily-driver",
Hardening = new SilverOS.Welcome.Core.Flavours.HardeningSpec { Modules = new[] { "00" } }
};
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap");
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap", System.Array.Empty<AppCatalogEntry>());
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));