From dfbf1d1ec8d71693fd29493756760a042671da87 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Tue, 9 Jun 2026 01:53:41 +0100 Subject: [PATCH] docs(windows): SilverOS Welcome app implementation plan 16-task, 5-phase TDD plan: flavour engine (manifest/loader/4 flavours) -> apply orchestrator (parameterised Invoke-Hardening, account/BitLocker/bootstrap services, ApplyService) -> MAUI Blazor wizard -> bootstrap/build integration (autounattend AutoLogon, SetupComplete defers to Welcome, build bakes app+flavours, CI) -> VM e2e. Daily account = Standard + SilverOS Admin; Daily-Driver default; Stack stubbed. Co-Authored-By: Claude Opus 4.8 --- windows/welcome-app-plan.md | 1083 +++++++++++++++++++++++++++++++++++ 1 file changed, 1083 insertions(+) create mode 100644 windows/welcome-app-plan.md diff --git a/windows/welcome-app-plan.md b/windows/welcome-app-plan.md new file mode 100644 index 0000000..1a6ffb9 --- /dev/null +++ b/windows/welcome-app-plan.md @@ -0,0 +1,1083 @@ +# SilverOS Welcome App — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the SilverOS Welcome app — a first-logon Blazor Hybrid (.NET MAUI) wizard that lets the user pick a flavour, create a least-privilege account + BitLocker PIN, and have the device configure itself by orchestrating the existing §A–H PowerShell hardening modules. + +**Architecture:** A `net9.0-windows` solution under `windows/welcome/`. A **Core** class library holds the flavour engine (manifest model/loader/validator) and the apply-orchestrator (runs PS modules per manifest, creates accounts, enrols BitLocker, tears down the bootstrap) behind interfaces so it is unit-testable headless. A **MAUI Blazor** app hosts the wizard UI and calls Core. The image build (`build.ps1`) bakes the published app + flavour manifests and wires a self-destructing bootstrap auto-login. Hardening logic stays in PowerShell (single source of truth); Core only orchestrates it. + +**Tech Stack:** .NET 9, .NET MAUI Blazor Hybrid (WebView2), C#, xUnit + bUnit + Moq, System.Text.Json, PowerShell (existing modules), Windows local-account + BitLocker cmdlets. + +**Build/test environment:** This solution targets Windows x64 and must be built/tested on the `silverlabs-runner-win` runner or a Windows x64 VM (the repo's dev box is ARM64). CI integration mirrors `build-iso-windows.yaml`. + +**Spec:** [`welcome-app-spec.md`](welcome-app-spec.md). Read it before starting. + +--- + +## File structure + +``` +windows/welcome/ +├── SilverOS.Welcome.sln +├── src/ +│ ├── SilverOS.Welcome.Core/ # class lib (net9.0-windows) — no UI +│ │ ├── SilverOS.Welcome.Core.csproj +│ │ ├── Flavours/ +│ │ │ ├── FlavourManifest.cs # the manifest model (records) +│ │ │ ├── IFlavourLoader.cs +│ │ │ └── FlavourLoader.cs # load + validate *.json from a dir +│ │ ├── Apply/ +│ │ │ ├── IProcessRunner.cs # abstraction over running a process +│ │ │ ├── ProcessRunner.cs # real powershell.exe runner +│ │ │ ├── IAccountService.cs +│ │ │ ├── AccountService.cs # create standard + admin local accounts +│ │ │ ├── IBitLockerService.cs +│ │ │ ├── BitLockerService.cs # Enable-BitLocker TPM+PIN +│ │ │ ├── IBootstrapService.cs +│ │ │ ├── BootstrapService.cs # remove AutoLogon + bootstrap account +│ │ │ ├── ApplyRequest.cs # inputs for an apply run +│ │ │ ├── ApplyProgress.cs # progress event model +│ │ │ ├── IApplyService.cs +│ │ │ └── ApplyService.cs # the orchestrator +│ │ └── SilverOS.Welcome.Core.csproj +│ └── SilverOS.Welcome.App/ # MAUI Blazor Hybrid (net9.0-windows) +│ ├── SilverOS.Welcome.App.csproj +│ ├── MauiProgram.cs +│ ├── Components/ +│ │ ├── Routes.razor / App.razor / _Imports.razor +│ │ ├── WizardState.cs # holds selections across steps +│ │ └── Steps/ +│ │ ├── WelcomeStep.razor +│ │ ├── FlavourStep.razor +│ │ ├── AccountStep.razor +│ │ ├── PrefsStep.razor +│ │ ├── ApplyStep.razor +│ │ └── DoneStep.razor +│ └── wwwroot/ (Mercury-aligned css) +├── tests/ +│ └── SilverOS.Welcome.Tests/ # xUnit + bUnit + Moq +└── (manifests live at) windows/flavours/*.json +``` + +Plus changes to: `windows/installer/build.ps1`, `windows/installer/autounattend/autounattend.xml`, `windows/hardening/Invoke-Hardening.ps1`, `.gitea/workflows/build-iso-windows.yaml`. + +--- + +## Phase 1 — Flavour engine (Core) + +### Task 1: Solution + Core project skeleton + +**Files:** +- Create: `windows/welcome/SilverOS.Welcome.sln` +- Create: `windows/welcome/src/SilverOS.Welcome.Core/SilverOS.Welcome.Core.csproj` +- Create: `windows/welcome/tests/SilverOS.Welcome.Tests/SilverOS.Welcome.Tests.csproj` + +- [ ] **Step 1: Create the solution + projects** + +Run (on a Windows x64 box with .NET 9 SDK): +```bash +cd windows/welcome +dotnet new sln -n SilverOS.Welcome +dotnet new classlib -n SilverOS.Welcome.Core -o src/SilverOS.Welcome.Core -f net9.0-windows +dotnet new xunit -n SilverOS.Welcome.Tests -o tests/SilverOS.Welcome.Tests -f net9.0-windows +dotnet sln add src/SilverOS.Welcome.Core tests/SilverOS.Welcome.Tests +dotnet add tests/SilverOS.Welcome.Tests reference src/SilverOS.Welcome.Core +dotnet add tests/SilverOS.Welcome.Tests package Moq +``` + +- [ ] **Step 2: Verify it builds** + +Run: `dotnet build` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 3: Commit** + +```bash +git add windows/welcome +git commit -m "feat(welcome): solution + Core/Test project skeleton" +``` + +### Task 2: Flavour manifest model + +**Files:** +- Create: `windows/welcome/src/SilverOS.Welcome.Core/Flavours/FlavourManifest.cs` +- Test: `windows/welcome/tests/SilverOS.Welcome.Tests/FlavourManifestTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +using System.Text.Json; +using SilverOS.Welcome.Core.Flavours; +using Xunit; + +public class FlavourManifestTests +{ + [Fact] + public void Deserializes_a_full_manifest() + { + var json = """ + { + "id": "daily-driver", "label": "Daily-Driver", + "description": "Balanced.", "isDefault": true, + "hardening": { "modules": ["00","03","05"], "params": { "wdac": "audit" } }, + "appSet": ["SilverBrowser"], "settings": { "autoLock": 120 } + } + """; + var m = JsonSerializer.Deserialize(json, FlavourManifest.JsonOptions)!; + Assert.Equal("daily-driver", m.Id); + Assert.True(m.IsDefault); + Assert.Equal(new[] { "00", "03", "05" }, m.Hardening.Modules); + Assert.Equal("audit", m.Hardening.Params["wdac"]); + Assert.Contains("SilverBrowser", m.AppSet); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test --filter FlavourManifestTests` +Expected: FAIL — `FlavourManifest` does not exist. + +- [ ] **Step 3: Write the model** + +```csharp +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SilverOS.Welcome.Core.Flavours; + +public sealed record FlavourManifest +{ + public string Id { get; init; } = ""; + public string Label { get; init; } = ""; + public string Description { get; init; } = ""; + public bool IsDefault { get; init; } + public HardeningSpec Hardening { get; init; } = new(); + public IReadOnlyList AppSet { get; init; } = Array.Empty(); + public IReadOnlyDictionary Settings { get; init; } + = new Dictionary(); + + public static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; +} + +public sealed record HardeningSpec +{ + public IReadOnlyList Modules { get; init; } = Array.Empty(); + public IReadOnlyDictionary Params { get; init; } + = new Dictionary(); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test --filter FlavourManifestTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add windows/welcome +git commit -m "feat(welcome): flavour manifest model" +``` + +### Task 3: Flavour loader + validation + +**Files:** +- Create: `windows/welcome/src/SilverOS.Welcome.Core/Flavours/IFlavourLoader.cs` +- Create: `windows/welcome/src/SilverOS.Welcome.Core/Flavours/FlavourLoader.cs` +- Test: `windows/welcome/tests/SilverOS.Welcome.Tests/FlavourLoaderTests.cs` + +- [ ] **Step 1: Write the failing tests** + +```csharp +using SilverOS.Welcome.Core.Flavours; +using Xunit; + +public class FlavourLoaderTests +{ + private static string WriteTemp(params (string name, string json)[] files) + { + var dir = Directory.CreateTempSubdirectory("flav").FullName; + foreach (var (name, json) in files) File.WriteAllText(Path.Combine(dir, name), json); + return dir; + } + + [Fact] + public void Loads_all_manifests_sorted_with_default_first() + { + var dir = WriteTemp( + ("privacy-max.json", """{ "id":"privacy-max","label":"Privacy-Max","hardening":{"modules":["00"]} }"""), + ("daily-driver.json", """{ "id":"daily-driver","label":"Daily-Driver","isDefault":true,"hardening":{"modules":["00"]} }""")); + var loaded = new FlavourLoader().Load(dir); + Assert.Equal(2, loaded.Count); + Assert.Equal("daily-driver", loaded[0].Id); // default first + } + + [Fact] + public void Throws_when_a_manifest_has_no_id() + { + var dir = WriteTemp(("bad.json", """{ "label":"No Id","hardening":{"modules":["00"]} }""")); + var ex = Assert.Throws(() => new FlavourLoader().Load(dir)); + Assert.Contains("bad.json", ex.Message); + } + + [Fact] + public void Throws_when_no_default_flavour_present() + { + var dir = WriteTemp(("a.json", """{ "id":"a","label":"A","hardening":{"modules":["00"]} }""")); + Assert.Throws(() => new FlavourLoader().Load(dir)); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test --filter FlavourLoaderTests` +Expected: FAIL — `FlavourLoader` / `FlavourValidationException` do not exist. + +- [ ] **Step 3: Write the loader** + +```csharp +// IFlavourLoader.cs +namespace SilverOS.Welcome.Core.Flavours; +public interface IFlavourLoader { IReadOnlyList Load(string directory); } +``` + +```csharp +// FlavourLoader.cs +using System.Text.Json; +namespace SilverOS.Welcome.Core.Flavours; + +public sealed class FlavourValidationException(string message) : Exception(message); + +public sealed class FlavourLoader : IFlavourLoader +{ + public IReadOnlyList Load(string directory) + { + var list = new List(); + foreach (var file in Directory.EnumerateFiles(directory, "*.json").OrderBy(f => f)) + { + FlavourManifest m; + try { m = JsonSerializer.Deserialize(File.ReadAllText(file), FlavourManifest.JsonOptions) + ?? throw new FlavourValidationException($"{Path.GetFileName(file)}: empty"); } + catch (JsonException ex) { throw new FlavourValidationException($"{Path.GetFileName(file)}: {ex.Message}"); } + if (string.IsNullOrWhiteSpace(m.Id)) throw new FlavourValidationException($"{Path.GetFileName(file)}: missing id"); + if (m.Hardening.Modules.Count == 0) throw new FlavourValidationException($"{Path.GetFileName(file)}: no hardening modules"); + list.Add(m); + } + if (list.Count == 0) throw new FlavourValidationException("no flavour manifests found"); + if (list.Count(m => m.IsDefault) != 1) throw new FlavourValidationException("exactly one flavour must be isDefault"); + return list.OrderByDescending(m => m.IsDefault).ThenBy(m => m.Label).ToList(); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test --filter FlavourLoaderTests` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add windows/welcome +git commit -m "feat(welcome): flavour loader + validation" +``` + +### Task 4: Author the four flavour manifests + +**Files:** +- Create: `windows/flavours/daily-driver.json` +- Create: `windows/flavours/privacy-max.json` +- Create: `windows/flavours/journalist.json` +- Create: `windows/flavours/developer.json` +- Test: `windows/welcome/tests/SilverOS.Welcome.Tests/ShippedFlavoursTests.cs` + +- [ ] **Step 1: Write the four manifests** + +`daily-driver.json` (default): +```json +{ + "id": "daily-driver", + "label": "Daily-Driver", + "description": "Balanced privacy and usability. Full hardened baseline; app control in audit.", + "isDefault": true, + "hardening": { "modules": ["00","01","02","03","04","05","06","07"], "params": { "wdac": "audit" } }, + "appSet": ["SilverBrowser","SilverVPN","SilverKeys"], + "settings": { "autoLock": 120 } +} +``` + +`privacy-max.json`: +```json +{ + "id": "privacy-max", + "label": "Privacy-Max", + "description": "Maximum lockdown. App control enforced, tightest toggles.", + "isDefault": false, + "hardening": { "modules": ["00","01","02","03","04","05","06","07"], "params": { "wdac": "enforce" } }, + "appSet": ["SilverBrowser","SilverVPN","SilverKeys","SilverDuress","SilverChat"], + "settings": { "autoLock": 60 } +} +``` + +`journalist.json`: +```json +{ + "id": "journalist", + "label": "Journalist", + "description": "Privacy-first with duress + secure comms emphasis.", + "isDefault": false, + "hardening": { "modules": ["00","01","02","03","04","05","06","07"], "params": { "wdac": "enforce" } }, + "appSet": ["SilverBrowser","SilverVPN","SilverChat","SilverDuress","SilverKeys","SilverSync"], + "settings": { "autoLock": 60 } +} +``` + +`developer.json`: +```json +{ + "id": "developer", + "label": "Developer", + "description": "Hardened baseline with developer tooling allowances.", + "isDefault": false, + "hardening": { "modules": ["00","01","02","03","04","05","06","07"], "params": { "wdac": "audit" } }, + "appSet": ["SilverBrowser","SilverVPN","SilverKeys"], + "settings": { "autoLock": 300 } +} +``` + +- [ ] **Step 2: Write a test that the shipped manifests all load** + +```csharp +using SilverOS.Welcome.Core.Flavours; +using Xunit; + +public class ShippedFlavoursTests +{ + private static string FlavoursDir() + { + var d = AppContext.BaseDirectory; + while (d is not null && !Directory.Exists(Path.Combine(d, "windows", "flavours"))) + d = Directory.GetParent(d)?.FullName; + return Path.Combine(d!, "windows", "flavours"); + } + + [Fact] + public void All_shipped_flavours_are_valid_and_one_is_default() + { + var loaded = new FlavourLoader().Load(FlavoursDir()); + Assert.Equal(4, loaded.Count); + Assert.Equal("daily-driver", loaded[0].Id); + } +} +``` + +- [ ] **Step 3: Run it** + +Run: `dotnet test --filter ShippedFlavoursTests` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add windows/flavours windows/welcome +git commit -m "feat(welcome): author Daily-Driver/Privacy-Max/Journalist/Developer flavours" +``` + +--- + +## Phase 2 — Apply orchestrator (Core) + +### Task 5: Process runner abstraction + +**Files:** +- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apply/IProcessRunner.cs` +- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apply/ProcessRunner.cs` +- Test: `windows/welcome/tests/SilverOS.Welcome.Tests/ProcessRunnerTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +using SilverOS.Welcome.Core.Apply; +using Xunit; + +public class ProcessRunnerTests +{ + [Fact] + public async Task Runs_powershell_and_captures_output_and_exit() + { + var r = await new ProcessRunner().RunAsync( + "powershell.exe", "-NoProfile -Command \"Write-Output hello; exit 3\""); + Assert.Equal(3, r.ExitCode); + Assert.Contains("hello", r.StdOut); + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `dotnet test --filter ProcessRunnerTests` +Expected: FAIL — `ProcessRunner` not defined. + +- [ ] **Step 3: Implement** + +```csharp +// IProcessRunner.cs +namespace SilverOS.Welcome.Core.Apply; +public readonly record struct ProcessResult(int ExitCode, string StdOut, string StdErr); +public interface IProcessRunner +{ + Task RunAsync(string file, string args, CancellationToken ct = default); +} +``` + +```csharp +// ProcessRunner.cs +using System.Diagnostics; +namespace SilverOS.Welcome.Core.Apply; + +public sealed class ProcessRunner : IProcessRunner +{ + public async Task RunAsync(string file, string args, CancellationToken ct = default) + { + using var p = new Process { StartInfo = new ProcessStartInfo(file, args) + { + RedirectStandardOutput = true, RedirectStandardError = true, + UseShellExecute = false, CreateNoWindow = true + }}; + p.Start(); + var outT = p.StandardOutput.ReadToEndAsync(ct); + var errT = p.StandardError.ReadToEndAsync(ct); + await p.WaitForExitAsync(ct); + return new ProcessResult(p.ExitCode, await outT, await errT); + } +} +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `dotnet test --filter ProcessRunnerTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add windows/welcome +git commit -m "feat(welcome): process runner abstraction" +``` + +### Task 6: Parameterise the PowerShell hardening runner + +**Files:** +- Modify: `windows/hardening/Invoke-Hardening.ps1` + +- [ ] **Step 1: Add a `-Modules` filter + `-Params` passthrough** + +Replace the body of `Invoke-Hardening.ps1` with: +```powershell +#Requires -Version 5.1 +<# Runs the §A-H modules (optionally a subset) then Verify. + -Modules "00","03","05" -> run only those numeric-prefixed modules (default: all 0*). + -ParamsJson '{"wdac":"audit"}' -> exported as $env:SM_PARAMS for modules to read. #> +[CmdletBinding()] param([string[]]$Modules, [string]$ParamsJson) +$ErrorActionPreference = 'Continue' +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +if ($ParamsJson) { $env:SM_PARAMS = $ParamsJson } +Write-Host "=== SilverMetal hardening modules ===" +$all = Get-ChildItem (Join-Path $here '0*.ps1') | Sort-Object Name +if ($Modules) { $all = $all | Where-Object { $Modules -contains $_.Name.Substring(0,2) } } +foreach ($f in $all) { + Write-Host "--> $($f.Name)" + try { & $f.FullName } catch { Write-Warning "$($f.Name) FAILED: $_" } +} +Write-Host "=== Verify ===" +try { & (Join-Path $here 'Verify-SilverMetalWindows.ps1') } catch { Write-Warning "Verify error: $_" } +Write-Host "=== runner done ===" +``` + +- [ ] **Step 2: Manually sanity-check the param parse on a Windows box** + +Run: `powershell -File windows\hardening\Invoke-Hardening.ps1 -Modules 00 -ParamsJson '{"wdac":"audit"}'` +Expected: runs only `00-provisioning.ps1` then Verify; `$env:SM_PARAMS` set. (Full effect only on a real install; here just confirm it filters + doesn't error on parse.) + +- [ ] **Step 3: Commit** + +```bash +git add windows/hardening/Invoke-Hardening.ps1 +git commit -m "feat(welcome): Invoke-Hardening accepts -Modules subset + -ParamsJson" +``` + +### Task 7: Account + BitLocker + Bootstrap services + +**Files:** +- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apply/IAccountService.cs` + `AccountService.cs` +- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apply/IBitLockerService.cs` + `BitLockerService.cs` +- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apply/IBootstrapService.cs` + `BootstrapService.cs` +- Test: `windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServicesTests.cs` + +- [ ] **Step 1: Write failing tests (verify the right commands are issued via a mocked runner)** + +```csharp +using Moq; +using SilverOS.Welcome.Core.Apply; +using Xunit; + +public class ApplyServicesTests +{ + private static Mock Ok() + { + var m = new Mock(); + m.Setup(r => r.RunAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProcessResult(0, "", "")); + return m; + } + + [Fact] + public async Task AccountService_creates_standard_daily_and_admin() + { + var run = Ok(); + await new AccountService(run.Object).CreateAccountsAsync("alice", "pw1", "adminpw"); + // daily user is a Standard user (added to Users, NOT Administrators) + run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => + s.Contains("New-LocalUser") && s.Contains("alice")), It.IsAny())); + run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => + s.Contains("'SilverOS Admin'") && s.Contains("Administrators")), It.IsAny())); + } + + [Fact] + public async Task BitLockerService_enables_tpm_and_pin() + { + var run = Ok(); + await new BitLockerService(run.Object).EnableAsync("123456"); + run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => + s.Contains("Enable-BitLocker") && s.Contains("TpmAndPinProtector")), It.IsAny())); + } + + [Fact] + public async Task BootstrapService_removes_autologon_and_account() + { + var run = Ok(); + await new BootstrapService(run.Object).TearDownAsync("sm-bootstrap"); + run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => + s.Contains("AutoAdminLogon") && s.Contains("0")), It.IsAny())); + run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => + s.Contains("Remove-LocalUser") && s.Contains("sm-bootstrap")), It.IsAny())); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test --filter ApplyServicesTests` +Expected: FAIL — services not defined. + +- [ ] **Step 3: Implement the three services** + +```csharp +// IAccountService.cs +namespace SilverOS.Welcome.Core.Apply; +public interface IAccountService { Task CreateAccountsAsync(string user, string password, string adminPassword, CancellationToken ct = default); } +``` +```csharp +// AccountService.cs +namespace SilverOS.Welcome.Core.Apply; +public sealed class AccountService(IProcessRunner runner) : IAccountService +{ + public async Task CreateAccountsAsync(string user, string password, string adminPassword, CancellationToken ct = default) + { + // Daily account = Standard User (Users group only). + await Ps($"$p=ConvertTo-SecureString '{Esc(password)}' -AsPlainText -Force; " + + $"New-LocalUser -Name '{Esc(user)}' -Password $p -FullName '{Esc(user)}' -AccountNeverExpires; " + + $"Add-LocalGroupMember -Group 'Users' -Member '{Esc(user)}'", ct); + // Separate elevation account. + await Ps($"$a=ConvertTo-SecureString '{Esc(adminPassword)}' -AsPlainText -Force; " + + $"New-LocalUser -Name 'SilverOS Admin' -Password $a -AccountNeverExpires; " + + $"Add-LocalGroupMember -Group 'Administrators' -Member 'SilverOS Admin'", ct); + } + private Task Ps(string script, CancellationToken ct) => + runner.RunAsync("powershell.exe", $"-NoProfile -ExecutionPolicy Bypass -Command \"{script}\"", ct); + private static string Esc(string s) => s.Replace("'", "''"); +} +``` +```csharp +// IBitLockerService.cs / BitLockerService.cs +namespace SilverOS.Welcome.Core.Apply; +public interface IBitLockerService { Task EnableAsync(string pin, CancellationToken ct = default); } +public sealed class BitLockerService(IProcessRunner runner) : IBitLockerService +{ + public Task EnableAsync(string pin, CancellationToken ct = default) => + runner.RunAsync("powershell.exe", + $"-NoProfile -ExecutionPolicy Bypass -Command \"$p=ConvertTo-SecureString '{pin.Replace("'","''")}' -AsPlainText -Force; " + + "Enable-BitLocker -MountPoint $env:SystemDrive -EncryptionMethod XtsAes256 -TpmAndPinProtector -Pin $p -SkipHardwareTest\"", ct); +} +``` +```csharp +// IBootstrapService.cs / BootstrapService.cs +namespace SilverOS.Welcome.Core.Apply; +public interface IBootstrapService { Task TearDownAsync(string bootstrapUser, CancellationToken ct = default); } +public sealed class BootstrapService(IProcessRunner runner) : IBootstrapService +{ + public async Task TearDownAsync(string bootstrapUser, CancellationToken ct = default) + { + const string key = "'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon'"; + await Ps($"Set-ItemProperty {key} -Name AutoAdminLogon -Value 0; " + + $"Remove-ItemProperty {key} -Name DefaultPassword -EA SilentlyContinue", ct); + await Ps($"Remove-LocalUser -Name '{bootstrapUser}' -EA SilentlyContinue", ct); + } + private Task Ps(string s, CancellationToken ct) => + runner.RunAsync("powershell.exe", $"-NoProfile -ExecutionPolicy Bypass -Command \"{s}\"", ct); +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `dotnet test --filter ApplyServicesTests` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add windows/welcome +git commit -m "feat(welcome): account + BitLocker + bootstrap services" +``` + +### Task 8: ApplyService orchestrator + +**Files:** +- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs`, `ApplyProgress.cs`, `IApplyService.cs`, `ApplyService.cs` +- Test: `windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs` + +- [ ] **Step 1: Write the failing test (ordering + teardown-last + progress)** + +```csharp +using Moq; +using SilverOS.Welcome.Core.Apply; +using SilverOS.Welcome.Core.Flavours; +using Xunit; + +public class ApplyServiceTests +{ + [Fact] + public async Task Runs_modules_then_accounts_then_bitlocker_then_bootstrap_last() + { + var order = new List(); + var run = new Mock(); + run.Setup(r => r.RunAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, a, _) => { if (a.Contains("Invoke-Hardening")) order.Add("modules"); }) + .ReturnsAsync(new ProcessResult(0, "", "")); + var acct = new Mock(); acct.Setup(a => a.CreateAccountsAsync(It.IsAny(),It.IsAny(),It.IsAny(),It.IsAny())).Callback(() => order.Add("accounts")).Returns(Task.CompletedTask); + var bl = new Mock(); bl.Setup(b => b.EnableAsync(It.IsAny(),It.IsAny())).Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask); + var boot = new Mock(); boot.Setup(b => b.TearDownAsync(It.IsAny(),It.IsAny())).Callback(() => order.Add("bootstrap")).Returns(Task.CompletedTask); + + var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.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 progress = new List(); + + await sut.RunAsync(req, new Progress(p => progress.Add(p.Stage))); + + Assert.Equal(new[]{"modules","accounts","bitlocker","bootstrap"}, order); + Assert.Contains("Applying hardening", progress); + } + + [Fact] + public async Task Does_not_tear_down_bootstrap_if_account_creation_fails() + { + var run = new Mock(); run.Setup(r => r.RunAsync(It.IsAny(),It.IsAny(),It.IsAny())).ReturnsAsync(new ProcessResult(0,"","")); + var acct = new Mock(); acct.Setup(a => a.CreateAccountsAsync(It.IsAny(),It.IsAny(),It.IsAny(),It.IsAny())).ThrowsAsync(new InvalidOperationException("boom")); + var bl = new Mock(); var boot = new Mock(); + 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"); + await Assert.ThrowsAsync(() => sut.RunAsync(req, new Progress(_ => {}))); + boot.Verify(b => b.TearDownAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test --filter ApplyServiceTests` +Expected: FAIL — types not defined. + +- [ ] **Step 3: Implement** + +```csharp +// ApplyRequest.cs +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); + +// ApplyProgress.cs +namespace SilverOS.Welcome.Core.Apply; +public sealed record ApplyProgress(string Stage, int Percent); + +// IApplyService.cs +namespace SilverOS.Welcome.Core.Apply; +public interface IApplyService { Task RunAsync(ApplyRequest req, IProgress progress, CancellationToken ct = default); } +``` +```csharp +// ApplyService.cs +using System.Text.Json; +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 +{ + public async Task RunAsync(ApplyRequest req, IProgress progress, CancellationToken ct = default) + { + progress.Report(new("Applying hardening", 10)); + var mods = string.Join(",", req.Flavour.Hardening.Modules.Select(m => $"'{m}'")); + var pjson = JsonSerializer.Serialize(req.Flavour.Hardening.Params).Replace("\"", "\\\""); + var script = Path.Combine(hardeningDir, "Invoke-Hardening.ps1"); + var res = await runner.RunAsync("powershell.exe", + $"-NoProfile -ExecutionPolicy Bypass -File \"{script}\" -Modules {mods} -ParamsJson \"{pjson}\"", ct); + if (res.ExitCode != 0) throw new InvalidOperationException($"hardening failed: {res.StdErr}"); + + progress.Report(new("Creating your account", 55)); + await accounts.CreateAccountsAsync(req.Username, req.Password, req.AdminPassword, ct); + + progress.Report(new("Encrypting the disk", 75)); + await bitlocker.EnableAsync(req.BitLockerPin, ct); + + progress.Report(new("Finishing up", 95)); + await bootstrap.TearDownAsync(req.BootstrapUser, ct); // last — only after success + progress.Report(new("Done", 100)); + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `dotnet test --filter ApplyServiceTests` +Expected: PASS (2 tests). Then `dotnet test` (whole suite) — all green. + +- [ ] **Step 5: Commit** + +```bash +git add windows/welcome +git commit -m "feat(welcome): ApplyService orchestrator (modules->accounts->bitlocker->teardown)" +``` + +--- + +## Phase 3 — MAUI Blazor wizard UI + +### Task 9: MAUI Blazor app skeleton + DI + +**Files:** +- Create: `windows/welcome/src/SilverOS.Welcome.App/` (MAUI Blazor project) +- Modify: `windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs` +- Create: `windows/welcome/src/SilverOS.Welcome.App/Components/WizardState.cs` + +- [ ] **Step 1: Create the MAUI Blazor project** + +Run: +```bash +cd windows/welcome +dotnet new maui-blazor -n SilverOS.Welcome.App -o src/SilverOS.Welcome.App -f net9.0-windows10.0.19041.0 +dotnet sln add src/SilverOS.Welcome.App +dotnet add src/SilverOS.Welcome.App reference src/SilverOS.Welcome.Core +``` +*(Windows-only target; remove other TFMs from the csproj `` so it is `net9.0-windows10.0.19041.0` only.)* + +- [ ] **Step 2: Register Core services + WizardState in DI** + +In `MauiProgram.cs`, before `return builder.Build();`: +```csharp +var flavoursDir = Path.Combine(AppContext.BaseDirectory, "flavours"); +var hardeningDir = @"C:\Windows\Setup\Scripts\hardening"; +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => new ApplyService( + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), hardeningDir)); +builder.Services.AddScoped(); +``` +With `WizardState.cs`: +```csharp +using SilverOS.Welcome.Core.Flavours; +namespace SilverOS.Welcome.App.Components; +public sealed class WizardState +{ + public FlavourManifest? Flavour { get; set; } + public string Username { get; set; } = ""; + public string Password { get; set; } = ""; + public string AdminPassword { get; set; } = ""; + public string BitLockerPin { get; set; } = ""; +} +``` + +- [ ] **Step 3: Build** + +Run: `dotnet build src/SilverOS.Welcome.App` +Expected: Build succeeded. + +- [ ] **Step 4: Commit** + +```bash +git add windows/welcome +git commit -m "feat(welcome): MAUI Blazor app skeleton + DI wiring" +``` + +### Task 10: Wizard steps (UI) with a bUnit navigation test + +**Files:** +- Create: `Components/Steps/*.razor` (Welcome/Flavour/Account/Prefs/Apply/Done) +- Modify: `Components/Routes.razor` (single-page wizard host) +- Add bUnit: `dotnet add tests/SilverOS.Welcome.Tests package bunit` +- Test: `windows/welcome/tests/SilverOS.Welcome.Tests/FlavourStepTests.cs` + +- [ ] **Step 1: Write a failing bUnit test for the flavour step** + +```csharp +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using SilverOS.Welcome.App.Components.Steps; +using SilverOS.Welcome.App.Components; +using SilverOS.Welcome.Core.Flavours; +using Xunit; + +public class FlavourStepTests : TestContext +{ + [Fact] + public void Renders_one_card_per_flavour_and_preselects_default() + { + var flavours = new[] + { + new FlavourManifest { Id="daily-driver", Label="Daily-Driver", IsDefault=true, Hardening=new(){Modules=new[]{"00"}} }, + new FlavourManifest { Id="privacy-max", Label="Privacy-Max", Hardening=new(){Modules=new[]{"00"}} }, + }; + Services.AddSingleton(new WizardState()); + var cut = RenderComponent(p => p.Add(s => s.Flavours, flavours)); + Assert.Equal(2, cut.FindAll(".flavour-card").Count); + Assert.Contains("selected", cut.Find(".flavour-card[data-id=daily-driver]").ClassList); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test --filter FlavourStepTests` +Expected: FAIL — `FlavourStep` not defined. + +- [ ] **Step 3: Implement the steps** + +`Components/Steps/FlavourStep.razor`: +```razor +@inject WizardState State +
+

What's this device for?

+
+ @foreach (var f in Flavours) + { +
+

@f.Label

@f.Description

+
+ } +
+
+@code { + [Parameter] public IReadOnlyList Flavours { get; set; } = Array.Empty(); + protected override void OnInitialized() => State.Flavour ??= Flavours.FirstOrDefault(f => f.IsDefault); + void Select(FlavourManifest f) => State.Flavour = f; +} +``` +Implement `WelcomeStep`, `AccountStep` (daily user/password + admin password + BitLocker PIN fields, with validation), `PrefsStep`, `ApplyStep` (calls `IApplyService.RunAsync` with an `IProgress` bound to a progress bar), `DoneStep` (a "Restart now" button → `shutdown /r /t 5`). The wizard host (`Routes.razor` or a `Wizard.razor`) tracks the current step index and Next/Back, loading flavours via `IFlavourLoader` from the `flavours` dir on init. + +- [ ] **Step 4: Run to verify pass** + +Run: `dotnet test --filter FlavourStepTests` +Expected: PASS. Then full `dotnet test` — all green. + +- [ ] **Step 5: Commit** + +```bash +git add windows/welcome +git commit -m "feat(welcome): wizard steps + flavour selection UI" +``` + +### Task 11: Apply step wiring + Mercury styling + +**Files:** +- Modify: `Components/Steps/ApplyStep.razor`, `wwwroot/css/app.css` +- Test: `windows/welcome/tests/SilverOS.Welcome.Tests/ApplyStepTests.cs` + +- [ ] **Step 1: Write a failing bUnit test that Apply invokes IApplyService with the wizard selections** + +```csharp +using Bunit; using Moq; using Microsoft.Extensions.DependencyInjection; +using SilverOS.Welcome.App.Components; using SilverOS.Welcome.App.Components.Steps; +using SilverOS.Welcome.Core.Apply; using SilverOS.Welcome.Core.Flavours; using Xunit; + +public class ApplyStepTests : TestContext +{ + [Fact] + public async Task Calls_apply_with_the_wizard_selections() + { + var apply = new Mock(); + var state = new WizardState { Flavour = new FlavourManifest{Id="daily-driver",Hardening=new(){Modules=new[]{"00"}}}, + Username="alice", Password="pw", AdminPassword="apw", BitLockerPin="123456" }; + Services.AddSingleton(state); Services.AddSingleton(apply.Object); + var cut = RenderComponent(); + await cut.InvokeAsync(() => cut.Instance.StartAsync()); + apply.Verify(a => a.RunAsync(It.Is(r => r.Username=="alice" && r.Flavour.Id=="daily-driver"), + It.IsAny>(), It.IsAny()), Times.Once); + } +} +``` + +- [ ] **Step 2–4:** Run (FAIL) → implement `ApplyStep` (`StartAsync` builds an `ApplyRequest` from `WizardState` with `BootstrapUser="sm-bootstrap"` and calls `IApplyService.RunAsync` with an `IProgress` updating a bound percent/label; on completion advance to Done) → run (PASS). Add Mercury-aligned CSS. + +- [ ] **Step 5: Commit** + +```bash +git add windows/welcome +git commit -m "feat(welcome): apply step wiring + Mercury styling" +``` + +--- + +## Phase 4 — Bootstrap + build integration + +### Task 12: Answer file → bootstrap account + AutoLogon + RunOnce + +**Files:** +- Modify: `windows/installer/autounattend/autounattend.xml` + +- [ ] **Step 1: Replace the `oobeSystem` LocalAccount with a locked bootstrap + AutoLogon** + +Change the `Microsoft-Windows-Shell-Setup` (oobeSystem) component so the created `LocalAccount` is `sm-bootstrap` (Administrators), and add: +```xml + + true + 1 + sm-bootstrap + bootstrap-OneTime!true</PlainText></Password> +</AutoLogon> +<FirstLogonCommands> + <SynchronousCommand wcm:action="add"><Order>1</Order> + <CommandLine>cmd /c "C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe"</CommandLine> + <Description>SilverOS Welcome</Description> + </SynchronousCommand> +</FirstLogonCommands> +``` +*(The bootstrap password is one-time and the account self-destructs at end of onboarding; document that clearly. Keep `HideOnlineAccountScreens`/`HideLocalAccountScreen` so OOBE still auto-completes into the bootstrap session.)* + +- [ ] **Step 2:** Validate XML: `[xml](Get-Content windows\installer\autounattend\autounattend.xml -Raw)` → no error. + +- [ ] **Step 3: Commit** + +```bash +git add windows/installer/autounattend/autounattend.xml +git commit -m "feat(welcome): bootstrap auto-login launches the Welcome app" +``` + +### Task 13: SetupComplete stops applying hardening (Welcome owns it) + +**Files:** +- Modify: `windows/installer/oem/SetupComplete.cmd` + +- [ ] **Step 1: Gate the §A–H run behind the absence of the Welcome app** + +Replace the hardening invocation so that, when `WELCOME_ENABLED`, `SetupComplete.cmd` only logs "deferred to SilverOS Welcome" and does NOT run `Invoke-Hardening.ps1` (the Welcome app runs the flavour's subset). Keep the direct-hardening path when Welcome is disabled. +```bat +if exist "C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe" ( + echo [%DATE% %TIME%] hardening deferred to SilverOS Welcome >> "%LOG%" +) else ( + powershell -NoProfile -ExecutionPolicy Bypass -File "%HARD%\Invoke-Hardening.ps1" >> "%LOG%" 2>&1 +) +``` + +- [ ] **Step 2:** Commit: +```bash +git add windows/installer/oem/SetupComplete.cmd +git commit -m "feat(welcome): SetupComplete defers hardening to Welcome when present" +``` + +### Task 14: build.ps1 publishes + bakes the Welcome app and flavours + +**Files:** +- Modify: `windows/installer/build.ps1` + +- [ ] **Step 1: Add a stage (between ServiceWim and InjectUnattend) that publishes + bakes the app** + +Add an `Invoke-StageWelcome` that, when `$env:SILVERMETAL_WELCOME_ENABLED -ne '0'`: +```powershell +function Invoke-StageWelcome { + Write-Stage 'Stage 3c: stage SilverOS Welcome app + flavours' + $proj = Join-Path $WindowsDir 'welcome\src\SilverOS.Welcome.App' + $out = Join-Path $WorkDir 'welcome-publish' + & dotnet publish $proj -c Release -r win-x64 --self-contained true -o $out + if ($LASTEXITCODE -ne 0) { throw 'Welcome publish failed' } + # mount install.wim is already serviced/dismounted; re-mount to inject app, OR + # inject during Invoke-ServiceWim. SIMPLEST: stage into the WIM in ServiceWim. + Copy-Item "$out\*" (Join-Path $mount 'Program Files\SilverOS\Welcome') -Recurse -Force + Copy-Item (Join-Path $WindowsDir 'flavours\*.json') (Join-Path $mount 'Program Files\SilverOS\Welcome\flavours') -Force +} +``` +*Implementation note:* the cleanest is to fold this copy into `Invoke-ServiceWim` (while `install.wim` is mounted) — do the `dotnet publish` first, then copy into `$mount\Program Files\SilverOS\Welcome` alongside the existing hardening-payload copy. Refactor `Invoke-ServiceWim` to call a `Copy-WelcomePayload` helper. Keep the §A–H modules staged too (Welcome runs them). + +- [ ] **Step 2:** Parse-check `build.ps1`; commit: +```bash +git add windows/installer/build.ps1 +git commit -m "feat(welcome): build bakes the published Welcome app + flavours into the image" +``` + +### Task 15: CI builds + tests the Welcome solution + +**Files:** +- Modify: `.gitea/workflows/build-iso-windows.yaml` + +- [ ] **Step 1: Add a step (before the ISO build) that builds + tests the solution** + +```yaml + - name: Build + test SilverOS Welcome + shell: pwsh + run: | + dotnet test windows/welcome/SilverOS.Welcome.sln -c Release +``` +*(Requires the .NET 9 SDK + MAUI workload on the runner: `dotnet workload install maui` in an ensure-step, mirroring the ADK ensure-step.)* + +- [ ] **Step 2:** Commit: +```bash +git add .gitea/workflows/build-iso-windows.yaml +git commit -m "ci(welcome): build + test the Welcome solution before the ISO build" +``` + +--- + +## Phase 5 — End-to-end VM validation + +### Task 16: Full VM run via the SLAB01 harness + +**Files:** none (operational), using the VM-test approach proven in this repo's history. + +- [ ] **Step 1:** Build the ISO (CI green) with `WELCOME_ENABLED` on; transfer to SLAB01; reset VM 102 clean; boot (legacy setup, 1 Alt+N) → OOBE auto → **bootstrap auto-login launches the Welcome app**. +- [ ] **Step 2:** Drive the wizard (or scripted sendkeys): pick Daily-Driver, create `alice`/password + admin password + BitLocker PIN, apply. +- [ ] **Step 3:** After reboot, offline-mount the disk (`qemu-nbd -r` + `mount -t ntfs3 -o ro`) and assert: + - first-boot/apply log shows the Daily-Driver module subset ran; + - `alice` exists and is **NOT** in Administrators; `SilverOS Admin` exists and IS; + - `sm-bootstrap` is **gone**; AutoAdminLogon = 0; + - `verify-report.json` present. +- [ ] **Step 4:** Document the result in `welcome-app-spec.md` (a "Validated" note) and commit. + +--- + +## Notes / risks (carry into execution) + +- **MAUI workload + WebView2 on the runner and in the image** — confirm `dotnet workload install maui` on the runner and that WebView2 Evergreen is present on IoT Enterprise LTSC (else bundle the fixed-version runtime in Task 14). +- **Kiosk lock-down** of the bootstrap session is NOT in v1 tasks above (the wizard runs as a normal app under `sm-bootstrap`). Add a hardening pass (disable Explorer shell escape) before shipping — tracked as a follow-up, not a v1 blocker for the VM test. +- **Stack installs remain stubs** — the `appSet` is recorded/logged but `08-stack-install.ps1` stays a no-op until native Windows Stack builds exist; the app-set install path is wired but installs nothing real yet. +- **Apply failure UX** — `ApplyService` already guarantees bootstrap teardown runs only after success; the `ApplyStep` UI must surface failures and offer retry (covered in Task 11 implementation).