From e4241f7f598221c85f71addb88f0207a5c3abe5b Mon Sep 17 00:00:00 2001 From: sysadmin Date: Tue, 9 Jun 2026 13:59:29 +0100 Subject: [PATCH] docs(windows): first-boot branding implementation plan Phased TDD plan: A (branding module, hardware-free), B (kiosk: Shell Launcher v2 + Keyboard Filter), C (MAUI fullscreen glass presentation), D (build integration + VM e2e). Bite-sized tasks with complete code. Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-09-first-boot-branding.md | 1034 +++++++++++++++++ 1 file changed, 1034 insertions(+) create mode 100644 windows/docs/superpowers/plans/2026-06-09-first-boot-branding.md diff --git a/windows/docs/superpowers/plans/2026-06-09-first-boot-branding.md b/windows/docs/superpowers/plans/2026-06-09-first-boot-branding.md new file mode 100644 index 0000000..0b1eb23 --- /dev/null +++ b/windows/docs/superpowers/plans/2026-06-09-first-boot-branding.md @@ -0,0 +1,1034 @@ +# SilverMetal Windows — First-Boot Experience & Branding 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:** Bake SilverMetal Windows' four branding layers declaratively into the WIM, lock the one-time onboarding session into a hardened kiosk, and present the Welcome app as a fullscreen branded glass card. + +**Architecture:** A shared dual-mode PowerShell branding module (`windows/branding/`) writes registry + stages assets, run offline by `build.ps1`'s `Invoke-Brand` and online by self-apply. A build-only kiosk uses Shell Launcher v2 + Keyboard Filter configured at end-of-setup for the `sm-bootstrap` session. The MAUI Welcome app becomes a borderless fullscreen window with a CSS-only frosted-glass presentation. + +**Tech Stack:** PowerShell 5.1 + DISM + offline registry (`reg load`), Pester v5 tests, Windows IoT Enterprise LTSC features (Shell Launcher v2 / Keyboard Filter via WMI), .NET MAUI Blazor (WebView2) + WinUI AppWindow, CSS. + +**Spec:** [`../specs/2026-06-09-first-boot-branding-design.md`](../specs/2026-06-09-first-boot-branding-design.md) + +**Branch:** `feat/first-boot-branding` (already created off `origin/main`; spec committed at `66e7fd4`). + +**Conventions to follow (from the existing repo):** +- `build.ps1` already does offline `reg load`/`reg unload` with a `[gc]::Collect(); Start-Sleep` guard before unload — reuse that idiom. +- Hardening modules are `windows/hardening/NN-*.ps1`, `Set-StrictMode -Version Latest`, `$ErrorActionPreference='Stop'`, `Write-Stage`/`Write-Host` logging. Match that style. +- Tests live in `windows/tests/`. The repo already has `Assert-IsoStructure.ps1`. + +--- + +## Phase A — Branding module (`windows/branding/`) + +Self-contained and testable on any Windows box with Pester — **no ISO, no hardware**. Do this phase first. + +### Task A1: Branding manifest + asset folder skeleton + +**Files:** +- Create: `windows/branding/branding.manifest.json` +- Create: `windows/branding/assets/README.md` +- Create: `windows/branding/README.md` + +- [ ] **Step 1: Write the manifest (single source of truth for all branding strings)** + +`windows/branding/branding.manifest.json`: +```json +{ + "schemaVersion": 1, + "productName": "SilverMetal Windows", + "oem": { + "manufacturer": "SilverLABS", + "model": "SilverMetal Windows", + "supportUrl": "https://silverlabs.uk", + "supportHours": "24/7 community + paid SLA", + "logo": "oemlogo.bmp" + }, + "bitlocker": { + "recoveryMessage": "SilverMetal Windows. Locked out? silverlabs.uk", + "recoveryUrl": "https://silverlabs.uk" + }, + "lockScreen": { + "image": "lockscreen.jpg", + "lock": true + }, + "desktop": { + "wallpaper": "wallpaper.jpg", + "theme": "SilverMetal.theme", + "accentBgr": "00d4ff", + "darkMode": true, + "lockWallpaper": false + } +} +``` + +- [ ] **Step 2: Write the assets README (documents required placeholder assets)** + +`windows/branding/assets/README.md`: +```markdown +# Branding assets + +Placeholder void/cyan assets until the brand identity is finalised +(`shared/branding/README.md`). Replace in place; keep filenames. + +| File | Spec | Used by | +|------|------|---------| +| `oemlogo.bmp` | 120×120 24-bit BMP | OEM About logo | +| `lockscreen.jpg` | display-resolution JPG, dark | Lock/sign-in | +| `wallpaper.jpg` | display-resolution JPG, dark | Desktop | +| `SilverMetal.theme` | Windows .theme (dark + cyan accent) | Desktop theme | +``` + +- [ ] **Step 3: Write the module README** + +`windows/branding/README.md`: +```markdown +# SilverMetal Windows branding (shared, dual-mode) + +`Apply-Branding.ps1` writes the four branding layers either OFFLINE into a +mounted WIM (`-Mode Offline -MountPath `, used by `installer/build.ps1` +`Invoke-Brand`) or ONLINE onto the running system (`-Mode Online`, self-apply). + +Strings live in `branding.manifest.json`; images in `assets/`. See +`docs/superpowers/specs/2026-06-09-first-boot-branding-design.md`. + +Layers: BitLocker pre-boot recovery message, lock-screen image (locked), +desktop wallpaper+theme (changeable), OEM About. +``` + +- [ ] **Step 4: Commit** + +```bash +git add windows/branding +git commit -m "feat(branding): manifest + module skeleton for SilverMetal Windows branding" +``` + +--- + +### Task A2: Registry hive abstraction + Pester harness + +The module must write to a hive root that is either a loaded offline hive (`HKLM\SM_*`) or the live hive (`HKLM:`/default-user). Build a tiny helper that takes a **PSDrive-style root** so tests can point it at a throwaway key. + +**Files:** +- Create: `windows/branding/lib/RegistryHelpers.ps1` +- Test: `windows/tests/Branding.Tests.ps1` + +- [ ] **Step 1: Write the failing test** + +`windows/tests/Branding.Tests.ps1`: +```powershell +#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } +. "$PSScriptRoot\..\branding\lib\RegistryHelpers.ps1" + +Describe 'Set-SmRegValue' { + BeforeAll { + $script:root = 'HKCU:\Software\SilverMetalTest' + if (Test-Path $root) { Remove-Item $root -Recurse -Force } + } + AfterAll { + if (Test-Path $script:root) { Remove-Item $script:root -Recurse -Force } + } + + It 'creates the key path and writes a string value' { + Set-SmRegValue -Root $script:root -SubKey 'A\B' -Name 'Greeting' -Type String -Value 'hi' + (Get-ItemProperty "$script:root\A\B").Greeting | Should -Be 'hi' + } + + It 'writes a dword value' { + Set-SmRegValue -Root $script:root -SubKey 'A' -Name 'Flag' -Type DWord -Value 1 + (Get-ItemProperty "$script:root\A").Flag | Should -Be 1 + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1"` +Expected: FAIL — `RegistryHelpers.ps1` not found / `Set-SmRegValue` not defined. + +- [ ] **Step 3: Implement the helper** + +`windows/branding/lib/RegistryHelpers.ps1`: +```powershell +Set-StrictMode -Version Latest + +# Write a registry value under an arbitrary hive root (a live HKLM:/HKCU: path +# OR a loaded offline hive exposed as a PSDrive). Creates intermediate keys. +function Set-SmRegValue { + param( + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string]$SubKey, + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][ValidateSet('String','ExpandString','DWord','Binary')][string]$Type, + [Parameter(Mandatory)]$Value + ) + $key = Join-Path $Root $SubKey + if (-not (Test-Path $key)) { New-Item -Path $key -Force | Out-Null } + New-ItemProperty -Path $key -Name $Name -PropertyType $Type -Value $Value -Force | Out-Null +} +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1"` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add windows/branding/lib windows/tests/Branding.Tests.ps1 +git commit -m "feat(branding): registry helper + Pester harness" +``` + +--- + +### Task A3: Layer writers (pure functions over a hive root) + +Each layer is a function that writes its values under a given `SOFTWARE` root and/or default-user root. Pure registry writes — testable against throwaway keys. Asset staging is separate (Task A5). + +**Files:** +- Create: `windows/branding/lib/BrandingLayers.ps1` +- Modify: `windows/tests/Branding.Tests.ps1` (append) + +- [ ] **Step 1: Write the failing tests (append to `Branding.Tests.ps1`)** + +```powershell +. "$PSScriptRoot\..\branding\lib\BrandingLayers.ps1" + +Describe 'Branding layer writers' { + BeforeAll { + $script:sw = 'HKCU:\Software\SilverMetalTest\SW' + $script:du = 'HKCU:\Software\SilverMetalTest\DU' + $script:m = Get-Content "$PSScriptRoot\..\branding\branding.manifest.json" -Raw | ConvertFrom-Json + } + AfterAll { + Remove-Item 'HKCU:\Software\SilverMetalTest' -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'writes OEM About info' { + Set-OemInformation -SoftwareRoot $script:sw -Manifest $script:m -LogoPath 'C:\Windows\System32\oemlogo.bmp' + $k = Get-ItemProperty "$script:sw\Microsoft\Windows\CurrentVersion\OEMInformation" + $k.Manufacturer | Should -Be 'SilverLABS' + $k.Model | Should -Be 'SilverMetal Windows' + $k.SupportURL | Should -Be 'https://silverlabs.uk' + $k.Logo | Should -Be 'C:\Windows\System32\oemlogo.bmp' + } + + It 'writes a locked lock-screen image' { + Set-LockScreen -SoftwareRoot $script:sw -ImagePath 'C:\Windows\Web\Screen\SilverMetal\lockscreen.jpg' -Lock $true + (Get-ItemProperty "$script:sw\Microsoft\Windows\CurrentVersion\PersonalizationCSP").LockScreenImageStatus | Should -Be 1 + (Get-ItemProperty "$script:sw\Policies\Microsoft\Windows\Personalization").NoChangingLockScreen | Should -Be 1 + } + + It 'writes desktop wallpaper + dark mode into the default-user root' { + Set-DesktopBranding -DefaultUserRoot $script:du -Manifest $script:m -WallpaperPath 'C:\Windows\Web\Wallpaper\SilverMetal\wallpaper.jpg' + (Get-ItemProperty "$script:du\Control Panel\Desktop").WallPaper | Should -Be 'C:\Windows\Web\Wallpaper\SilverMetal\wallpaper.jpg' + (Get-ItemProperty "$script:du\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize").AppsUseLightTheme | Should -Be 0 + } + + It 'writes the BitLocker pre-boot recovery message policy' { + Set-BitLockerPreboot -SoftwareRoot $script:sw -Manifest $script:m + $k = Get-ItemProperty "$script:sw\Policies\Microsoft\FVE" + $k.UseCustomRecoveryMessage | Should -Be 1 + $k.RecoveryMessage | Should -Be 'SilverMetal Windows. Locked out? silverlabs.uk' + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1"` +Expected: FAIL — layer functions not defined. + +- [ ] **Step 3: Implement the layer writers** + +`windows/branding/lib/BrandingLayers.ps1`: +```powershell +Set-StrictMode -Version Latest +. "$PSScriptRoot\RegistryHelpers.ps1" + +function Set-OemInformation { + param([Parameter(Mandatory)][string]$SoftwareRoot,[Parameter(Mandatory)]$Manifest,[Parameter(Mandatory)][string]$LogoPath) + $sub = 'Microsoft\Windows\CurrentVersion\OEMInformation' + Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'Manufacturer' -Type String -Value $Manifest.oem.manufacturer + Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'Model' -Type String -Value $Manifest.oem.model + Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'SupportURL' -Type String -Value $Manifest.oem.supportUrl + Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'SupportHours' -Type String -Value $Manifest.oem.supportHours + Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'Logo' -Type String -Value $LogoPath +} + +function Set-LockScreen { + param([Parameter(Mandatory)][string]$SoftwareRoot,[Parameter(Mandatory)][string]$ImagePath,[bool]$Lock=$true) + # Per-device modern lock-screen image (reliable on Enterprise/IoT). + $csp = 'Microsoft\Windows\CurrentVersion\PersonalizationCSP' + Set-SmRegValue -Root $SoftwareRoot -SubKey $csp -Name 'LockScreenImagePath' -Type String -Value $ImagePath + Set-SmRegValue -Root $SoftwareRoot -SubKey $csp -Name 'LockScreenImageUrl' -Type String -Value $ImagePath + Set-SmRegValue -Root $SoftwareRoot -SubKey $csp -Name 'LockScreenImageStatus' -Type DWord -Value 1 + if ($Lock) { + $pol = 'Policies\Microsoft\Windows\Personalization' + Set-SmRegValue -Root $SoftwareRoot -SubKey $pol -Name 'LockScreenImage' -Type String -Value $ImagePath + Set-SmRegValue -Root $SoftwareRoot -SubKey $pol -Name 'NoChangingLockScreen' -Type DWord -Value 1 + } +} + +function Set-DesktopBranding { + param([Parameter(Mandatory)][string]$DefaultUserRoot,[Parameter(Mandatory)]$Manifest,[Parameter(Mandatory)][string]$WallpaperPath) + Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Control Panel\Desktop' -Name 'WallPaper' -Type String -Value $WallpaperPath + Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Control Panel\Desktop' -Name 'WallpaperStyle' -Type String -Value '10' # fill + if ($Manifest.desktop.darkMode) { + $p = 'Software\Microsoft\Windows\CurrentVersion\Themes\Personalize' + Set-SmRegValue -Root $DefaultUserRoot -SubKey $p -Name 'AppsUseLightTheme' -Type DWord -Value 0 + Set-SmRegValue -Root $DefaultUserRoot -SubKey $p -Name 'SystemUsesLightTheme' -Type DWord -Value 0 + } + # Accent (cyan). BGR DWORD from manifest hex (stored little-endian as 0x00BBGGRR). + $bgr = [Convert]::ToInt32($Manifest.desktop.accentBgr,16) + Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\DWM' -Name 'AccentColor' -Type DWord -Value $bgr + Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\DWM' -Name 'ColorizationColor' -Type DWord -Value $bgr + if (-not $Manifest.desktop.lockWallpaper) { return } + Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\CurrentVersion\Policies\ActiveDesktop' -Name 'NoChangingWallPaper' -Type DWord -Value 1 +} + +function Set-BitLockerPreboot { + param([Parameter(Mandatory)][string]$SoftwareRoot,[Parameter(Mandatory)]$Manifest) + # GPO "Configure pre-boot recovery message and URL" (ADMX VolumeEncryption). + # NOTE: only the BitLocker RECOVERY screen is customisable; the normal PIN-entry + # screen text is fixed Windows UI. Exact value names are asserted by the read-back + # test; if a name is wrong the offline-apply verify (Task A4) catches it. + $fve = 'Policies\Microsoft\FVE' + Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'UseCustomRecoveryMessage' -Type DWord -Value 1 + Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'RecoveryMessage' -Type String -Value $Manifest.bitlocker.recoveryMessage + Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'UseCustomRecoveryUrl' -Type DWord -Value 1 + Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'RecoveryUrl' -Type String -Value $Manifest.bitlocker.recoveryUrl +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1"` +Expected: PASS (all layer tests green). + +- [ ] **Step 5: Commit** + +```bash +git add windows/branding/lib/BrandingLayers.ps1 windows/tests/Branding.Tests.ps1 +git commit -m "feat(branding): OEM/lockscreen/desktop/bitlocker layer writers + tests" +``` + +--- + +### Task A4: `Apply-Branding.ps1` orchestrator (offline + online hive mounting) + +Ties the layers together and handles `reg load`/`reg unload` for offline mode and the default-user hive for online mode. + +**Files:** +- Create: `windows/branding/Apply-Branding.ps1` +- Modify: `windows/tests/Branding.Tests.ps1` (append an offline integration test against a throwaway hive file) + +- [ ] **Step 1: Write the failing offline-mode integration test (append)** + +```powershell +Describe 'Apply-Branding -Mode Offline' { + BeforeAll { + # Build a throwaway "mount" tree with empty SOFTWARE + default NTUSER hives. + $script:mount = Join-Path $env:TEMP ("sm-brandtest-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force "$script:mount\Windows\System32\config","$script:mount\Users\Default" | Out-Null + # Create two empty hive files by loading/unloading a fresh key. + foreach ($h in @("$script:mount\Windows\System32\config\SOFTWARE","$script:mount\Users\Default\NTUSER.DAT")) { + reg load 'HKLM\SM_SEED' (New-Item -ItemType File -Force $h | Select-Object -Expand FullName) 2>$null | Out-Null + } + # The above seed is best-effort; if reg can't init an empty file, the apply + # script creates the hives via reg load of the path it expects. + } + AfterAll { Remove-Item $script:mount -Recurse -Force -ErrorAction SilentlyContinue } + + It 'applies all layers into the offline SOFTWARE hive and reports success' { + $r = & "$PSScriptRoot\..\branding\Apply-Branding.ps1" -Mode Offline -MountPath $script:mount -PassThru + $r.OemApplied | Should -BeTrue + $r.LockScreenApplied | Should -BeTrue + $r.DesktopApplied | Should -BeTrue + $r.BitLockerApplied | Should -BeTrue + } +} +``` + +> Note: this test requires an **elevated** shell (`reg load` needs admin). The CI Windows runner runs elevated; document that in `windows/tests/README.md` (Task A6). + +- [ ] **Step 2: Run to verify failure** + +Run (elevated): `pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1 -Tag ''"` +Expected: FAIL — `Apply-Branding.ps1` missing. + +- [ ] **Step 3: Implement the orchestrator** + +`windows/branding/Apply-Branding.ps1`: +```powershell +#Requires -Version 5.1 +<# +.SYNOPSIS Apply SilverMetal Windows branding (4 layers), offline (WIM) or online. +.DESCRIPTION + Offline: reg-load the mounted image's SOFTWARE + default NTUSER hives, write + values, stage assets, reg-unload. Online: write live HKLM + default-user hive. + Design: ../docs/superpowers/specs/2026-06-09-first-boot-branding-design.md +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidateSet('Offline','Online')][string]$Mode, + [string]$MountPath, # required for Offline + [string]$Manifest = "$PSScriptRoot\branding.manifest.json", + [string]$AssetsDir = "$PSScriptRoot\assets", + [switch]$PassThru +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. "$PSScriptRoot\lib\BrandingLayers.ps1" +function Write-Stage { param($m) Write-Host "==> $m" -ForegroundColor Cyan } + +if ($Mode -eq 'Offline' -and -not $MountPath) { throw 'Offline mode requires -MountPath.' } +$m = Get-Content $Manifest -Raw | ConvertFrom-Json + +# Destination paths (image-relative for offline, live for online). +$winRoot = if ($Mode -eq 'Offline') { $MountPath } else { 'C:' } +$logoDest = Join-Path $winRoot 'Windows\System32\oemlogo.bmp' +$lockDir = Join-Path $winRoot 'Windows\Web\Screen\SilverMetal' +$wallDir = Join-Path $winRoot 'Windows\Web\Wallpaper\SilverMetal' +$themeDest = Join-Path $winRoot 'Windows\Resources\Themes' +$lockLive = 'C:\Windows\Web\Screen\SilverMetal\' + $m.lockScreen.image +$wallLive = 'C:\Windows\Web\Wallpaper\SilverMetal\' + $m.desktop.wallpaper +$logoLive = 'C:\Windows\System32\oemlogo.bmp' + +# --- stage assets --- +Write-Stage "Stage branding assets ($Mode)" +New-Item -ItemType Directory -Force $lockDir,$wallDir,$themeDest,(Split-Path $logoDest) | Out-Null +Copy-Item (Join-Path $AssetsDir $m.oem.logo) $logoDest -Force +Copy-Item (Join-Path $AssetsDir $m.lockScreen.image) (Join-Path $lockDir $m.lockScreen.image) -Force +Copy-Item (Join-Path $AssetsDir $m.desktop.wallpaper)(Join-Path $wallDir $m.desktop.wallpaper) -Force +Copy-Item (Join-Path $AssetsDir $m.desktop.theme) (Join-Path $themeDest $m.desktop.theme) -Force + +$result = [ordered]@{ OemApplied=$false; LockScreenApplied=$false; DesktopApplied=$false; BitLockerApplied=$false } + +function Invoke-WithHive { + param([string]$HivePath,[string]$Name,[scriptblock]$Body) + & reg load "HKLM\$Name" $HivePath | Out-Null + if ($LASTEXITCODE -ne 0) { throw "reg load $Name ($HivePath) failed" } + try { & $Body "Registry::HKEY_LOCAL_MACHINE\$Name" } + finally { [gc]::Collect(); Start-Sleep -Milliseconds 500; & reg unload "HKLM\$Name" | Out-Null } +} + +if ($Mode -eq 'Offline') { + $swHive = Join-Path $MountPath 'Windows\System32\config\SOFTWARE' + $duHive = Join-Path $MountPath 'Users\Default\NTUSER.DAT' + Invoke-WithHive $swHive 'SM_BRAND_SW' { + param($sw) + Write-Stage 'OEM About'; Set-OemInformation -SoftwareRoot $sw -Manifest $m -LogoPath $logoLive; $result.OemApplied=$true + Write-Stage 'Lock screen'; Set-LockScreen -SoftwareRoot $sw -ImagePath $lockLive -Lock:$m.lockScreen.lock; $result.LockScreenApplied=$true + Write-Stage 'BitLocker preboot';Set-BitLockerPreboot -SoftwareRoot $sw -Manifest $m; $result.BitLockerApplied=$true + } + Invoke-WithHive $duHive 'SM_BRAND_DU' { + param($du) + Write-Stage 'Desktop'; Set-DesktopBranding -DefaultUserRoot $du -Manifest $m -WallpaperPath $wallLive; $result.DesktopApplied=$true + } +} else { + Set-OemInformation -SoftwareRoot 'HKLM:\SOFTWARE' -Manifest $m -LogoPath $logoLive; $result.OemApplied=$true + Set-LockScreen -SoftwareRoot 'HKLM:\SOFTWARE' -ImagePath $lockLive -Lock:$m.lockScreen.lock; $result.LockScreenApplied=$true + Set-BitLockerPreboot -SoftwareRoot 'HKLM:\SOFTWARE' -Manifest $m; $result.BitLockerApplied=$true + Invoke-WithHive 'C:\Users\Default\NTUSER.DAT' 'SM_BRAND_DU' { + param($du) Set-DesktopBranding -DefaultUserRoot $du -Manifest $m -WallpaperPath $wallLive; $result.DesktopApplied=$true + } +} + +Write-Host 'Branding applied.' -ForegroundColor Green +if ($PassThru) { [pscustomobject]$result } +``` + +- [ ] **Step 4: Provide placeholder assets so staging succeeds** + +Create minimal placeholder files so `Copy-Item` works in tests/builds (real art lands later): +```powershell +# Generate tiny valid placeholders (run once, commit the outputs) +Add-Type -AssemblyName System.Drawing +$dir = 'windows/branding/assets' +$bmp = New-Object System.Drawing.Bitmap 120,120 +$g = [System.Drawing.Graphics]::FromImage($bmp); $g.Clear([System.Drawing.Color]::FromArgb(11,15,20)) +$bmp.Save("$dir/oemlogo.bmp", [System.Drawing.Imaging.ImageFormat]::Bmp) +$big = New-Object System.Drawing.Bitmap 1920,1200 +$g2 = [System.Drawing.Graphics]::FromImage($big); $g2.Clear([System.Drawing.Color]::FromArgb(8,15,23)) +$big.Save("$dir/lockscreen.jpg", [System.Drawing.Imaging.ImageFormat]::Jpeg) +$big.Save("$dir/wallpaper.jpg", [System.Drawing.Imaging.ImageFormat]::Jpeg) +@" +[Theme] +DisplayName=SilverMetal +[Control Panel\Desktop] +Wallpaper=%SystemRoot%\Web\Wallpaper\SilverMetal\wallpaper.jpg +WallpaperStyle=10 +[VisualStyles] +SystemMode=Dark +AppMode=Dark +"@ | Set-Content "$dir/SilverMetal.theme" -Encoding ASCII +``` + +- [ ] **Step 5: Run the full branding test suite (elevated)** + +Run: `pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1"` +Expected: PASS (helper + layers + offline integration). + +- [ ] **Step 6: Commit** + +```bash +git add windows/branding/Apply-Branding.ps1 windows/branding/assets windows/tests/Branding.Tests.ps1 +git commit -m "feat(branding): Apply-Branding orchestrator (offline/online) + placeholder assets" +``` + +--- + +### Task A5: Wire `Invoke-Brand` in `build.ps1` + +**Files:** +- Modify: `windows/installer/build.ps1:249-250` (replace the `Invoke-Brand` stub) + +- [ ] **Step 1: Replace the stub** + +Replace lines 249-250 (`function Invoke-Brand { ... deferred to M4. }`) with: +```powershell +# --- 5. Brand -------------------------------------------------------------- +# NOTE: branding edits the OFFLINE hives, so it must run while the WIM is still +# mounted. We therefore call it from inside Invoke-ServiceWim (see Step 2), and +# this stage just asserts the staged result for the SBOM/log. +function Invoke-Brand { + Write-Stage 'Stage 5: branding (applied during WIM servicing)' + Write-Host ' branding layers baked via branding\Apply-Branding.ps1 -Mode Offline' +} +``` + +- [ ] **Step 2: Call branding inside the mounted-WIM block** + +In `Invoke-ServiceWim`, after `Copy-WelcomePayload` (line 214) and before the UAC block, add: +```powershell + # Bake the four branding layers into the offline hives (must be inside the mount). + Write-Stage 'Stage 3e: bake SilverMetal branding (OEM/lockscreen/desktop/bitlocker)' + & (Join-Path $WindowsDir 'branding\Apply-Branding.ps1') -Mode Offline -MountPath $mount + if ($LASTEXITCODE -ne 0) { throw 'branding apply failed' } +``` + +- [ ] **Step 3: Lint-parse the script** + +Run: `pwsh -NoProfile -Command "[void][System.Management.Automation.Language.Parser]::ParseFile('windows/installer/build.ps1',[ref]$null,[ref]$null); 'OK'"` +Expected: `OK` + +- [ ] **Step 4: Commit** + +```bash +git add windows/installer/build.ps1 +git commit -m "feat(build): wire branding into Invoke-ServiceWim (offline hive bake)" +``` + +--- + +### Task A6: Test docs + +**Files:** +- Modify: `windows/tests/README.md` + +- [ ] **Step 1: Document the branding test + elevation requirement** + +Append to `windows/tests/README.md`: +```markdown +## Branding.Tests.ps1 + +Pester v5 unit + offline-integration tests for `windows/branding/`. +**Requires an elevated shell** (the offline-integration test uses `reg load`). +Run: `pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1"`. +``` + +- [ ] **Step 2: Commit** + +```bash +git add windows/tests/README.md +git commit -m "docs(tests): document branding test suite + elevation requirement" +``` + +**Phase A done** → branding bakes offline and is unit-tested. + +--- + +## Phase B — Hardened kiosk (build-only) + +### Task B1: `Configure-Kiosk.ps1` — Shell Launcher v2 + Keyboard Filter + +**Files:** +- Create: `windows/installer/oem/Configure-Kiosk.ps1` + +- [ ] **Step 1: Implement the kiosk configurator (runs as SYSTEM at end-of-setup)** + +`windows/installer/oem/Configure-Kiosk.ps1`: +```powershell +#Requires -Version 5.1 +<# +.SYNOPSIS Configure the one-time sm-bootstrap onboarding kiosk. +.DESCRIPTION + Runs from SetupComplete.cmd as SYSTEM, after accounts exist, before first + logon. Sets the sm-bootstrap shell to an elevating launcher for the Welcome + app (no Explorer => no taskbar/Start), turns on the Keyboard Filter for shell + hotkeys, and disables Task Manager / lock / fast-user-switch escapes. + Reverted by the Welcome app's ApplyService on wizard success. +#> +[CmdletBinding()] +param([string]$BootstrapUser='sm-bootstrap', + [string]$WelcomeExe='C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe') +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +$log='C:\Windows\Setup\Scripts\silvermetal-kiosk.log' +function Log($m){ "$(Get-Date -f s) $m" | Add-Content $log } + +# Elevating launcher: Shell Launcher runs this as the shell; it relaunches the +# Welcome app elevated (silent via the baked UAC auto-approve). +$launcher='C:\Windows\Setup\Scripts\Start-WelcomeShell.cmd' +@" +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '$WelcomeExe' -Verb RunAs" +:loop +timeout /t 3600 >nul +goto loop +"@ | Set-Content $launcher -Encoding ASCII +Log "wrote launcher $launcher" + +# --- Shell Launcher v2 (WMI bridge) --- +$cls='root\standardcimv2\embedded' +$wesl=Get-CimInstance -Namespace $cls -ClassName WESL_UserSetting -ErrorAction Stop +Invoke-CimMethod -Namespace $cls -ClassName WESL_UserSetting -MethodName SetEnabled -Arguments @{Enabled=$true} | Out-Null +# Default shell stays Explorer for everyone else. +Invoke-CimMethod -InputObject $wesl -MethodName SetDefaultShell -Arguments @{Shell='explorer.exe';DefaultAction=[uint32]0} | Out-Null +# sm-bootstrap => the elevating launcher; on exit, restart the shell (action 0). +Invoke-CimMethod -InputObject $wesl -MethodName SetCustomShell -Arguments @{ + Sid=(New-Object System.Security.Principal.NTAccount($BootstrapUser)).Translate([System.Security.Principal.SecurityIdentifier]).Value + Shell="cmd.exe /c `"$launcher`"" + DefaultAction=[uint32]0 +} | Out-Null +Log 'shell launcher configured for sm-bootstrap' + +# --- Keyboard Filter (block shell hotkeys) --- +Enable-WindowsOptionalFeature -Online -FeatureName Client-KeyboardFilter -NoRestart -ErrorAction SilentlyContinue | Out-Null +$kf='root\standardcimv2\embedded' +foreach($combo in 'Win','Win+L','Ctrl+Esc','Ctrl+Win+F','Win+R'){ + $p=Get-CimInstance -Namespace $kf -ClassName WEKF_PredefinedKey -Filter "Id='$combo'" -ErrorAction SilentlyContinue + if($p){ $p.Enabled=$true; Set-CimInstance -InputObject $p } +} +Log 'keyboard filter rules enabled' + +# --- escape policies (machine-wide; reverted at teardown) --- +$sys='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' +New-Item $sys -Force | Out-Null +Set-ItemProperty $sys -Name DisableTaskMgr -Value 1 -Type DWord +Set-ItemProperty $sys -Name DisableLockWorkstation -Value 1 -Type DWord +Set-ItemProperty $sys -Name HideFastUserSwitching -Value 1 -Type DWord +Log 'escape policies set; kiosk ready' +``` + +- [ ] **Step 2: Parse-lint** + +Run: `pwsh -NoProfile -Command "[void][System.Management.Automation.Language.Parser]::ParseFile('windows/installer/oem/Configure-Kiosk.ps1',[ref]$null,[ref]$null); 'OK'"` +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add windows/installer/oem/Configure-Kiosk.ps1 +git commit -m "feat(kiosk): Configure-Kiosk.ps1 (Shell Launcher v2 + Keyboard Filter + escapes)" +``` + +--- + +### Task B2: Enable kiosk features offline in `build.ps1` + +**Files:** +- Modify: `windows/installer/build.ps1` (`Invoke-ServiceWim`, after drivers block ~line 193) + +- [ ] **Step 1: Enable the optional features in the mounted WIM** + +After the drivers block in `Invoke-ServiceWim`, add: +```powershell + # Kiosk features (Shell Launcher v2 + Keyboard Filter) — IoT Enterprise LTSC. + if ($env:SILVERMETAL_WELCOME_ENABLED -ne '0') { + Write-Host ' enabling Shell Launcher + Keyboard Filter features' + Enable-WindowsOptionalFeature -Path $mount -FeatureName Client-EmbeddedShellLauncher -All -NoRestart | Out-Null + Enable-WindowsOptionalFeature -Path $mount -FeatureName Client-KeyboardFilter -All -NoRestart | Out-Null + } +``` + +- [ ] **Step 2: Stage `Configure-Kiosk.ps1` alongside the hardening payload** + +In the hardening-staging block (~line 210), after copying `SetupComplete.cmd`, add: +```powershell + Copy-Item (Join-Path $PSScriptRoot 'oem\Configure-Kiosk.ps1') $scripts -Force +``` + +- [ ] **Step 3: Parse-lint** (same command as Task A5 Step 3) → `OK` + +- [ ] **Step 4: Commit** + +```bash +git add windows/installer/build.ps1 +git commit -m "feat(build): enable kiosk features offline + stage Configure-Kiosk.ps1" +``` + +--- + +### Task B3: Invoke kiosk config from `SetupComplete.cmd` + +**Files:** +- Modify: `windows/installer/oem/SetupComplete.cmd` + +- [ ] **Step 1: Add the kiosk-config call (before the Welcome-present branch)** + +Insert after line 15 (`echo [...] first-boot start`): +```bat +if exist "C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe" ( + echo [%DATE% %TIME%] configuring onboarding kiosk >> "%LOG%" + powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Configure-Kiosk.ps1" >> "%LOG%" 2>&1 +) +``` + +- [ ] **Step 2: Verify the file still has the deferral branch intact** (read it; the existing if/else for hardening deferral must remain). + +Run: `pwsh -NoProfile -Command "Get-Content windows/installer/oem/SetupComplete.cmd"` +Expected: kiosk-config block present AND the original hardening-deferral if/else present. + +- [ ] **Step 3: Commit** + +```bash +git add windows/installer/oem/SetupComplete.cmd +git commit -m "feat(kiosk): configure kiosk from SetupComplete before first logon" +``` + +--- + +### Task B4: Remove the redundant `FirstLogonCommands` launch + +Shell Launcher now launches the Welcome app as the session shell, so the answer-file launch would double-launch. + +**Files:** +- Modify: `windows/installer/autounattend/autounattend.xml:122-128` + +- [ ] **Step 1: Delete the `` block** + +Remove lines 122-128 (the whole `` element) and replace the preceding comment (lines 115-121) with: +```xml + +``` + +- [ ] **Step 2: Validate the XML still parses** + +Run: `pwsh -NoProfile -Command "[xml](Get-Content windows/installer/autounattend/autounattend.xml -Raw); 'OK'"` +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add windows/installer/autounattend/autounattend.xml +git commit -m "feat(kiosk): drop FirstLogonCommands launch (Shell Launcher owns launch)" +``` + +--- + +### Task B5: Kiosk teardown in the Welcome app `ApplyService` + +The app already deletes `sm-bootstrap` + removes AutoLogon on success. Add reversal of the kiosk config so the real user gets a normal desktop. + +**Files:** +- Modify: the bootstrap/teardown service in `windows/welcome/src/SilverOS.Welcome.Core` (find the class that removes AutoLogon — likely `BootstrapService`). +- Test: existing Core test project (mirror its pattern). + +- [ ] **Step 1: Find the teardown code** + +Run: `pwsh -NoProfile -Command "Select-String -Path windows/welcome/src/**/*.cs -Pattern 'AutoAdminLogon|sm-bootstrap|DeleteUser|Bootstrap' -List | Select-Object Path"` +Expected: locates `BootstrapService` (or equivalent). Read it to match its `IProcessRunner`/registry idiom. + +- [ ] **Step 2: Write a failing test for `RevertKioskAsync` (match the project's existing test style)** + +Add to the Core test project a test asserting `BootstrapService.RevertKioskAsync()` invokes: a process that clears the `sm-bootstrap` WESL custom shell, and registry deletes for `DisableTaskMgr`/`DisableLockWorkstation`/`HideFastUserSwitching`. Use the project's existing fake `IProcessRunner` to capture invocations. (Mirror an existing `BootstrapService` test exactly — repeat its arrange/fake setup.) + +- [ ] **Step 3: Run to verify failure** → method not defined. + +- [ ] **Step 4: Implement `RevertKioskAsync`** + +Add to `BootstrapService`: +```csharp +public async Task RevertKioskAsync() +{ + // Remove sm-bootstrap custom shell + disable Shell Launcher's per-user entry. + await _runner.RunPowerShellAsync( + "$c='root\\standardcimv2\\embedded';" + + "$w=Get-CimInstance -Namespace $c -ClassName WESL_UserSetting;" + + "$sid=(New-Object System.Security.Principal.NTAccount('sm-bootstrap')).Translate([System.Security.Principal.SecurityIdentifier]).Value;" + + "Invoke-CimMethod -InputObject $w -MethodName RemoveCustomShell -Arguments @{Sid=$sid} -ErrorAction SilentlyContinue;" + + "Invoke-CimMethod -InputObject $w -MethodName SetEnabled -Arguments @{Enabled=$false} -ErrorAction SilentlyContinue"); + // Revert escape policies. + await _runner.RunPowerShellAsync( + "$s='HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System';" + + "Remove-ItemProperty $s -Name DisableTaskMgr,DisableLockWorkstation,HideFastUserSwitching -ErrorAction SilentlyContinue"); +} +``` +> If `IProcessRunner` has no `RunPowerShellAsync`, use its existing method (Step 1 told you the real signature) — adapt the two calls to it. + +- [ ] **Step 5: Call `RevertKioskAsync` from the success path** + +In `ApplyService`, in the teardown sequence (where `sm-bootstrap` is deleted / AutoLogon removed), call `await _bootstrap.RevertKioskAsync();` **before** the reboot and **before** deleting `sm-bootstrap` (the SID must still resolve). + +- [ ] **Step 6: Run tests** → PASS. Build the whole solution (`dotnet build` on the welcome `.sln`) → 0 errors. + +- [ ] **Step 7: Commit** + +```bash +git add windows/welcome +git commit -m "feat(kiosk): revert kiosk (shell launcher + escapes) on wizard success" +``` + +**Phase B done** → onboarding session is an escape-proof kiosk, reverted for the real user. + +--- + +## Phase C — First-boot presentation (MAUI Welcome app) + +### Task C1: Borderless fullscreen window + +**Files:** +- Modify: `windows/welcome/src/SilverOS.Welcome.App/App.xaml.cs` +- Create: `windows/welcome/src/SilverOS.Welcome.App/Platforms/Windows/WindowExtensions.cs` + +- [ ] **Step 1: Add a Windows-only window customizer** + +`Platforms/Windows/WindowExtensions.cs`: +```csharp +#if WINDOWS +using Microsoft.UI.Windowing; +using Microsoft.UI; +using WinRT.Interop; + +namespace SilverOS.Welcome.App; + +public static class WindowExtensions +{ + // Borderless, fullscreen, non-closable kiosk window. + public static void ApplyKioskChrome(this Microsoft.UI.Xaml.Window winuiWindow) + { + var hwnd = WindowNative.GetWindowHandle(winuiWindow); + var id = Win32Interop.GetWindowIdFromWindow(hwnd); + var appWindow = AppWindow.GetFromWindowId(id); + if (appWindow.Presenter is OverlappedPresenter p) + { + p.SetBorderAndTitleBar(false, false); + p.IsResizable = false; p.IsMaximizable = false; p.IsMinimizable = false; + } + appWindow.SetPresenter(AppWindowPresenterKind.FullScreen); + // Block the close box; the wizard exits by rebooting, not by closing. + appWindow.Closing += (s, e) => e.Cancel = true; + } +} +#endif +``` + +- [ ] **Step 2: Call it when the native window is created** + +In `App.xaml.cs`, replace `CreateWindow` with one that hooks the handler: +```csharp +protected override Window CreateWindow(IActivationState? activationState) +{ + var window = new Window(new MainPage()) { Title = "SilverMetal Windows" }; +#if WINDOWS + window.HandlerChanged += (s, e) => + { + if (window.Handler?.PlatformView is Microsoft.UI.Xaml.Window native) + native.ApplyKioskChrome(); + }; +#endif + return window; +} +``` + +- [ ] **Step 3: Build for Windows** + +Run: `dotnet build windows/welcome/src/SilverOS.Welcome.App -f net9.0-windows10.0.19041.0` +Expected: 0 errors. + +- [ ] **Step 4: Commit** + +```bash +git add windows/welcome/src/SilverOS.Welcome.App +git commit -m "feat(welcome): borderless fullscreen non-closable kiosk window" +``` + +--- + +### Task C2: Brand stylesheet (the void/cyan glass system) + +**Files:** +- Create: `windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/silvermetal.css` +- Modify: `windows/welcome/src/SilverOS.Welcome.App/wwwroot/index.html` (link the stylesheet) + +- [ ] **Step 1: Add the brand stylesheet** (ported from the approved mockups — backdrop wall + frosted glass card + step rail) + +`wwwroot/css/silvermetal.css`: +```css +:root{ + --void:#0b0f14; --ink:#e8edf5; --mid:#8fa4bc; + --accent:#00d4ff; --accent2:#00e5a0; --line:rgba(255,255,255,.08); +} +html,body{height:100%;margin:0;background:#05080c;color:var(--ink); + font-family:"Segoe UI Variable","Segoe UI",system-ui,sans-serif;overflow:hidden} +.sm-wall{position:fixed;inset:0;background: + radial-gradient(85% 75% at 28% 18%, #173247, transparent 60%), + radial-gradient(75% 75% at 82% 92%, #0f3528, transparent 55%), + linear-gradient(135deg,#080f17,#0a1612)} +.sm-wall::after{content:"SILVERMETAL";position:fixed;right:26px;bottom:18px; + font:700 12px/1 system-ui;letter-spacing:4px;color:rgba(255,255,255,.18)} +.sm-glass{position:fixed;inset:12% 16%;border-radius:18px; + background:rgba(16,22,31,.55);backdrop-filter:blur(18px); + border:1px solid rgba(255,255,255,.14); + box-shadow:0 24px 70px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.12); + display:flex;flex-direction:column;overflow:hidden;animation:sm-rise .5s ease both} +@keyframes sm-rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}} +.sm-rail{display:flex;gap:14px;padding:14px 26px;font:600 10px/1 "Cascadia Mono",Consolas,monospace; + letter-spacing:1px;color:var(--mid);border-bottom:1px solid var(--line)} +.sm-rail .on{color:var(--accent)} .sm-rail .done{color:var(--accent2)} +.sm-body{flex:1;padding:18px 30px;overflow:auto;min-height:0} +.sm-next{align-self:flex-end;margin:14px 26px;background:linear-gradient(180deg,#13b6e6,#0a93c8); + color:#001018;font-weight:700;border:0;border-radius:9px;padding:10px 22px;cursor:pointer} +``` + +- [ ] **Step 2: Link it in `index.html`** (after the existing stylesheet links) + +```html + +``` + +- [ ] **Step 3: Build** → 0 errors. Commit. + +```bash +git add windows/welcome/src/SilverOS.Welcome.App/wwwroot +git commit -m "feat(welcome): SilverMetal void/cyan glass stylesheet" +``` + +--- + +### Task C3: Apply the Hybrid shell to `MainLayout` + +**Files:** +- Modify: `windows/welcome/src/SilverOS.Welcome.App/Components/Layout/MainLayout.razor` + +- [ ] **Step 1: Replace the stock template layout with the glass shell** + +`MainLayout.razor`: +```razor +@inherits LayoutComponentBase + +
+
+
+ @Body +
+
+``` +> The step rail (`sm-rail`) and the Next button (`sm-next`) live inside the wizard step components, which render the per-step content into `@Body`. If a shared step chrome already exists, move the rail here instead — match whatever the existing step components expect (read `Components/Steps` first). + +- [ ] **Step 2: Build + run the app on the dev box (windowed sanity check is fine without the kiosk)** + +Run: `dotnet build windows/welcome/src/SilverOS.Welcome.App -f net9.0-windows10.0.19041.0` +Expected: 0 errors. (Visual verification happens in the VM e2e, Task D2.) + +- [ ] **Step 3: Commit** + +```bash +git add windows/welcome/src/SilverOS.Welcome.App/Components +git commit -m "feat(welcome): Hybrid glass-card shell in MainLayout" +``` + +--- + +### Task C4: Remove the dead default-template nav + +The stock `NavMenu` + the "About → learn.microsoft.com" top row are template cruft that the kiosk must not show. + +**Files:** +- Modify: `windows/welcome/src/SilverOS.Welcome.App/Components/Layout/MainLayout.razor` (already replaced — confirms no `` / top-row remain) +- Delete: `windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor` (if nothing else references it) + +- [ ] **Step 1: Check for other references to NavMenu** + +Run: `pwsh -NoProfile -Command "Select-String -Path windows/welcome/src/**/*.razor -Pattern 'NavMenu'"` +Expected: only the (now-removed) MainLayout — if so, delete `NavMenu.razor`. If referenced elsewhere, leave it and just ensure it's not rendered in the kiosk path. + +- [ ] **Step 2: Build** → 0 errors. **Commit.** + +```bash +git add -A windows/welcome/src/SilverOS.Welcome.App/Components +git commit -m "chore(welcome): remove stock template nav from kiosk shell" +``` + +**Phase C done** → Welcome app presents as the fullscreen branded glass card. + +--- + +## Phase D — Integration + VM end-to-end + +### Task D1: Build a test ISO with branding + kiosk enabled + +**Files:** none (operational). Build host = Windows + ADK (per `iso-builder.md`). + +- [ ] **Step 1: Run the pipeline against a licensed base ISO** + +Run (elevated, on the Windows runner / ADK box): +```powershell +windows\installer\build.ps1 -SourceIso -SkipInputVerify +``` +Expected: completes through Stage 7; `out\SilverMetal-Enhanced-Windows.iso` + `.sha256` + `.sbom.json` produced. Branding stage 3e and kiosk feature-enable log lines present. + +- [ ] **Step 2: Assert ISO structure** + +Run: `pwsh -NoProfile windows/tests/Assert-IsoStructure.ps1 -IsoPath windows/installer/out/SilverMetal-Enhanced-Windows.iso` (or the script's actual parameter). +Expected: PASS. + +--- + +### Task D2: VM boot e2e (uses the existing harness) + +**Files:** none (operational). Harness = SLAB01 VM 102 + `_stageiso.py` / `_pverun.py` / `_shot.py` (see memory `silveros-welcome-app-implementation.md`). + +- [ ] **Step 1: Stage + boot the ISO in VM 102** (per the established harness flow; eject CD after file-copy to avoid the reinstall loop). + +- [ ] **Step 2: Verify the kiosk session** — screenshot the sm-bootstrap session. Assert: + - The Welcome app fills the screen as the **glass card on the branded wall**. + - **No taskbar, no Start** is present (Shell Launcher replaced Explorer). + - Win key / Win+L / Ctrl+Alt+Del → Task Manager are blocked (try via `_pverun`/host key-send; the wizard stays foreground). + +- [ ] **Step 3: Complete the wizard** (pick a flavour, create the real user, set BitLocker PIN per existing flow). Let it reboot. + +- [ ] **Step 4: Verify the real-user desktop**: + - Logs into a **normal Explorer desktop** (taskbar back) — kiosk reverted. + - **Branded wallpaper** + dark theme + cyan accent. + - **Lock screen** shows the branded image and cannot be changed (Settings ▸ Personalization ▸ Lock screen is greyed / policy-managed). + - **Settings ▸ System ▸ About** shows Manufacturer `SilverLABS`, Model `SilverMetal Windows`, Support `https://silverlabs.uk`, logo. + +- [ ] **Step 5: Verify BitLocker recovery message** (best-effort): trigger recovery (or inspect `manage-bde -status` + the FVE policy) to confirm the custom recovery message/URL applied. If the `FVE` value names from Task A3 proved wrong, fix them now and re-run Phase A tests + rebuild. + +- [ ] **Step 6: Record evidence** — capture screenshots into `.superpowers/` (gitignored) and note results in the PR description. + +--- + +### Task D3: Finish the branch + +- [ ] **Step 1: Run the full local test suite** + +Run: `pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1"` → PASS, and `dotnet build` the welcome `.sln` → 0 errors. + +- [ ] **Step 2: Use `superpowers:finishing-a-development-branch`** to open the PR (`feat/first-boot-branding` → `main`), summarizing the three components + VM evidence. Note follow-ups: SilverOS→SilverMetal rename; final brand assets; any `FVE`/`PersonalizationCSP` value-name corrections discovered on the VM. + +--- + +## Self-review notes (author) + +- **Spec coverage**: A=branding module (§4) ✓; A5=Invoke-Brand wiring (§7.1) ✓; B=kiosk (§5, §7.2–7.4) ✓; C=presentation (§6, §7.5) ✓; D=testing (§8) ✓. Honest BitLocker limitation (§4) is carried into A3's comment + D2 Step 5. Open items (§9) are attached to the tasks that resolve them (FVE names → A3/D2; PersonalizationCSP → A3/D2; elevation route → B1; assets → A4; rename → D3). +- **Type consistency**: `Set-SmRegValue`, `Set-OemInformation`, `Set-LockScreen`, `Set-DesktopBranding`, `Set-BitLockerPreboot`, `Apply-Branding.ps1 -Mode/-MountPath/-PassThru`, `Configure-Kiosk.ps1`, `RevertKioskAsync`, `ApplyKioskChrome` are used consistently across tasks. +- **Known soft spots flagged in-place (not placeholders)**: exact `FVE` value names and `PersonalizationCSP` reliability are written concretely but guarded by read-back tests + the VM verify; `IProcessRunner` method name is resolved by reading the real class in B5 Step 1.