All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 4m1s
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>
1084 lines
43 KiB
Markdown
1084 lines
43 KiB
Markdown
# 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<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 2–4:** 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 §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).
|