diff --git a/.gitea/workflows/build-iso-windows.yaml b/.gitea/workflows/build-iso-windows.yaml index 3f3d1c9..2838028 100644 --- a/.gitea/workflows/build-iso-windows.yaml +++ b/.gitea/workflows/build-iso-windows.yaml @@ -58,6 +58,28 @@ jobs: } if (-not (Test-Path $deploy)) { throw 'ADK Deployment Tools install failed.' } + - name: Ensure Windows ADK WinPE add-on + shell: pwsh + run: | + # build.ps1 (Invoke-ForceLegacySetup) calls Add-WindowsPackage with the + # WinPE_OCs cabs, which only exist if the ADK WinPE add-on is installed. + $ocs = 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Windows Preinstallation Environment\amd64\WinPE_OCs' + if (Test-Path $ocs) { + Write-Host 'ADK WinPE add-on (WinPE_OCs) already present.'; exit 0 + } + Write-Host 'Installing ADK WinPE add-on...' + # Prefer winget; fall back to the WinPE add-on web installer. + $ok = $false + try { winget install --id Microsoft.ADKPEAddon -e --accept-source-agreements --accept-package-agreements --silent; $ok = $true } catch {} + if (-not $ok) { + # NOTE: fwlink id is ADK-version-specific; update if the channel rolls. + Invoke-WebRequest 'https://go.microsoft.com/fwlink/?linkid=2289981' -OutFile "$env:TEMP\adkwinpesetup.exe" + Start-Process "$env:TEMP\adkwinpesetup.exe" -ArgumentList '/quiet','/norestart','/features','OptionId.WindowsPreinstallationEnvironment' -Wait + } + # The WinPE collector is a required, core feature of this image, so a missing + # WinPE_OCs dir is a hard build gate (fail fast with a clear message). + if (-not (Test-Path $ocs)) { throw 'ADK WinPE add-on install failed (WinPE_OCs missing)' } + - name: Setup .NET 9 SDK uses: actions/setup-dotnet@v4 with: diff --git a/windows/collector/Collector.ps1 b/windows/collector/Collector.ps1 new file mode 100644 index 0000000..1d2ae25 --- /dev/null +++ b/windows/collector/Collector.ps1 @@ -0,0 +1,255 @@ +#Requires -Version 5.1 +# SilverMetal WinPE pre-config collector (WinForms UI). Runs in WinPE under the +# ADK WinPE-NetFx + WinPE-PowerShell optional components. ASCII body only (WinPE +# PowerShell 5.1 mis-parses smart quotes / em-dashes). On Finish it generates the +# answer file and launches legacy Setup; on Cancel / error it exits 1 so the +# wrapping Start-Collector.cmd falls back to the default autounattend.xml. + +try { + Add-Type -AssemblyName System.Windows.Forms, System.Drawing + + # Validation helpers + answer-file generator live next to this script under X:\sm\. + . (Join-Path $PSScriptRoot 'Test-SmInput.ps1') + . (Join-Path $PSScriptRoot 'New-SmAnswerFile.ps1') + + # --- palette --------------------------------------------------------------- + $colBg = [Drawing.Color]::FromArgb(18, 18, 22) + $colPanel = [Drawing.Color]::FromArgb(28, 28, 34) + $colText = [Drawing.Color]::FromArgb(232, 232, 238) + $colMuted = [Drawing.Color]::FromArgb(150, 150, 162) + $colAccent = [Drawing.Color]::FromArgb(96, 200, 170) + $colError = [Drawing.Color]::FromArgb(236, 110, 110) + $colField = [Drawing.Color]::FromArgb(40, 40, 48) + + $fontBase = New-Object Drawing.Font('Segoe UI', 11) + $fontTitle = New-Object Drawing.Font('Segoe UI Semibold', 26, [Drawing.FontStyle]::Bold) + $fontLabel = New-Object Drawing.Font('Segoe UI', 10) + + # --- form ------------------------------------------------------------------ + $form = New-Object Windows.Forms.Form + $form.Text = 'SilverMetal Setup' + $form.WindowState = 'Maximized' + $form.FormBorderStyle = 'None' + $form.BackColor = $colBg + $form.ForeColor = $colText + $form.Font = $fontBase + $form.KeyPreview = $true + + # Helpers to build consistent controls. + function New-Label([string]$text, [int]$x, [int]$y, [int]$w = 220) { + $l = New-Object Windows.Forms.Label + $l.Text = $text; $l.AutoSize = $false + $l.Location = New-Object Drawing.Point($x, $y) + $l.Size = New-Object Drawing.Size($w, 24) + $l.ForeColor = $colMuted; $l.Font = $fontLabel + $form.Controls.Add($l); $l + } + function New-Field([int]$x, [int]$y, [int]$w = 360, [bool]$mask = $false) { + $t = New-Object Windows.Forms.TextBox + $t.Location = New-Object Drawing.Point($x, $y) + $t.Size = New-Object Drawing.Size($w, 28) + $t.BackColor = $colField; $t.ForeColor = $colText + $t.BorderStyle = 'FixedSingle'; $t.Font = $fontBase + if ($mask) { $t.UseSystemPasswordChar = $true } + $form.Controls.Add($t); $t + } + + # Title + subtitle (left rail). + $title = New-Object Windows.Forms.Label + $title.Text = 'SilverMetal' + $title.Font = $fontTitle; $title.ForeColor = $colAccent + $title.AutoSize = $true + $title.Location = New-Object Drawing.Point(80, 70) + $form.Controls.Add($title) + + $subtitle = New-Object Windows.Forms.Label + $subtitle.Text = 'Pre-install configuration. Set your account, machine and security options before Windows installs.' + $subtitle.Font = $fontLabel; $subtitle.ForeColor = $colMuted + $subtitle.AutoSize = $false + $subtitle.Location = New-Object Drawing.Point(82, 120) + $subtitle.Size = New-Object Drawing.Size(640, 24) + $form.Controls.Add($subtitle) + + # --- left column fields ---------------------------------------------------- + $colX = 80; $fldX = 80; $y = 180; $rowH = 64 + + New-Label 'Display name' $colX $y | Out-Null + $txtDisplay = New-Field $fldX ($y + 26) + $y += $rowH + + New-Label 'Username' $colX $y | Out-Null + $txtUser = New-Field $fldX ($y + 26) + $y += $rowH + + New-Label 'Password' $colX $y | Out-Null + $txtPass = New-Field $fldX ($y + 26) 360 $true + $y += $rowH + + New-Label 'Confirm password' $colX $y | Out-Null + $txtPassC = New-Field $fldX ($y + 26) 360 $true + $y += $rowH + + New-Label 'Computer name' $colX $y | Out-Null + $txtComputer = New-Field $fldX ($y + 26) + $y += $rowH + + # --- right column: flavour + BitLocker ------------------------------------- + $rX = 540; $rY = 180 + + $grpFlavour = New-Object Windows.Forms.GroupBox + $grpFlavour.Text = 'Flavour' + $grpFlavour.ForeColor = $colMuted + $grpFlavour.Location = New-Object Drawing.Point($rX, $rY) + $grpFlavour.Size = New-Object Drawing.Size(360, 220) + $form.Controls.Add($grpFlavour) + + # 'essentials' is the always-on baseline role (no flavour manifest), not a selectable + # flavour -- the four below are the real flavours; essentials apps ship with every one. + $flavours = @('developer', 'journalist', 'daily-driver', 'privacy-max') + $radioFlavours = @() + $fy = 32 + foreach ($f in $flavours) { + $rb = New-Object Windows.Forms.RadioButton + $rb.Text = $f; $rb.ForeColor = $colText; $rb.Font = $fontBase + $rb.Location = New-Object Drawing.Point(20, $fy) + $rb.Size = New-Object Drawing.Size(320, 28) + $rb.Tag = $f + if ($f -eq 'daily-driver') { $rb.Checked = $true } + $grpFlavour.Controls.Add($rb) + $radioFlavours += $rb + $fy += 36 + } + + $chkBitLocker = New-Object Windows.Forms.CheckBox + $chkBitLocker.Text = 'Enable BitLocker (TPM + PIN)' + $chkBitLocker.ForeColor = $colText; $chkBitLocker.Font = $fontBase + $chkBitLocker.AutoSize = $true + $chkBitLocker.Location = New-Object Drawing.Point($rX, ($rY + 240)) + $form.Controls.Add($chkBitLocker) + + $lblPin = New-Label 'BitLocker PIN' $rX ($rY + 280) | Out-Null + $lblPin = $form.Controls[$form.Controls.Count - 1] + $txtPin = New-Field ($rX) ($rY + 306) 360 $true + $lblPinC = New-Label 'Confirm PIN' $rX ($rY + 348) | Out-Null + $lblPinC = $form.Controls[$form.Controls.Count - 1] + $txtPinC = New-Field ($rX) ($rY + 374) 360 $true + + # PIN fields disabled until BitLocker is checked. + $setPinEnabled = { + $on = $chkBitLocker.Checked + $txtPin.Enabled = $on; $txtPinC.Enabled = $on + $fg = if ($on) { $colMuted } else { [Drawing.Color]::FromArgb(90, 90, 98) } + $lblPin.ForeColor = $fg; $lblPinC.ForeColor = $fg + } + $chkBitLocker.Add_CheckedChanged($setPinEnabled) + & $setPinEnabled + + # --- status line ----------------------------------------------------------- + $lblStatus = New-Object Windows.Forms.Label + $lblStatus.Text = '' + $lblStatus.ForeColor = $colError; $lblStatus.Font = $fontBase + $lblStatus.AutoSize = $false + $lblStatus.Location = New-Object Drawing.Point(80, ($y + 8)) + $lblStatus.Size = New-Object Drawing.Size(820, 28) + $form.Controls.Add($lblStatus) + + # --- buttons --------------------------------------------------------------- + function Style-Button($b, $primary) { + $b.FlatStyle = 'Flat'; $b.Font = $fontBase + $b.Size = New-Object Drawing.Size(150, 40) + $b.FlatAppearance.BorderSize = 1 + if ($primary) { + $b.BackColor = $colAccent; $b.ForeColor = $colBg + $b.FlatAppearance.BorderColor = $colAccent + } else { + $b.BackColor = $colPanel; $b.ForeColor = $colText + $b.FlatAppearance.BorderColor = $colMuted + } + } + + $btnDefaults = New-Object Windows.Forms.Button + $btnDefaults.Text = 'Use defaults' + Style-Button $btnDefaults $false + $btnDefaults.Location = New-Object Drawing.Point(80, ($y + 48)) + $form.Controls.Add($btnDefaults) + + $btnCancel = New-Object Windows.Forms.Button + $btnCancel.Text = 'Cancel' + Style-Button $btnCancel $false + $btnCancel.Location = New-Object Drawing.Point(580, ($y + 48)) + $form.Controls.Add($btnCancel) + + $btnFinish = New-Object Windows.Forms.Button + $btnFinish.Text = 'Finish' + Style-Button $btnFinish $true + $btnFinish.Location = New-Object Drawing.Point(750, ($y + 48)) + $form.Controls.Add($btnFinish) + + # --- behaviour ------------------------------------------------------------- + $btnDefaults.Add_Click({ + $txtDisplay.Text = 'SilverMetal User' + $txtUser.Text = 'silver' + $txtComputer.Text = 'SILVER-PC' + # Leave passwords blank on purpose: the user must set them. + $txtPass.Text = ''; $txtPassC.Text = '' + foreach ($rb in $radioFlavours) { $rb.Checked = ($rb.Tag -eq 'daily-driver') } + $chkBitLocker.Checked = $false + $txtPin.Text = ''; $txtPinC.Text = '' + $lblStatus.ForeColor = $colMuted + $lblStatus.Text = 'Defaults applied. Set a password to continue.' + }) + + $btnCancel.Add_Click({ [Environment]::Exit(1) }) + $form.Add_KeyDown({ if ($_.KeyCode -eq 'Escape') { [Environment]::Exit(1) } }) + + $btnFinish.Add_Click({ + $lblStatus.ForeColor = $colError + + $rUser = Test-SmUsername $txtUser.Text + if (-not $rUser.Ok) { $lblStatus.Text = $rUser.Message; return } + + $rPass = Test-SmPassword $txtPass.Text $txtPassC.Text + if (-not $rPass.Ok) { $lblStatus.Text = $rPass.Message; return } + + $rComp = Test-SmComputerName $txtComputer.Text + if (-not $rComp.Ok) { $lblStatus.Text = $rComp.Message; return } + + $blEnabled = $chkBitLocker.Checked + $pin = '' + if ($blEnabled) { + $rPin = Test-SmPin $txtPin.Text $txtPinC.Text + if (-not $rPin.Ok) { $lblStatus.Text = $rPin.Message; return } + $pin = $txtPin.Text + } + + $flavour = 'daily-driver' + foreach ($rb in $radioFlavours) { if ($rb.Checked) { $flavour = [string]$rb.Tag } } + + $display = if ([string]::IsNullOrWhiteSpace($txtDisplay.Text)) { $txtUser.Text } else { $txtDisplay.Text } + + $lblStatus.ForeColor = $colAccent + $lblStatus.Text = 'Generating answer file and starting Windows Setup...' + $form.Refresh() + + $xml = New-SmAnswerFile -DisplayName $display -Username $txtUser.Text -Password $txtPass.Text ` + -ComputerName $txtComputer.Text -Flavour $flavour ` + -BitLockerEnable $blEnabled -BitLockerPin $pin + + Set-Content -Path 'X:\sm\unattend.generated.xml' -Value $xml -Encoding UTF8 + + try { [void][xml](Get-Content 'X:\sm\unattend.generated.xml' -Raw) } + catch { [Environment]::Exit(1) } # bad XML -> fall back to default answer file + + $setup = if (Test-Path 'X:\sources\setup.exe') { 'X:\sources\setup.exe' } else { 'X:\setup.exe' } + Start-Process -FilePath $setup -ArgumentList '/unattend:X:\sm\unattend.generated.xml' -Wait + [Environment]::Exit(0) + }) + + [void]$form.ShowDialog() + # If the form closes without Finish/Cancel handling exiting, treat as cancel. + [Environment]::Exit(1) +} +catch { + # Any failure -> exit 1 so Start-Collector.cmd falls back to the default answer file. + [Environment]::Exit(1) +} diff --git a/windows/collector/New-SmAnswerFile.ps1 b/windows/collector/New-SmAnswerFile.ps1 new file mode 100644 index 0000000..d0cc6a2 --- /dev/null +++ b/windows/collector/New-SmAnswerFile.ps1 @@ -0,0 +1,105 @@ +#Requires -Version 5.1 +# Pure generator: collected values -> Windows Setup answer-file XML string. +# No WinForms dependency (unit-testable). Mirrors the legacy autounattend.xml but with +# ONE real local-admin account (no sm-bootstrap) and an embedded preconfig.json that a +# specialize-pass command writes to C:\ProgramData\SilverMetal\preconfig.json. + +function New-SmAnswerFile { + param( + [string]$DisplayName, [string]$Username, [string]$Password, + [string]$ComputerName, + [string]$InputLocale = '0809:00000809', [string]$SystemLocale = 'en-GB', + [string]$UiLanguage = 'en-US', [string]$UserLocale = 'en-GB', + [string]$Flavour, [bool]$BitLockerEnable = $false, [string]$BitLockerPin = '' + ) + + $pre = [ordered]@{ + schemaVersion = 1 + flavour = $Flavour + bitlocker = [ordered]@{ enable = [bool]$BitLockerEnable; pin = $BitLockerPin } + apps = [ordered]@{ useFlavourDefaults = $true } + } + $preJson = ($pre | ConvertTo-Json -Depth 6 -Compress) + $preB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($preJson)) + + function Esc([string]$s) { [Security.SecurityElement]::Escape($s) } + # Escape ONLY the characters XML element content requires (& < >). Unlike + # SecurityElement::Escape this leaves single/double quotes literal, so the + # embedded command keeps a working FromBase64String('...') literal. + function EscContent([string]$s) { $s.Replace('&','&').Replace('<','<').Replace('>','>') } + $dn = Esc $DisplayName; $un = Esc $Username; $pw = Esc $Password; $cn = Esc $ComputerName + + $writePre = "powershell -NoProfile -ExecutionPolicy Bypass -Command "" New-Item -ItemType Directory -Force 'C:\ProgramData\SilverMetal' | Out-Null; [IO.File]::WriteAllBytes('C:\ProgramData\SilverMetal\preconfig.json', [Convert]::FromBase64String('$preB64')) """ + +@" + + + + + $UiLanguage + $InputLocale$SystemLocale + $UiLanguage$UserLocale + + + + OnError + + 0true + + 1EFI300 + 2MSR16 + 3Primarytrue + + + 11FAT32 + 22 + 33NTFSC + + + + + 03 + /IMAGE/INDEX1 + + true + + + + + + + 1 + $(EscContent $writePre) + Write SilverMetal preconfig + + + + + + + $InputLocale$SystemLocale + $UiLanguage$UiLanguage$UserLocale + + + truetruetruetruetrue3 + + + $unAdministrators$dn + $pwtrue</PlainText></Password> + </LocalAccount> + </LocalAccounts></UserAccounts> + <AutoLogon><Enabled>true</Enabled><LogonCount>1</LogonCount><Username>$un</Username><Password><Value>$pw</Value><PlainText>true</PlainText></Password></AutoLogon> + <ComputerName>$cn</ComputerName> + <FirstLogonCommands> + <SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"> + <Order>1</Order> + <CommandLine>cmd /c powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath 'C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe' -Verb RunAs"</CommandLine> + <Description>Launch SilverMetal toolbox (run-once)</Description> + </SynchronousCommand> + </FirstLogonCommands> + <RegisteredOwner>SilverMetal</RegisteredOwner><RegisteredOrganization>SilverLABS</RegisteredOrganization> + </component> + </settings> +</unattend> +"@ +} diff --git a/windows/collector/Start-Collector.cmd b/windows/collector/Start-Collector.cmd new file mode 100644 index 0000000..68e639a --- /dev/null +++ b/windows/collector/Start-Collector.cmd @@ -0,0 +1,15 @@ +@echo off +set "SETUP=X:\sources\setup.exe" +if not exist "%SETUP%" set "SETUP=X:\setup.exe" +REM WinPE entry point. SM_UNATTENDED=1 -> skip the UI and launch Setup with the default +REM answer file (used by CI / non-interactive builds). +if "%SM_UNATTENDED%"=="1" ( + start /wait "%SETUP%" /unattend:X:\autounattend.xml + exit /b 0 +) +powershell -NoProfile -ExecutionPolicy Bypass -File X:\sm\Collector.ps1 +if errorlevel 1 ( + REM Collector failed or was cancelled -> fall back to the default answer file so install still proceeds. + start /wait "%SETUP%" /unattend:X:\autounattend.xml +) +exit /b 0 diff --git a/windows/collector/Test-SmInput.ps1 b/windows/collector/Test-SmInput.ps1 new file mode 100644 index 0000000..a464f25 --- /dev/null +++ b/windows/collector/Test-SmInput.ps1 @@ -0,0 +1,36 @@ +#Requires -Version 5.1 +# Pure validation helpers for the WinPE collector. No WinForms dependency so they +# are unit-testable headless. Each returns [pscustomobject]@{ Ok=[bool]; Message=[string] }. + +function New-SmResult([bool]$ok, [string]$msg = '') { [pscustomobject]@{ Ok = $ok; Message = $msg } } + +$script:SmReserved = @('administrator','guest','system','defaultaccount','wdagutilityaccount','sm-bootstrap') + +function Test-SmUsername([string]$name) { + if ([string]::IsNullOrWhiteSpace($name)) { return New-SmResult $false 'Username is required.' } + if ($name.Length -gt 20) { return New-SmResult $false 'Username must be 20 characters or fewer.' } + if ($script:SmReserved -contains $name.ToLower()) { return New-SmResult $false 'That username is reserved.' } + if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]*$') { return New-SmResult $false 'Username has illegal characters.' } + New-SmResult $true +} + +function Test-SmPassword([string]$pw, [string]$confirm) { + if ([string]::IsNullOrEmpty($pw)) { return New-SmResult $false 'Password is required.' } + if ($pw.Length -lt 8) { return New-SmResult $false 'Password must be at least 8 characters.' } + if ($pw -ne $confirm) { return New-SmResult $false 'Passwords do not match.' } + New-SmResult $true +} + +function Test-SmPin([string]$pin, [string]$confirm) { + if ($pin -notmatch '^[0-9]+$') { return New-SmResult $false 'PIN must be numeric.' } + if ($pin.Length -lt 6) { return New-SmResult $false 'PIN must be at least 6 digits.' } + if ($pin -ne $confirm) { return New-SmResult $false 'PINs do not match.' } + New-SmResult $true +} + +function Test-SmComputerName([string]$name) { + if ([string]::IsNullOrWhiteSpace($name)) { return New-SmResult $false 'Computer name is required.' } + if ($name.Length -gt 15) { return New-SmResult $false 'Computer name must be 15 characters or fewer.' } + if ($name -notmatch '^[A-Za-z0-9-]+$') { return New-SmResult $false 'Computer name: letters, digits, hyphens only.' } + New-SmResult $true +} diff --git a/windows/collector/winpeshl.ini b/windows/collector/winpeshl.ini new file mode 100644 index 0000000..19f803c --- /dev/null +++ b/windows/collector/winpeshl.ini @@ -0,0 +1,2 @@ +[LaunchApps] +%SYSTEMDRIVE%\sm\Start-Collector.cmd diff --git a/windows/docs/superpowers/plans/2026-06-10-winpe-preconfig-collector.md b/windows/docs/superpowers/plans/2026-06-10-winpe-preconfig-collector.md new file mode 100644 index 0000000..f2c0cf5 --- /dev/null +++ b/windows/docs/superpowers/plans/2026-06-10-winpe-preconfig-collector.md @@ -0,0 +1,716 @@ +# WinPE Pre-Config Collector Implementation Plan (SP1) + +> **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:** A branded WinPE collector runs before Windows Setup, captures identity + install-shaping choices, generates the answer file so Setup creates the real local-admin account natively (no `sm-bootstrap`), and hands the rest (flavour, BitLocker PIN, app defaults) to a simplified run-once-then-persist first-boot toolbox. + +**Architecture:** Pure-PowerShell collector logic (validation + answer-file generation) that is unit-tested headless with Pester, wrapped by a WinForms shell launched from `boot.wim` via `winpeshl.ini`. The collector writes a generated answer file plus a base64-embedded `preconfig.json` carried into the installed OS via the `specialize` pass. The existing MAUI Welcome app is trimmed into the toolbox: account creation, `sm-bootstrap` teardown, and the heavy kiosk are removed; a `PreconfigLoader` pre-seeds state and Apply becomes `apps -> bitlocker -> done`. + +**Tech Stack:** PowerShell 5.1 + WinForms (WinPE `WinPE-NetFx`/`WinPE-PowerShell`), Pester v5, .NET 9 / C# (SilverOS.Welcome), xUnit + Moq, DISM (`Add-WindowsPackage`), `oscdimg`. + +**Spec:** [`../specs/2026-06-10-winpe-preconfig-collector-design.md`](../specs/2026-06-10-winpe-preconfig-collector-design.md) + +**Branch:** `docs/winpe-preconfig-collector` (spec committed at `59418e3`). Implementation continues on this branch (rename/PR at the end is fine). + +**Conventions:** +- Pester runs v5 under `pwsh` via a config object (the CI pattern): `New-PesterConfiguration`; `$cfg.Run.Path = '...'`; `Invoke-Pester -Configuration $cfg`. Run locally: `pwsh -NoProfile -Command "$c=New-PesterConfiguration; $c.Run.Path='windows/tests/Collector.Tests.ps1'; $c.Output.Verbosity='Detailed'; Invoke-Pester -Configuration $c"` (install Pester 5 first if absent: `Install-Module Pester -MinimumVersion 5.0 -Scope CurrentUser -Force -SkipPublisherCheck`). +- PowerShell files run by Windows PE / Windows-PowerShell 5.1 must be **ASCII / UTF-8-with-BOM** and contain **no em-dashes or smart quotes** (mojibake breaks parsing — a repeat bug in this repo). Use ASCII hyphens. +- C# tests: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release`. +- The collector logic must be split so the **testable parts have no WinForms dependency** (Pester can't load WinForms reliably headless and we don't want UI in unit tests). + +--- + +## File structure + +``` +windows/collector/ + Test-SmInput.ps1 # pure validation functions (no WinForms) -- Pester-tested + New-SmAnswerFile.ps1 # pure answer-file generator (no WinForms) -- Pester-tested + Collector.ps1 # WinForms shell: builds the form, calls the two above, launches Setup + Start-Collector.cmd # winpeshl entry point -> powershell Collector.ps1 (with fallback) + winpeshl.ini # tells WinPE to run Start-Collector.cmd instead of Setup + assets/sm-logo.png # collector branding (optional; form degrades without it) +windows/tests/ + Collector.Tests.ps1 # Pester: Test-SmInput + New-SmAnswerFile +windows/welcome/src/SilverOS.Welcome.Core/Preconfig/ + Preconfig.cs # record + JsonOptions + IPreconfigStore.cs / PreconfigStore.cs # load + clear-pin + configured-marker +windows/welcome/tests/SilverOS.Welcome.Tests/ + PreconfigTests.cs # xUnit for PreconfigStore +``` +Modified: `windows/installer/build.ps1` (Stage 2b boot.wim), `windows/welcome/src/SilverOS.Welcome.Core/Apply/{ApplyRequest,ApplyService}.cs`, `windows/welcome/src/SilverOS.Welcome.UI/Components/{Routes.razor,WizardState.cs}`, `windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor`, `windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs`, `windows/installer/autounattend/SetupComplete` flow, `windows/tests/Assert-IsoStructure.ps1`. + +--- + +## Phase A — Collector validation logic (Pester TDD) + +### Task A1: `Test-SmInput` validation functions + +**Files:** +- Create: `windows/collector/Test-SmInput.ps1` +- Create: `windows/tests/Collector.Tests.ps1` + +- [ ] **Step 1: Write the failing Pester tests** (`windows/tests/Collector.Tests.ps1`) + +```powershell +#Requires -Version 5.1 +. (Join-Path $PSScriptRoot '..\collector\Test-SmInput.ps1') + +Describe 'Test-SmUsername' { + It 'accepts a simple username' { (Test-SmUsername 'jamie').Ok | Should -BeTrue } + It 'rejects empty' { (Test-SmUsername '').Ok | Should -BeFalse } + It 'rejects reserved name' { (Test-SmUsername 'Administrator').Ok | Should -BeFalse } + It 'rejects illegal chars' { (Test-SmUsername 'a\b').Ok | Should -BeFalse } + It 'rejects > 20 chars' { (Test-SmUsername ('x'*21)).Ok| Should -BeFalse } +} + +Describe 'Test-SmPassword' { + It 'accepts matching 8+ char password' { (Test-SmPassword 'Sup3rPass!' 'Sup3rPass!').Ok | Should -BeTrue } + It 'rejects mismatch' { (Test-SmPassword 'a' 'b').Ok | Should -BeFalse } + It 'rejects < 8 chars' { (Test-SmPassword 'short' 'short').Ok | Should -BeFalse } +} + +Describe 'Test-SmPin' { + It 'accepts 6-digit matching pin' { (Test-SmPin '246810' '246810').Ok | Should -BeTrue } + It 'rejects < 6 digits' { (Test-SmPin '123' '123').Ok | Should -BeFalse } + It 'rejects non-numeric' { (Test-SmPin 'abcdef' 'abcdef').Ok | Should -BeFalse } + It 'rejects mismatch' { (Test-SmPin '246810' '999999').Ok | Should -BeFalse } +} + +Describe 'Test-SmComputerName' { + It 'accepts a valid name' { (Test-SmComputerName 'SILVER-01').Ok | Should -BeTrue } + It 'rejects empty' { (Test-SmComputerName '').Ok | Should -BeFalse } + It 'rejects > 15 chars' { (Test-SmComputerName ('A'*16)).Ok | Should -BeFalse } + It 'rejects illegal chars' { (Test-SmComputerName 'bad name').Ok | Should -BeFalse } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pwsh -NoProfile -Command "$c=New-PesterConfiguration; $c.Run.Path='windows/tests/Collector.Tests.ps1'; $c.Output.Verbosity='Detailed'; Invoke-Pester -Configuration $c"` +Expected: FAIL — `Test-SmUsername` not recognized. + +- [ ] **Step 3: Implement** (`windows/collector/Test-SmInput.ps1`, ASCII + UTF-8-BOM) + +```powershell +#Requires -Version 5.1 +# Pure validation helpers for the WinPE collector. No WinForms dependency so they +# are unit-testable headless. Each returns [pscustomobject]@{ Ok=[bool]; Message=[string] }. + +function New-SmResult([bool]$ok, [string]$msg = '') { [pscustomobject]@{ Ok = $ok; Message = $msg } } + +$script:SmReserved = @('administrator','guest','system','defaultaccount','wdagutilityaccount','sm-bootstrap') + +function Test-SmUsername([string]$name) { + if ([string]::IsNullOrWhiteSpace($name)) { return New-SmResult $false 'Username is required.' } + if ($name.Length -gt 20) { return New-SmResult $false 'Username must be 20 characters or fewer.' } + if ($script:SmReserved -contains $name.ToLower()) { return New-SmResult $false 'That username is reserved.' } + if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]*$') { return New-SmResult $false 'Username has illegal characters.' } + New-SmResult $true +} + +function Test-SmPassword([string]$pw, [string]$confirm) { + if ([string]::IsNullOrEmpty($pw)) { return New-SmResult $false 'Password is required.' } + if ($pw.Length -lt 8) { return New-SmResult $false 'Password must be at least 8 characters.' } + if ($pw -ne $confirm) { return New-SmResult $false 'Passwords do not match.' } + New-SmResult $true +} + +function Test-SmPin([string]$pin, [string]$confirm) { + if ($pin -notmatch '^[0-9]+$') { return New-SmResult $false 'PIN must be numeric.' } + if ($pin.Length -lt 6) { return New-SmResult $false 'PIN must be at least 6 digits.' } + if ($pin -ne $confirm) { return New-SmResult $false 'PINs do not match.' } + New-SmResult $true +} + +function Test-SmComputerName([string]$name) { + if ([string]::IsNullOrWhiteSpace($name)) { return New-SmResult $false 'Computer name is required.' } + if ($name.Length -gt 15) { return New-SmResult $false 'Computer name must be 15 characters or fewer.' } + if ($name -notmatch '^[A-Za-z0-9-]+$') { return New-SmResult $false 'Computer name: letters, digits, hyphens only.' } + New-SmResult $true +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run the same Pester command. Expected: PASS (all `Test-Sm*` contexts green). + +- [ ] **Step 5: Commit** + +```bash +git add windows/collector/Test-SmInput.ps1 windows/tests/Collector.Tests.ps1 +git commit -m "feat(collector): WinPE input validation helpers + Pester tests" +``` + +--- + +## Phase B — Answer-file generator (Pester TDD) + +### Task B1: `New-SmAnswerFile` + +**Files:** +- Create: `windows/collector/New-SmAnswerFile.ps1` +- Modify (append): `windows/tests/Collector.Tests.ps1` + +- [ ] **Step 1: Append failing tests** to `windows/tests/Collector.Tests.ps1` + +```powershell +. (Join-Path $PSScriptRoot '..\collector\New-SmAnswerFile.ps1') + +Describe 'New-SmAnswerFile' { + $cfg = @{ + DisplayName = 'Jamie' + Username = 'jamie' + Password = 'Sup3rPass!' + ComputerName = 'SILVER-01' + InputLocale = '0809:00000809' + SystemLocale = 'en-GB' + UiLanguage = 'en-US' + UserLocale = 'en-GB' + Flavour = 'developer' + BitLockerEnable = $true + BitLockerPin = '246810' + } + $xml = New-SmAnswerFile @cfg + $doc = [xml]$xml + + It 'is valid XML' { { [xml]$xml } | Should -Not -Throw } + It 'creates the real account in Administrators' { + $xml | Should -Match '<Name>jamie</Name>' + $xml | Should -Match '<Group>Administrators</Group>' + } + It 'does NOT contain sm-bootstrap' { $xml | Should -Not -Match 'sm-bootstrap' } + It 'sets AutoLogon once as the user' { + $xml | Should -Match '<LogonCount>1</LogonCount>' + $xml | Should -Match '<Username>jamie</Username>' + } + It 'sets the computer name' { $xml | Should -Match '<ComputerName>SILVER-01</ComputerName>' } + It 'keeps WillWipeDisk for disk 0' { $xml | Should -Match '<WillWipeDisk>true</WillWipeDisk>' } + It 'embeds a base64 preconfig write in specialize' { + $xml | Should -Match 'preconfig\.json' + $xml | Should -Match 'FromBase64String' + } + It 'embedded preconfig round-trips with the flavour and pin' { + $m = [regex]::Match($xml, "FromBase64String\('([^']+)'\)") + $m.Success | Should -BeTrue + $json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($m.Groups[1].Value)) | ConvertFrom-Json + $json.flavour | Should -Be 'developer' + $json.bitlocker.pin | Should -Be '246810' + } + It 'launches the toolbox in FirstLogonCommands' { $xml | Should -Match 'SilverOS\.Welcome\.App\.exe' } +} +``` + +- [ ] **Step 2: Run to verify it fails** (same Pester command). Expected: FAIL — `New-SmAnswerFile` not defined. + +- [ ] **Step 3: Implement** (`windows/collector/New-SmAnswerFile.ps1`, ASCII + UTF-8-BOM) + +```powershell +#Requires -Version 5.1 +# Pure generator: collected values -> Windows Setup answer-file XML string. +# No WinForms dependency (unit-testable). Mirrors the legacy autounattend.xml but with +# ONE real local-admin account (no sm-bootstrap) and an embedded preconfig.json that a +# specialize-pass command writes to C:\ProgramData\SilverMetal\preconfig.json. + +function New-SmAnswerFile { + param( + [string]$DisplayName, [string]$Username, [string]$Password, + [string]$ComputerName, + [string]$InputLocale = '0809:00000809', [string]$SystemLocale = 'en-GB', + [string]$UiLanguage = 'en-US', [string]$UserLocale = 'en-GB', + [string]$Flavour, [bool]$BitLockerEnable = $false, [string]$BitLockerPin = '' + ) + + # Build the carried-forward config and base64-embed it. + $pre = [ordered]@{ + schemaVersion = 1 + flavour = $Flavour + bitlocker = [ordered]@{ enable = [bool]$BitLockerEnable; pin = $BitLockerPin } + apps = [ordered]@{ useFlavourDefaults = $true } + } + $preJson = ($pre | ConvertTo-Json -Depth 6 -Compress) + $preB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($preJson)) + + # XML-escape user-supplied strings. + function Esc([string]$s) { [Security.SecurityElement]::Escape($s) } + $dn = Esc $DisplayName; $un = Esc $Username; $pw = Esc $Password; $cn = Esc $ComputerName + + # specialize command: recreate dir + decode the embedded preconfig to C:. + $writePre = "powershell -NoProfile -ExecutionPolicy Bypass -Command "" New-Item -ItemType Directory -Force 'C:\ProgramData\SilverMetal' | Out-Null; [IO.File]::WriteAllBytes('C:\ProgramData\SilverMetal\preconfig.json', [Convert]::FromBase64String('$preB64')) """ + +@" +<?xml version="1.0" encoding="utf-8"?> +<unattend xmlns="urn:schemas-microsoft-com:unattend"> + <settings pass="windowsPE"> + <component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS"> + <SetupUILanguage><UILanguage>$UiLanguage</UILanguage></SetupUILanguage> + <InputLocale>$InputLocale</InputLocale><SystemLocale>$SystemLocale</SystemLocale> + <UILanguage>$UiLanguage</UILanguage><UserLocale>$UserLocale</UserLocale> + </component> + <component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS"> + <DiskConfiguration> + <WillShowUI>OnError</WillShowUI> + <Disk wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"> + <DiskID>0</DiskID><WillWipeDisk>true</WillWipeDisk> + <CreatePartitions> + <CreatePartition wcm:action="add"><Order>1</Order><Type>EFI</Type><Size>300</Size></CreatePartition> + <CreatePartition wcm:action="add"><Order>2</Order><Type>MSR</Type><Size>16</Size></CreatePartition> + <CreatePartition wcm:action="add"><Order>3</Order><Type>Primary</Type><Extend>true</Extend></CreatePartition> + </CreatePartitions> + <ModifyPartitions> + <ModifyPartition wcm:action="add"><Order>1</Order><PartitionID>1</PartitionID><Label>System</Label><Format>FAT32</Format></ModifyPartition> + <ModifyPartition wcm:action="add"><Order>2</Order><PartitionID>2</PartitionID></ModifyPartition> + <ModifyPartition wcm:action="add"><Order>3</Order><PartitionID>3</PartitionID><Label>Windows</Label><Format>NTFS</Format><Letter>C</Letter></ModifyPartition> + </ModifyPartitions> + </Disk> + </DiskConfiguration> + <ImageInstall><OSImage> + <InstallTo><DiskID>0</DiskID><PartitionID>3</PartitionID></InstallTo> + <InstallFrom><MetaData wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Key>/IMAGE/INDEX</Key><Value>1</Value></MetaData></InstallFrom> + </OSImage></ImageInstall> + <UserData><AcceptEula>true</AcceptEula></UserData> + </component> + </settings> + <settings pass="specialize"> + <component name="Microsoft-Windows-Deployment" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS"> + <RunSynchronous> + <RunSynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"> + <Order>1</Order> + <Path>$([Security.SecurityElement]::Escape($writePre))</Path> + <Description>Write SilverMetal preconfig</Description> + </RunSynchronousCommand> + </RunSynchronous> + </component> + </settings> + <settings pass="oobeSystem"> + <component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS"> + <InputLocale>$InputLocale</InputLocale><SystemLocale>$SystemLocale</SystemLocale> + <UILanguage>$UiLanguage</UILanguage><UILanguageFallback>$UiLanguage</UILanguageFallback><UserLocale>$UserLocale</UserLocale> + </component> + <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS"> + <OOBE><HideEULAPage>true</HideEULAPage><HideOEMRegistrationScreen>true</HideOEMRegistrationScreen><HideOnlineAccountScreens>true</HideOnlineAccountScreens><HideLocalAccountScreen>true</HideLocalAccountScreen><HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE><ProtectYourPC>3</ProtectYourPC></OOBE> + <UserAccounts><LocalAccounts> + <LocalAccount wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"> + <Name>$un</Name><Group>Administrators</Group><DisplayName>$dn</DisplayName> + <Password><Value>$pw</Value><PlainText>true</PlainText></Password> + </LocalAccount> + </LocalAccounts></UserAccounts> + <AutoLogon><Enabled>true</Enabled><LogonCount>1</LogonCount><Username>$un</Username><Password><Value>$pw</Value><PlainText>true</PlainText></Password></AutoLogon> + <ComputerName>$cn</ComputerName> + <FirstLogonCommands> + <SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"> + <Order>1</Order> + <CommandLine>cmd /c powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath 'C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe' -Verb RunAs"</CommandLine> + <Description>Launch SilverMetal toolbox (run-once)</Description> + </SynchronousCommand> + </FirstLogonCommands> + <RegisteredOwner>SilverMetal</RegisteredOwner><RegisteredOrganization>SilverLABS</RegisteredOrganization> + </component> + </settings> +</unattend> +"@ +} +``` + +> Note: `ComputerName` is set in `oobeSystem` here for simplicity; if 24H2 ignores it there, move it to a `specialize` `Microsoft-Windows-Shell-Setup` component in a follow-up — the test only asserts the element is present. + +- [ ] **Step 4: Run to verify it passes** (same Pester command). Expected: PASS (all `New-SmAnswerFile` assertions, incl. base64 round-trip). + +- [ ] **Step 5: Commit** + +```bash +git add windows/collector/New-SmAnswerFile.ps1 windows/tests/Collector.Tests.ps1 +git commit -m "feat(collector): answer-file generator (real account, no sm-bootstrap, embedded preconfig)" +``` + +--- + +## Phase C — Preconfig consumer in the toolbox (xUnit TDD) + +### Task C1: `Preconfig` record + `PreconfigStore` + +**Files:** +- Create: `windows/welcome/src/SilverOS.Welcome.Core/Preconfig/Preconfig.cs` +- Create: `windows/welcome/src/SilverOS.Welcome.Core/Preconfig/IPreconfigStore.cs` +- Create: `windows/welcome/src/SilverOS.Welcome.Core/Preconfig/PreconfigStore.cs` +- Create test: `windows/welcome/tests/SilverOS.Welcome.Tests/PreconfigTests.cs` + +- [ ] **Step 1: Write failing tests** (`PreconfigTests.cs`) + +```csharp +using System.IO; +using System.Text.Json; +using SilverOS.Welcome.Core.Preconfig; +using Xunit; + +public class PreconfigTests +{ + static string TempDir() + { + var d = Path.Combine(Path.GetTempPath(), "smpre-" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(d); + return d; + } + + const string Sample = """ + { "schemaVersion":1, "flavour":"developer", + "bitlocker":{"enable":true,"pin":"246810"}, + "apps":{"useFlavourDefaults":true} } + """; + + [Fact] + public void Loads_flavour_and_pin() + { + var dir = TempDir(); + File.WriteAllText(Path.Combine(dir, "preconfig.json"), Sample); + var p = new PreconfigStore(dir).Load(); + Assert.NotNull(p); + Assert.Equal("developer", p!.Flavour); + Assert.True(p.Bitlocker.Enable); + Assert.Equal("246810", p.Bitlocker.Pin); + Assert.True(p.Apps.UseFlavourDefaults); + } + + [Fact] + public void Missing_or_bad_file_returns_null_not_throw() + { + Assert.Null(new PreconfigStore(TempDir()).Load()); // missing + var dir = TempDir(); + File.WriteAllText(Path.Combine(dir, "preconfig.json"), "{ not json"); + Assert.Null(new PreconfigStore(dir).Load()); // corrupt + } + + [Fact] + public void ClearPin_rewrites_without_the_pin() + { + var dir = TempDir(); + File.WriteAllText(Path.Combine(dir, "preconfig.json"), Sample); + var store = new PreconfigStore(dir); + store.ClearPin(); + var reread = store.Load(); + Assert.True(string.IsNullOrEmpty(reread!.Bitlocker.Pin)); + Assert.Equal("developer", reread.Flavour); // rest preserved + } + + [Fact] + public void Configured_marker_roundtrips() + { + var dir = TempDir(); + var store = new PreconfigStore(dir); + Assert.False(store.IsConfigured()); + store.MarkConfigured(); + Assert.True(store.IsConfigured()); + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release --filter Preconfig` +Expected: FAIL — types not defined. + +- [ ] **Step 3: Implement the records + store** + +`Preconfig.cs`: +```csharp +using System.Text.Json; + +namespace SilverOS.Welcome.Core.Preconfig; + +public sealed record BitlockerConfig { public bool Enable { get; init; } public string? Pin { get; init; } } +public sealed record AppsConfig { public bool UseFlavourDefaults { get; init; } = true; public IReadOnlyList<string>? Selected { get; init; } } + +public sealed record Preconfig +{ + public int SchemaVersion { get; init; } = 1; + public string Flavour { get; init; } = ""; + public BitlockerConfig Bitlocker { get; init; } = new(); + public AppsConfig Apps { get; init; } = new(); + + public static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; +} +``` + +`IPreconfigStore.cs`: +```csharp +namespace SilverOS.Welcome.Core.Preconfig; + +public interface IPreconfigStore +{ + Preconfig? Load(); // null if missing/corrupt (fail-open) + void ClearPin(); // rewrite preconfig without the BitLocker pin + bool IsConfigured(); // configured marker present? + void MarkConfigured(); // write the configured marker +} +``` + +`PreconfigStore.cs`: +```csharp +using System.Text.Json; + +namespace SilverOS.Welcome.Core.Preconfig; + +public sealed class PreconfigStore(string dir) : IPreconfigStore +{ + private string File_ => Path.Combine(dir, "preconfig.json"); + private string Marker => Path.Combine(dir, "configured"); + + public Preconfig? Load() + { + try + { + if (!File.Exists(File_)) return null; + return JsonSerializer.Deserialize<Preconfig>(File.ReadAllText(File_), Preconfig.JsonOptions); + } + catch (JsonException) { return null; } // fail-open + } + + public void ClearPin() + { + var p = Load(); + if (p is null) return; + var stripped = p with { Bitlocker = p.Bitlocker with { Pin = null } }; + File.WriteAllText(File_, JsonSerializer.Serialize(stripped, Preconfig.JsonOptions)); + } + + public bool IsConfigured() => File.Exists(Marker); + public void MarkConfigured() { Directory.CreateDirectory(dir); File.WriteAllText(Marker, "1"); } +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release --filter Preconfig` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add windows/welcome/src/SilverOS.Welcome.Core/Preconfig windows/welcome/tests/SilverOS.Welcome.Tests/PreconfigTests.cs +git commit -m "feat(toolbox): preconfig store (load fail-open, clear-pin, configured marker)" +``` + +--- + +## Phase D — Trim the toolbox + consume preconfig + +> This phase removes account creation, `sm-bootstrap` teardown, and the heavy kiosk, and rewires Apply to `apps -> bitlocker -> done`, pre-seeded from preconfig. It touches several existing tests — update them as specified. + +### Task D1: Slim `ApplyRequest` + `ApplyService` to apps + bitlocker + +**Files:** +- Modify: `windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs` +- Modify: `windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs` +- Modify tests: `ApplyServiceTests.cs`, `ApplyServicesTests.cs`, `ApplyServiceHardeningIntegrationTests.cs`, `BootstrapServiceRevertKioskTests.cs`, `ApplyStepTests.cs` + +- [ ] **Step 1: Read the current files** (`ApplyRequest.cs`, `ApplyService.cs`) to see the exact ctor/params and progress stages. The current `ApplyService` order is hardening -> accounts -> apps -> bitlocker -> RevertKiosk -> TearDown -> done; `ApplyRequest` is `(FlavourManifest Flavour, string Username, string Password, string AdminPassword, string BitLockerPin, string BootstrapUser, IReadOnlyList<AppCatalogEntry> Apps)`. + +- [ ] **Step 2: Update the failing tests first** — change `ApplyServiceTests` ordering expectation to the new pipeline and drop account/teardown assertions. + +New `ApplyRequest.cs`: +```csharp +using SilverOS.Welcome.Core.Flavours; +using SilverOS.Welcome.Core.Apps; +namespace SilverOS.Welcome.Core.Apply; +// Toolbox model: account is created by Windows Setup (WinPE collector), and hardening +// runs from SetupComplete. Apply only installs apps + enrols BitLocker. +public sealed record ApplyRequest(FlavourManifest Flavour, string BitLockerPin, IReadOnlyList<AppCatalogEntry> Apps); +``` + +In `ApplyServiceTests.cs`, the ordering test becomes (replace the old `modules/accounts/apps/bitlocker/bootstrap` sequence): +```csharp +// Apply now: apps -> bitlocker. No accounts/hardening/teardown in the toolbox. +Assert.Equal(new[] { "apps", "bitlocker" }, order); +``` +Remove (delete) `BootstrapServiceRevertKioskTests.cs` and `ApplyServiceHardeningIntegrationTests.cs` (the kiosk revert + in-Apply hardening are gone). Update any `new ApplyRequest(...)` call sites to the 3-arg form `new ApplyRequest(flavour, "", apps)` / with a pin where relevant. + +- [ ] **Step 3: Run to verify failure** — `dotnet test ... -c Release` fails to compile (ApplyRequest arity changed). Good (red). + +- [ ] **Step 4: Implement the slimmed `ApplyService`** + +Rewrite `ApplyService` to: +```csharp +using SilverOS.Welcome.Core.Apps; +namespace SilverOS.Welcome.Core.Apply; + +public sealed class ApplyService(IProcessRunner runner, IBitLockerService bitlocker, IAppInstaller installer) : IApplyService +{ + public async Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default) + { + progress.Report(new("Installing apps", 30)); + await installer.InstallAsync(req.Apps, progress, ct); // continue-on-failure; never throws + + if (!string.IsNullOrWhiteSpace(req.BitLockerPin)) + { + progress.Report(new("Encrypting the disk", 75)); + await bitlocker.EnableAsync(req.BitLockerPin, ct); + } + progress.Report(new("Done", 100)); + } +} +``` +Delete the now-unused `IAccountService`/`AccountService`/`IBootstrapService`/`BootstrapService` (and their tests `AccountStepTests.cs` if it only tests account creation — keep if it tests the removed step UI, but that step is deleted in D2). Keep `IProcessRunner`/`ProcessRunner`/`BitLockerService`/`IAppInstaller`. + +- [ ] **Step 5: Run to verify pass** — `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release`. Fix remaining call sites until green. + +- [ ] **Step 6: Commit** + +```bash +git add windows/welcome +git commit -m "refactor(toolbox): Apply is apps+bitlocker only (account via Setup, hardening via SetupComplete)" +``` + +### Task D2: Remove Account step + pre-seed from preconfig + run-mode + +**Files:** +- Modify: `windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor` +- Delete: `windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/AccountStep.razor` (+ `AccountStepTests.cs`) +- Modify: `windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor` +- Modify: `windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs` + +- [ ] **Step 1:** Remove the Account step from `Routes.razor` (delete its case + title; renumber: Welcome, Flavour, Apps, Prefs, Apply, Done). Inject `IPreconfigStore` (registered in MauiProgram pointing at `C:\ProgramData\SilverMetal`). On init: `var pre = PreconfigStore.Load();` if non-null and `!IsConfigured()`, pre-seed `State.Flavour` (match by id from loaded flavours), seed `State.SelectedApps` from `DefaultSelectionForRole(pre.Flavour)`, set `State.BitLockerPin = pre.Bitlocker.Pin` when `pre.Bitlocker.Enable`. If `IsConfigured()`, start on a minimal **toolbox-home** view (a simple page with a "Re-run setup" button) instead of auto-advancing. + +- [ ] **Step 2:** `ApplyStep.razor` builds `new ApplyRequest(State.Flavour!, State.BitLockerPin, apps)` (3-arg). After a successful Apply: `PreconfigStore.ClearPin(); PreconfigStore.MarkConfigured();`. + +- [ ] **Step 3:** `MauiProgram.cs` — register `IPreconfigStore`: +```csharp +builder.Services.AddSingleton<IPreconfigStore>(_ => new PreconfigStore(@"C:\ProgramData\SilverMetal")); +``` +and update the `ApplyService` factory to the new 3-arg ctor `new ApplyService(runner, bitlocker, installer)` (drop accounts/bootstrap/hardeningDir). Remove the `IAccountService`/`IBootstrapService` registrations. + +- [ ] **Step 4: Build** `dotnet build windows/welcome/src/SilverOS.Welcome.App -c Release` -> 0 errors; `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release` -> green (update/delete `FlavourStepTests`/`ApplyStepTests` as needed for the new wiring; keep their real assertions). + +- [ ] **Step 5: Commit** + +```bash +git add windows/welcome +git commit -m "feat(toolbox): drop Account step, pre-seed from preconfig, run-once vs toolbox-home" +``` + +--- + +## Phase E — Build wiring + SetupComplete scrub + ISO assertions + +### Task E1: Stage the collector into boot.wim + winpeshl + +**Files:** +- Modify: `windows/installer/build.ps1` (`Invoke-ForceLegacySetup`, around lines 103-135) +- Create: `windows/collector/Start-Collector.cmd`, `windows/collector/winpeshl.ini`, `windows/collector/Collector.ps1` + +- [ ] **Step 1:** Create `Start-Collector.cmd` (ASCII): +```bat +@echo off +REM WinPE entry point. SM_UNATTENDED=1 -> skip UI and launch Setup with the default answer file (CI). +if "%SM_UNATTENDED%"=="1" ( + start /wait X:\sources\setup.exe /unattend:X:\autounattend.xml + exit /b 0 +) +powershell -NoProfile -ExecutionPolicy Bypass -File X:\sm\Collector.ps1 +if errorlevel 1 ( + REM Collector failed/cancelled -> fall back to the default answer file so install still proceeds. + start /wait X:\sources\setup.exe /unattend:X:\autounattend.xml +) +exit /b 0 +``` + +- [ ] **Step 2:** Create `winpeshl.ini` (ASCII): +```ini +[LaunchApps] +%SYSTEMDRIVE%\sm\Start-Collector.cmd +``` + +- [ ] **Step 3:** Create `Collector.ps1` — the WinForms shell. It dot-sources `Test-SmInput.ps1` + `New-SmAnswerFile.ps1` from `X:\sm\`, shows a branded full-screen form collecting {DisplayName, Username, Password+confirm, ComputerName, locale (default), flavour (radio list), BitLocker enable + PIN+confirm}, validates each with the `Test-Sm*` functions (block OK until valid), then on Finish: `$xml = New-SmAnswerFile @collected; Set-Content X:\sm\unattend.generated.xml $xml -Encoding UTF8; Start-Process X:\sources\setup.exe "/unattend:X:\sm\unattend.generated.xml" -Wait`. On Cancel: `exit 1` (Start-Collector falls back). Wrap the whole body in try/catch that `exit 1` on any error. (UI code is not unit-tested; the validated logic + generator are.) + +- [ ] **Step 4:** In `build.ps1` `Invoke-ForceLegacySetup`, after copying `autounattend.xml` into the boot mount, also: add the WinPE optional components and stage the collector. Insert inside the `try` (after line 120), before the `reg load`: +```powershell + # Add WinPE .NET + PowerShell so the collector (WinForms) can run. + $adk = 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Windows Preinstallation Environment\amd64\WinPE_OCs' + foreach ($oc in @('WinPE-WMI.cab','WinPE-NetFx.cab','WinPE-Scripting.cab','WinPE-PowerShell.cab')) { + $cab = Join-Path $adk $oc + if (Test-Path $cab) { Add-WindowsPackage -Path $bootmnt -PackagePath $cab | Out-Null } + else { Write-Warning " WinPE OC missing on runner: $cab (collector needs the ADK WinPE add-on)" } + } + # Stage the collector + winpeshl so WinPE launches it instead of Setup. + $smDir = Join-Path $bootmnt 'sm'; $null = New-Item -ItemType Directory -Force $smDir + Copy-Item (Join-Path $PSScriptRoot '..\collector\*') $smDir -Recurse -Force + Copy-Item (Join-Path $smDir 'winpeshl.ini') (Join-Path $bootmnt 'Windows\System32\winpeshl.ini') -Force +``` +Keep the existing `reg add ... CmdLine` (it is the fallback path / legacy-setup forcing). Leave the static `autounattend.xml` copy in place (default/fallback answer file). + +- [ ] **Step 5: Parse-lint** `build.ps1`, `Collector.ps1`, `Start-Collector.cmd`: +``` +pwsh -NoProfile -Command "[void][System.Management.Automation.Language.Parser]::ParseFile('C:\Staging\Source\SilverMetal\windows\installer\build.ps1',[ref]$null,[ref]$null); [void][System.Management.Automation.Language.Parser]::ParseFile('C:\Staging\Source\SilverMetal\windows\collector\Collector.ps1',[ref]$null,[ref]$null); 'ok'" +``` +Expected: `ok`. + +- [ ] **Step 6: Commit** + +```bash +git add windows/collector windows/installer/build.ps1 +git commit -m "feat(build): stage WinPE collector into boot.wim (winpeshl + WinPE-NetFx/PowerShell) with SM_UNATTENDED fallback" +``` + +### Task E2: SetupComplete Panther scrub + ISO assertions + +**Files:** +- Modify: the SetupComplete script staged by build (find it: `windows/hardening/` `SetupComplete.cmd` or where build writes `C:\Windows\Setup\Scripts\SetupComplete.cmd`) +- Modify: `windows/tests/Assert-IsoStructure.ps1` + +- [ ] **Step 1:** Add a scrub line near the end of the SetupComplete flow (after hardening, runs as SYSTEM): +```bat +del /f /q "%WINDIR%\Panther\unattend.xml" 2>nul +del /f /q "%WINDIR%\Panther\Unattend\unattend.xml" 2>nul +``` +(Find the actual SetupComplete generator in build.ps1 / hardening and append these two lines.) + +- [ ] **Step 2:** In `Assert-IsoStructure.ps1`, add boot.wim collector assertions. After the install.wim block, add a boot.wim check: +```powershell + # boot.wim must carry the WinPE collector + winpeshl. + $bootwim = "$drive\sources\boot.wim" + Assert 'boot.wim present' (Test-Path $bootwim) + if (Test-Path $bootwim) { + $bmount = Join-Path $env:TEMP ('sm-assert-boot-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force $bmount | Out-Null + Mount-WindowsImage -ImagePath $bootwim -Index 2 -Path $bmount -ReadOnly | Out-Null + try { + Assert 'collector staged in boot.wim' (Test-Path (Join-Path $bmount 'sm\Collector.ps1')) + Assert 'winpeshl.ini set' (Test-Path (Join-Path $bmount 'Windows\System32\winpeshl.ini')) + } finally { Dismount-WindowsImage -Path $bmount -Discard | Out-Null; Remove-Item $bmount -Recurse -Force -EA SilentlyContinue } + } +``` +Also assert the staged toolbox no longer ships the bootstrap teardown: `Assert 'no sm-bootstrap in answer file' (-not (Select-String -Path "$drive\autounattend.xml" -Pattern 'sm-bootstrap' -Quiet))`. + +- [ ] **Step 3: Parse-lint** Assert-IsoStructure.ps1 (same ParseFile check). Expected `ok`. + +- [ ] **Step 4: Commit** + +```bash +git add windows/tests/Assert-IsoStructure.ps1 windows/hardening +git commit -m "feat(build): scrub Panther unattend + assert collector baked into boot.wim" +``` + +--- + +## Phase F — Verify + PR + +### Task F1: Full test + parity + PR + +- [ ] **Step 1:** `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release` -> all green. +- [ ] **Step 2:** Run the collector Pester: `pwsh -NoProfile -Command "$c=New-PesterConfiguration; $c.Run.Path='windows/tests/Collector.Tests.ps1'; $c.Output.Verbosity='Detailed'; $r=Invoke-Pester -Configuration $c -PassThru; if($r.FailedCount){throw $r.FailedCount}"` -> 0 failures. +- [ ] **Step 3:** `dotnet build windows/welcome/src/SilverOS.Welcome.App -c Release` -> 0 errors. +- [ ] **Step 4:** Use `superpowers:finishing-a-development-branch` to open the PR (`docs/winpe-preconfig-collector` -> main). In the PR note the **runner prerequisite**: the CI Windows runner needs the **ADK WinPE add-on** (`WinPE_OCs` cabs) for `Add-WindowsPackage` to succeed; without it the collector isn't added and boot.wim assertions fail. The end-to-end (collector form in WinPE -> native account -> toolbox run-once) is verified on the **next VM cycle**. + +--- + +## Self-review notes (author) + +- **Spec coverage:** collector scope (§2) -> A1/E1 (fields) ; WinForms UI (§2,§4a) -> E1 Collector.ps1 ; single admin (§2,§4b) -> B1 ; toolbox run-once+persist (§2,§4d) -> D2 ; handoff generated-answer-file + base64 (§2,§5) -> B1 ; hardening canonical in SetupComplete (§2) -> D1 (Apply drops hardening) + E2 ; preconfig contract (§4c) -> C1 ; build wiring (§4e) -> E1 ; fallback/SM_UNATTENDED (§4e,§6) -> E1 Start-Collector ; error handling fail-open (§6) -> C1 (loader) + E1 (collector fallback) ; security scrub + pin-clear (§7) -> E2 + C1/D2 ; testing (§8) -> A1/B1/C1 + E2 (Assert-IsoStructure) ; phasing (§9) -> SP2/SP3 left out. +- **Placeholder scan:** none — the testable units carry full code; integration tasks carry the exact code/diffs. The one soft spot (Collector.ps1 WinForms body) is intentionally described not pasted, since UI isn't unit-tested and its logic delegates to the fully-specified `Test-Sm*`/`New-SmAnswerFile`; the executor builds the form against those contracts. +- **Type consistency:** `Test-SmUsername/Password/Pin/ComputerName` (A1) reused by Collector.ps1 (E1). `New-SmAnswerFile` param names (B1) = the hashtable Collector.ps1 splats. `Preconfig`/`BitlockerConfig`/`AppsConfig` + `PreconfigStore.Load/ClearPin/IsConfigured/MarkConfigured` (C1) used in D2. `ApplyRequest(Flavour,BitLockerPin,Apps)` (D1) used in ApplyStep (D2). `IAppInstaller.InstallAsync` unchanged. +- **Known risk flagged in-place:** ADK WinPE add-on on the runner (E1 warning + F1 PR note); `ComputerName` pass placement (B1 note). diff --git a/windows/docs/superpowers/specs/2026-06-10-winpe-preconfig-collector-design.md b/windows/docs/superpowers/specs/2026-06-10-winpe-preconfig-collector-design.md new file mode 100644 index 0000000..fd1775a --- /dev/null +++ b/windows/docs/superpowers/specs/2026-06-10-winpe-preconfig-collector-design.md @@ -0,0 +1,223 @@ +# SilverMetal WinPE Pre-Config Collector + +> **Status**: design — 2026-06-10. Approved in brainstorming. Adds a branded WinPE +> collector that runs *before* Windows Setup, captures identity + install-shaping +> choices, generates the answer file so Setup creates the real account natively +> (eliminating the `sm-bootstrap` account + its teardown), and hands the rest to a +> simplified run-once-then-persist first-boot toolbox (the existing MAUI Welcome app, +> trimmed). Builds on `windows/installer/build.ps1`, `windows/installer/autounattend/`, +> and `windows/welcome/` (the MAUI wizard). + +## 1. Goal + +Let the user pre-configure the installation **before** the unattended installer runs. +A WinPE collector gathers **identity + install-shaping** choices, writes them into a +generated answer file (native account, computer name, locale, auto-logon) plus a +carried-forward config file (flavour, BitLocker PIN, app defaults). Windows Setup then +creates the real local administrator itself — so the first-boot app no longer creates +an account or removes a bootstrap user, and becomes a **config/toolbox**: it applies the +remaining post-install config once (apps, BitLocker), shows the recovery key, then +persists as a launchable tool. + +## 2. Decisions (locked in brainstorming) + +- **Collector scope = identity + install shaping.** It owns: the user **account** + (display name, username, password), **computer name**, **locale/keyboard**, + **flavour**, and the **BitLocker** choice + PIN. The toolbox owns **apps and ongoing + config**; hardening runs headless via `SetupComplete.cmd` (see the last bullet). +- **Collector UI = PowerShell + WinForms.** WinPE cannot run the MAUI/WebView2 app (no + Edge/Chromium, no modern .NET), so the collector is a separate, branded full-screen + WinForms form. `WinPE-NetFx` + `WinPE-PowerShell` (and dependencies) are added to + `boot.wim`. +- **Single real admin account.** One local **Administrators** account that the user + defines — replacing both the ephemeral `sm-bootstrap` *and* today's "daily user + + SilverOS Admin" split. (The two-account split can return later as a flavour option; + out of scope here.) +- **Toolbox = run-once apply, then persist.** On first logon (as the real user) it + auto-runs once, applies the collected config (apps, BitLocker PIN, flavour config), + shows progress + the BitLocker recovery key, reaches Done — then stays installed as a + launchable "SilverMetal" app. The heavy kiosk lockdown (Shell Launcher / Keyboard + Filter / `sm-bootstrap` teardown) is dropped. +- **Handoff = generated answer file + embedded config (Approach 1).** Setup keeps owning + the disk. The collector generates the answer file and embeds the non-OS config as + base64 that a `specialize`-pass command writes to `C:` once it exists. +- **Hardening is canonical in SetupComplete.** Hardening runs headless from + `SetupComplete.cmd` as SYSTEM (as the answer file already intends). The toolbox Apply + focuses on **apps + BitLocker** — no duplicate hardening — so hardening is guaranteed + even if the toolbox is closed. (A read-only hardening-status view in the toolbox is SP2.) + +## 3. Architecture / flow + +``` +Boot ISO -> WinPE (boot.wim) + winpeshl.ini -> SilverMetal Collector (PowerShell + WinForms) + collects: account . computer name . locale . flavour . BitLocker PIN + -> New-SmAnswerFile -> X:\sm\unattend.generated.xml (embeds preconfig.json base64) + -> setup.exe /unattend:X:\sm\unattend.generated.xml +Windows Setup: + windowsPE pass : wipe disk0 (WillWipeDisk) -> install LTSC index 1 -> locale + specialize pass: decode embedded preconfig.json -> C:\ProgramData\SilverMetal\preconfig.json + oobeSystem pass: create REAL local admin -> AutoLogon(once) -> ComputerName + SetupComplete.cmd (SYSTEM): hardening (canonical) + scrub C:\Windows\Panther\unattend.xml +First logon (real user, auto, once): + Toolbox run-once -> read preconfig -> install apps + enrol BitLocker + -> show recovery key -> Done -> clear PIN -> set "configured" marker + Subsequent launches -> toolbox-home mode +``` + +## 4. Components + +### 4a. WinPE collector (`windows/collector/`) +- **Launch**: `boot.wim` `winpeshl.ini` runs `X:\sm\Start-Collector.cmd` (instead of the + default Setup shell), which calls `powershell -ExecutionPolicy Bypass -File X:\sm\Collector.ps1`. +- **UI** (`Collector.ps1`): a branded full-screen WinForms form (dark theme + SilverMetal + logo) with fields: + - Account: display name, username, password + confirm. + - Computer name. + - Locale / keyboard (defaults to today's en-GB / `0809:00000809`). + - Flavour (the existing flavours, read from a small bundled list). + - BitLocker: enable toggle + PIN + confirm (or skip). + - A **"Use defaults"** fast path that fills sensible defaults so a user can click through. +- **Validation** (`Test-SmInput.ps1`, separated from the WinForms shell so it is + unit-testable headless): username rules (non-empty, valid local-account chars, not a + reserved name), password present + confirm-match (+ minimum length), PIN numeric + + length (>= 6) + confirm-match when BitLocker enabled, computer-name rules + (<= 15 chars, valid NetBIOS charset). +- **Output**: calls `New-SmAnswerFile` then launches Setup with the generated file. + +### 4b. Answer-file generator (`windows/collector/New-SmAnswerFile.ps1`) +Pure function: collected values (a hashtable) -> answer XML string. Mirrors the current +`autounattend.xml` structure with these differences: +- **windowsPE**: keep `DiskConfiguration` (wipe disk 0, EFI/MSR/Primary) + `ImageInstall` + index 1; locale from the collector. +- **oobeSystem**: emit **one** `LocalAccount` in `Administrators` (the user's + account); `AutoLogon` Enabled with `LogonCount=1` as that user; `ComputerName`; + `Microsoft-Windows-International-Core` locale; the existing `OOBE` hide-pages block. + **No `sm-bootstrap`.** +- **specialize**: a `RunSynchronousCommand` that base64-decodes the embedded + `preconfig.json` to `C:\ProgramData\SilverMetal\preconfig.json` (creating the dir). +- **FirstLogonCommands**: launch the toolbox elevated (as today, but as the real user). +- The **scrub** runs from `SetupComplete.cmd` (SYSTEM, end of Setup — guaranteed after + account creation): it deletes `C:\Windows\Panther\unattend.xml` and the cached answer + copy. + +### 4c. `preconfig.json` contract +Written to `C:\ProgramData\SilverMetal\preconfig.json`: +```json +{ + "schemaVersion": 1, + "flavour": "developer", + "bitlocker": { "enable": true, "pin": "246810" }, + "apps": { "useFlavourDefaults": true } +} +``` +- `flavour`: the chosen flavour id (drives the toolbox's app defaults + flavour config). +- `bitlocker.pin`: consumed by the toolbox to enrol TPM+PIN, then **the field is cleared** + (rewritten without `pin`) once enrolment succeeds. +- `apps`: `useFlavourDefaults:true` (toolbox pre-checks the flavour defaults) or an + explicit `selected: ["id", ...]` list (future: collector app picking — not in SP1; SP1 + always emits `useFlavourDefaults:true`). + +### 4d. Simplified first-boot toolbox (`windows/welcome/`, trimmed) +- **Remove**: the Account step + `AccountService` account creation; `BootstrapService` + `sm-bootstrap` teardown; the heavy kiosk lockdown (`Configure-Kiosk.ps1` Keyboard + Filter / DisableTaskMgr / Shell Launcher path). Branding stays (baked offline; online + re-apply unaffected). +- **Add**: a `PreconfigLoader` that reads `C:\ProgramData\SilverMetal\preconfig.json` and + pre-seeds `WizardState` (flavour, app selection = flavour defaults, BitLocker PIN). On + **first run** (no "configured" marker) the toolbox auto-applies: apps install + + BitLocker enrol (using the preconfig PIN) + recovery-key display + Done; then it clears + the PIN from preconfig and writes a `configured` marker + (`C:\ProgramData\SilverMetal\configured`). +- **Apply pipeline** (trimmed): `apps -> bitlocker -> done`. No account step, no + hardening (SetupComplete owns it), no teardown. Idempotent / re-runnable. +- **Persist**: the app stays installed with a Start-menu shortcut "SilverMetal". + Subsequent launches (marker present) open **toolbox-home** (a simple landing page; rich + ongoing-config surfaces are SP2). +- **Run mode selection**: marker present -> toolbox-home; marker absent + preconfig + present -> first-run auto-apply; neither -> toolbox-home with flavour defaults + (fail-open). + +### 4e. `build.ps1` changes +- **Stage 2b (boot.wim servicing)**: in addition to forcing legacy Setup, add the WinPE + optional components (`WinPE-NetFx`, `WinPE-PowerShell`, and their deps `WinPE-WMI`, + `WinPE-Scripting`) via `Add-WindowsPackage`; copy `windows/collector/*` to the mounted + boot.wim at `\sm\`; write `winpeshl.ini` to launch `\sm\Start-Collector.cmd`. +- **Fallback**: keep the static `autounattend.xml` on the ISO as the **default** answer + file. The collector uses it as its template and as the fallback if cancelled. An env/marker + `SM_UNATTENDED=1` (or a build flag) makes `Start-Collector.cmd` skip the UI and launch + Setup with the static default — so **CI ISO builds remain non-interactive**. + +## 5. Data flow / handoff + +Single source of truth = the collector's captured values. From them: +- **OS-native fields** (account, computer name, locale, auto-logon) -> the generated + answer file -> applied by Setup. +- **Non-OS config** (flavour, BitLocker PIN, app defaults) -> base64-embedded in the + answer file -> written to `C:\ProgramData\SilverMetal\preconfig.json` in the + `specialize` pass -> read by the toolbox. + +No extra partition; Setup still owns disk partitioning (kept simplest + safest). + +## 6. Error handling (must never brick boot) + +- **Collector cancelled / crashes** -> launch Setup with the bundled **default** answer + file (sensible defaults) so install still proceeds. The collector wraps its UI in + try/catch and always has a path to Setup. +- **Generated XML fails validation** (schema/parse check before launch) -> use the + default answer file. +- **`preconfig.json` missing / corrupt** at first boot -> toolbox proceeds with + **flavour defaults** (fail-open, mirroring the app-catalog loader). +- **winget unavailable** -> already handled (graceful skip; PR #19). +- **BitLocker enrol fails** -> toolbox surfaces it but still reaches Done (apps/onboarding + not blocked); PIN retained in preconfig only on failure so a re-run can retry. + +## 7. Security + +- **Local account only** (no Microsoft account / no cloud key escrow) — unchanged. +- **Account password** transits the generated answer file briefly; `SetupComplete.cmd` + **scrubs `C:\Windows\Panther\unattend.xml`** (and the cached copy) after account + creation. +- **BitLocker PIN** transits `preconfig.json`; the toolbox **clears it after enrolment**. +- **Residual (documented):** plaintext secrets exist transiently in WinPE memory and on + the freshly-formatted disk until the scrub/clear runs. Acceptable for a local, + operator-present install; a future hardening pass could DPAPI-wrap the PIN or prompt it + interactively at first logon instead. + +## 8. Testing + +- **Pester — `New-SmAnswerFile`**: given inputs, the XML contains the real + `LocalAccount` in Administrators, `AutoLogon` once as that user, the `ComputerName`, + the locale, the `specialize` base64-write command, and the Panther scrub; and contains + **no `sm-bootstrap`**. Base64 round-trips back to the original `preconfig.json`. +- **Pester — `Test-SmInput`**: username/password/PIN/computer-name validation rules + (valid + each invalid case). +- **xUnit — toolbox `PreconfigLoader`**: parses a sample, fails open to flavour defaults + on missing/corrupt, clears the PIN after a simulated enrol, honours the `configured` + marker for run-mode selection. +- **`Assert-IsoStructure.ps1`**: boot.wim contains `winpeshl.ini` + `\sm\Collector.ps1`; + WinPE-NetFx present (e.g. `Get-WindowsPackage` shows the package or a marker file); the + toolbox payload no longer references `sm-bootstrap`. +- **VM e2e**: collector form renders in WinPE -> fill in account/flavour/PIN -> install -> + first logon auto-applies (apps skipped if no network, BitLocker per env) -> recovery key + shown -> Done -> relaunch shows toolbox-home. + +## 9. Scope / phasing + +- **SP1 (this spec):** WinPE collector (account, flavour, computer name, locale, BitLocker + PIN) + answer-file generation + `preconfig.json` handoff + simplified run-once-then-persist + toolbox; remove `sm-bootstrap` + account step + heavy kiosk; hardening canonical in + SetupComplete. +- **SP2 (later):** rich toolbox-home — ongoing config surfaces (re-run/extend apps, change + settings, view hardening status). +- **SP3 (later):** install-time disk target / partitioning + BitLocker pre-provisioning on + the blank drive (the deferred "maximal" collector scope). + +## 10. Out of scope (SP1) + +- App selection inside the collector (SP1 emits `useFlavourDefaults:true`; the toolbox + remains the place to pick apps). +- The two-account (daily + admin) split — SP1 is single admin. +- Disk target / partition UI; BitLocker pre-provision in WinPE. +- DPAPI-wrapping or interactive-at-first-logon secret handling (noted as a future + hardening option in §7). diff --git a/windows/installer/build.ps1 b/windows/installer/build.ps1 index 6147606..6ae6410 100644 --- a/windows/installer/build.ps1 +++ b/windows/installer/build.ps1 @@ -118,6 +118,18 @@ function Invoke-ForceLegacySetup { # unreliable when setup is launched via the CmdLine override (legacy Setup # otherwise still shows the language page). Copy-Item (Join-Path $PSScriptRoot 'autounattend\autounattend.xml') (Join-Path $bootmnt 'autounattend.xml') -Force + # Add WinPE .NET + PowerShell so the collector (WinForms) can run in WinPE. + $adk = 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Windows Preinstallation Environment\amd64\WinPE_OCs' + foreach ($oc in @('WinPE-WMI.cab','WinPE-NetFx.cab','WinPE-Scripting.cab','WinPE-PowerShell.cab')) { + $cab = Join-Path $adk $oc + if (Test-Path $cab) { Add-WindowsPackage -Path $bootmnt -PackagePath $cab | Out-Null; Write-Host " added WinPE OC: $oc" } + else { Write-Warning " WinPE OC missing on runner: $cab (collector needs the ADK WinPE add-on; boot.wim assertions will fail without it)" } + } + # Stage the collector + winpeshl so WinPE launches it instead of Setup. + $smDir = Join-Path $bootmnt 'sm'; $null = New-Item -ItemType Directory -Force $smDir + Copy-Item (Join-Path $PSScriptRoot '..\collector\*') $smDir -Recurse -Force + Copy-Item (Join-Path $smDir 'winpeshl.ini') (Join-Path $bootmnt 'Windows\System32\winpeshl.ini') -Force + Write-Host " staged collector to boot.wim \sm\ + winpeshl.ini" $setup = if (Test-Path (Join-Path $bootmnt 'sources\setup.exe')) { 'X:\sources\setup.exe' } else { 'X:\setup.exe' } $cmdline = "$setup /unattend:X:\autounattend.xml" $hive = Join-Path $bootmnt 'Windows\System32\config\SYSTEM' diff --git a/windows/installer/oem/SetupComplete.cmd b/windows/installer/oem/SetupComplete.cmd index a3a67c4..1fffef9 100644 --- a/windows/installer/oem/SetupComplete.cmd +++ b/windows/installer/oem/SetupComplete.cmd @@ -33,5 +33,10 @@ if exist "C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe" ( powershell -NoProfile -ExecutionPolicy Bypass -File "%HARD%\Invoke-Hardening.ps1" >> "%LOG%" 2>&1 ) +REM Plaintext-password hygiene: delete the cached answer file that holds the +REM local account password in clear text. Runs as SYSTEM after accounts exist. +del /f /q "%WINDIR%\Panther\unattend.xml" 2>nul +del /f /q "%WINDIR%\Panther\Unattend\unattend.xml" 2>nul + echo [%DATE% %TIME%] SilverMetal first-boot done >> "%LOG%" exit /b 0 diff --git a/windows/tests/Assert-IsoStructure.ps1 b/windows/tests/Assert-IsoStructure.ps1 index b7b749b..d8a3c43 100644 --- a/windows/tests/Assert-IsoStructure.ps1 +++ b/windows/tests/Assert-IsoStructure.ps1 @@ -56,6 +56,20 @@ try { } } finally { Dismount-WindowsImage -Path $mount -Discard | Out-Null } } + + # boot.wim must carry the WinPE collector + winpeshl (the pre-config front-end). + $bootwim = "$drive\sources\boot.wim" + Assert 'boot.wim present' (Test-Path $bootwim) + if (Test-Path $bootwim) { + $bmount = Join-Path $env:TEMP ('sm-assert-boot-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force $bmount | Out-Null + Mount-WindowsImage -ImagePath $bootwim -Index 2 -Path $bmount -ReadOnly | Out-Null + try { + Assert 'collector staged in boot.wim' (Test-Path (Join-Path $bmount 'sm\Collector.ps1')) + Assert 'winpeshl.ini set' (Test-Path (Join-Path $bmount 'Windows\System32\winpeshl.ini')) + Assert 'answer-file generator staged' (Test-Path (Join-Path $bmount 'sm\New-SmAnswerFile.ps1')) + } finally { Dismount-WindowsImage -Path $bmount -Discard | Out-Null; Remove-Item $bmount -Recurse -Force -EA SilentlyContinue } + } } finally { Dismount-DiskImage -ImagePath $IsoPath | Out-Null Remove-Item $mount -Recurse -Force -EA SilentlyContinue diff --git a/windows/tests/Collector.Tests.ps1 b/windows/tests/Collector.Tests.ps1 new file mode 100644 index 0000000..b4ef7e2 --- /dev/null +++ b/windows/tests/Collector.Tests.ps1 @@ -0,0 +1,69 @@ +#Requires -Version 5.1 +BeforeAll { + . (Join-Path $PSScriptRoot '..\collector\Test-SmInput.ps1') +} + +Describe 'Test-SmUsername' { + It 'accepts a simple username' { (Test-SmUsername 'jamie').Ok | Should -BeTrue } + It 'rejects empty' { (Test-SmUsername '').Ok | Should -BeFalse } + It 'rejects reserved name' { (Test-SmUsername 'Administrator').Ok | Should -BeFalse } + It 'rejects illegal chars' { (Test-SmUsername 'a\b').Ok | Should -BeFalse } + It 'rejects > 20 chars' { (Test-SmUsername ('x'*21)).Ok| Should -BeFalse } +} + +Describe 'Test-SmPassword' { + It 'accepts matching 8+ char password' { (Test-SmPassword 'Sup3rPass!' 'Sup3rPass!').Ok | Should -BeTrue } + It 'rejects mismatch' { (Test-SmPassword 'a' 'b').Ok | Should -BeFalse } + It 'rejects < 8 chars' { (Test-SmPassword 'short' 'short').Ok | Should -BeFalse } +} + +Describe 'Test-SmPin' { + It 'accepts 6-digit matching pin' { (Test-SmPin '246810' '246810').Ok | Should -BeTrue } + It 'rejects < 6 digits' { (Test-SmPin '123' '123').Ok | Should -BeFalse } + It 'rejects non-numeric' { (Test-SmPin 'abcdef' 'abcdef').Ok | Should -BeFalse } + It 'rejects mismatch' { (Test-SmPin '246810' '999999').Ok | Should -BeFalse } +} + +Describe 'Test-SmComputerName' { + It 'accepts a valid name' { (Test-SmComputerName 'SILVER-01').Ok | Should -BeTrue } + It 'rejects empty' { (Test-SmComputerName '').Ok | Should -BeFalse } + It 'rejects > 15 chars' { (Test-SmComputerName ('A'*16)).Ok | Should -BeFalse } + It 'rejects illegal chars' { (Test-SmComputerName 'bad name').Ok | Should -BeFalse } +} + +Describe 'New-SmAnswerFile' { + BeforeAll { + . (Join-Path $PSScriptRoot '..\collector\New-SmAnswerFile.ps1') + $cfg = @{ + DisplayName = 'Jamie'; Username = 'jamie'; Password = 'Sup3rPass!' + ComputerName = 'SILVER-01' + InputLocale = '0809:00000809'; SystemLocale = 'en-GB'; UiLanguage = 'en-US'; UserLocale = 'en-GB' + Flavour = 'developer'; BitLockerEnable = $true; BitLockerPin = '246810' + } + $script:xml = New-SmAnswerFile @cfg + } + It 'is valid XML' { { [xml]$script:xml } | Should -Not -Throw } + It 'creates the real account in Administrators' { + $script:xml | Should -Match '<Name>jamie</Name>' + $script:xml | Should -Match '<Group>Administrators</Group>' + } + It 'does NOT contain sm-bootstrap' { $script:xml | Should -Not -Match 'sm-bootstrap' } + It 'sets AutoLogon once as the user' { + $script:xml | Should -Match '<LogonCount>1</LogonCount>' + $script:xml | Should -Match '<Username>jamie</Username>' + } + It 'sets the computer name' { $script:xml | Should -Match '<ComputerName>SILVER-01</ComputerName>' } + It 'keeps WillWipeDisk for disk 0' { $script:xml | Should -Match '<WillWipeDisk>true</WillWipeDisk>' } + It 'embeds a base64 preconfig write in specialize' { + $script:xml | Should -Match 'preconfig\.json' + $script:xml | Should -Match 'FromBase64String' + } + It 'embedded preconfig round-trips with the flavour and pin' { + $m = [regex]::Match($script:xml, "FromBase64String\('([^']+)'\)") + $m.Success | Should -BeTrue + $json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($m.Groups[1].Value)) | ConvertFrom-Json + $json.flavour | Should -Be 'developer' + $json.bitlocker.pin | Should -Be '246810' + } + It 'launches the toolbox in FirstLogonCommands' { $script:xml | Should -Match 'SilverOS\.Welcome\.App\.exe' } +} diff --git a/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs b/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs index 4647f55..f713e5b 100644 --- a/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs +++ b/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using SilverOS.Welcome.Core.Apply; using SilverOS.Welcome.Core.Apps; using SilverOS.Welcome.Core.Flavours; +using SilverOS.Welcome.Core.Preconfig; using SilverOS.Welcome.App.Components; namespace SilverOS.Welcome.App; @@ -49,22 +50,16 @@ public static class MauiProgram builder.Logging.AddDebug(); #endif - var hardeningDir = @"C:\Windows\Setup\Scripts\hardening"; builder.Services.AddSingleton<IProcessRunner, ProcessRunner>(); builder.Services.AddSingleton<IFlavourLoader, FlavourLoader>(); builder.Services.AddSingleton<IAppCatalog, AppCatalog>(); - builder.Services.AddSingleton<IAccountService, AccountService>(); builder.Services.AddSingleton<IBitLockerService, BitLockerService>(); - builder.Services.AddSingleton<IBootstrapService, BootstrapService>(); var appsDir = Path.Combine(AppContext.BaseDirectory, "apps"); builder.Services.AddSingleton<IAppInstaller>(sp => new AppInstaller(sp.GetRequiredService<IProcessRunner>(), appsDir)); builder.Services.AddSingleton<IApplyService>(sp => new ApplyService( - sp.GetRequiredService<IProcessRunner>(), - sp.GetRequiredService<IAccountService>(), sp.GetRequiredService<IBitLockerService>(), - sp.GetRequiredService<IBootstrapService>(), - sp.GetRequiredService<IAppInstaller>(), - hardeningDir)); + sp.GetRequiredService<IAppInstaller>())); + builder.Services.AddSingleton<IPreconfigStore>(_ => new PreconfigStore(@"C:\ProgramData\SilverMetal")); builder.Services.AddScoped<WizardState>(); return builder.Build(); diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/AccountService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/AccountService.cs deleted file mode 100644 index 31659b9..0000000 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/AccountService.cs +++ /dev/null @@ -1,25 +0,0 @@ -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 — NOT Administrators). - 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)}'", "Daily account creation", 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'", "Admin account creation", ct); - } - - // $ErrorActionPreference='Stop' turns the (otherwise non-terminating) cmdlet errors into a - // non-zero exit so EnsureSuccess can surface them instead of silently continuing. - private async Task Ps(string script, string operation, CancellationToken ct) - { - var r = await runner.RunAsync("powershell.exe", - $"-NoProfile -ExecutionPolicy Bypass -Command \"$ErrorActionPreference='Stop'; {script}\"", ct); - r.EnsureSuccess(operation); - } - private static string Esc(string s) => s.Replace("'", "''"); -} diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs index a1c8179..5dadff1 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs @@ -1,6 +1,6 @@ -using SilverOS.Welcome.Core.Apps; using SilverOS.Welcome.Core.Flavours; +using SilverOS.Welcome.Core.Apps; namespace SilverOS.Welcome.Core.Apply; -public sealed record ApplyRequest(FlavourManifest Flavour, string Username, string Password, - string AdminPassword, string BitLockerPin, string BootstrapUser, - IReadOnlyList<AppCatalogEntry> Apps); +// Toolbox model: the account is created by Windows Setup (WinPE collector), and hardening +// runs from SetupComplete. Apply only installs apps + enrols BitLocker. +public sealed record ApplyRequest(FlavourManifest Flavour, string BitLockerPin, IReadOnlyList<AppCatalogEntry> Apps); diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs index 17727d2..a77dad2 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs @@ -1,45 +1,21 @@ -using System.Text.Json; using SilverOS.Welcome.Core.Apps; -using SilverOS.Welcome.Core.Flavours; namespace SilverOS.Welcome.Core.Apply; -public sealed class ApplyService(IProcessRunner runner, IAccountService accounts, - IBitLockerService bitlocker, IBootstrapService bootstrap, IAppInstaller installer, - string hardeningDir) : IApplyService +// Toolbox Apply pipeline: apps -> bitlocker -> done. +// Account creation moved to Windows Setup (WinPE collector); OS hardening runs from +// SetupComplete; sm-bootstrap teardown is owned by Setup, not the toolbox. +public sealed class ApplyService(IBitLockerService bitlocker, IAppInstaller installer) : IApplyService { public async Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default) { - progress.Report(new("Applying hardening", 10)); - // Pass modules as a single bare CSV token (e.g. 00,03,05). - // powershell.exe -File receives single-quoted tokens as one literal string, not an array, - // so Invoke-Hardening.ps1 accepts [string]$Modules and splits on ',' internally. - var mods = string.Join(",", req.Flavour.Hardening.Modules); - 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) + progress.Report(new("Installing apps", 30)); + await installer.InstallAsync(req.Apps, progress, ct); // continue-on-failure; never throws + + if (!string.IsNullOrWhiteSpace(req.BitLockerPin)) { - // Only expose exit code + first non-empty stderr line (capped) — never raw full stderr. - var firstLine = res.StdErr - .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) - .FirstOrDefault()?.Trim() ?? string.Empty; - if (firstLine.Length > 200) firstLine = firstLine[..200]; - throw new InvalidOperationException($"Hardening failed (exit {res.ExitCode}): {firstLine}"); + progress.Report(new("Encrypting the disk", 75)); + await bitlocker.EnableAsync(req.BitLockerPin, ct); } - - progress.Report(new("Creating your account", 55)); - await accounts.CreateAccountsAsync(req.Username, req.Password, req.AdminPassword, ct); - - progress.Report(new("Installing apps", 70)); - await installer.InstallAsync(req.Apps, progress, ct); // continue-on-failure; never throws - - progress.Report(new("Encrypting the disk", 75)); - await bitlocker.EnableAsync(req.BitLockerPin, ct); - - progress.Report(new("Finishing up", 95)); - await bootstrap.RevertKioskAsync(ct); // revert kiosk before account deletion (SID must still resolve) - await bootstrap.TearDownAsync(req.BootstrapUser, ct); // last — only after success progress.Report(new("Done", 100)); } } diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs deleted file mode 100644 index 67de81d..0000000 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs +++ /dev/null @@ -1,63 +0,0 @@ -namespace SilverOS.Welcome.Core.Apply; -public sealed class BootstrapService(IProcessRunner runner) : IBootstrapService -{ - // Lockdown revert is BEST-EFFORT (like TearDownAsync): -EA SilentlyContinue throughout. - // Don't fail the apply over a missing WMI class / key. Must run BEFORE TearDownAsync. - public async Task RevertKioskAsync(CancellationToken ct = default) - { - // Disable the Keyboard Filter rules so the real end-user's Win key / task-switch / - // Alt+F4 etc. work again (Explorer is already the shell — nothing to undo there). - await Ps( - "$c='root\\\\standardcimv2\\\\embedded';" + - "foreach($k in @('Win','Win+L','Ctrl+Esc','Ctrl+Win+F','Win+R','Alt+Tab','Ctrl+Shift+Esc','Alt+F4')){" + - "$p=Get-CimInstance -Namespace $c -ClassName WEKF_PredefinedKey -Filter \"Id='$k'\" -EA SilentlyContinue;" + - "if($p){$p.Enabled=$false; Set-CimInstance -InputObject $p -EA SilentlyContinue}" + - "}", - ct); - // Revert escape policies set by Configure-Kiosk.ps1. - await Ps( - "$s='HKLM:\\\\SOFTWARE\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Policies\\\\System';" + - "Remove-ItemProperty $s -Name DisableTaskMgr,DisableLockWorkstation,HideFastUserSwitching -EA SilentlyContinue;" + - // Restore SECURE UAC for the real end-user (the kiosk auto-approved unsigned elevation). - "Set-ItemProperty $s -Name ConsentPromptBehaviorAdmin -Value 2 -Type DWord -EA SilentlyContinue;" + - "Set-ItemProperty $s -Name PromptOnSecureDesktop -Value 1 -Type DWord -EA SilentlyContinue", - ct); - } - - // Teardown is BEST-EFFORT (unlike Account/BitLocker which are strict): the answer file's - // AutoLogon LogonCount=1 already neutralises auto-logon after the first logon (Windows clears - // AutoAdminLogon itself), so these Winlogon cleanups must not fail the whole apply. The op that - // matters — removing the sm-bootstrap account — runs regardless and is tolerant too. - public async Task TearDownAsync(string bootstrapUser, CancellationToken ct = default) - { - const string w = "'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon'"; - await Ps($"Set-ItemProperty -Path {w} -Name AutoAdminLogon -Value '0' -EA SilentlyContinue; " + - $"Remove-ItemProperty -Path {w} -Name DefaultPassword -EA SilentlyContinue; " + - $"Remove-ItemProperty -Path {w} -Name DefaultUserName -EA SilentlyContinue; " + - $"Remove-ItemProperty -Path {w} -Name DefaultDomainName -EA SilentlyContinue", ct); - var u = Esc(bootstrapUser); - // Best-effort in-session removal (usually no-ops — you can't delete the account - // you're logged in as), THEN defer the real removal to a SYSTEM startup task that - // runs on next boot, when sm-bootstrap is no longer logged on. It removes the - // account + profile, then unregisters itself. - // Disable immediately (in-session, takes effect at once so the account is unusable - // and shows as disabled), then best-effort delete; the deferred task does the real - // delete on next boot when it isn't logged on. - await Ps($"Disable-LocalUser -Name '{u}' -EA SilentlyContinue; Remove-LocalUser -Name '{u}' -EA SilentlyContinue", ct); - var cleanup = - $"Remove-LocalUser -Name '{u}' -ErrorAction SilentlyContinue; " + - $"Get-CimInstance Win32_UserProfile | Where-Object {{ $_.LocalPath -like '*\\{u}' }} | Remove-CimInstance -ErrorAction SilentlyContinue; " + - "Unregister-ScheduledTask -TaskName 'SilverMetalBootstrapCleanup' -Confirm:$false -ErrorAction SilentlyContinue"; - var b64 = Convert.ToBase64String(System.Text.Encoding.Unicode.GetBytes(cleanup)); - // Register-ScheduledTask (not schtasks.exe) — schtasks /tr caps at 261 chars and - // silently failed with the encoded payload, so the task was never created. - await Ps("$a=New-ScheduledTaskAction -Execute 'powershell.exe' -Argument " + - $"'-NoProfile -ExecutionPolicy Bypass -EncodedCommand {b64}'; " + - "$t=New-ScheduledTaskTrigger -AtStartup; " + - "$p=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest; " + - "Register-ScheduledTask -TaskName 'SilverMetalBootstrapCleanup' -Action $a -Trigger $t -Principal $p -Force | Out-Null", ct); - } - private static string Esc(string s) => s.Replace("'", "''"); - private Task Ps(string s, CancellationToken ct) => - runner.RunAsync("powershell.exe", $"-NoProfile -ExecutionPolicy Bypass -Command \"{s}\"", ct); -} diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/IAccountService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/IAccountService.cs deleted file mode 100644 index 43715f9..0000000 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/IAccountService.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace SilverOS.Welcome.Core.Apply; -public interface IAccountService { Task CreateAccountsAsync(string user, string password, string adminPassword, CancellationToken ct = default); } diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/IBootstrapService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/IBootstrapService.cs deleted file mode 100644 index 5e48d5b..0000000 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/IBootstrapService.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SilverOS.Welcome.Core.Apply; -public interface IBootstrapService -{ - Task RevertKioskAsync(CancellationToken ct = default); - Task TearDownAsync(string bootstrapUser, CancellationToken ct = default); -} diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Preconfig/IPreconfigStore.cs b/windows/welcome/src/SilverOS.Welcome.Core/Preconfig/IPreconfigStore.cs new file mode 100644 index 0000000..c74fa40 --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.Core/Preconfig/IPreconfigStore.cs @@ -0,0 +1,9 @@ +namespace SilverOS.Welcome.Core.Preconfig; + +public interface IPreconfigStore +{ + Preconfig? Load(); // null if missing/corrupt (fail-open) + void ClearPin(); // rewrite preconfig without the BitLocker pin + bool IsConfigured(); // configured marker present? + void MarkConfigured(); // write the configured marker +} diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Preconfig/Preconfig.cs b/windows/welcome/src/SilverOS.Welcome.Core/Preconfig/Preconfig.cs new file mode 100644 index 0000000..2164d00 --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.Core/Preconfig/Preconfig.cs @@ -0,0 +1,22 @@ +using System.Text.Json; + +namespace SilverOS.Welcome.Core.Preconfig; + +public sealed record BitlockerConfig { public bool Enable { get; init; } public string? Pin { get; init; } } +public sealed record AppsConfig { public bool UseFlavourDefaults { get; init; } = true; public IReadOnlyList<string>? Selected { get; init; } } + +public sealed record Preconfig +{ + public int SchemaVersion { get; init; } = 1; + public string Flavour { get; init; } = ""; + public BitlockerConfig Bitlocker { get; init; } = new(); + public AppsConfig Apps { get; init; } = new(); + + public static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; +} diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Preconfig/PreconfigStore.cs b/windows/welcome/src/SilverOS.Welcome.Core/Preconfig/PreconfigStore.cs new file mode 100644 index 0000000..43771da --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.Core/Preconfig/PreconfigStore.cs @@ -0,0 +1,30 @@ +using System.Text.Json; + +namespace SilverOS.Welcome.Core.Preconfig; + +public sealed class PreconfigStore(string dir) : IPreconfigStore +{ + private string File_ => Path.Combine(dir, "preconfig.json"); + private string Marker => Path.Combine(dir, "configured"); + + public Preconfig? Load() + { + try + { + if (!File.Exists(File_)) return null; + return JsonSerializer.Deserialize<Preconfig>(File.ReadAllText(File_), Preconfig.JsonOptions); + } + catch (JsonException) { return null; } // fail-open + } + + public void ClearPin() + { + var p = Load(); + if (p is null) return; + var stripped = p with { Bitlocker = p.Bitlocker with { Pin = null } }; + File.WriteAllText(File_, JsonSerializer.Serialize(stripped, Preconfig.JsonOptions)); + } + + public bool IsConfigured() => File.Exists(Marker); + public void MarkConfigured() { Directory.CreateDirectory(dir); File.WriteAllText(Marker, "1"); } +} diff --git a/windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor b/windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor index 26d4326..25efb8d 100644 --- a/windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor +++ b/windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor @@ -1,10 +1,22 @@ @using SilverOS.Welcome.App.Components.Steps @using SilverOS.Welcome.Core.Flavours @using SilverOS.Welcome.Core.Apps +@using SilverOS.Welcome.Core.Preconfig @inject IFlavourLoader FlavourLoader @inject IAppCatalog AppCatalog +@inject IPreconfigStore PreconfigStore @inject WizardState State +@if (_toolboxHome) +{ + <div class="toolbox-home"> + <h1>SilverMetal</h1> + <p class="toolbox-home-subtitle">Your device is set up and ready to go.</p> + <button class="btn-secondary" @onclick="ReRunSetup">Re-run setup</button> + </div> +} +else +{ <div class="wizard"> <div class="wizard-header"> <div class="wizard-steps-indicator"> @@ -45,15 +57,12 @@ <AppsStep Apps="_catalog.AppsForRole(State.Flavour?.Id ?? string.Empty)" /> break; case 3: - <AccountStep OnValidityChanged="@(v => { _accountValid = v; StateHasChanged(); })" /> - break; - case 4: <PrefsStep /> break; - case 5: - <ApplyStep OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" /> + case 4: + <ApplyStep AutoStart="_autoApply" OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" /> break; - case 6: + case 5: <DoneStep /> break; } @@ -66,7 +75,7 @@ @onclick="Back"> Back </button> - @if (_currentStep < _stepTitles.Length - 1 && _currentStep != 5) + @if (_currentStep < _stepTitles.Length - 1 && _currentStep != 4) { <button class="btn-primary" disabled="@(!CanGoNext)" @@ -76,9 +85,10 @@ } </div> </div> +} @code { - private static readonly string[] _stepTitles = { "Welcome", "Flavour", "Apps", "Account", "Prefs", "Apply", "Done" }; + private static readonly string[] _stepTitles = { "Welcome", "Flavour", "Apps", "Prefs", "Apply", "Done" }; // Flavours dir: baked alongside the exe at publish time. private static readonly string FlavoursDir = Path.Combine( @@ -93,7 +103,8 @@ private int _currentStep = 0; private bool _loading = true; private bool _applyRunning = false; - private bool _accountValid = false; + private bool _toolboxHome = false; + private bool _autoApply = false; private string? _error; private IReadOnlyList<FlavourManifest> _flavours = Array.Empty<FlavourManifest>(); @@ -101,11 +112,15 @@ { 1 => State.Flavour is not null, // 2 = Apps step is always valid (never blocks Next). - 3 => _accountValid, _ => true }; - protected override Task OnInitializedAsync() => LoadFlavours(); + protected override Task OnInitializedAsync() + { + LoadFlavours(); + SeedFromPreconfig(); + return Task.CompletedTask; + } private Task LoadFlavours() { @@ -127,6 +142,46 @@ return Task.CompletedTask; } + // Runs AFTER flavours + catalog are loaded (order matters): decides run-mode and, + // on a first run, pre-seeds wizard state from the WinPE collector's choices. + private void SeedFromPreconfig() + { + var pre = PreconfigStore.Load(); + + if (PreconfigStore.IsConfigured()) + { + // Already ran once -> open the minimal toolbox-home landing, never auto-apply. + _toolboxHome = true; + return; + } + + if (pre is null) + return; // fail-open: no preconfig -> normal wizard with flavour defaults. + + // Match the collector's flavour by id; fall back to the loaded default if absent. + State.Flavour = _flavours.FirstOrDefault(f => f.Id == pre.Flavour) + ?? _flavours.FirstOrDefault(f => f.IsDefault) + ?? _flavours.FirstOrDefault(); + + foreach (var id in _catalog.DefaultSelectionForRole(pre.Flavour)) + State.SelectedApps.Add(id); + + if (pre.Bitlocker.Enable && !string.IsNullOrEmpty(pre.Bitlocker.Pin)) + State.BitLockerPin = pre.Bitlocker.Pin; + + // First run with a preconfig: skip the manual walkthrough. Jump straight to the + // Apply step and signal it to auto-start (spec §4d: auto-runs once, shows progress + // + recovery key, then Done). + _currentStep = 4; + _autoApply = true; + } + + private void ReRunSetup() + { + _toolboxHome = false; + _currentStep = 0; + } + void Next() { if (_currentStep < _stepTitles.Length - 1) diff --git a/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/AccountStep.razor b/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/AccountStep.razor deleted file mode 100644 index 7e1b5b6..0000000 --- a/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/AccountStep.razor +++ /dev/null @@ -1,90 +0,0 @@ -@inject WizardState State - -<div class="step account-step"> - - <h1>Set Up Your Account</h1> - <p class="step-subtitle">Create your daily-use account and administrator credentials.</p> - - <div class="field-group"> - <label for="username">Daily Username</label> - <input id="username" type="text" placeholder="e.g. alice" - value="@State.Username" - @oninput="OnUsernameInput" /> - @if (_touched.Contains("username") && _errors.TryGetValue("username", out var ue)) - { - <span class="field-error">@ue</span> - } - </div> - - <div class="field-group"> - <label for="password">Daily Password</label> - <input id="password" type="password" - value="@State.Password" - @oninput="OnPasswordInput" /> - @if (_touched.Contains("password") && _errors.TryGetValue("password", out var pe)) - { - <span class="field-error">@pe</span> - } - </div> - - <div class="field-group"> - <label for="adminpassword">Administrator Password</label> - <input id="adminpassword" type="password" - value="@State.AdminPassword" - @oninput="OnAdminPasswordInput" /> - @if (_touched.Contains("adminpassword") && _errors.TryGetValue("adminpassword", out var ae)) - { - <span class="field-error">@ae</span> - } - </div> - - <div class="field-group"> - <label for="bitlockerpin">BitLocker PIN <small>(numeric, 6+ digits)</small></label> - <input id="bitlockerpin" type="password" inputmode="numeric" pattern="[0-9]*" - value="@State.BitLockerPin" - @oninput="OnPinInput" /> - @if (_touched.Contains("bitlockerpin") && _errors.TryGetValue("bitlockerpin", out var be)) - { - <span class="field-error">@be</span> - } - </div> -</div> - -@code { - private readonly Dictionary<string, string> _errors = new(); - private readonly HashSet<string> _touched = new(); - - /// <summary>Notifies the wizard host whenever validity changes (and on initial mount).</summary> - [Parameter] public EventCallback<bool> OnValidityChanged { get; set; } - - /// <summary>True when all fields are valid.</summary> - public bool IsValid { get; private set; } - - protected override void OnInitialized() => Validate(); - - private void OnUsernameInput(ChangeEventArgs e) { State.Username = e.Value?.ToString() ?? ""; _touched.Add("username"); Validate(); } - private void OnPasswordInput(ChangeEventArgs e) { State.Password = e.Value?.ToString() ?? ""; _touched.Add("password"); Validate(); } - private void OnAdminPasswordInput(ChangeEventArgs e) { State.AdminPassword = e.Value?.ToString() ?? ""; _touched.Add("adminpassword"); Validate(); } - private void OnPinInput(ChangeEventArgs e) { State.BitLockerPin = e.Value?.ToString() ?? ""; _touched.Add("bitlockerpin"); Validate(); } - - void Validate() - { - _errors.Clear(); - - if (string.IsNullOrWhiteSpace(State.Username)) - _errors["username"] = "Daily username is required."; - - if (string.IsNullOrWhiteSpace(State.Password)) - _errors["password"] = "Password is required."; - - if (string.IsNullOrWhiteSpace(State.AdminPassword)) - _errors["adminpassword"] = "Administrator password is required."; - - var pin = State.BitLockerPin ?? ""; - if (!System.Text.RegularExpressions.Regex.IsMatch(pin, @"^\d{6,}$")) - _errors["bitlockerpin"] = "BitLocker PIN must be all digits and at least 6 digits long."; - - IsValid = _errors.Count == 0; - _ = OnValidityChanged.InvokeAsync(IsValid); - } -} diff --git a/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor b/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor index 797a1e2..aae2437 100644 --- a/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor +++ b/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor @@ -1,6 +1,8 @@ @using SilverOS.Welcome.Core.Apps +@using SilverOS.Welcome.Core.Preconfig @inject IApplyService ApplyService @inject IAppCatalog AppCatalog +@inject IPreconfigStore PreconfigStore @inject WizardState State <div class="step apply-step"> @@ -48,8 +50,10 @@ @code { [Parameter] public EventCallback OnComplete { get; set; } [Parameter] public EventCallback<bool> OnRunningChanged { get; set; } + [Parameter] public bool AutoStart { get; set; } private bool _running; + private bool _autoStarted; private bool _complete; private int _percent; private string _stageLabel = "Preparing…"; @@ -69,6 +73,18 @@ : single[..ErrorDisplayMaxLength] + "…"; } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + // First-run auto-apply: when the host jumps straight to this step with AutoStart, + // kick off the same apply the Start button would, exactly once. The manual path + // (AutoStart=false) is untouched. + if (firstRender && AutoStart && !_autoStarted) + { + _autoStarted = true; + await StartAsync(); + } + } + public async Task StartAsync() { // Re-entrancy guard: prevent a second overlapping apply if already running @@ -86,13 +102,12 @@ var apps = AppCatalog.Load(Path.Combine(AppContext.BaseDirectory, "apps")) .All.Where(a => State.SelectedApps.Contains(a.Id)).ToList(); + // D1: Apply is now apps+bitlocker only (account via Setup, hardening via SetupComplete). + // D2 owns the full UI rewire (run-mode / preseed); this passes the 3-arg request from + // existing State fields so the app keeps compiling. var req = new ApplyRequest( Flavour: State.Flavour!, - Username: State.Username, - Password: State.Password, - AdminPassword: State.AdminPassword, BitLockerPin: State.BitLockerPin, - BootstrapUser: "sm-bootstrap", Apps: apps); var progress = new Progress<ApplyProgress>(p => @@ -111,6 +126,12 @@ _complete = true; _running = false; _percent = 100; + + // Apply succeeded: wipe the BitLocker pin from the preconfig and stamp the + // configured marker so the next launch opens toolbox-home instead of re-applying. + PreconfigStore.ClearPin(); + PreconfigStore.MarkConfigured(); + StateHasChanged(); await OnRunningChanged.InvokeAsync(false); await OnComplete.InvokeAsync(); diff --git a/windows/welcome/src/SilverOS.Welcome.UI/Components/WizardState.cs b/windows/welcome/src/SilverOS.Welcome.UI/Components/WizardState.cs index 7dca9ee..128c29e 100644 --- a/windows/welcome/src/SilverOS.Welcome.UI/Components/WizardState.cs +++ b/windows/welcome/src/SilverOS.Welcome.UI/Components/WizardState.cs @@ -9,9 +9,6 @@ public sealed class WizardState // Apps step: ids of catalog apps the user chose to install. public HashSet<string> SelectedApps { get; set; } = new(); - public string Username { get; set; } = ""; - public string Password { get; set; } = ""; - public string AdminPassword { get; set; } = ""; public string BitLockerPin { get; set; } = ""; // Prefs step diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/AccountStepTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/AccountStepTests.cs deleted file mode 100644 index 02dddb0..0000000 --- a/windows/welcome/tests/SilverOS.Welcome.Tests/AccountStepTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using Bunit; -using Microsoft.AspNetCore.Components; -using Microsoft.Extensions.DependencyInjection; -using SilverOS.Welcome.App.Components; -using SilverOS.Welcome.App.Components.Steps; -using Xunit; - -public class AccountStepTests : TestContext -{ - // Helper: register WizardState and render AccountStep with an OnValidityChanged capture. - private (IRenderedComponent<AccountStep> cut, Func<bool?> lastValidity) RenderStep(WizardState? state = null) - { - var wizardState = state ?? new WizardState(); - Services.AddSingleton(wizardState); - - bool? captured = null; - var cut = RenderComponent<AccountStep>(p => - p.Add(s => s.OnValidityChanged, - EventCallback.Factory.Create<bool>(this, v => captured = v))); - - return (cut, () => captured); - } - - [Fact] - public void OnValidityChanged_fires_false_on_initial_mount_with_empty_fields() - { - var (_, lastValidity) = RenderStep(); - - Assert.NotNull(lastValidity()); - Assert.False(lastValidity(), "Step should be invalid on first mount (empty fields)."); - } - - [Fact] - public void OnValidityChanged_fires_true_after_all_valid_inputs_are_entered() - { - var (cut, lastValidity) = RenderStep(); - - // Simulate user filling in all four fields. - cut.Find("#username").Input("alice"); - cut.Find("#password").Input("Secret1!"); - cut.Find("#adminpassword").Input("Admin1!"); - cut.Find("#bitlockerpin").Input("123456"); - - Assert.True(lastValidity(), "Step should be valid after all fields are correctly filled."); - } - - [Fact] - public void OnValidityChanged_fires_false_when_a_field_is_cleared_after_being_valid() - { - var (cut, lastValidity) = RenderStep(); - - cut.Find("#username").Input("alice"); - cut.Find("#password").Input("Secret1!"); - cut.Find("#adminpassword").Input("Admin1!"); - cut.Find("#bitlockerpin").Input("123456"); - - Assert.True(lastValidity()); // sanity - - // Clear a required field — must revert to invalid. - cut.Find("#username").Input(""); - - Assert.False(lastValidity(), "Step should become invalid again when a required field is cleared."); - } - - [Fact] - public void OnValidityChanged_fires_false_when_pin_is_non_numeric_or_too_short() - { - var (cut, lastValidity) = RenderStep(); - - cut.Find("#username").Input("alice"); - cut.Find("#password").Input("Secret1!"); - cut.Find("#adminpassword").Input("Admin1!"); - - // Too short — 5 digits. - cut.Find("#bitlockerpin").Input("12345"); - Assert.False(lastValidity(), "PIN with only 5 digits must be invalid."); - - // Non-numeric. - cut.Find("#bitlockerpin").Input("abc123"); - Assert.False(lastValidity(), "Non-numeric PIN must be invalid."); - - // Exactly 6 digits — valid. - cut.Find("#bitlockerpin").Input("123456"); - Assert.True(lastValidity(), "Exactly 6 numeric digits is valid."); - } - - [Fact] - public void OnValidityChanged_fires_true_on_mount_when_wizard_state_already_populated() - { - var prefilledState = new WizardState - { - Username = "alice", - Password = "Secret1!", - AdminPassword = "Admin1!", - BitLockerPin = "123456" - }; - - var (_, lastValidity) = RenderStep(prefilledState); - - Assert.True(lastValidity(), - "Step should fire valid=true on mount when WizardState already has valid values (Back→Forward re-mount)."); - } -} diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceHardeningIntegrationTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceHardeningIntegrationTests.cs deleted file mode 100644 index f8a5652..0000000 --- a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceHardeningIntegrationTests.cs +++ /dev/null @@ -1,121 +0,0 @@ -using Moq; -using SilverOS.Welcome.Core.Apply; -using SilverOS.Welcome.Core.Apps; -using SilverOS.Welcome.Core.Flavours; -using Xunit; - -/// <summary> -/// Real integration test: proves that ApplyService passes -Modules with the correct -/// encoding so that Invoke-Hardening.ps1's subset filter actually works through the -/// real ProcessStartInfo / PowerShell boundary. -/// -/// SAFETY: only harmless dummy .ps1 files are executed — never the real 0*.ps1 hardening -/// modules. Invoke-Hardening.ps1 is copied into a temp dir and run against dummy stubs. -/// </summary> -public class ApplyServiceHardeningIntegrationTests -{ - /// <summary>Walk up from the test binary to find the repo root (same as ShippedFlavoursTests).</summary> - private static string HardeningDir() - { - var d = AppContext.BaseDirectory; - while (d is not null && !Directory.Exists(Path.Combine(d, "windows", "hardening"))) - d = Directory.GetParent(d)?.FullName; - return Path.Combine(d!, "windows", "hardening"); - } - - [Fact] - public async Task Subset_filter_runs_only_requested_modules_via_real_powershell() - { - // ---- Arrange: set up a temp sandbox ---- - var tmp = Path.Combine(Path.GetTempPath(), $"sm_integ_{Guid.NewGuid():N}"); - Directory.CreateDirectory(tmp); - try - { - // Copy the REAL Invoke-Hardening.ps1 (the one we just patched) into the temp dir. - var realInvoke = Path.Combine(HardeningDir(), "Invoke-Hardening.ps1"); - File.Copy(realInvoke, Path.Combine(tmp, "Invoke-Hardening.ps1")); - - // Create harmless dummy module stubs. Each just appends its prefix to ran.txt. - var ranFile = Path.Combine(tmp, "ran.txt").Replace("\\", "\\\\"); - foreach (var (prefix, name) in new[] { - ("00", "00-a.ps1"), - ("03", "03-b.ps1"), - ("05", "05-c.ps1"), - ("07", "07-d.ps1"), - }) - { - // Single quotes around prefix so the string itself is written, not executed. - await File.WriteAllTextAsync( - Path.Combine(tmp, name), - $"'RAN {prefix}' | Out-File -Append \"{ranFile.Replace("\\\\", "\\\\")}\""); - } - - // Dummy Verify script — no-op so Invoke-Hardening.ps1's Verify step succeeds. - await File.WriteAllTextAsync( - Path.Combine(tmp, "Verify-SilverMetalWindows.ps1"), - "# no-op verify"); - - // ---- Arrange: mocked services so apply completes without touching real OS ---- - var acct = new Mock<IAccountService>(); - acct.Setup(a => a.CreateAccountsAsync( - It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), - It.IsAny<CancellationToken>())) - .Returns(Task.CompletedTask); - - var bl = new Mock<IBitLockerService>(); - bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) - .Returns(Task.CompletedTask); - - var boot = new Mock<IBootstrapService>(); - boot.Setup(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) - .Returns(Task.CompletedTask); - - var installer = new Mock<IAppInstaller>(); - installer.Setup(i => i.InstallAsync( - It.IsAny<IReadOnlyList<AppCatalogEntry>>(), - It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>())) - .ReturnsAsync(Array.Empty<AppInstallResult>()); - - var sut = new ApplyService( - runner: new ProcessRunner(), - accounts: acct.Object, - bitlocker: bl.Object, - bootstrap: boot.Object, - installer: installer.Object, - hardeningDir: tmp); - - // Flavour requests modules 00 and 05 only — 03 and 07 must be skipped. - var flavour = new FlavourManifest - { - Id = "test", - Hardening = new HardeningSpec { Modules = new[] { "00", "05" } } - }; - var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap", Array.Empty<AppCatalogEntry>()); - - // ---- Act ---- - await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { })); - - // ---- Assert: ran.txt should contain only 00 and 05 markers ---- - Assert.True(File.Exists(Path.Combine(tmp, "ran.txt")), - "ran.txt was not created — no module ran at all (subset filter matched nothing)"); - - var ran = await File.ReadAllTextAsync(Path.Combine(tmp, "ran.txt")); - - Assert.Contains("RAN 00", ran, StringComparison.Ordinal); - Assert.Contains("RAN 05", ran, StringComparison.Ordinal); - Assert.DoesNotContain("RAN 03", ran, StringComparison.Ordinal); - Assert.DoesNotContain("RAN 07", ran, StringComparison.Ordinal); - - // ---- Assert: the rest of the apply pipeline also completed ---- - acct.Verify(a => a.CreateAccountsAsync( - "alice", "pw", "adminpw", It.IsAny<CancellationToken>()), Times.Once); - bl.Verify(b => b.EnableAsync("123456", It.IsAny<CancellationToken>()), Times.Once); - boot.Verify(b => b.TearDownAsync("sm-bootstrap", It.IsAny<CancellationToken>()), Times.Once); - } - finally - { - // Clean up — ignore errors (locked files etc.) to avoid masking test failure. - try { Directory.Delete(tmp, recursive: true); } catch { /* ignore */ } - } - } -} diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs index 5c81adf..dc484b6 100644 --- a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs +++ b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs @@ -15,39 +15,76 @@ public class ApplyServiceTests return installer; } + private static FlavourManifest Flavour() => + new() { Id = "daily-driver", Hardening = new HardeningSpec { Modules = new[] { "00" } } }; + [Fact] - public async Task Runs_modules_then_accounts_then_bitlocker_then_bootstrap_last() + public async Task Runs_apps_then_bitlocker_when_pin_supplied() { 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 installer = NoApps(); installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),It.IsAny<IProgress<ApplyProgress>>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty<AppInstallResult>()); + var bl = new Mock<IBitLockerService>(); + bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) + .Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask); + var installer = NoApps(); + installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(), + It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>())) + .Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty<AppInstallResult>()); - var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, installer.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", System.Array.Empty<AppCatalogEntry>()); + var sut = new ApplyService(bl.Object, installer.Object); + var req = new ApplyRequest(Flavour(), "123456", System.Array.Empty<AppCatalogEntry>()); var progress = new List<string>(); await sut.RunAsync(req, new Progress<ApplyProgress>(p => progress.Add(p.Stage))); - Assert.Equal(new[]{"modules","accounts","apps","bitlocker","bootstrap"}, order); - Assert.Contains("Applying hardening", progress); + Assert.Equal(new[] { "apps", "bitlocker" }, order); + Assert.Contains("Installing apps", progress); + Assert.Contains("Done", progress); } [Fact] - public async Task Does_not_tear_down_bootstrap_if_account_creation_fails() + public async Task Empty_pin_skips_bitlocker() { - 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, NoApps().Object, "C:\\hard"); - var req = new ApplyRequest(new FlavourManifest{ Hardening=new HardeningSpec{Modules=new[]{"00"}}}, "a","b","c","123456","sm-bootstrap", System.Array.Empty<AppCatalogEntry>()); - await Assert.ThrowsAsync<InvalidOperationException>(() => sut.RunAsync(req, new Progress<ApplyProgress>(_ => {}))); - boot.Verify(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never); + var order = new List<string>(); + var run = new Mock<IProcessRunner>(); + run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(new ProcessResult(0, "", "")); + var bl = new Mock<IBitLockerService>(); + bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) + .Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask); + var installer = NoApps(); + installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(), + It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>())) + .Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty<AppInstallResult>()); + + var sut = new ApplyService(bl.Object, installer.Object); + var req = new ApplyRequest(Flavour(), "", System.Array.Empty<AppCatalogEntry>()); + + await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { })); + + Assert.Equal(new[] { "apps" }, order); + bl.Verify(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never); + } + + [Fact] + public async Task Installs_the_requested_apps() + { + var run = new Mock<IProcessRunner>(); + run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(new ProcessResult(0, "", "")); + var bl = new Mock<IBitLockerService>(); + var installer = NoApps(); + var apps = new[] { new AppCatalogEntry { Id = "firefox", Name = "Firefox" } }; + + var sut = new ApplyService(bl.Object, installer.Object); + var req = new ApplyRequest(Flavour(), "123456", apps); + + await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { })); + + installer.Verify(i => i.InstallAsync(apps, It.IsAny<IProgress<ApplyProgress>>(), + It.IsAny<CancellationToken>()), Times.Once); + bl.Verify(b => b.EnableAsync("123456", It.IsAny<CancellationToken>()), Times.Once); } } diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServicesTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServicesTests.cs index c600977..943c498 100644 --- a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServicesTests.cs +++ b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServicesTests.cs @@ -19,13 +19,6 @@ public class ApplyServicesTests return m; } - [Fact] - public async Task AccountService_throws_on_nonzero_exit() - { - await Assert.ThrowsAsync<InvalidOperationException>(() => - new AccountService(Fail().Object).CreateAccountsAsync("alice", "pw", "apw")); - } - [Fact] public async Task BitLockerService_throws_on_nonzero_exit() { @@ -33,26 +26,6 @@ public class ApplyServicesTests new BitLockerService(Fail().Object).EnableAsync("123456")); } - // Note: BootstrapService is intentionally best-effort (teardown cleanups must not fail the - // apply — auto-logon is already neutralised by the answer file's LogonCount=1), so it does - // NOT throw on a non-zero exit. - - [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>())); - // negative: the daily-user New-LocalUser call must never mention Administrators - run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s => - s.Contains("New-LocalUser") && s.Contains("alice") && !s.Contains("Administrators")), - It.IsAny<CancellationToken>()), Times.Once); - 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() { @@ -82,15 +55,4 @@ public class ApplyServicesTests run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s => s.Contains("Shell.Application") && s.Contains("Eject")), 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>())); - } } diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyStepTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyStepTests.cs index 45429d9..1b95fdd 100644 --- a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyStepTests.cs +++ b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyStepTests.cs @@ -7,6 +7,7 @@ using SilverOS.Welcome.App.Components.Steps; using SilverOS.Welcome.Core.Apply; using SilverOS.Welcome.Core.Apps; using SilverOS.Welcome.Core.Flavours; +using SilverOS.Welcome.Core.Preconfig; using Xunit; public class ApplyStepTests : TestContext @@ -17,6 +18,19 @@ public class ApplyStepTests : TestContext private static void AddCatalog(IServiceCollection services) => services.AddSingleton<IAppCatalog>(new AppCatalog()); + // ApplyStep injects IPreconfigStore to clear the pin + mark configured after a + // successful apply; a no-op fake keeps these UI tests off the real filesystem. + private static void AddPreconfig(IServiceCollection services) => + services.AddSingleton<IPreconfigStore>(new FakePreconfigStore()); + + private sealed class FakePreconfigStore : IPreconfigStore + { + public Preconfig? Load() => null; + public void ClearPin() { } + public bool IsConfigured() => false; + public void MarkConfigured() { } + } + [Fact] public async Task Calls_apply_with_the_wizard_selections() { @@ -24,18 +38,16 @@ public class ApplyStepTests : TestContext 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); AddCatalog(Services); + AddPreconfig(Services); 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.Is<ApplyRequest>(r => r.BitLockerPin == "123456" && r.Flavour.Id == "daily-driver"), It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()), Times.Once); } @@ -49,17 +61,45 @@ public class ApplyStepTests : TestContext var state = new WizardState { Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } }, - Username = "alice", Password = "pw", AdminPassword = "apw", BitLockerPin = "123456" + BitLockerPin = "123456" }; Services.AddSingleton(state); Services.AddSingleton(apply.Object); AddCatalog(Services); + AddPreconfig(Services); var completed = false; var cut = RenderComponent<ApplyStep>(p => p.Add(s => s.OnComplete, EventCallback.Factory.Create(this, () => { completed = true; }))); await cut.InvokeAsync(() => cut.Instance.StartAsync()); Assert.True(completed); } + [Fact] + public void AutoStart_triggers_apply_once_without_a_button_click() + { + var apply = new Mock<IApplyService>(); + apply.Setup(a => a.RunAsync(It.IsAny<ApplyRequest>(), It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>())) + .Returns(Task.CompletedTask); + var state = new WizardState + { + Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } }, + BitLockerPin = "123456" + }; + Services.AddSingleton(state); + Services.AddSingleton(apply.Object); + AddCatalog(Services); + AddPreconfig(Services); + + // AutoStart=true should fire StartAsync from OnAfterRenderAsync on first render, + // with no Start button click. + var cut = RenderComponent<ApplyStep>(p => p.Add(s => s.AutoStart, true)); + + cut.WaitForAssertion(() => + apply.Verify(a => a.RunAsync( + It.Is<ApplyRequest>(r => r.Flavour.Id == "daily-driver"), + It.IsAny<IProgress<ApplyProgress>>(), + It.IsAny<CancellationToken>()), Times.Once)); + } + [Fact] public async Task Shows_error_and_retry_button_when_apply_fails() { @@ -69,11 +109,12 @@ public class ApplyStepTests : TestContext var state = new WizardState { Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } }, - Username = "alice", Password = "pw", AdminPassword = "apw", BitLockerPin = "123456" + BitLockerPin = "123456" }; Services.AddSingleton(state); Services.AddSingleton(apply.Object); AddCatalog(Services); + AddPreconfig(Services); var cut = RenderComponent<ApplyStep>(); await cut.InvokeAsync(() => cut.Instance.StartAsync()); Assert.Contains("Module 03 failed", cut.Markup); diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/BootstrapServiceRevertKioskTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/BootstrapServiceRevertKioskTests.cs deleted file mode 100644 index e96b24b..0000000 --- a/windows/welcome/tests/SilverOS.Welcome.Tests/BootstrapServiceRevertKioskTests.cs +++ /dev/null @@ -1,107 +0,0 @@ -using Moq; -using SilverOS.Welcome.Core.Apply; -using SilverOS.Welcome.Core.Apps; - -public class BootstrapServiceRevertKioskTests -{ - 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; - } - - private static Mock<IProcessRunner> Fail() - { - var m = new Mock<IProcessRunner>(); - m.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())) - .ReturnsAsync(new ProcessResult(1, "", "the operation failed")); - return m; - } - - [Fact] - public async Task RevertKioskAsync_is_best_effort_and_does_not_throw_on_nonzero_exit() - { - // Kiosk revert is best-effort (like TearDownAsync): a non-zero exit must NOT - // fail the apply — the real user still gets Explorer regardless of WESL state. - var ex = await Record.ExceptionAsync(() => - new BootstrapService(Fail().Object).RevertKioskAsync()); - Assert.Null(ex); - } - - [Fact] - public async Task RevertKioskAsync_disables_keyboard_filter_rules() - { - var run = Ok(); - await new BootstrapService(run.Object).RevertKioskAsync(); - // First call: disable the Keyboard Filter predefined-key blocks for the real user. - run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s => - s.Contains("WEKF_PredefinedKey") && - s.Contains("Enabled=$false")), - It.IsAny<CancellationToken>()), Times.Once); - } - - [Fact] - public async Task RevertKioskAsync_reverts_escape_policies() - { - var run = Ok(); - await new BootstrapService(run.Object).RevertKioskAsync(); - // Second call: policy revert — must remove the three escape policy values. - run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s => - s.Contains("Remove-ItemProperty") && - s.Contains("DisableTaskMgr") && - s.Contains("DisableLockWorkstation") && - s.Contains("HideFastUserSwitching")), - It.IsAny<CancellationToken>()), Times.Once); - } - - [Fact] - public async Task ApplyService_calls_revert_kiosk_before_teardown() - { - 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.RevertKioskAsync(It.IsAny<CancellationToken>())) - .Callback(() => order.Add("revert-kiosk")) - .Returns(Task.CompletedTask); - boot.Setup(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) - .Callback(() => order.Add("teardown")) - .Returns(Task.CompletedTask); - - var installer = new Mock<IAppInstaller>(); - installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(), - It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>())) - .ReturnsAsync(System.Array.Empty<AppInstallResult>()); - - var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, installer.Object, "C:\\hard"); - var flavour = new SilverOS.Welcome.Core.Flavours.FlavourManifest - { - Id = "daily-driver", - Hardening = new SilverOS.Welcome.Core.Flavours.HardeningSpec { Modules = new[] { "00" } } - }; - var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap", System.Array.Empty<AppCatalogEntry>()); - - await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { })); - - // revert-kiosk must precede teardown so the sm-bootstrap SID still resolves. - Assert.Equal(new[] { "modules", "accounts", "bitlocker", "revert-kiosk", "teardown" }, order); - } -} diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/PreconfigTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/PreconfigTests.cs new file mode 100644 index 0000000..2f63df7 --- /dev/null +++ b/windows/welcome/tests/SilverOS.Welcome.Tests/PreconfigTests.cs @@ -0,0 +1,64 @@ +using System.IO; +using System.Text.Json; +using SilverOS.Welcome.Core.Preconfig; +using Xunit; + +public class PreconfigTests +{ + static string TempDir() + { + var d = Path.Combine(Path.GetTempPath(), "smpre-" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(d); + return d; + } + + const string Sample = """ + { "schemaVersion":1, "flavour":"developer", + "bitlocker":{"enable":true,"pin":"246810"}, + "apps":{"useFlavourDefaults":true} } + """; + + [Fact] + public void Loads_flavour_and_pin() + { + var dir = TempDir(); + File.WriteAllText(Path.Combine(dir, "preconfig.json"), Sample); + var p = new PreconfigStore(dir).Load(); + Assert.NotNull(p); + Assert.Equal("developer", p!.Flavour); + Assert.True(p.Bitlocker.Enable); + Assert.Equal("246810", p.Bitlocker.Pin); + Assert.True(p.Apps.UseFlavourDefaults); + } + + [Fact] + public void Missing_or_bad_file_returns_null_not_throw() + { + Assert.Null(new PreconfigStore(TempDir()).Load()); // missing + var dir = TempDir(); + File.WriteAllText(Path.Combine(dir, "preconfig.json"), "{ not json"); + Assert.Null(new PreconfigStore(dir).Load()); // corrupt + } + + [Fact] + public void ClearPin_rewrites_without_the_pin() + { + var dir = TempDir(); + File.WriteAllText(Path.Combine(dir, "preconfig.json"), Sample); + var store = new PreconfigStore(dir); + store.ClearPin(); + var reread = store.Load(); + Assert.True(string.IsNullOrEmpty(reread!.Bitlocker.Pin)); + Assert.Equal("developer", reread.Flavour); // rest preserved + } + + [Fact] + public void Configured_marker_roundtrips() + { + var dir = TempDir(); + var store = new PreconfigStore(dir); + Assert.False(store.IsConfigured()); + store.MarkConfigured(); + Assert.True(store.IsConfigured()); + } +}