Files
SilverMetal/windows/welcome-app-plan.md
sysadmin dfbf1d1ec8
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 4m1s
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 <noreply@anthropic.com>
2026-06-09 01:53:41 +01:00

1084 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 §AH 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<FlavourManifest>(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<string> AppSet { get; init; } = Array.Empty<string>();
public IReadOnlyDictionary<string, JsonElement> Settings { get; init; }
= new Dictionary<string, JsonElement>();
public static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
}
public sealed record HardeningSpec
{
public IReadOnlyList<string> Modules { get; init; } = Array.Empty<string>();
public IReadOnlyDictionary<string, string> Params { get; init; }
= new Dictionary<string, string>();
}
```
- [ ] **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<FlavourValidationException>(() => 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<FlavourValidationException>(() => 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<FlavourManifest> 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<FlavourManifest> Load(string directory)
{
var list = new List<FlavourManifest>();
foreach (var file in Directory.EnumerateFiles(directory, "*.json").OrderBy(f => f))
{
FlavourManifest m;
try { m = JsonSerializer.Deserialize<FlavourManifest>(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<ProcessResult> 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<ProcessResult> 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<IProcessRunner> Ok()
{
var m = new Mock<IProcessRunner>();
m.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.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<string>(s =>
s.Contains("New-LocalUser") && s.Contains("alice")), It.IsAny<CancellationToken>()));
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("'SilverOS Admin'") && s.Contains("Administrators")), It.IsAny<CancellationToken>()));
}
[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<string>(s =>
s.Contains("Enable-BitLocker") && s.Contains("TpmAndPinProtector")), It.IsAny<CancellationToken>()));
}
[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<string>(s =>
s.Contains("AutoAdminLogon") && s.Contains("0")), It.IsAny<CancellationToken>()));
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("Remove-LocalUser") && s.Contains("sm-bootstrap")), It.IsAny<CancellationToken>()));
}
}
```
- [ ] **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<string>();
var run = new Mock<IProcessRunner>();
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string,string,CancellationToken>((_, a, _) => { if (a.Contains("Invoke-Hardening")) order.Add("modules"); })
.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>())).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 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<string>();
await sut.RunAsync(req, new Progress<ApplyProgress>(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<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");
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.RunAsync(req, new Progress<ApplyProgress>(_ => {})));
boot.Verify(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<ApplyProgress> 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<ApplyProgress> 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 `<TargetFrameworks>` 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<IProcessRunner, ProcessRunner>();
builder.Services.AddSingleton<IFlavourLoader, FlavourLoader>();
builder.Services.AddSingleton<IAccountService, AccountService>();
builder.Services.AddSingleton<IBitLockerService, BitLockerService>();
builder.Services.AddSingleton<IBootstrapService, BootstrapService>();
builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
sp.GetRequiredService<IProcessRunner>(), sp.GetRequiredService<IAccountService>(),
sp.GetRequiredService<IBitLockerService>(), sp.GetRequiredService<IBootstrapService>(), hardeningDir));
builder.Services.AddScoped<WizardState>();
```
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<FlavourStep>(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
<div class="step">
<h1>What's this device for?</h1>
<div class="flavour-grid">
@foreach (var f in Flavours)
{
<div class="flavour-card @(State.Flavour?.Id == f.Id ? "selected" : "")"
data-id="@f.Id" @onclick="() => Select(f)">
<h3>@f.Label</h3><p>@f.Description</p>
</div>
}
</div>
</div>
@code {
[Parameter] public IReadOnlyList<FlavourManifest> Flavours { get; set; } = Array.Empty<FlavourManifest>();
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<IApplyService>();
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<ApplyStep>();
await cut.InvokeAsync(() => cut.Instance.StartAsync());
apply.Verify(a => a.RunAsync(It.Is<ApplyRequest>(r => r.Username=="alice" && r.Flavour.Id=="daily-driver"),
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()), Times.Once);
}
}
```
- [ ] **Step 24:** Run (FAIL) → implement `ApplyStep` (`StartAsync` builds an `ApplyRequest` from `WizardState` with `BootstrapUser="sm-bootstrap"` and calls `IApplyService.RunAsync` with an `IProgress<ApplyProgress>` 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
<AutoLogon>
<Enabled>true</Enabled>
<LogonCount>1</LogonCount>
<Username>sm-bootstrap</Username>
<Password><Value>bootstrap-OneTime!</Value><PlainText>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 §AH 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 §AH 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).