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:
@@ -39,11 +39,14 @@ public static class MauiProgram
|
|||||||
builder.Services.AddSingleton<IAccountService, AccountService>();
|
builder.Services.AddSingleton<IAccountService, AccountService>();
|
||||||
builder.Services.AddSingleton<IBitLockerService, BitLockerService>();
|
builder.Services.AddSingleton<IBitLockerService, BitLockerService>();
|
||||||
builder.Services.AddSingleton<IBootstrapService, BootstrapService>();
|
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(
|
builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
|
||||||
sp.GetRequiredService<IProcessRunner>(),
|
sp.GetRequiredService<IProcessRunner>(),
|
||||||
sp.GetRequiredService<IAccountService>(),
|
sp.GetRequiredService<IAccountService>(),
|
||||||
sp.GetRequiredService<IBitLockerService>(),
|
sp.GetRequiredService<IBitLockerService>(),
|
||||||
sp.GetRequiredService<IBootstrapService>(),
|
sp.GetRequiredService<IBootstrapService>(),
|
||||||
|
sp.GetRequiredService<IAppInstaller>(),
|
||||||
hardeningDir));
|
hardeningDir));
|
||||||
builder.Services.AddScoped<WizardState>();
|
builder.Services.AddScoped<WizardState>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using SilverOS.Welcome.Core.Apps;
|
||||||
using SilverOS.Welcome.Core.Flavours;
|
using SilverOS.Welcome.Core.Flavours;
|
||||||
namespace SilverOS.Welcome.Core.Apply;
|
namespace SilverOS.Welcome.Core.Apply;
|
||||||
public sealed record ApplyRequest(FlavourManifest Flavour, string Username, string Password,
|
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);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using SilverOS.Welcome.Core.Apps;
|
||||||
using SilverOS.Welcome.Core.Flavours;
|
using SilverOS.Welcome.Core.Flavours;
|
||||||
namespace SilverOS.Welcome.Core.Apply;
|
namespace SilverOS.Welcome.Core.Apply;
|
||||||
|
|
||||||
public sealed class ApplyService(IProcessRunner runner, IAccountService accounts,
|
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)
|
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));
|
progress.Report(new("Creating your account", 55));
|
||||||
await accounts.CreateAccountsAsync(req.Username, req.Password, req.AdminPassword, ct);
|
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));
|
progress.Report(new("Encrypting the disk", 75));
|
||||||
await bitlocker.EnableAsync(req.BitLockerPin, ct);
|
await bitlocker.EnableAsync(req.BitLockerPin, ct);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
@using SilverOS.Welcome.Core.Apps
|
||||||
@inject IApplyService ApplyService
|
@inject IApplyService ApplyService
|
||||||
|
@inject IAppCatalog AppCatalog
|
||||||
@inject WizardState State
|
@inject WizardState State
|
||||||
|
|
||||||
<div class="step apply-step">
|
<div class="step apply-step">
|
||||||
@@ -81,13 +83,17 @@
|
|||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
await OnRunningChanged.InvokeAsync(true);
|
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(
|
var req = new ApplyRequest(
|
||||||
Flavour: State.Flavour!,
|
Flavour: State.Flavour!,
|
||||||
Username: State.Username,
|
Username: State.Username,
|
||||||
Password: State.Password,
|
Password: State.Password,
|
||||||
AdminPassword: State.AdminPassword,
|
AdminPassword: State.AdminPassword,
|
||||||
BitLockerPin: State.BitLockerPin,
|
BitLockerPin: State.BitLockerPin,
|
||||||
BootstrapUser: "sm-bootstrap");
|
BootstrapUser: "sm-bootstrap",
|
||||||
|
Apps: apps);
|
||||||
|
|
||||||
var progress = new Progress<ApplyProgress>(p =>
|
var progress = new Progress<ApplyProgress>(p =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Moq;
|
using Moq;
|
||||||
using SilverOS.Welcome.Core.Apply;
|
using SilverOS.Welcome.Core.Apply;
|
||||||
|
using SilverOS.Welcome.Core.Apps;
|
||||||
using SilverOS.Welcome.Core.Flavours;
|
using SilverOS.Welcome.Core.Flavours;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -69,11 +70,18 @@ public class ApplyServiceHardeningIntegrationTests
|
|||||||
boot.Setup(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
boot.Setup(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
.Returns(Task.CompletedTask);
|
.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(
|
var sut = new ApplyService(
|
||||||
runner: new ProcessRunner(),
|
runner: new ProcessRunner(),
|
||||||
accounts: acct.Object,
|
accounts: acct.Object,
|
||||||
bitlocker: bl.Object,
|
bitlocker: bl.Object,
|
||||||
bootstrap: boot.Object,
|
bootstrap: boot.Object,
|
||||||
|
installer: installer.Object,
|
||||||
hardeningDir: tmp);
|
hardeningDir: tmp);
|
||||||
|
|
||||||
// Flavour requests modules 00 and 05 only — 03 and 07 must be skipped.
|
// Flavour requests modules 00 and 05 only — 03 and 07 must be skipped.
|
||||||
@@ -82,7 +90,7 @@ public class ApplyServiceHardeningIntegrationTests
|
|||||||
Id = "test",
|
Id = "test",
|
||||||
Hardening = new HardeningSpec { Modules = new[] { "00", "05" } }
|
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 ----
|
// ---- Act ----
|
||||||
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
|
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
using Moq;
|
using Moq;
|
||||||
using SilverOS.Welcome.Core.Apply;
|
using SilverOS.Welcome.Core.Apply;
|
||||||
|
using SilverOS.Welcome.Core.Apps;
|
||||||
using SilverOS.Welcome.Core.Flavours;
|
using SilverOS.Welcome.Core.Flavours;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
public class ApplyServiceTests
|
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]
|
[Fact]
|
||||||
public async Task Runs_modules_then_accounts_then_bitlocker_then_bootstrap_last()
|
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 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 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 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 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>();
|
var progress = new List<string>();
|
||||||
|
|
||||||
await sut.RunAsync(req, new Progress<ApplyProgress>(p => progress.Add(p.Stage)));
|
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);
|
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 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 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 bl = new Mock<IBitLockerService>(); var boot = new Mock<IBootstrapService>();
|
||||||
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, NoApps().Object, "C:\\hard");
|
||||||
var req = new ApplyRequest(new FlavourManifest{ Hardening=new HardeningSpec{Modules=new[]{"00"}}}, "a","b","c","123456","sm-bootstrap");
|
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>(_ => {})));
|
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.RunAsync(req, new Progress<ApplyProgress>(_ => {})));
|
||||||
boot.Verify(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
boot.Verify(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using SilverOS.Welcome.App.Components;
|
using SilverOS.Welcome.App.Components;
|
||||||
using SilverOS.Welcome.App.Components.Steps;
|
using SilverOS.Welcome.App.Components.Steps;
|
||||||
using SilverOS.Welcome.Core.Apply;
|
using SilverOS.Welcome.Core.Apply;
|
||||||
|
using SilverOS.Welcome.Core.Apps;
|
||||||
using SilverOS.Welcome.Core.Flavours;
|
using SilverOS.Welcome.Core.Flavours;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
public class ApplyStepTests : TestContext
|
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]
|
[Fact]
|
||||||
public async Task Calls_apply_with_the_wizard_selections()
|
public async Task Calls_apply_with_the_wizard_selections()
|
||||||
{
|
{
|
||||||
@@ -24,6 +31,7 @@ public class ApplyStepTests : TestContext
|
|||||||
};
|
};
|
||||||
Services.AddSingleton(state);
|
Services.AddSingleton(state);
|
||||||
Services.AddSingleton(apply.Object);
|
Services.AddSingleton(apply.Object);
|
||||||
|
AddCatalog(Services);
|
||||||
var cut = RenderComponent<ApplyStep>();
|
var cut = RenderComponent<ApplyStep>();
|
||||||
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
||||||
apply.Verify(a => a.RunAsync(
|
apply.Verify(a => a.RunAsync(
|
||||||
@@ -45,6 +53,7 @@ public class ApplyStepTests : TestContext
|
|||||||
};
|
};
|
||||||
Services.AddSingleton(state);
|
Services.AddSingleton(state);
|
||||||
Services.AddSingleton(apply.Object);
|
Services.AddSingleton(apply.Object);
|
||||||
|
AddCatalog(Services);
|
||||||
var completed = false;
|
var completed = false;
|
||||||
var cut = RenderComponent<ApplyStep>(p => p.Add(s => s.OnComplete, EventCallback.Factory.Create(this, () => { completed = true; })));
|
var cut = RenderComponent<ApplyStep>(p => p.Add(s => s.OnComplete, EventCallback.Factory.Create(this, () => { completed = true; })));
|
||||||
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
||||||
@@ -64,6 +73,7 @@ public class ApplyStepTests : TestContext
|
|||||||
};
|
};
|
||||||
Services.AddSingleton(state);
|
Services.AddSingleton(state);
|
||||||
Services.AddSingleton(apply.Object);
|
Services.AddSingleton(apply.Object);
|
||||||
|
AddCatalog(Services);
|
||||||
var cut = RenderComponent<ApplyStep>();
|
var cut = RenderComponent<ApplyStep>();
|
||||||
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
||||||
Assert.Contains("Module 03 failed", cut.Markup);
|
Assert.Contains("Module 03 failed", cut.Markup);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Moq;
|
using Moq;
|
||||||
using SilverOS.Welcome.Core.Apply;
|
using SilverOS.Welcome.Core.Apply;
|
||||||
|
using SilverOS.Welcome.Core.Apps;
|
||||||
|
|
||||||
public class BootstrapServiceRevertKioskTests
|
public class BootstrapServiceRevertKioskTests
|
||||||
{
|
{
|
||||||
@@ -85,13 +86,18 @@ public class BootstrapServiceRevertKioskTests
|
|||||||
.Callback(() => order.Add("teardown"))
|
.Callback(() => order.Add("teardown"))
|
||||||
.Returns(Task.CompletedTask);
|
.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
|
var flavour = new SilverOS.Welcome.Core.Flavours.FlavourManifest
|
||||||
{
|
{
|
||||||
Id = "daily-driver",
|
Id = "daily-driver",
|
||||||
Hardening = new SilverOS.Welcome.Core.Flavours.HardeningSpec { Modules = new[] { "00" } }
|
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>(_ => { }));
|
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user