Compare commits
64 Commits
fix/brandi
...
51ab88b1f8
| Author | SHA1 | Date | |
|---|---|---|---|
| 51ab88b1f8 | |||
|
|
709744d533 | ||
|
|
ddd8784b56 | ||
| 226a823c68 | |||
|
|
67befa56df | ||
|
|
13df66d137 | ||
| 541a17c792 | |||
|
|
9fa613b8c1 | ||
| 8f61d5fb61 | |||
|
|
09e1f94b7d | ||
| 8ceb38c3dd | |||
|
|
a169d2a452 | ||
|
|
20743e9b54 | ||
| 75f97778f8 | |||
|
|
18475fa731 | ||
| 04a6f6eabb | |||
|
|
7e99d7e304 | ||
| 731ae88adf | |||
|
|
fce4b77bd6 | ||
| 3538f43267 | |||
|
|
7eec584a66 | ||
| c154e70495 | |||
|
|
e6c292da25 | ||
|
|
6c96e92fa5 | ||
|
|
a82ca271a0 | ||
| bd215cba54 | |||
|
|
084dd6a1d7 | ||
|
|
9d05a4a223 | ||
|
|
5bc345b1bd | ||
|
|
e88e476cd6 | ||
|
|
2730b29cb6 | ||
|
|
30a168e853 | ||
|
|
f3d66fb9d3 | ||
|
|
bd1e2885df | ||
|
|
42d86734b0 | ||
|
|
72e401113a | ||
|
|
27a08ac1ab | ||
|
|
59418e37c8 | ||
| 72fa329ddd | |||
|
|
3daa770584 | ||
| f6dac0fdfd | |||
|
|
4268a337f3 | ||
| 129b8741fd | |||
|
|
260023a1a5 | ||
|
|
3c7654f128 | ||
|
|
de18ee8dd2 | ||
|
|
993d4028a2 | ||
|
|
52d7187991 | ||
|
|
cd3808de64 | ||
|
|
18eb42324a | ||
|
|
bfb53bd295 | ||
|
|
1e59029e53 | ||
|
|
58d261cc6b | ||
|
|
583ed4400c | ||
| efdf5888ac | |||
|
|
e83ce6bcf0 | ||
| 5f0df87405 | |||
| 94de283495 | |||
| 66ab2e1aed | |||
| 2dc7882483 | |||
| 3c072a4edb | |||
| 6d90f41f8f | |||
| 09216d6cfb | |||
| 863be56d15 |
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Keep binary assets verbatim (no EOL/charset normalization on checkout).
|
||||
*.sys binary
|
||||
*.cat binary
|
||||
*.exe binary
|
||||
*.dll binary
|
||||
*.iso binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.ico binary
|
||||
*.cab binary
|
||||
windows/drivers/** binary
|
||||
@@ -40,6 +40,22 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Free disk space
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Each successful build persists a ~5 GB ISO to C:\silvermetal\out, and the
|
||||
# boot.wim now carries WinPE-NetFx/PowerShell (bigger image). On the single-volume
|
||||
# runner that accumulation starves oscdimg ("Insufficient disk space"). Clear the
|
||||
# prior build output + any stale work dir before building so space self-heals.
|
||||
$before = [math]::Round((Get-PSDrive C).Free/1GB,1)
|
||||
Remove-Item 'C:\silvermetal\out\*' -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Get-ChildItem 'C:\gitea-runner\workspace' -Directory -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$t = Join-Path $_.FullName 'tmp\smbuild'
|
||||
if (Test-Path $t) { Remove-Item $t -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
$after = [math]::Round((Get-PSDrive C).Free/1GB,1)
|
||||
Write-Host " C: free ${before}GB -> ${after}GB (cleared prior ISO output + stale smbuild)"
|
||||
|
||||
- name: Ensure Windows ADK (oscdimg)
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -58,6 +74,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:
|
||||
@@ -146,6 +184,15 @@ jobs:
|
||||
# RUNNER_TEMP is per-job/ephemeral. Keep the latest validated build at a
|
||||
# stable path so it can be retrieved (e.g. for VM boot-testing) out of band.
|
||||
New-Item -ItemType Directory -Force 'C:\silvermetal\out' | Out-Null
|
||||
# The ISO is already built + validated; free the build working set (extracted ISO
|
||||
# tree + the mounted/expanded install.wim + the 5GB base ISO) BEFORE the ~5GB persist
|
||||
# copy, or the single-volume runner runs out of space mid-copy. The ISO itself lives
|
||||
# in RUNNER_TEMP\out (untouched) and the SBOM/SHA uploads read from there too.
|
||||
$before = [math]::Round((Get-PSDrive C).Free/1GB,1)
|
||||
Remove-Item "$env:RUNNER_TEMP\smbuild" -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item "$env:RUNNER_TEMP\base.iso" -Force -ErrorAction SilentlyContinue
|
||||
$after = [math]::Round((Get-PSDrive C).Free/1GB,1)
|
||||
Write-Host " freed build working set: C: ${before}GB -> ${after}GB before persist"
|
||||
Copy-Item "$env:RUNNER_TEMP\out\*" 'C:\silvermetal\out\' -Force
|
||||
Get-ChildItem 'C:\silvermetal\out' | ForEach-Object { Write-Host $_.Name }
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -68,3 +68,7 @@ coverage/
|
||||
|
||||
# SBOM intermediates (final SBOMs are committed; intermediates are not)
|
||||
sbom/work/
|
||||
|
||||
# Driver binaries (e.g. virtio NetKVM netkvmp.exe) must be tracked despite the global
|
||||
# *.exe / *.msi ignores above -- they are referenced by the .inf and DISM needs them.
|
||||
!windows/drivers/**
|
||||
|
||||
68
windows/apps/bootstrap-winget.ps1
Normal file
68
windows/apps/bootstrap-winget.ps1
Normal file
@@ -0,0 +1,68 @@
|
||||
#Requires -Version 5.1
|
||||
# Provision winget (the App Installer) when absent. Windows IoT Enterprise LTSC
|
||||
# ships WITHOUT the inbox Microsoft.DesktopAppInstaller package, so re-registering
|
||||
# it is not enough - we download and install it (plus dependencies) online at apply
|
||||
# time. Best-effort and idempotent: exit 0 if winget ends up available, else 1.
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
|
||||
function Test-Winget {
|
||||
return [bool](Get-Command winget -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
# Fast path 1: winget already on PATH.
|
||||
if (Test-Winget) { exit 0 }
|
||||
|
||||
# Fast path 2: an inbox App Installer package is present - just re-register it.
|
||||
Get-AppxPackage -AllUsers Microsoft.DesktopAppInstaller |
|
||||
ForEach-Object { Add-AppxPackage -DisableDevelopmentMode -Register "$($_.InstallLocation)\AppXManifest.xml" }
|
||||
if (Test-Winget) { exit 0 }
|
||||
|
||||
# Slow path: download + install the App Installer and its dependencies online.
|
||||
$temp = $env:TEMP
|
||||
$bundlePath = Join-Path $temp 'Microsoft.DesktopAppInstaller.msixbundle'
|
||||
$vclibsPath = Join-Path $temp 'Microsoft.VCLibs.x64.14.00.Desktop.appx'
|
||||
$uixamlNupkg = Join-Path $temp 'microsoft.ui.xaml.2.8.6.nupkg'
|
||||
$uixamlExtract = Join-Path $temp 'uixaml.2.8.6'
|
||||
$uixamlAppx = Join-Path $uixamlExtract 'tools\AppX\x64\Release\Microsoft.UI.Xaml.2.8.appx'
|
||||
|
||||
$bundleUrl = 'https://aka.ms/getwinget'
|
||||
$vclibsUrl = 'https://aka.ms/Microsoft.VCLibs.x64.14.00.Desktop.appx'
|
||||
$uixamlUrl = 'https://globalcdn.nuget.org/packages/microsoft.ui.xaml.2.8.6.nupkg'
|
||||
|
||||
function Get-File {
|
||||
param([string]$Url, [string]$Destination)
|
||||
try {
|
||||
Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing
|
||||
return (Test-Path $Destination)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Download the App Installer bundle (required).
|
||||
if (-not (Get-File -Url $bundleUrl -Destination $bundlePath)) { exit 1 }
|
||||
|
||||
# Download the VCLibs desktop dependency (required).
|
||||
if (-not (Get-File -Url $vclibsUrl -Destination $vclibsPath)) { exit 1 }
|
||||
|
||||
# Download the UI.Xaml 2.8 nuget package (a .zip) and extract the appx from it.
|
||||
if (-not (Get-File -Url $uixamlUrl -Destination $uixamlNupkg)) { exit 1 }
|
||||
try {
|
||||
if (Test-Path $uixamlExtract) { Remove-Item -Path $uixamlExtract -Recurse -Force }
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
[System.IO.Compression.ZipFile]::ExtractToDirectory($uixamlNupkg, $uixamlExtract)
|
||||
} catch {
|
||||
exit 1
|
||||
}
|
||||
if (-not (Test-Path $uixamlAppx)) { exit 1 }
|
||||
|
||||
# Install order: VCLibs dependency, then UI.Xaml dependency, then the bundle with
|
||||
# both supplied as DependencyPath. Per-user Add-AppxPackage (toolbox runs as the
|
||||
# real admin user at first logon).
|
||||
Add-AppxPackage -Path $vclibsPath
|
||||
Add-AppxPackage -Path $uixamlAppx
|
||||
Add-AppxPackage -Path $bundlePath -DependencyPath $vclibsPath, $uixamlAppx
|
||||
|
||||
# Final re-check.
|
||||
if (Test-Winget) { exit 0 }
|
||||
exit 1
|
||||
285
windows/apps/catalog.json
Normal file
285
windows/apps/catalog.json
Normal file
@@ -0,0 +1,285 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apps": [
|
||||
{
|
||||
"id": "thunderbird",
|
||||
"name": "Thunderbird",
|
||||
"description": "Open-source email, calendar, and chat client.",
|
||||
"source": { "winget": "Mozilla.Thunderbird" },
|
||||
"group": "essentials",
|
||||
"roles": ["essentials"],
|
||||
"defaultFor": ["essentials"],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "vlc",
|
||||
"name": "VLC",
|
||||
"description": "Plays virtually any audio or video file.",
|
||||
"source": { "winget": "VideoLAN.VLC" },
|
||||
"group": "essentials",
|
||||
"roles": ["essentials"],
|
||||
"defaultFor": ["essentials"],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "7zip",
|
||||
"name": "7-Zip",
|
||||
"description": "High-ratio archive extractor and compressor.",
|
||||
"source": { "winget": "7zip.7zip" },
|
||||
"group": "essentials",
|
||||
"roles": ["essentials"],
|
||||
"defaultFor": ["essentials"],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "libreoffice",
|
||||
"name": "LibreOffice",
|
||||
"description": "Full office suite for documents, sheets, and slides.",
|
||||
"source": { "winget": "TheDocumentFoundation.LibreOffice" },
|
||||
"group": "essentials",
|
||||
"roles": ["essentials"],
|
||||
"defaultFor": ["essentials"],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "ungoogled-chromium",
|
||||
"name": "ungoogled-chromium",
|
||||
"description": "Chromium with Google integration stripped.",
|
||||
"source": { "winget": "eloston.ungoogled-chromium" },
|
||||
"group": "essentials",
|
||||
"roles": ["essentials"],
|
||||
"defaultFor": [],
|
||||
"configure": "ungoogled-chromium.ps1"
|
||||
},
|
||||
{
|
||||
"id": "keepassxc",
|
||||
"name": "KeePassXC",
|
||||
"description": "Offline, encrypted password manager.",
|
||||
"source": { "winget": "KeePassXCTeam.KeePassXC" },
|
||||
"group": "essentials",
|
||||
"roles": ["essentials", "journalist"],
|
||||
"defaultFor": ["journalist"],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "vscodium",
|
||||
"name": "VSCodium",
|
||||
"description": "Telemetry-free VS Code build.",
|
||||
"source": { "winget": "VSCodium.VSCodium" },
|
||||
"group": "developer",
|
||||
"roles": ["developer"],
|
||||
"defaultFor": ["developer"],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "git",
|
||||
"name": "Git",
|
||||
"description": "Distributed version control system.",
|
||||
"source": { "winget": "Git.Git" },
|
||||
"group": "developer",
|
||||
"roles": ["developer"],
|
||||
"defaultFor": ["developer"],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "dotnet-sdk-9",
|
||||
"name": ".NET 9 SDK",
|
||||
"description": "SDK for building .NET 9 applications.",
|
||||
"source": { "winget": "Microsoft.DotNet.SDK.9" },
|
||||
"group": "developer",
|
||||
"roles": ["developer"],
|
||||
"defaultFor": ["developer"],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "nodejs-lts",
|
||||
"name": "Node.js LTS",
|
||||
"description": "JavaScript runtime, long-term-support release.",
|
||||
"source": { "winget": "OpenJS.NodeJS.LTS" },
|
||||
"group": "developer",
|
||||
"roles": ["developer"],
|
||||
"defaultFor": ["developer"],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "windows-terminal",
|
||||
"name": "Windows Terminal",
|
||||
"description": "Modern tabbed terminal for the shell.",
|
||||
"source": { "winget": "Microsoft.WindowsTerminal" },
|
||||
"group": "developer",
|
||||
"roles": ["developer"],
|
||||
"defaultFor": ["developer"],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "powershell-7",
|
||||
"name": "PowerShell 7",
|
||||
"description": "Cross-platform PowerShell shell.",
|
||||
"source": { "winget": "Microsoft.PowerShell" },
|
||||
"group": "developer",
|
||||
"roles": ["developer"],
|
||||
"defaultFor": ["developer"],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "claude-desktop",
|
||||
"name": "Claude Desktop",
|
||||
"description": "Anthropic Claude desktop client.",
|
||||
"source": { "winget": "Anthropic.Claude" },
|
||||
"group": "developer",
|
||||
"roles": ["developer"],
|
||||
"defaultFor": ["developer"],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "visual-studio-2022",
|
||||
"name": "Visual Studio 2022",
|
||||
"description": "Full-featured IDE, Community edition.",
|
||||
"source": { "winget": "Microsoft.VisualStudio.2022.Community" },
|
||||
"group": "developer",
|
||||
"roles": ["developer"],
|
||||
"defaultFor": [],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "jetbrains-rider",
|
||||
"name": "JetBrains Rider",
|
||||
"description": "Cross-platform .NET IDE.",
|
||||
"source": { "winget": "JetBrains.Rider" },
|
||||
"group": "developer",
|
||||
"roles": ["developer"],
|
||||
"defaultFor": [],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "docker-desktop",
|
||||
"name": "Docker Desktop",
|
||||
"description": "Container build and run environment.",
|
||||
"source": { "winget": "Docker.DockerDesktop" },
|
||||
"group": "developer",
|
||||
"roles": ["developer"],
|
||||
"defaultFor": [],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "google-chrome",
|
||||
"name": "Google Chrome",
|
||||
"description": "Google's web browser.",
|
||||
"source": { "winget": "Google.Chrome" },
|
||||
"group": "developer",
|
||||
"roles": ["developer"],
|
||||
"defaultFor": [],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "postgresql",
|
||||
"name": "PostgreSQL",
|
||||
"description": "Relational database server.",
|
||||
"source": { "winget": "PostgreSQL.PostgreSQL" },
|
||||
"group": "developer",
|
||||
"roles": ["developer"],
|
||||
"defaultFor": [],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "bruno",
|
||||
"name": "Bruno",
|
||||
"description": "Offline-first API client.",
|
||||
"source": { "winget": "Bruno.Bruno" },
|
||||
"group": "developer",
|
||||
"roles": ["developer"],
|
||||
"defaultFor": [],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "veracrypt",
|
||||
"name": "VeraCrypt",
|
||||
"description": "On-the-fly disk and volume encryption.",
|
||||
"source": { "winget": "IDRIX.VeraCrypt" },
|
||||
"group": "journalist",
|
||||
"roles": ["journalist", "privacy-max"],
|
||||
"defaultFor": ["journalist"],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "joplin",
|
||||
"name": "Joplin",
|
||||
"description": "Encrypted, open-source note-taking app.",
|
||||
"source": { "winget": "Joplin.Joplin" },
|
||||
"group": "journalist",
|
||||
"roles": ["journalist"],
|
||||
"defaultFor": ["journalist"],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "obs-studio",
|
||||
"name": "OBS Studio",
|
||||
"description": "Screen recording and live streaming.",
|
||||
"source": { "winget": "OBSProject.OBSStudio" },
|
||||
"group": "journalist",
|
||||
"roles": ["journalist"],
|
||||
"defaultFor": [],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "standard-notes",
|
||||
"name": "Standard Notes",
|
||||
"description": "End-to-end encrypted notes app.",
|
||||
"source": { "winget": "StandardNotes.StandardNotes" },
|
||||
"group": "journalist",
|
||||
"roles": ["journalist"],
|
||||
"defaultFor": [],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "signal",
|
||||
"name": "Signal",
|
||||
"description": "Encrypted private messaging.",
|
||||
"source": { "winget": "OpenWhisperSystems.Signal" },
|
||||
"group": "journalist",
|
||||
"roles": ["journalist"],
|
||||
"defaultFor": [],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "tor-browser",
|
||||
"name": "Tor Browser",
|
||||
"description": "Anonymous browsing over the Tor network.",
|
||||
"source": { "winget": "TorProject.TorBrowser" },
|
||||
"group": "journalist",
|
||||
"roles": ["journalist"],
|
||||
"defaultFor": [],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "spotify",
|
||||
"name": "Spotify",
|
||||
"description": "Music and podcast streaming.",
|
||||
"source": { "winget": "Spotify.Spotify" },
|
||||
"group": "daily-driver",
|
||||
"roles": ["daily-driver"],
|
||||
"defaultFor": [],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "zoom",
|
||||
"name": "Zoom",
|
||||
"description": "Video conferencing client.",
|
||||
"source": { "winget": "Zoom.Zoom" },
|
||||
"group": "daily-driver",
|
||||
"roles": ["daily-driver"],
|
||||
"defaultFor": [],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "discord",
|
||||
"name": "Discord",
|
||||
"description": "Voice, video, and text chat.",
|
||||
"source": { "winget": "Discord.Discord" },
|
||||
"group": "daily-driver",
|
||||
"roles": ["daily-driver"],
|
||||
"defaultFor": [],
|
||||
"configure": null
|
||||
}
|
||||
]
|
||||
}
|
||||
7
windows/apps/configure/ungoogled-chromium.ps1
Normal file
7
windows/apps/configure/ungoogled-chromium.ps1
Normal file
@@ -0,0 +1,7 @@
|
||||
#Requires -Version 5.1
|
||||
$ErrorActionPreference='SilentlyContinue'
|
||||
$pol='HKLM:\SOFTWARE\Policies\Chromium'
|
||||
New-Item $pol -Force | Out-Null
|
||||
New-ItemProperty $pol -Name 'ForceGoogleSafeSearch' -Value 1 -PropertyType DWord -Force | Out-Null
|
||||
$ext="$pol\ExtensionInstallSources"; New-Item $ext -Force | Out-Null
|
||||
New-ItemProperty $ext -Name '1' -Value 'https://chrome.google.com/webstore/*' -PropertyType String -Force | Out-Null
|
||||
258
windows/collector/Collector.ps1
Normal file
258
windows/collector/Collector.ps1
Normal file
@@ -0,0 +1,258 @@
|
||||
#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 + 120))
|
||||
$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 + 120))
|
||||
$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 (e.g. WinForms can't load in WinPE) -> log it where Start-Collector.cmd
|
||||
# can show it on the console, then exit 1 so the wrapper falls back to the default answer file.
|
||||
try { ($_ | Out-String) | Set-Content 'X:\sm\collector-error.txt' -Encoding ASCII } catch {}
|
||||
Write-Host ('Collector error: ' + ($_ | Out-String))
|
||||
[Environment]::Exit(1)
|
||||
}
|
||||
131
windows/collector/New-SmAnswerFile.ps1
Normal file
131
windows/collector/New-SmAnswerFile.ps1
Normal file
@@ -0,0 +1,131 @@
|
||||
#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 the
|
||||
# oobeSystem FirstLogonCommands write (in short base64 chunks) to
|
||||
# C:\ProgramData\SilverMetal\preconfig.json. The base64 is carried in chunked echo
|
||||
# commands rather than a single specialize RunSynchronousCommand/Path, because that Path
|
||||
# is capped at ~259 chars and a full base64 blob overflows it -> "answer file is invalid".
|
||||
|
||||
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
|
||||
|
||||
# Build the oobeSystem FirstLogonCommands. The preconfig base64 is split into short
|
||||
# (<=150 char) chunks, each appended to a temp file by its own `echo` command, then
|
||||
# the file is whitespace-stripped + base64-decoded into preconfig.json. This keeps
|
||||
# every single command line well under the unattend length limits.
|
||||
$preDir = 'C:\ProgramData\SilverMetal'
|
||||
$preB64File = "$preDir\pre.b64"
|
||||
$preFile = "$preDir\preconfig.json"
|
||||
|
||||
# Split base64 into chunks of at most 150 chars (base64 alphabet has no XML/cmd
|
||||
# metachars, so chunks are safe in `echo` and in XML once `>` is escaped).
|
||||
$chunkSize = 150
|
||||
$chunks = for ($i = 0; $i -lt $preB64.Length; $i += $chunkSize) {
|
||||
$preB64.Substring($i, [Math]::Min($chunkSize, $preB64.Length - $i))
|
||||
}
|
||||
|
||||
$cmds = New-Object System.Collections.Generic.List[string]
|
||||
# 1. Create the target dir.
|
||||
$cmds.Add("cmd /c md ""$preDir"" 2>nul")
|
||||
# 2..N. Append each base64 chunk to the temp file.
|
||||
foreach ($c in $chunks) {
|
||||
$cmds.Add("cmd /c >>""$preB64File"" echo $c")
|
||||
}
|
||||
# N+1. Strip whitespace (chunks are newline-separated in the file) and decode.
|
||||
$cmds.Add("powershell -nop -c ""[IO.File]::WriteAllBytes('$preFile',[Convert]::FromBase64String(((gc '$preB64File' -raw) -replace '\s','')))""")
|
||||
# N+2. Clean up the temp file.
|
||||
$cmds.Add("cmd /c del ""$preB64File""")
|
||||
# N+3 (LAST). Launch the SilverMetal toolbox (run-once).
|
||||
$cmds.Add("cmd /c powershell -NoProfile -ExecutionPolicy Bypass -Command ""Start-Process -FilePath 'C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe' -Verb RunAs""")
|
||||
|
||||
$firstLogonSb = New-Object System.Text.StringBuilder
|
||||
$order = 0
|
||||
foreach ($cmd in $cmds) {
|
||||
$order++
|
||||
[void]$firstLogonSb.AppendLine(" <SynchronousCommand wcm:action=""add"" xmlns:wcm=""http://schemas.microsoft.com/WMIConfig/2002/State"">")
|
||||
[void]$firstLogonSb.AppendLine(" <Order>$order</Order>")
|
||||
[void]$firstLogonSb.AppendLine(" <CommandLine>$(EscContent $cmd)</CommandLine>")
|
||||
[void]$firstLogonSb.AppendLine(" </SynchronousCommand>")
|
||||
}
|
||||
$firstLogonCommands = $firstLogonSb.ToString().TrimEnd()
|
||||
|
||||
@"
|
||||
<?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="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>
|
||||
$firstLogonCommands
|
||||
</FirstLogonCommands>
|
||||
<RegisteredOwner>SilverMetal</RegisteredOwner><RegisteredOrganization>SilverLABS</RegisteredOrganization>
|
||||
</component>
|
||||
</settings>
|
||||
</unattend>
|
||||
"@
|
||||
}
|
||||
26
windows/collector/Start-Collector.cmd
Normal file
26
windows/collector/Start-Collector.cmd
Normal file
@@ -0,0 +1,26 @@
|
||||
@echo off
|
||||
set "SETUP=X:\sources\setup.exe"
|
||||
if not exist "%SETUP%" set "SETUP=X:\setup.exe"
|
||||
REM WinPE entry point (launched via Setup\CmdLine). SM_UNATTENDED=1 -> skip the UI and
|
||||
REM launch Setup with the default answer file (used by CI / non-interactive builds).
|
||||
if "%SM_UNATTENDED%"=="1" (
|
||||
start /wait "%SETUP%" /unattend:X:\autounattend.xml
|
||||
exit /b 0
|
||||
)
|
||||
REM Initialise WinPE (a Setup\CmdLine launch can bypass the normal startnet/wpeinit).
|
||||
wpeinit
|
||||
echo ============================================
|
||||
echo SilverMetal pre-config collector
|
||||
echo ============================================
|
||||
del /f /q X:\sm\collector-error.txt 2>nul
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File X:\sm\Collector.ps1
|
||||
set RC=%errorlevel%
|
||||
if %RC% GEQ 1 (
|
||||
echo.
|
||||
echo Collector exited with code %RC% -- falling back to default unattended install.
|
||||
if exist X:\sm\collector-error.txt type X:\sm\collector-error.txt
|
||||
echo (pausing ~25s so this is readable on the console)
|
||||
ping -n 26 127.0.0.1 >nul
|
||||
start /wait "%SETUP%" /unattend:X:\autounattend.xml
|
||||
)
|
||||
exit /b 0
|
||||
36
windows/collector/Test-SmInput.ps1
Normal file
36
windows/collector/Test-SmInput.ps1
Normal file
@@ -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
|
||||
}
|
||||
2
windows/collector/winpeshl.ini
Normal file
2
windows/collector/winpeshl.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
[LaunchApps]
|
||||
%SYSTEMDRIVE%\sm\Start-Collector.cmd
|
||||
@@ -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).
|
||||
730
windows/docs/superpowers/plans/2026-06-10-wizard-app-recipes.md
Normal file
730
windows/docs/superpowers/plans/2026-06-10-wizard-app-recipes.md
Normal file
@@ -0,0 +1,730 @@
|
||||
# Wizard App Recipes Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a per-role app-install picker to the SilverOS Welcome wizard — choosing a role shows grouped, pre-checked app checkboxes (role tools + privacy-trimmed essentials) that get installed via winget during Apply.
|
||||
|
||||
**Architecture:** A JSON app catalog (staged into the image like flavours) loaded by a Core loader mirroring `FlavourLoader`; a new `AppsStep` Blazor step writing selected ids into `WizardState`; an `AppInstaller` Core service that bootstraps winget and runs `winget install` per selected app (continue-on-failure) plus optional `configure` scripts; wired into `ApplyService` after the Stack and before BitLocker.
|
||||
|
||||
**Tech Stack:** .NET 9 / C# (SilverOS.Welcome.Core + .UI), MAUI Blazor, winget, xUnit + Moq, System.Text.Json.
|
||||
|
||||
**Spec:** [`../specs/2026-06-09-wizard-app-recipes-design.md`](../specs/2026-06-09-wizard-app-recipes-design.md)
|
||||
|
||||
**Branch:** `feat/app-recipes` (spec committed at `583ed44`).
|
||||
|
||||
**Conventions (match existing code):**
|
||||
- Loaders mirror `FlavourLoader` (`Core/Flavours/FlavourLoader.cs`) + `FlavourManifest.JsonOptions` (case-insensitive, comments, trailing commas).
|
||||
- `IProcessRunner.RunAsync(string file, string args, CancellationToken ct)` → `ProcessResult(ExitCode, StdOut, StdErr)` with `.EnsureSuccess(op)`.
|
||||
- Services are `AddSingleton` in `MauiProgram.cs`; `ApplyService` is built via a factory (lines 40-45) and takes a directory string (`hardeningDir`).
|
||||
- Tests: xUnit + Moq in `windows/welcome/tests/SilverOS.Welcome.Tests`. Run with `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release`.
|
||||
- The app namespace root is `SilverOS.Welcome.App` even in the `.UI`/`.Core` projects (see existing files).
|
||||
|
||||
---
|
||||
|
||||
## Phase A — App catalog (Core)
|
||||
|
||||
### Task A1: AppCatalogEntry + AppCatalog records
|
||||
|
||||
**Files:**
|
||||
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalogEntry.cs`
|
||||
- Test: `windows/welcome/tests/SilverOS.Welcome.Tests/AppCatalogTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
`AppCatalogTests.cs`:
|
||||
```csharp
|
||||
using System.Text.Json;
|
||||
using SilverOS.Welcome.Core.Apps;
|
||||
using Xunit;
|
||||
|
||||
public class AppCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void Deserializes_a_catalog_entry()
|
||||
{
|
||||
var json = """
|
||||
{ "id":"vscodium","name":"VSCodium","description":"Telemetry-free VS Code.",
|
||||
"source":{"winget":"VSCodium.VSCodium"},"group":"developer",
|
||||
"roles":["developer"],"defaultFor":["developer"],"configure":null }
|
||||
""";
|
||||
var e = JsonSerializer.Deserialize<AppCatalogEntry>(json, AppCatalogEntry.JsonOptions)!;
|
||||
Assert.Equal("vscodium", e.Id);
|
||||
Assert.Equal("VSCodium.VSCodium", e.Source.Winget);
|
||||
Assert.Contains("developer", e.Roles);
|
||||
Assert.Contains("developer", e.DefaultFor);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release --filter AppCatalogTests`
|
||||
Expected: FAIL — `AppCatalogEntry` not defined.
|
||||
|
||||
- [ ] **Step 3: Implement the records**
|
||||
|
||||
`AppCatalogEntry.cs`:
|
||||
```csharp
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SilverOS.Welcome.Core.Apps;
|
||||
|
||||
public sealed record AppSource
|
||||
{
|
||||
public string? Winget { get; init; }
|
||||
// Future: public string? Mirror { get; init; } // swappable to a curated mirror.
|
||||
}
|
||||
|
||||
public sealed record AppCatalogEntry
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string Name { get; init; } = "";
|
||||
public string Description { get; init; } = "";
|
||||
public AppSource Source { get; init; } = new();
|
||||
public string Group { get; init; } = "";
|
||||
public IReadOnlyList<string> Roles { get; init; } = Array.Empty<string>();
|
||||
public IReadOnlyList<string> DefaultFor { get; init; } = Array.Empty<string>();
|
||||
public string? Configure { get; init; }
|
||||
|
||||
public static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release --filter AppCatalogTests`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalogEntry.cs windows/welcome/tests/SilverOS.Welcome.Tests/AppCatalogTests.cs
|
||||
git commit -m "feat(apps): AppCatalogEntry record + test"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task A2: AppCatalog loader + role filtering
|
||||
|
||||
**Files:**
|
||||
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalog.cs`
|
||||
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppCatalog.cs`
|
||||
- Modify: `windows/welcome/tests/SilverOS.Welcome.Tests/AppCatalogTests.cs` (append)
|
||||
|
||||
- [ ] **Step 1: Write failing tests (append)**
|
||||
|
||||
```csharp
|
||||
public class AppCatalogLoaderTests
|
||||
{
|
||||
static string WriteCatalog(string body)
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "smcat-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(Path.Combine(dir, "catalog.json"), body);
|
||||
return dir;
|
||||
}
|
||||
|
||||
const string Body = """
|
||||
{ "schemaVersion":1, "apps":[
|
||||
{"id":"tb","name":"Thunderbird","source":{"winget":"Mozilla.Thunderbird"},"group":"essentials","roles":["essentials"],"defaultFor":["essentials"]},
|
||||
{"id":"vscodium","name":"VSCodium","source":{"winget":"VSCodium.VSCodium"},"group":"developer","roles":["developer"],"defaultFor":["developer"]},
|
||||
{"id":"rider","name":"Rider","source":{"winget":"JetBrains.Rider"},"group":"developer","roles":["developer"],"defaultFor":[]}
|
||||
]}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void AppsForRole_returns_essentials_plus_role()
|
||||
{
|
||||
var c = new AppCatalog().Load(WriteCatalog(Body));
|
||||
var ids = c.AppsForRole("developer").Select(a => a.Id).ToList();
|
||||
Assert.Contains("tb", ids); // essentials (all roles)
|
||||
Assert.Contains("vscodium", ids); // developer
|
||||
Assert.Contains("rider", ids); // developer (offered, not default)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSelection_only_pre_checks_defaultFor()
|
||||
{
|
||||
var c = new AppCatalog().Load(WriteCatalog(Body));
|
||||
var def = c.DefaultSelectionForRole("developer");
|
||||
Assert.Contains("tb", def);
|
||||
Assert.Contains("vscodium", def);
|
||||
Assert.DoesNotContain("rider", def);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_catalog_returns_empty_not_throw()
|
||||
{
|
||||
var c = new AppCatalog().Load(Path.Combine(Path.GetTempPath(), "nope-" + Guid.NewGuid().ToString("N")));
|
||||
Assert.Empty(c.All);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure** → `AppCatalog` not defined.
|
||||
|
||||
- [ ] **Step 3: Implement loader**
|
||||
|
||||
`IAppCatalog.cs`:
|
||||
```csharp
|
||||
namespace SilverOS.Welcome.Core.Apps;
|
||||
|
||||
public interface IAppCatalog
|
||||
{
|
||||
LoadedCatalog Load(string directory);
|
||||
}
|
||||
```
|
||||
|
||||
`AppCatalog.cs`:
|
||||
```csharp
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SilverOS.Welcome.Core.Apps;
|
||||
|
||||
public sealed record LoadedCatalog(IReadOnlyList<AppCatalogEntry> All)
|
||||
{
|
||||
// Essentials (offered to all roles) first, then the role's own apps. Stable, de-duped by id.
|
||||
public IReadOnlyList<AppCatalogEntry> AppsForRole(string role) =>
|
||||
All.Where(a => a.Roles.Contains("essentials") || a.Roles.Contains(role))
|
||||
.GroupBy(a => a.Id).Select(g => g.First())
|
||||
.OrderByDescending(a => a.Group == "essentials").ThenBy(a => a.Name).ToList();
|
||||
|
||||
public IReadOnlyList<string> DefaultSelectionForRole(string role) =>
|
||||
AppsForRole(role).Where(a => a.DefaultFor.Contains("essentials") || a.DefaultFor.Contains(role))
|
||||
.Select(a => a.Id).ToList();
|
||||
}
|
||||
|
||||
public sealed class AppCatalog : IAppCatalog
|
||||
{
|
||||
private sealed record CatalogFile(int SchemaVersion, IReadOnlyList<AppCatalogEntry>? Apps);
|
||||
|
||||
public LoadedCatalog Load(string directory)
|
||||
{
|
||||
var path = Path.Combine(directory, "catalog.json");
|
||||
if (!File.Exists(path)) return new LoadedCatalog(Array.Empty<AppCatalogEntry>());
|
||||
try
|
||||
{
|
||||
var f = JsonSerializer.Deserialize<CatalogFile>(File.ReadAllText(path), AppCatalogEntry.JsonOptions);
|
||||
return new LoadedCatalog(f?.Apps ?? Array.Empty<AppCatalogEntry>());
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A bad catalog must never block onboarding — degrade to "no extra apps".
|
||||
return new LoadedCatalog(Array.Empty<AppCatalogEntry>());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Note: `IAppCatalog.Load` returns `LoadedCatalog`; the test calls `new AppCatalog().Load(dir)` then `.AppsForRole`/`.DefaultSelectionForRole`/`.All`.
|
||||
|
||||
- [ ] **Step 4: Run to verify pass** → all AppCatalog tests green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalog.cs windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppCatalog.cs windows/welcome/tests/SilverOS.Welcome.Tests/AppCatalogTests.cs
|
||||
git commit -m "feat(apps): AppCatalog loader + role filtering"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase B — Install engine (Core)
|
||||
|
||||
### Task B1: IAppInstaller + AppInstaller (winget)
|
||||
|
||||
**Files:**
|
||||
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppInstaller.cs`
|
||||
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs`
|
||||
- Test: `windows/welcome/tests/SilverOS.Welcome.Tests/AppInstallerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
`AppInstallerTests.cs`:
|
||||
```csharp
|
||||
using Moq;
|
||||
using SilverOS.Welcome.Core.Apps;
|
||||
using SilverOS.Welcome.Core.Apply;
|
||||
|
||||
public class AppInstallerTests
|
||||
{
|
||||
static AppCatalogEntry App(string id, string winget, string? cfg = null) =>
|
||||
new() { Id = id, Name = id, Source = new AppSource { Winget = winget }, Configure = cfg };
|
||||
|
||||
static Mock<IProcessRunner> Runner(int exit = 0) {
|
||||
var m = new Mock<IProcessRunner>();
|
||||
m.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(exit, "", ""));
|
||||
return m;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Installs_each_selected_app_via_winget()
|
||||
{
|
||||
var run = Runner();
|
||||
// winget present -> no bootstrap needed
|
||||
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(0, "v1.8", ""));
|
||||
var sut = new AppInstaller(run.Object, "C:\\apps");
|
||||
var res = await sut.InstallAsync(new[] { App("vscodium", "VSCodium.VSCodium") },
|
||||
new Progress<ApplyProgress>(_ => { }));
|
||||
run.Verify(r => r.RunAsync("winget", It.Is<string>(s =>
|
||||
s.Contains("install") && s.Contains("VSCodium.VSCodium") && s.Contains("--silent")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
Assert.True(res.Single().Installed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstraps_winget_when_absent()
|
||||
{
|
||||
var run = Runner();
|
||||
// winget --version fails -> absent -> bootstrap path runs powershell
|
||||
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(1, "", "not found"));
|
||||
var sut = new AppInstaller(run.Object, "C:\\apps");
|
||||
await sut.InstallAsync(new[] { App("tb", "Mozilla.Thunderbird") }, new Progress<ApplyProgress>(_ => { }));
|
||||
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
|
||||
s.Contains("DesktopAppInstaller")), It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_app_failure_does_not_stop_the_rest()
|
||||
{
|
||||
var run = new Mock<IProcessRunner>();
|
||||
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(0, "v1.8", ""));
|
||||
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("Bad.App")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(1, "", "fail"));
|
||||
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("Good.App")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(0, "", ""));
|
||||
var sut = new AppInstaller(run.Object, "C:\\apps");
|
||||
var res = await sut.InstallAsync(new[] { App("bad","Bad.App"), App("good","Good.App") },
|
||||
new Progress<ApplyProgress>(_ => { }));
|
||||
Assert.False(res.First(r => r.Id == "bad").Installed);
|
||||
Assert.True(res.First(r => r.Id == "good").Installed);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure** → `AppInstaller`/`IAppInstaller` not defined.
|
||||
|
||||
- [ ] **Step 3: Implement the installer**
|
||||
|
||||
`IAppInstaller.cs`:
|
||||
```csharp
|
||||
using SilverOS.Welcome.Core.Apply;
|
||||
|
||||
namespace SilverOS.Welcome.Core.Apps;
|
||||
|
||||
public sealed record AppInstallResult(string Id, bool Installed);
|
||||
|
||||
public interface IAppInstaller
|
||||
{
|
||||
Task<IReadOnlyList<AppInstallResult>> InstallAsync(
|
||||
IReadOnlyList<AppCatalogEntry> apps, IProgress<ApplyProgress> progress, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
`AppInstaller.cs`:
|
||||
```csharp
|
||||
using SilverOS.Welcome.Core.Apply;
|
||||
|
||||
namespace SilverOS.Welcome.Core.Apps;
|
||||
|
||||
public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppInstaller
|
||||
{
|
||||
public async Task<IReadOnlyList<AppInstallResult>> InstallAsync(
|
||||
IReadOnlyList<AppCatalogEntry> apps, IProgress<ApplyProgress> progress, CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<AppInstallResult>();
|
||||
if (apps.Count == 0) return results;
|
||||
|
||||
await EnsureWingetAsync(ct);
|
||||
|
||||
var i = 0;
|
||||
foreach (var app in apps)
|
||||
{
|
||||
i++;
|
||||
progress.Report(new($"Installing {app.Name} ({i}/{apps.Count})", 80));
|
||||
var ok = false;
|
||||
var id = app.Source.Winget;
|
||||
if (!string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
var r = await runner.RunAsync("winget",
|
||||
$"install --id {id} --silent --accept-package-agreements --accept-source-agreements --disable-interactivity", ct);
|
||||
ok = r.ExitCode == 0;
|
||||
if (ok && !string.IsNullOrWhiteSpace(app.Configure))
|
||||
{
|
||||
var script = Path.Combine(appsDir, "configure", app.Configure);
|
||||
await runner.RunAsync("powershell.exe",
|
||||
$"-NoProfile -ExecutionPolicy Bypass -File \"{script}\"", ct); // best-effort config
|
||||
}
|
||||
}
|
||||
results.Add(new AppInstallResult(app.Id, ok));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// winget (App Installer) is NOT in IoT Enterprise LTSC. Detect, and if absent, provision it.
|
||||
private async Task EnsureWingetAsync(CancellationToken ct)
|
||||
{
|
||||
var probe = await runner.RunAsync("winget", "--version", ct);
|
||||
if (probe.ExitCode == 0) return;
|
||||
var bootstrap = Path.Combine(appsDir, "bootstrap-winget.ps1");
|
||||
await runner.RunAsync("powershell.exe",
|
||||
$"-NoProfile -ExecutionPolicy Bypass -Command \"if (Test-Path '{bootstrap}') {{ & '{bootstrap}' }} else {{ " +
|
||||
"Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe -EA SilentlyContinue }}\"", ct);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> The test asserts a `powershell.exe` call containing `DesktopAppInstaller` during bootstrap — the
|
||||
> inline command above contains it. The real `bootstrap-winget.ps1` (staged) does the robust
|
||||
> install; this keeps the engine testable without the script present.
|
||||
|
||||
- [ ] **Step 4: Run to verify pass** → AppInstaller tests green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppInstaller.cs windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs windows/welcome/tests/SilverOS.Welcome.Tests/AppInstallerTests.cs
|
||||
git commit -m "feat(apps): winget install engine (bootstrap + per-app + configure, continue-on-failure)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase C — Wizard step (UI)
|
||||
|
||||
### Task C1: WizardState.SelectedApps
|
||||
|
||||
**Files:**
|
||||
- Modify: `windows/welcome/src/SilverOS.Welcome.UI/Components/WizardState.cs`
|
||||
|
||||
- [ ] **Step 1: Add the property**
|
||||
|
||||
In `WizardState`, after `BitLockerPin`:
|
||||
```csharp
|
||||
// Apps step: ids of catalog apps the user chose to install.
|
||||
public HashSet<string> SelectedApps { get; set; } = new();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build** `dotnet build windows/welcome/src/SilverOS.Welcome.UI -c Release` → 0 errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add windows/welcome/src/SilverOS.Welcome.UI/Components/WizardState.cs
|
||||
git commit -m "feat(apps): WizardState.SelectedApps"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task C2: AppsStep.razor
|
||||
|
||||
**Files:**
|
||||
- Create: `windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/AppsStep.razor`
|
||||
|
||||
- [ ] **Step 1: Create the step**
|
||||
|
||||
`AppsStep.razor`:
|
||||
```razor
|
||||
@using SilverOS.Welcome.Core.Apps
|
||||
@inject WizardState State
|
||||
|
||||
<div class="step apps-step">
|
||||
<h1>Choose your apps</h1>
|
||||
<p class="step-subtitle">We'll install these during setup. The SilverLABS Stack (browser, VPN, keys) is already included.</p>
|
||||
|
||||
@foreach (var grp in _groups)
|
||||
{
|
||||
<h3 class="apps-group">@GroupTitle(grp.Key)</h3>
|
||||
<div class="apps-grid">
|
||||
@foreach (var app in grp)
|
||||
{
|
||||
<label class="app-card @(State.SelectedApps.Contains(app.Id) ? "selected" : "")">
|
||||
<input type="checkbox" checked="@State.SelectedApps.Contains(app.Id)"
|
||||
@onchange="e => Toggle(app.Id, (bool)e.Value!)" />
|
||||
<span class="app-name">@app.Name</span>
|
||||
<span class="app-desc">@app.Description</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<AppCatalogEntry> Apps { get; set; } = Array.Empty<AppCatalogEntry>();
|
||||
|
||||
private IEnumerable<IGrouping<string, AppCatalogEntry>> _groups =>
|
||||
Apps.GroupBy(a => a.Group).OrderByDescending(g => g.Key == "essentials");
|
||||
|
||||
private static string GroupTitle(string g) => g switch
|
||||
{
|
||||
"essentials" => "Essentials",
|
||||
"developer" => "Developer tools",
|
||||
"journalist" => "Journalist tools",
|
||||
"daily-driver" => "Everyday apps",
|
||||
"privacy-max" => "Privacy tools",
|
||||
_ => g
|
||||
};
|
||||
|
||||
void Toggle(string id, bool on)
|
||||
{
|
||||
if (on) State.SelectedApps.Add(id); else State.SelectedApps.Remove(id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build** the UI project → 0 errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/AppsStep.razor
|
||||
git commit -m "feat(apps): AppsStep grouped checkboxes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task C3: Wire AppsStep into Routes (after Flavour)
|
||||
|
||||
**Files:**
|
||||
- Modify: `windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor`
|
||||
|
||||
Read `Routes.razor` first. The wizard is a fixed-index switch with `_stepTitles`. Insert "Apps"
|
||||
as step index 2 (between Flavour=1 and Account, which becomes 3), and load the catalog like flavours.
|
||||
|
||||
- [ ] **Step 1: Add the catalog field + load**
|
||||
|
||||
In `Routes.razor` `@code`, alongside the flavour fields, inject + load the catalog:
|
||||
```razor
|
||||
@inject IAppCatalog AppCatalog
|
||||
```
|
||||
```csharp
|
||||
private LoadedCatalog _catalog = new(Array.Empty<AppCatalogEntry>());
|
||||
private static readonly string AppsDir = Path.Combine(AppContext.BaseDirectory, "apps");
|
||||
```
|
||||
In `LoadFlavours()` (or `OnInitializedAsync`), after flavours load:
|
||||
```csharp
|
||||
_catalog = AppCatalog.Load(AppsDir);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `_stepTitles` and the switch**
|
||||
|
||||
Change titles to: `{ "Welcome", "Flavour", "Apps", "Account", "Prefs", "Apply", "Done" }`.
|
||||
Insert the Apps case and renumber the rest:
|
||||
```razor
|
||||
case 2:
|
||||
<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(); })" />
|
||||
break;
|
||||
case 6:
|
||||
<DoneStep />
|
||||
break;
|
||||
```
|
||||
Update `CanGoNext` indices (Flavour stays 1; Account becomes 3): the `2 => _accountValid` case becomes `3 => _accountValid`, and the Apply step index in `Next()`/`_applyRunning` guard (`_currentStep != 4` → `_currentStep != 5`).
|
||||
|
||||
- [ ] **Step 3: Pre-select defaults when entering the Apps step**
|
||||
|
||||
In `FlavourStep`'s selection (or when advancing to Apps), seed `State.SelectedApps` from the catalog defaults. Simplest: in `Routes` `Next()`, when moving **into** step 2 and `SelectedApps` is empty, seed it:
|
||||
```csharp
|
||||
void Next()
|
||||
{
|
||||
if (_currentStep < _stepTitles.Length - 1) _currentStep++;
|
||||
if (_currentStep == 2 && State.SelectedApps.Count == 0 && State.Flavour is not null)
|
||||
foreach (var id in _catalog.DefaultSelectionForRole(State.Flavour.Id)) State.SelectedApps.Add(id);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build** the UI project → 0 errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor
|
||||
git commit -m "feat(apps): insert Apps step after Flavour + seed per-role defaults"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase D — Apply integration
|
||||
|
||||
### Task D1: ApplyRequest.SelectedApps + ApplyService + DI
|
||||
|
||||
**Files:**
|
||||
- Modify: `windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs`
|
||||
- Modify: `windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs`
|
||||
- Modify: `windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor`
|
||||
- Modify: `windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs`
|
||||
- Modify: `windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs` (and any other ApplyService test mocks)
|
||||
|
||||
- [ ] **Step 1: Extend ApplyRequest**
|
||||
|
||||
`ApplyRequest.cs`:
|
||||
```csharp
|
||||
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);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Inject IAppInstaller into ApplyService + run after Stack, before BitLocker**
|
||||
|
||||
`ApplyService.cs` ctor — add `IAppInstaller installer`:
|
||||
```csharp
|
||||
public sealed class ApplyService(IProcessRunner runner, IAccountService accounts,
|
||||
IBitLockerService bitlocker, IBootstrapService bootstrap, IAppInstaller installer, string hardeningDir) : IApplyService
|
||||
```
|
||||
After `CreateAccountsAsync` and before `bitlocker.EnableAsync`:
|
||||
```csharp
|
||||
progress.Report(new("Installing apps", 70));
|
||||
await installer.InstallAsync(req.Apps, progress, ct); // continue-on-failure; never throws
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update MauiProgram DI**
|
||||
|
||||
`MauiProgram.cs`: add (near the other singletons)
|
||||
```csharp
|
||||
builder.Services.AddSingleton<IAppCatalog, AppCatalog>();
|
||||
var appsDir = Path.Combine(AppContext.BaseDirectory, "apps");
|
||||
builder.Services.AddSingleton<IAppInstaller>(sp => new AppInstaller(sp.GetRequiredService<IProcessRunner>(), appsDir));
|
||||
```
|
||||
And add `installer` to the `ApplyService` factory:
|
||||
```csharp
|
||||
builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
|
||||
sp.GetRequiredService<IProcessRunner>(),
|
||||
sp.GetRequiredService<IAccountService>(),
|
||||
sp.GetRequiredService<IBitLockerService>(),
|
||||
sp.GetRequiredService<IBootstrapService>(),
|
||||
sp.GetRequiredService<IAppInstaller>(),
|
||||
hardeningDir));
|
||||
```
|
||||
|
||||
- [ ] **Step 4: ApplyStep passes the selected entries**
|
||||
|
||||
`ApplyStep.razor` — where it builds `ApplyRequest`, resolve the selected ids to entries and pass them. Inject `IAppCatalog`, load once, then:
|
||||
```csharp
|
||||
[Inject] IAppCatalog AppCatalog { get; set; } = default!;
|
||||
// ...
|
||||
var apps = AppCatalog.Load(Path.Combine(AppContext.BaseDirectory, "apps"))
|
||||
.All.Where(a => State.SelectedApps.Contains(a.Id)).ToList();
|
||||
var req = new ApplyRequest(State.Flavour!, State.Username, State.Password,
|
||||
State.AdminPassword, State.BitLockerPin, "sm-bootstrap", apps);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Fix the existing ApplyService test mocks**
|
||||
|
||||
In `ApplyServiceTests.cs` / `ApplyServicesTests.cs` / `ApplyServiceHardeningIntegrationTests.cs`: every `new ApplyService(...)` gains a mock `IAppInstaller` arg, and every `new ApplyRequest(...)` gains a final `Array.Empty<AppCatalogEntry>()` (or a small list). Add an installer mock:
|
||||
```csharp
|
||||
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>());
|
||||
```
|
||||
and a callback to record ordering if those tests assert order (insert `"apps"` between `"accounts"` and `"bitlocker"` in the expected sequence).
|
||||
|
||||
- [ ] **Step 6: Run the whole suite**
|
||||
|
||||
Run: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release`
|
||||
Expected: PASS (all green, including updated ordering assertions).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add windows/welcome
|
||||
git commit -m "feat(apps): install selected apps during Apply (after Stack, before BitLocker)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase E — Catalog data + build staging
|
||||
|
||||
### Task E1: catalog.json + configure + bootstrap + build.ps1 staging
|
||||
|
||||
**Files:**
|
||||
- Create: `windows/apps/catalog.json`
|
||||
- Create: `windows/apps/configure/ungoogled-chromium.ps1`
|
||||
- Create: `windows/apps/bootstrap-winget.ps1`
|
||||
- Modify: `windows/installer/build.ps1`
|
||||
|
||||
- [ ] **Step 1: Write `catalog.json`** (the §4 table from the spec, all entries, UTF-8 no-BOM is fine — JSON read by .NET)
|
||||
|
||||
Create `windows/apps/catalog.json` with `schemaVersion:1` and the full `apps` array exactly per the spec §4 table (id/name/description/source.winget/group/roles/defaultFor), with `eloston.ungoogled-chromium` carrying `"configure":"ungoogled-chromium.ps1"`. (Claude Code CLI entry: omit from v1 or give `"source":{}` and skip — winget-only engine ignores empty source.)
|
||||
|
||||
- [ ] **Step 2: `configure/ungoogled-chromium.ps1`** — enable the Web Store + safe search via Chromium policy (HKLM\SOFTWARE\Policies\Chromium):
|
||||
|
||||
```powershell
|
||||
#Requires -Version 5.1
|
||||
$ErrorActionPreference='SilentlyContinue'
|
||||
$pol='HKLM:\SOFTWARE\Policies\Chromium'
|
||||
New-Item $pol -Force | Out-Null
|
||||
New-ItemProperty $pol -Name 'ForceGoogleSafeSearch' -Value 1 -PropertyType DWord -Force | Out-Null
|
||||
$ext="$pol\ExtensionInstallSources"; New-Item $ext -Force | Out-Null
|
||||
New-ItemProperty $ext -Name '1' -Value 'https://chrome.google.com/webstore/*' -PropertyType String -Force | Out-Null
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `bootstrap-winget.ps1`** — robust App Installer provisioning:
|
||||
|
||||
```powershell
|
||||
#Requires -Version 5.1
|
||||
$ErrorActionPreference='SilentlyContinue'
|
||||
# Register the inbox App Installer if present, else nothing to do (offline image w/o it).
|
||||
Get-AppxPackage -AllUsers Microsoft.DesktopAppInstaller |
|
||||
ForEach-Object { Add-AppxPackage -DisableDevelopmentMode -Register "$($_.InstallLocation)\AppXManifest.xml" }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Stage `windows/apps/` in build.ps1**
|
||||
|
||||
In `Invoke-ServiceWim`, where the Welcome payload is staged (the `Copy-WelcomePayload` area), add the apps dir next to flavours:
|
||||
```powershell
|
||||
# Stage the app catalog + configure/bootstrap scripts next to the Welcome app.
|
||||
$appsDest = Join-Path $dest 'apps'
|
||||
$null = New-Item -ItemType Directory -Force $appsDest
|
||||
Copy-Item (Join-Path $WindowsDir 'apps\*') $appsDest -Recurse -Force
|
||||
```
|
||||
(where `$dest` is `C:\Program Files\SilverOS\Welcome`, same var the flavours copy uses).
|
||||
|
||||
- [ ] **Step 5: Parse-lint + verify JSON**
|
||||
|
||||
Run: `pwsh -NoProfile -Command "Get-Content windows/apps/catalog.json -Raw | ConvertFrom-Json | Out-Null; 'json ok'"`
|
||||
Run: `pwsh -NoProfile -Command "[void][System.Management.Automation.Language.Parser]::ParseFile('C:\Staging\Source\SilverMetal\windows\installer\build.ps1',[ref]$null,[ref]$null); 'ps ok'"`
|
||||
Expected: `json ok`, `ps ok`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add windows/apps windows/installer/build.ps1
|
||||
git commit -m "feat(apps): catalog.json + chromium configure + winget bootstrap + build staging"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase F — Verify
|
||||
|
||||
### Task F1: Full build + test
|
||||
|
||||
- [ ] **Step 1**: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release` → all pass.
|
||||
- [ ] **Step 2**: `dotnet build windows/welcome/src/SilverOS.Welcome.App -f net9.0-windows10.0.19041.0 -c Release` → 0 errors (the full MAUI app compiles with the new step + DI).
|
||||
- [ ] **Step 3**: Use `superpowers:finishing-a-development-branch` to open the PR (`feat/app-recipes` → main). The catalog/install actually exercised end-to-end is verified on the **next VM run** (or hardware): pick a role → Apps step shows grouped pre-checked apps → Apply installs them via winget. Note in the PR: winget needs network (the VM's HVCI-blocked NIC means in-VM install verification needs the virtio rig or hardware).
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (author)
|
||||
|
||||
- **Spec coverage**: catalog schema (§3a) → A1/A2 + E1; loader/filter (§3b) → A2; AppsStep (§3c) → C2/C3; AppInstaller winget+bootstrap+configure+continue-on-failure (§3d) → B1; per-role lists (§4) → E1 catalog.json; build staging (§5) → E1; error handling (§6) → A2 (missing catalog), B1 (per-app failure), E1 (bootstrap); testing (§7) → A1/A2/B1 + D1 ordering. WDAC caveat (§2) is a Done-step note (carried as a follow-up — surfaced in PR/Done summary, no code gate).
|
||||
- **Type consistency**: `AppCatalogEntry`, `AppSource.Winget`, `LoadedCatalog.AppsForRole/DefaultSelectionForRole/All`, `IAppCatalog.Load`, `IAppInstaller.InstallAsync` → `AppInstallResult(Id,Installed)`, `ApplyRequest(...Apps)`, `WizardState.SelectedApps` — used consistently across A→F.
|
||||
- **Known integration risk flagged in-place**: Routes step-index renumbering (C3) is the fiddly part — the plan renumbers the switch, `CanGoNext`, and the Apply-step guard explicitly; the executor must read Routes.razor first (it's stated).
|
||||
@@ -0,0 +1,166 @@
|
||||
# SilverOS Welcome — Role App Recipes
|
||||
|
||||
> **Status**: design — 2026-06-09. Approved in brainstorming. Adds a per-role app-install
|
||||
> picker to the first-logon Welcome wizard. Builds on the wizard in
|
||||
> `windows/welcome/` and the flavour model in `windows/flavours/`.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Make the wizard's role/flavour selection *do something*: after choosing a role, the user
|
||||
sees a grouped, pre-checked list of apps to install during onboarding — role-relevant tools
|
||||
plus privacy-trimmed essentials — and the chosen apps are installed as part of Apply.
|
||||
|
||||
## 2. Decisions (locked in brainstorming)
|
||||
|
||||
- **Install engine: winget, phased.** v1 uses winget (`winget install --id <id> --silent`).
|
||||
IoT Enterprise LTSC ships *without* winget, so the engine bootstraps the App Installer at
|
||||
apply time. Every catalog app has a winget id. The catalog's per-app `source` object is
|
||||
**swappable** to a curated SilverMetal mirror later (the air-gap/privacy end-state) without
|
||||
changing the UI or engine contract.
|
||||
- **Stack stays auto-installed.** The SilverLABS Stack (`SilverBrowser`=ungoogled-chromium
|
||||
rebrand, `SilverVPN`, `SilverKeys`, …) continues to install automatically via each flavour's
|
||||
`appSet` and is **not** in the picker (shown as "included"). The picker adds **role apps**
|
||||
plus **opt-in privacy-trimmed third-party** apps (incl. vanilla ungoogled-chromium and a
|
||||
Thunderbird email option).
|
||||
- **Per-role lists + Essentials defaults** as in §4 (operator-approved; editable in the JSON).
|
||||
- **WDAC caveat**: Developer/Daily-Driver run app-control in *audit* (apps run); Privacy-Max/
|
||||
Journalist run *enforce* (third-party apps blocked until allow-listed). v1 installs anyway and
|
||||
surfaces a clear note for enforce-mode roles; full WDAC allow-listing is a follow-up.
|
||||
|
||||
## 3. Architecture (four small units)
|
||||
|
||||
```
|
||||
windows/apps/catalog.json # the app catalog (staged into the image like flavours/)
|
||||
SilverOS.Welcome.Core/Apps/
|
||||
AppCatalog.cs # record + loader (mirrors FlavourManifest)
|
||||
AppCatalogEntry.cs
|
||||
IAppInstaller.cs / AppInstaller.cs # winget bootstrap + per-app install + configure
|
||||
SilverOS.Welcome.UI/Components/Steps/
|
||||
AppsStep.razor # new wizard step: grouped checkboxes
|
||||
WizardState.cs # + SelectedApps (ids)
|
||||
ApplyService.cs # calls IAppInstaller after the Stack/hardening
|
||||
```
|
||||
|
||||
### 3a. Catalog schema (`catalog.json`)
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apps": [
|
||||
{
|
||||
"id": "vscodium",
|
||||
"name": "VSCodium",
|
||||
"description": "Telemetry-free VS Code build.",
|
||||
"source": { "winget": "VSCodium.VSCodium" },
|
||||
"group": "developer",
|
||||
"roles": ["developer"],
|
||||
"defaultFor": ["developer"],
|
||||
"configure": null
|
||||
},
|
||||
{
|
||||
"id": "ungoogled-chromium",
|
||||
"name": "ungoogled-chromium",
|
||||
"description": "Chromium with Google integration stripped.",
|
||||
"source": { "winget": "eloston.ungoogled-chromium" },
|
||||
"group": "essentials",
|
||||
"roles": ["essentials"],
|
||||
"defaultFor": [],
|
||||
"configure": "ungoogled-chromium.ps1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- `roles`: which roles are *offered* this app (`essentials` = all roles).
|
||||
- `defaultFor`: roles where the checkbox is **pre-checked**.
|
||||
- `source.winget`: the winget id (only `source` key in v1; a future `mirror` key is additive).
|
||||
- `configure`: optional post-install script (relative to `windows/apps/configure/`), e.g. the
|
||||
ungoogled-chromium policy that enables the Chrome Web Store + sets safe search/suggestions.
|
||||
|
||||
### 3b. AppCatalog loader
|
||||
Mirrors `FlavourManifest`/`IFlavourLoader`: `AppCatalogEntry` record + `AppCatalog.Load(dir)`
|
||||
reading `catalog.json` with the same `JsonSerializerOptions` (case-insensitive, comments,
|
||||
trailing commas). A pure function `AppsForRole(role)` returns the entries to display grouped,
|
||||
and `DefaultSelectionForRole(role)` returns the pre-checked ids.
|
||||
|
||||
### 3c. AppsStep (new wizard step, after Flavour)
|
||||
- Renders **Essentials** group first, then the chosen role's group; each app a checkbox with
|
||||
name + description; pre-checked from `DefaultSelectionForRole`.
|
||||
- Writes the set of selected ids into `WizardState.SelectedApps`.
|
||||
- Always valid (zero apps is allowed) — Next is never blocked. Notifies the host like the other
|
||||
steps (so Next state is correct immediately — same `OnSelected`/`StateHasChanged` pattern as
|
||||
the FlavourStep fix).
|
||||
- `Routes.razor` gains a step between Flavour (1) and Account; step indices/titles shift by one.
|
||||
|
||||
### 3d. AppInstaller (Apply-step engine)
|
||||
`IAppInstaller.InstallAsync(IReadOnlyList<AppCatalogEntry> apps, IProgress<ApplyProgress>, ct)`:
|
||||
1. **Bootstrap winget** if absent: install `Microsoft.DesktopAppInstaller` + deps
|
||||
(`Microsoft.VCLibs…`, `Microsoft.UI.Xaml…`) via `Add-AppxProvisionedPackage`/staged msix.
|
||||
2. **Per app**: `winget install --id <source.winget> --silent --accept-package-agreements
|
||||
--accept-source-agreements`; capture exit code; **continue on failure** (a bad app must not
|
||||
fail onboarding); run `configure` script if present and the install succeeded.
|
||||
3. Return a per-app result list (id → installed/failed); ApplyService stows a summary for the
|
||||
Done step ("Installed N of M apps; failed: …").
|
||||
|
||||
Runs **after** hardening + Stack + accounts, **before** BitLocker (so encryption is last), via a
|
||||
new `progress.Report(new("Installing apps", …))` stage.
|
||||
|
||||
## 4. Catalog contents (v1 — editable)
|
||||
|
||||
| Group | App | winget id | default-checked for |
|
||||
|---|---|---|---|
|
||||
| essentials | Thunderbird | `Mozilla.Thunderbird` | all |
|
||||
| essentials | VLC | `VideoLAN.VLC` | all |
|
||||
| essentials | 7-Zip | `7zip.7zip` | all |
|
||||
| essentials | LibreOffice | `TheDocumentFoundation.LibreOffice` | all |
|
||||
| essentials | ungoogled-chromium | `eloston.ungoogled-chromium` | — (opt-in; configured) |
|
||||
| essentials | KeePassXC | `KeePassXCTeam.KeePassXC` | — |
|
||||
| developer | VSCodium | `VSCodium.VSCodium` | developer |
|
||||
| developer | Git | `Git.Git` | developer |
|
||||
| developer | .NET 9 SDK | `Microsoft.DotNet.SDK.9` | developer |
|
||||
| developer | Node.js LTS | `OpenJS.NodeJS.LTS` | developer |
|
||||
| developer | Windows Terminal | `Microsoft.WindowsTerminal` | developer |
|
||||
| developer | PowerShell 7 | `Microsoft.PowerShell` | developer |
|
||||
| developer | Claude Desktop | `Anthropic.Claude` | developer |
|
||||
| developer | Visual Studio 2022 | `Microsoft.VisualStudio.2022.Community` | — |
|
||||
| developer | JetBrains Rider | `JetBrains.Rider` | — |
|
||||
| developer | Docker Desktop | `Docker.DockerDesktop` | — |
|
||||
| developer | Claude Code (CLI) | *(npm `@anthropic-ai/claude-code`, needs Node)* | — |
|
||||
| developer | Google Chrome | `Google.Chrome` | — |
|
||||
| developer | PostgreSQL | `PostgreSQL.PostgreSQL` | — |
|
||||
| developer | Bruno (API client) | `Bruno.Bruno` | — |
|
||||
| journalist | VeraCrypt | `IDRIX.VeraCrypt` | journalist |
|
||||
| journalist | KeePassXC | `KeePassXCTeam.KeePassXC` | journalist |
|
||||
| journalist | Joplin | `Joplin.Joplin` | journalist |
|
||||
| journalist | OBS Studio | `OBSProject.OBSStudio` | — |
|
||||
| journalist | Standard Notes | `StandardNotes.StandardNotes` | — |
|
||||
| journalist | Signal | `OpenWhisperSystems.Signal` | — |
|
||||
| journalist | Tor Browser | `TorProject.TorBrowser` | — |
|
||||
| daily-driver | Spotify | `Spotify.Spotify` | — |
|
||||
| daily-driver | Zoom | `Zoom.Zoom` | — |
|
||||
| daily-driver | Discord | `Discord.Discord` | — |
|
||||
| privacy-max | VeraCrypt | `IDRIX.VeraCrypt` | — |
|
||||
|
||||
> Claude Code (CLI) installs via npm, not winget — modelled with a `source.npm` variant the
|
||||
> engine handles separately (and only if Node is selected/present). Listed but lower priority.
|
||||
|
||||
## 5. Build wiring
|
||||
- `build.ps1`: stage `windows/apps/` (catalog.json + configure/) into the image
|
||||
(`C:\Program Files\SilverOS\Welcome\apps\` next to the flavours), same as flavours.
|
||||
- The Welcome app reads the catalog from `AppContext.BaseDirectory\apps\catalog.json`.
|
||||
|
||||
## 6. Error handling
|
||||
- Catalog missing/!parse → the Apps step shows an empty/"no extra apps" state and onboarding
|
||||
continues (never blocks).
|
||||
- winget bootstrap fails (offline) → log it, skip the install stage with a Done-step note;
|
||||
onboarding still completes.
|
||||
- Per-app install failure → recorded, surfaced in the Done summary, never throws.
|
||||
|
||||
## 7. Testing
|
||||
- `AppCatalog` deserialization + `AppsForRole`/`DefaultSelectionForRole` unit tests (xUnit).
|
||||
- `AppInstaller` against a fake `IProcessRunner`: asserts winget-bootstrap when absent, the exact
|
||||
`winget install` invocation per selected app, continue-on-failure, and configure-script run.
|
||||
- `AppsStep` selection/validity (bUnit, matching the existing step tests' style).
|
||||
|
||||
## 8. Out of scope (follow-ups)
|
||||
- Curated SilverMetal mirror (the `source.mirror` end-state) + signing for WDAC-enforce.
|
||||
- WDAC allow-listing of installed apps for Privacy-Max/Journalist enforce mode.
|
||||
- Per-app version pinning / update policy.
|
||||
@@ -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).
|
||||
BIN
windows/drivers/netkvm/netkvm.cat
Normal file
BIN
windows/drivers/netkvm/netkvm.cat
Normal file
Binary file not shown.
375
windows/drivers/netkvm/netkvm.inf
Normal file
375
windows/drivers/netkvm/netkvm.inf
Normal file
@@ -0,0 +1,375 @@
|
||||
;-------------------------------------------------------------------------------
|
||||
;Copyright (c) 2008-2021 Red Hat Inc.
|
||||
;
|
||||
;
|
||||
;Module Name:
|
||||
; netkvm.inf
|
||||
;
|
||||
; VirtIO Ethernet Adapter
|
||||
;
|
||||
;-------------------------------------------------------------------------------
|
||||
;Installation Notes:
|
||||
; Step by step driver installation wiki:
|
||||
; https://github.com/virtio-win/kvm-guest-drivers-windows/wiki/Driver-installation
|
||||
;
|
||||
|
||||
[version]
|
||||
Signature = "$Windows NT$"
|
||||
Class = Net
|
||||
CatalogFile = netkvm.cat
|
||||
ClassGUID = {4d36e972-e325-11ce-bfc1-08002be10318}
|
||||
Provider=%VENDOR%
|
||||
DriverVer = 07/09/2025,100.101.104.28500
|
||||
DriverPackageType = PlugAndPlay
|
||||
DriverPackageDisplayName = %kvmnet6.DeviceDesc%
|
||||
PnpLockDown=1
|
||||
|
||||
[Manufacturer]
|
||||
%VENDOR% = NetKVM, NTamd64.10.0...16299
|
||||
|
||||
[NetKVM.NTamd64.10.0...16299]
|
||||
%kvmnet6.DeviceDesc% = kvmnet6.ndi, PCI\VEN_1AF4&DEV_1000&SUBSYS_00011AF4&REV_00, PCI\VEN_1AF4&DEV_1000
|
||||
%kvmnet6.DeviceDesc% = kvmnet6.ndi, PCI\VEN_1AF4&DEV_1041&SUBSYS_11001AF4&REV_01, PCI\VEN_1AF4&DEV_1041
|
||||
|
||||
[kvmnet6.ndi.hw]
|
||||
AddReg = kvmnet6.EnableMSI
|
||||
|
||||
[kvmnet6.EnableMSI]
|
||||
;HKR, "Interrupt Management",, 0x00000010
|
||||
;HKR, "Interrupt Management\MessageSignaledInterruptProperties",, 0x00000010
|
||||
HKR, "Interrupt Management\MessageSignaledInterruptProperties", MSISupported, 0x00010001, 1
|
||||
HKR, "Interrupt Management\MessageSignaledInterruptProperties", MessageNumberLimit, 0x00010001, 2048
|
||||
;HKR, "Interrupt Management\Affinity Policy",, 0x00000010
|
||||
HKR, "Interrupt Management\Affinity Policy", DevicePolicy, 0x00010001, 0
|
||||
HKR, "Interrupt Management\Affinity Policy", DevicePriority, 0x00010001, 2
|
||||
|
||||
[kvmnet6.ndi]
|
||||
Characteristics = 0x84 ; NCF_PHYSICAL | NCF_HAS_UI
|
||||
BusType = 5 ; PCI
|
||||
AddReg = kvmnet6.Reg, Parameters
|
||||
CopyFiles = kvmnet6.CopyFiles, netkvmp.CopyFiles
|
||||
*IfType = 6
|
||||
*MediaType = 0 ; NdisMedium802_3
|
||||
*PhysicalMediaType = 0 ; NdisPhysicalMediumUnspecified
|
||||
|
||||
[kvmnet6.ndi.Services]
|
||||
AddService = netkvm, 2, kvmnet6.Service, kvmnet6.EventLog
|
||||
|
||||
;-----------------------------------------------------------------------------
|
||||
; Red Hat ParaVirtualized Miniport Common
|
||||
;-----------------------------------------------------------------------------
|
||||
[Parameters]
|
||||
|
||||
HKR, Ndi\Params\Priority, ParamDesc, 0, %Priority%
|
||||
HKR, Ndi\Params\Priority, Default, 0, "1"
|
||||
HKR, Ndi\Params\Priority, type, 0, "enum"
|
||||
HKR, Ndi\Params\Priority\enum, "1", 0, %Enable%
|
||||
HKR, Ndi\Params\Priority\enum, "0", 0, %Disable%
|
||||
|
||||
HKR, Ndi\Params\*PriorityVLANTag, ParamDesc, 0, %PriorityVlanTag%
|
||||
HKR, Ndi\Params\*PriorityVLANTag, Default, 0, "3"
|
||||
HKR, Ndi\Params\*PriorityVLANTag, type, 0, "enum"
|
||||
HKR, Ndi\Params\*PriorityVLANTag\enum, "3", 0, %Priority_Vlan%
|
||||
HKR, Ndi\Params\*PriorityVLANTag\enum, "2", 0, %VLan%
|
||||
HKR, Ndi\Params\*PriorityVLANTag\enum, "1", 0, %PriorityOnly%
|
||||
HKR, Ndi\Params\*PriorityVLANTag\enum, "0", 0, %Disable%
|
||||
|
||||
HKR, Ndi\params\VlanID, ParamDesc, 0, %VLan_ID%
|
||||
HKR, Ndi\params\VlanID, type, 0, "long"
|
||||
HKR, Ndi\params\VlanID, default, 0, "0"
|
||||
HKR, Ndi\params\VlanID, min, 0, "0"
|
||||
HKR, Ndi\params\VlanID, max, 0, "4094"
|
||||
|
||||
HKR, Ndi\Params\DoLog, ParamDesc, 0, %EnableLogging%
|
||||
HKR, Ndi\Params\DoLog, Default, 0, "1"
|
||||
HKR, Ndi\Params\DoLog, type, 0, "enum"
|
||||
HKR, Ndi\Params\DoLog\enum, "1", 0, %Enable%
|
||||
HKR, Ndi\Params\DoLog\enum, "0", 0, %Disable%
|
||||
|
||||
HKR, Ndi\params\DebugLevel, ParamDesc, 0, %DebugLevel%
|
||||
HKR, Ndi\params\DebugLevel, type, 0, "int"
|
||||
HKR, Ndi\params\DebugLevel, default, 0, "0"
|
||||
HKR, Ndi\params\DebugLevel, min, 0, "0"
|
||||
HKR, Ndi\params\DebugLevel, max, 0, "8"
|
||||
HKR, Ndi\params\DebugLevel, step, 0, "1"
|
||||
|
||||
HKR, Ndi\params\*JumboPacket, ParamDesc, 0, %JumboPacket%
|
||||
HKR, Ndi\params\*JumboPacket, type, 0, "long"
|
||||
HKR, Ndi\params\*JumboPacket, default, 0, "1514"
|
||||
HKR, Ndi\params\*JumboPacket, min, 0, "590"
|
||||
HKR, Ndi\params\*JumboPacket, max, 0, "65500"
|
||||
HKR, Ndi\params\*JumboPacket, step, 0, "1"
|
||||
|
||||
HKR, Ndi\params\TxCapacity, ParamDesc, 0, %TxCapacity%
|
||||
HKR, Ndi\params\TxCapacity, type, 0, "enum"
|
||||
HKR, Ndi\params\TxCapacity, default, 0, "1024"
|
||||
HKR, Ndi\Params\TxCapacity\enum, "16", 0, %String_16%
|
||||
HKR, Ndi\Params\TxCapacity\enum, "32", 0, %String_32%
|
||||
HKR, Ndi\Params\TxCapacity\enum, "64", 0, %String_64%
|
||||
HKR, Ndi\Params\TxCapacity\enum, "128", 0, %String_128%
|
||||
HKR, Ndi\Params\TxCapacity\enum, "256", 0, %String_256%
|
||||
HKR, Ndi\Params\TxCapacity\enum, "512", 0, %String_512%
|
||||
HKR, Ndi\Params\TxCapacity\enum, "1024", 0, %String_1024%
|
||||
|
||||
HKR, Ndi\params\RxCapacity, ParamDesc, 0, %RxCapacity%
|
||||
HKR, Ndi\params\RxCapacity, type, 0, "enum"
|
||||
HKR, Ndi\params\RxCapacity, default, 0, "1024"
|
||||
HKR, Ndi\Params\RxCapacity\enum, "16", 0, %String_16%
|
||||
HKR, Ndi\Params\RxCapacity\enum, "32", 0, %String_32%
|
||||
HKR, Ndi\Params\RxCapacity\enum, "64", 0, %String_64%
|
||||
HKR, Ndi\Params\RxCapacity\enum, "128", 0, %String_128%
|
||||
HKR, Ndi\Params\RxCapacity\enum, "256", 0, %String_256%
|
||||
HKR, Ndi\Params\RxCapacity\enum, "512", 0, %String_512%
|
||||
HKR, Ndi\Params\RxCapacity\enum, "1024", 0, %String_1024%
|
||||
HKR, Ndi\Params\RxCapacity\enum, "2048", 0, %String_2048%
|
||||
HKR, Ndi\Params\RxCapacity\enum, "4096", 0, %String_4096%
|
||||
|
||||
HKR, Ndi\Params\SeparateTail, ParamDesc, 0, %SeparateTail%
|
||||
HKR, Ndi\Params\SeparateTail, Default, 0, "1"
|
||||
HKR, Ndi\Params\SeparateTail, type, 0, "enum"
|
||||
HKR, Ndi\Params\SeparateTail\enum, "1", 0, %Enable%
|
||||
HKR, Ndi\Params\SeparateTail\enum, "0", 0, %Disable%
|
||||
|
||||
HKR, Ndi\Params\FastInit, ParamDesc, 0, %FastInit%
|
||||
HKR, Ndi\Params\FastInit, Default, 0, "1"
|
||||
HKR, Ndi\Params\FastInit, type, 0, "enum"
|
||||
HKR, Ndi\Params\FastInit\enum, "1", 0, %Enable%
|
||||
HKR, Ndi\Params\FastInit\enum, "0", 0, %Disable%
|
||||
|
||||
HKR, Ndi\params\NetworkAddress, ParamDesc, 0, %NetworkAddress%
|
||||
HKR, Ndi\params\NetworkAddress, type, 0, "edit"
|
||||
HKR, Ndi\params\NetworkAddress, Optional, 0, "1"
|
||||
|
||||
HKR, Ndi\Params\OffLoad.TxChecksum, ParamDesc, 0, %OffLoad.TxChecksum%
|
||||
HKR, Ndi\Params\OffLoad.TxChecksum, Default, 0, "31"
|
||||
HKR, Ndi\Params\OffLoad.TxChecksum, type, 0, "enum"
|
||||
HKR, Ndi\Params\OffLoad.TxChecksum\enum, "31", 0, %All%
|
||||
HKR, Ndi\Params\OffLoad.TxChecksum\enum, "27", 0, %TCPUDPAll%
|
||||
HKR, Ndi\Params\OffLoad.TxChecksum\enum, "3", 0, %TCPUDPv4%
|
||||
HKR, Ndi\Params\OffLoad.TxChecksum\enum, "1", 0, %TCPv4%
|
||||
HKR, Ndi\Params\OffLoad.TxChecksum\enum, "0", 0, %Disable%
|
||||
|
||||
HKR, Ndi\Params\OffLoad.TxLSO, ParamDesc, 0, %OffLoad.TxLSO%
|
||||
HKR, Ndi\Params\OffLoad.TxLSO, Default, 0, "2"
|
||||
HKR, Ndi\Params\OffLoad.TxLSO, type, 0, "enum"
|
||||
HKR, Ndi\Params\OffLoad.TxLSO\enum, "2", 0, %Maximal%
|
||||
HKR, Ndi\Params\OffLoad.TxLSO\enum, "1", 0, %IPv4%
|
||||
HKR, Ndi\Params\OffLoad.TxLSO\enum, "0", 0, %Disable%
|
||||
|
||||
HKR, Ndi\Params\OffLoad.RxCS, ParamDesc, 0, %OffLoad.RxCS%
|
||||
HKR, Ndi\Params\OffLoad.RxCS, Default, 0, "31"
|
||||
HKR, Ndi\Params\OffLoad.RxCS, type, 0, "enum"
|
||||
HKR, Ndi\Params\OffLoad.RxCS\enum, "31", 0, %All%
|
||||
HKR, Ndi\Params\OffLoad.RxCS\enum, "27", 0, %TCPUDPAll%
|
||||
HKR, Ndi\Params\OffLoad.RxCS\enum, "3", 0, %TCPUDPv4%
|
||||
HKR, Ndi\Params\OffLoad.RxCS\enum, "1", 0, %TCPv4%
|
||||
HKR, Ndi\Params\OffLoad.RxCS\enum, "0", 0, %Disable%
|
||||
|
||||
HKR, Ndi\Params\*IPChecksumOffloadIPv4, ParamDesc, 0, %Std.IPChecksumOffloadv4%
|
||||
HKR, Ndi\Params\*IPChecksumOffloadIPv4, Default, 0, "3"
|
||||
HKR, Ndi\Params\*IPChecksumOffloadIPv4, type, 0, "enum"
|
||||
HKR, Ndi\Params\*IPChecksumOffloadIPv4\enum, "3", 0, %TxRx%
|
||||
HKR, Ndi\Params\*IPChecksumOffloadIPv4\enum, "2", 0, %Rx%
|
||||
HKR, Ndi\Params\*IPChecksumOffloadIPv4\enum, "1", 0, %Tx%
|
||||
HKR, Ndi\Params\*IPChecksumOffloadIPv4\enum, "0", 0, %Disable%
|
||||
|
||||
HKR, Ndi\Params\*LsoV2IPv4, ParamDesc, 0, %Std.LsoV2IPv4%
|
||||
HKR, Ndi\Params\*LsoV2IPv4, Default, 0, "1"
|
||||
HKR, Ndi\Params\*LsoV2IPv4, type, 0, "enum"
|
||||
HKR, Ndi\Params\*LsoV2IPv4\enum, "1", 0, %Enable%
|
||||
HKR, Ndi\Params\*LsoV2IPv4\enum, "0", 0, %Disable%
|
||||
|
||||
HKR, Ndi\Params\*LsoV2IPv6, ParamDesc, 0, %Std.LsoV2IPv6%
|
||||
HKR, Ndi\Params\*LsoV2IPv6, Default, 0, "1"
|
||||
HKR, Ndi\Params\*LsoV2IPv6, type, 0, "enum"
|
||||
HKR, Ndi\Params\*LsoV2IPv6\enum, "1", 0, %Enable%
|
||||
HKR, Ndi\Params\*LsoV2IPv6\enum, "0", 0, %Disable%
|
||||
|
||||
HKR, Ndi\Params\*UDPChecksumOffloadIPv4, ParamDesc, 0, %Std.UDPChecksumOffloadIPv4%
|
||||
HKR, Ndi\Params\*UDPChecksumOffloadIPv4, Default, 0, "3"
|
||||
HKR, Ndi\Params\*UDPChecksumOffloadIPv4, type, 0, "enum"
|
||||
HKR, Ndi\Params\*UDPChecksumOffloadIPv4\enum, "3", 0, %TxRx%
|
||||
HKR, Ndi\Params\*UDPChecksumOffloadIPv4\enum, "2", 0, %Rx%
|
||||
HKR, Ndi\Params\*UDPChecksumOffloadIPv4\enum, "1", 0, %Tx%
|
||||
HKR, Ndi\Params\*UDPChecksumOffloadIPv4\enum, "0", 0, %Disable%
|
||||
|
||||
HKR, Ndi\Params\*TCPChecksumOffloadIPv4, ParamDesc, 0, %Std.TCPChecksumOffloadIPv4%
|
||||
HKR, Ndi\Params\*TCPChecksumOffloadIPv4, Default, 0, "3"
|
||||
HKR, Ndi\Params\*TCPChecksumOffloadIPv4, type, 0, "enum"
|
||||
HKR, Ndi\Params\*TCPChecksumOffloadIPv4\enum, "3", 0, %TxRx%
|
||||
HKR, Ndi\Params\*TCPChecksumOffloadIPv4\enum, "2", 0, %Rx%
|
||||
HKR, Ndi\Params\*TCPChecksumOffloadIPv4\enum, "1", 0, %Tx%
|
||||
HKR, Ndi\Params\*TCPChecksumOffloadIPv4\enum, "0", 0, %Disable%
|
||||
|
||||
HKR, Ndi\Params\*TCPChecksumOffloadIPv6, ParamDesc, 0, %Std.TCPChecksumOffloadIPv6%
|
||||
HKR, Ndi\Params\*TCPChecksumOffloadIPv6, Default, 0, "3"
|
||||
HKR, Ndi\Params\*TCPChecksumOffloadIPv6, type, 0, "enum"
|
||||
HKR, Ndi\Params\*TCPChecksumOffloadIPv6\enum, "3", 0, %TxRx%
|
||||
HKR, Ndi\Params\*TCPChecksumOffloadIPv6\enum, "2", 0, %Rx%
|
||||
HKR, Ndi\Params\*TCPChecksumOffloadIPv6\enum, "1", 0, %Tx%
|
||||
HKR, Ndi\Params\*TCPChecksumOffloadIPv6\enum, "0", 0, %Disable%
|
||||
|
||||
HKR, Ndi\Params\*UDPChecksumOffloadIPv6, ParamDesc, 0, %Std.UDPChecksumOffloadIPv6%
|
||||
HKR, Ndi\Params\*UDPChecksumOffloadIPv6, Default, 0, "3"
|
||||
HKR, Ndi\Params\*UDPChecksumOffloadIPv6, type, 0, "enum"
|
||||
HKR, Ndi\Params\*UDPChecksumOffloadIPv6\enum, "3", 0, %TxRx%
|
||||
HKR, Ndi\Params\*UDPChecksumOffloadIPv6\enum, "2", 0, %Rx%
|
||||
HKR, Ndi\Params\*UDPChecksumOffloadIPv6\enum, "1", 0, %Tx%
|
||||
HKR, Ndi\Params\*UDPChecksumOffloadIPv6\enum, "0", 0, %Disable%
|
||||
|
||||
HKR, Ndi\params\MinRxBufferPercent, ParamDesc, 0, %MinRxBufferPercent%
|
||||
HKR, Ndi\params\MinRxBufferPercent, type, 0, "int"
|
||||
HKR, Ndi\params\MinRxBufferPercent, default, 0, "0"
|
||||
HKR, Ndi\params\MinRxBufferPercent, min, 0, "0"
|
||||
HKR, Ndi\params\MinRxBufferPercent, max, 0, "100"
|
||||
HKR, Ndi\params\MinRxBufferPercent, step, 0, "1"
|
||||
|
||||
[kvmnet6.CopyFiles]
|
||||
netkvm.sys,,,2
|
||||
|
||||
[netkvmp.CopyFiles]
|
||||
netkvmp.exe,,,2
|
||||
|
||||
[kvmnet6.Service]
|
||||
DisplayName = %kvmnet6.Service.DispName%
|
||||
ServiceType = 1 ;%SERVICE_KERNEL_DRIVER%
|
||||
StartType = 3 ;%SERVICE_DEMAND_START%
|
||||
ErrorControl = 1 ;%SERVICE_ERROR_NORMAL%
|
||||
ServiceBinary = %13%\netkvm.sys
|
||||
LoadOrderGroup = NDIS
|
||||
AddReg = TextModeFlags.Reg
|
||||
|
||||
[kvmnet6.EventLog]
|
||||
AddReg = kvmnet6.AddEventLog.Reg
|
||||
|
||||
[kvmnet6.AddEventLog.Reg]
|
||||
HKR, , EventMessageFile, 0x00020000, "%%SystemRoot%%\System32\netevent.dll"
|
||||
HKR, , TypesSupported, 0x00010001, 7
|
||||
|
||||
[TextModeFlags.Reg]
|
||||
HKR,,TextModeFlags,0x00010001, 0x0001
|
||||
HKR,Parameters,DisableMSI,,"0"
|
||||
HKR,Parameters,EarlyDebug,,"3"
|
||||
HKR,Parameters,DmaRemappingCompatible,0x00010001,2
|
||||
|
||||
|
||||
[SourceDisksNames]
|
||||
1 = %DiskId1%,,,""
|
||||
|
||||
[SourceDisksFiles]
|
||||
netkvm.sys = 1,,
|
||||
netkvmp.exe = 1,,
|
||||
|
||||
[DestinationDirs]
|
||||
kvmnet6.CopyFiles = 13
|
||||
netkvmp.CopyFiles = 11
|
||||
|
||||
[Strings]
|
||||
VENDOR = "Red Hat, Inc."
|
||||
kvmnet6.DeviceDesc = "Red Hat VirtIO Ethernet Adapter"
|
||||
kvmnet6.Service.DispName = "Red Hat VirtIO Ethernet Adapter Service"
|
||||
DiskId1 = "Red Hat VirtIO Ethernet Adapter Driver Disk #1"
|
||||
NetworkAddress = "Assign MAC"
|
||||
Priority = "Init.Do802.1PQ"
|
||||
JumboPacket = "Jumbo Packet"
|
||||
TxCapacity = "Init.MaxTxBuffers"
|
||||
RxCapacity = "Init.MaxRxBuffers"
|
||||
SeparateTail = "Init.SeparateRxTail"
|
||||
FastInit = "Fast Initialization"
|
||||
Offload.TxChecksum = "Offload.Tx.Checksum"
|
||||
Offload.TxLSO = "Offload.Tx.LSO"
|
||||
Offload.RxCS = "Offload.Rx.Checksum"
|
||||
EnableLogging = "Logging.Enable"
|
||||
DebugLevel = "Logging.Level"
|
||||
Tx = "Tx Enabled";
|
||||
Rx = "Rx Enabled";
|
||||
TxRx = "Rx & Tx Enabled";
|
||||
Std.LsoV2IPv4 = "Large Send Offload V2 (IPv4)"
|
||||
Std.LsoV2IPv6 = "Large Send Offload V2 (IPv6)"
|
||||
Std.UDPChecksumOffloadIPv4 = "UDP Checksum Offload (IPv4)"
|
||||
Std.TCPChecksumOffloadIPv4 = "TCP Checksum Offload (IPv4)"
|
||||
Std.UDPChecksumOffloadIPv6 = "UDP Checksum Offload (IPv6)"
|
||||
Std.TCPChecksumOffloadIPv6 = "TCP Checksum Offload (IPv6)"
|
||||
Std.IPChecksumOffloadv4 = "IPv4 Checksum Offload"
|
||||
Disable = "Disabled"
|
||||
Enable = "Enabled"
|
||||
Enable* = "Enabled*"
|
||||
String_16 = "16"
|
||||
String_32 = "32"
|
||||
String_64 = "64"
|
||||
String_128 = "128"
|
||||
String_256 = "256"
|
||||
String_512 = "512"
|
||||
String_1024 = "1024"
|
||||
String_2048 = "2048"
|
||||
String_4096 = "4096"
|
||||
PriorityVlanTag = "Priority and VLAN tagging"
|
||||
PriorityOnly = "Priority"
|
||||
VLan = "VLan"
|
||||
VLan_ID = "VLan ID"
|
||||
Priority_Vlan = "All"
|
||||
10M = "10M"
|
||||
100M = "100M"
|
||||
1G = "1G"
|
||||
10G = "10G"
|
||||
TCPv4 = "TCP(v4)"
|
||||
TCPUDPv4 = "TCP/UDP(v4)"
|
||||
TCPUDPAll = "TCP/UDP(v4,v6)"
|
||||
All = "All"
|
||||
IPv4 = "IPv4"
|
||||
Maximal = "Maximal"
|
||||
MinRxBufferPercent = "MinRxBufferPercent"
|
||||
|
||||
[kvmnet6.Reg]
|
||||
HKR, , BusNumber, 0, "0"
|
||||
HKR, Ndi, Service, 0, "netkvm"
|
||||
HKR, Ndi\Interfaces, UpperRange, 0, "ndis5"
|
||||
HKR, Ndi\Interfaces, LowerRange, 0, "ethernet"
|
||||
|
||||
HKR, Ndi\params\*RSS, ParamDesc, 0, "Receive Side Scaling"
|
||||
HKR, Ndi\params\*RSS, Type, 0, "enum"
|
||||
HKR, Ndi\params\*RSS, Default, 0, "1"
|
||||
HKR, Ndi\params\*RSS, Optional, 0, "0"
|
||||
HKR, Ndi\params\*RSS\enum, "0", 0, "Disabled"
|
||||
HKR, Ndi\params\*RSS\enum, "1", 0, "Enabled"
|
||||
|
||||
HKR, Ndi\params\*NumRssQueues, ParamDesc, 0, "Maximum Number of RSS Queues"
|
||||
HKR, Ndi\params\*NumRssQueues, type, 0, "int"
|
||||
HKR, Ndi\params\*NumRssQueues, default, 0, "16"
|
||||
HKR, Ndi\params\*NumRssQueues, min, 0, "1"
|
||||
HKR, Ndi\params\*NumRssQueues, max, 0, "32"
|
||||
HKR, Ndi\params\*NumRssQueues, step, 0, "1"
|
||||
|
||||
HKR, Ndi\params\*RscIPv4, ParamDesc, 0, "Recv Segment Coalescing (IPv4)"
|
||||
HKR, Ndi\params\*RscIPv4, Type, 0, "enum"
|
||||
HKR, Ndi\params\*RscIPv4, Default, 0, "1"
|
||||
HKR, Ndi\params\*RscIPv4, Optional, 0, "0"
|
||||
HKR, Ndi\params\*RscIPv4\enum, "0", 0, "Disabled"
|
||||
HKR, Ndi\params\*RscIPv4\enum, "1", 0, "Enabled"
|
||||
|
||||
HKR, Ndi\params\*RscIPv6, ParamDesc, 0, "Recv Segment Coalescing (IPv6)"
|
||||
HKR, Ndi\params\*RscIPv6, Type, 0, "enum"
|
||||
HKR, Ndi\params\*RscIPv6, Default, 0, "1"
|
||||
HKR, Ndi\params\*RscIPv6, Optional, 0, "0"
|
||||
HKR, Ndi\params\*RscIPv6\enum, "0", 0, "Disabled"
|
||||
HKR, Ndi\params\*RscIPv6\enum, "1", 0, "Enabled"
|
||||
HKR, Ndi\params\*UsoIPv4, ParamDesc, 0, "UDP Segmentation Offload (IPv4)"
|
||||
HKR, Ndi\params\*UsoIPv4, Type, 0, "enum"
|
||||
HKR, Ndi\params\*UsoIPv4, Default, 0, "1"
|
||||
HKR, Ndi\params\*UsoIPv4, Optional, 0, "0"
|
||||
HKR, Ndi\params\*UsoIPv4\enum, "0", 0, "Disabled"
|
||||
HKR, Ndi\params\*UsoIPv4\enum, "1", 0, "Enabled"
|
||||
|
||||
HKR, Ndi\params\*UsoIPv6, ParamDesc, 0, "UDP Segmentation Offload (IPv6)"
|
||||
HKR, Ndi\params\*UsoIPv6, Type, 0, "enum"
|
||||
HKR, Ndi\params\*UsoIPv6, Default, 0, "1"
|
||||
HKR, Ndi\params\*UsoIPv6, Optional, 0, "0"
|
||||
HKR, Ndi\params\*UsoIPv6\enum, "0", 0, "Disabled"
|
||||
HKR, Ndi\params\*UsoIPv6\enum, "1", 0, "Enabled"
|
||||
|
||||
HKR, Ndi\params\*NdisPoll, ParamDesc, 0, "Ndis Poll Mode"
|
||||
HKR, Ndi\params\*NdisPoll, Type, 0, "enum"
|
||||
HKR, Ndi\params\*NdisPoll, Default, 0, "1"
|
||||
HKR, Ndi\params\*NdisPoll, Optional, 0, "0"
|
||||
HKR, Ndi\params\*NdisPoll\enum, "0", 0, "Disabled"
|
||||
HKR, Ndi\params\*NdisPoll\enum, "1", 0, "Enabled"
|
||||
BIN
windows/drivers/netkvm/netkvm.sys
Normal file
BIN
windows/drivers/netkvm/netkvm.sys
Normal file
Binary file not shown.
BIN
windows/drivers/netkvm/netkvmco.exe
Normal file
BIN
windows/drivers/netkvm/netkvmco.exe
Normal file
Binary file not shown.
BIN
windows/drivers/netkvm/netkvmp.exe
Normal file
BIN
windows/drivers/netkvm/netkvmp.exe
Normal file
Binary file not shown.
@@ -33,6 +33,6 @@ New-Item $ki -Force | Out-Null
|
||||
Set-ItemProperty $ki -Name DeviceEnumerationPolicy -Type DWord -Value 0 # block until authorized
|
||||
|
||||
# TODO-M1: confirm msinfo32 reports VBS=Running + Credential Guard + HVCI after reboot;
|
||||
# confirm whether Kernel DMA Protection shows On (IVRS bit) — open question §8.
|
||||
# confirm whether Kernel DMA Protection shows On (IVRS bit) -- open question §8.
|
||||
|
||||
Write-Host ' [D] policy set (VBS/HVCI/CredGuard/LSA-PPL/DMA). Effective after reboot.'
|
||||
|
||||
@@ -118,11 +118,19 @@
|
||||
render when launched as a bare Shell Launcher shell). Configure-Kiosk.ps1
|
||||
bakes the silent-elevation UAC policy + the lockdown (Keyboard Filter,
|
||||
DisableTaskMgr, hidden taskbar); the wizard runs fullscreen-topmost on top.
|
||||
|
||||
Launch is via a single hidden-window PowerShell (no `cmd /c` wrapper): the
|
||||
old `cmd /c powershell ...` spawned an extra process AND flashed a visible
|
||||
console window on the bare first-boot desktop — which itself read as "the
|
||||
machine is doing something broken" before the wizard appeared. `-WindowStyle
|
||||
Hidden` + dropping the cmd shim removes that flash and one process off the
|
||||
critical path. Elevation (-Verb RunAs) is still required for ApplyService
|
||||
(account/BitLocker/hardening) and is silent thanks to the baked UAC policy.
|
||||
-->
|
||||
<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>
|
||||
<CommandLine>powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -Command "Start-Process -FilePath 'C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe' -Verb RunAs"</CommandLine>
|
||||
<Description>Launch SilverOS Welcome elevated</Description>
|
||||
</SynchronousCommand>
|
||||
</FirstLogonCommands>
|
||||
|
||||
@@ -118,8 +118,25 @@ 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
|
||||
$setup = if (Test-Path (Join-Path $bootmnt 'sources\setup.exe')) { 'X:\sources\setup.exe' } else { 'X:\setup.exe' }
|
||||
$cmdline = "$setup /unattend:X:\autounattend.xml"
|
||||
# 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\CmdLine is the WinPE setup-image shell launch and is AUTHORITATIVE over
|
||||
# winpeshl.ini -- point it at the SilverMetal collector so the pre-config UI runs
|
||||
# FIRST. The collector then launches the LEGACY setup.exe itself (X:\sources\setup.exe,
|
||||
# preserving the legacy-Setup bypass) with its generated answer file, or falls back to
|
||||
# the default autounattend.xml on cancel/error. (Pointing Setup\CmdLine straight at
|
||||
# setup.exe bypassed the collector entirely -- it won over winpeshl.ini.)
|
||||
$cmdline = "cmd /c X:\sm\Start-Collector.cmd"
|
||||
$hive = Join-Path $bootmnt 'Windows\System32\config\SYSTEM'
|
||||
& reg load 'HKLM\SM_BOOT' $hive | Out-Null
|
||||
try {
|
||||
@@ -172,6 +189,33 @@ function Copy-WelcomePayload {
|
||||
} else {
|
||||
Write-Warning " No *.json flavour files found in $flavoursDir -- image will ship with no flavours."
|
||||
}
|
||||
# Stage the app catalog + configure/bootstrap scripts next to the Welcome app
|
||||
# (mirrors the flavours copy above): catalog.json, configure\*.ps1, bootstrap-winget.ps1.
|
||||
$appsDest = Join-Path $dest 'apps'
|
||||
$null = New-Item -ItemType Directory -Force $appsDest
|
||||
$appsDir = Join-Path $WindowsDir 'apps'
|
||||
if (Test-Path $appsDir) {
|
||||
Copy-Item (Join-Path $appsDir '*') $appsDest -Recurse -Force
|
||||
Write-Host " Copied app catalog + scripts to $appsDest"
|
||||
} else {
|
||||
Write-Warning " No apps dir found at $appsDir -- image will ship with no app catalog."
|
||||
}
|
||||
# Stage the fixed-version WebView2 runtime, if vendored, next to the app.
|
||||
# Cold-start + air-gap: a fixed-version runtime is just files (no installer
|
||||
# step at first boot) and removes the dependency on whether IoT Enterprise LTSC
|
||||
# ships WebView2 at all. Operator populates windows\welcome\runtime\webview2\
|
||||
# with an EXTRACTED "Microsoft Edge WebView2 Fixed Version" distribution (the
|
||||
# folder that contains msedgewebview2.exe) -- handled like the drivers dir:
|
||||
# absent is allowed (VM/dev test), in which case the app falls back to Evergreen.
|
||||
$wv2Src = Join-Path $WindowsDir 'welcome\runtime\webview2'
|
||||
if (Test-Path (Join-Path $wv2Src 'msedgewebview2.exe')) {
|
||||
$wv2Dest = Join-Path $dest 'webview2'
|
||||
$null = New-Item -ItemType Directory -Force $wv2Dest
|
||||
Copy-Item (Join-Path $wv2Src '*') $wv2Dest -Recurse -Force
|
||||
Write-Host " Staged fixed-version WebView2 runtime to $wv2Dest"
|
||||
} else {
|
||||
Write-Warning " No fixed-version WebView2 runtime at $wv2Src (expected msedgewebview2.exe) -- image will rely on the Evergreen runtime being present at first boot. See windows\welcome\runtime\webview2\README.md."
|
||||
}
|
||||
# --- Guard: verify the payload actually landed in the mounted image -------
|
||||
$stagedExe = Join-Path $dest 'SilverOS.Welcome.App.exe'
|
||||
if (-not (Test-Path $stagedExe)) {
|
||||
@@ -181,6 +225,10 @@ function Copy-WelcomePayload {
|
||||
if (-not $stagedFlavours) {
|
||||
throw "Welcome bake failed: no flavour manifests staged in '$destFlavours'. Add *.json files under windows/flavours/ or the installed wizard will have no flavour choices."
|
||||
}
|
||||
$stagedCatalog = Join-Path $appsDest 'catalog.json'
|
||||
if (-not (Test-Path $stagedCatalog)) {
|
||||
throw "Welcome bake failed: app catalog.json missing from image (expected at '$stagedCatalog'). Add windows/apps/catalog.json or the wizard's Apps step will be empty."
|
||||
}
|
||||
Write-Host " Welcome payload staged at $dest"
|
||||
}
|
||||
|
||||
@@ -206,7 +254,13 @@ function Invoke-ServiceWim {
|
||||
# Drivers (GPD Pocket 4 pack) -- skipped silently if dir empty (e.g. VM test).
|
||||
$drv = Join-Path $WindowsDir 'drivers'
|
||||
if ((Get-ChildItem $drv -Recurse -Filter *.inf -EA SilentlyContinue)) {
|
||||
Write-Host ' adding drivers'; Add-WindowsDriver -Path $mount -Driver $drv -Recurse | Out-Null
|
||||
# -ForceUnsigned: skip the offline-inject signature check (the virtio NetKVM
|
||||
# driver is WHQL-signed and loads fine at boot; the offline check can still
|
||||
# reject it on the build host). Non-fatal: a driver issue must not brick the
|
||||
# whole image build -- warn and continue without it.
|
||||
Write-Host ' adding drivers'
|
||||
try { Add-WindowsDriver -Path $mount -Driver $drv -Recurse -ForceUnsigned -ErrorAction Stop | Out-Null }
|
||||
catch { Write-Warning " driver inject failed (continuing without it): $($_.Exception.Message)" }
|
||||
} else { Write-Host ' no .inf drivers staged (ok for VM test)' }
|
||||
|
||||
# Kiosk features (Shell Launcher v2 + Keyboard Filter) — IoT Enterprise LTSC.
|
||||
|
||||
@@ -24,11 +24,29 @@ Log 'configuring onboarding lockdown (Explorer shell + policy)'
|
||||
# --- Keyboard Filter: block shell/escape hotkeys for the locked-down session ---
|
||||
Enable-WindowsOptionalFeature -Online -FeatureName Client-KeyboardFilter -NoRestart -ErrorAction SilentlyContinue | Out-Null
|
||||
$kf='root\standardcimv2\embedded'
|
||||
# CRITICAL: by default the Keyboard Filter EXEMPTS administrators, and sm-bootstrap is an
|
||||
# admin -> Win/Start/etc. were NOT blocked. Turn that exemption off so the filter applies.
|
||||
$adm=Get-CimInstance -Namespace $kf -ClassName WEKF_Settings -Filter "Name='DisableKeyboardFilterForAdministrators'" -ErrorAction SilentlyContinue
|
||||
if($adm){ $adm.Value='false'; Set-CimInstance -InputObject $adm -ErrorAction SilentlyContinue }
|
||||
foreach($combo in 'Win','Win+L','Ctrl+Esc','Ctrl+Win+F','Win+R','Alt+Tab','Ctrl+Shift+Esc','Alt+F4'){
|
||||
$p=Get-CimInstance -Namespace $kf -ClassName WEKF_PredefinedKey -Filter "Id='$combo'" -ErrorAction SilentlyContinue
|
||||
if($p){ $p.Enabled=$true; Set-CimInstance -InputObject $p -ErrorAction SilentlyContinue }
|
||||
}
|
||||
Log 'keyboard filter rules enabled'
|
||||
Log 'keyboard filter rules enabled (admins included)'
|
||||
|
||||
# --- Hide the taskbar for the locked-down session (auto-hide in the default-user hive,
|
||||
# which the sm-bootstrap profile inherits). The fullscreen wizard covers it, but
|
||||
# auto-hide stops it peeking. StuckRects3 byte 8: 0x03 = auto-hide on. ---
|
||||
try {
|
||||
& reg load 'HKLM\SM_DU_TB' 'C:\Users\Default\NTUSER.DAT' 2>$null | Out-Null
|
||||
$sr='HKLM:\SM_DU_TB\Software\Microsoft\Windows\CurrentVersion\Explorer\StuckRects3'
|
||||
New-Item $sr -Force | Out-Null
|
||||
$bytes=[byte[]](0x30,0x00,0x00,0x00,0x28,0x00,0x00,0x00,0x03,0x00,0x00,0x00,0x30,0x00,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00)
|
||||
Set-ItemProperty $sr -Name 'Settings' -Value $bytes -Type Binary
|
||||
} catch {} finally { [gc]::Collect(); Start-Sleep -Milliseconds 300; & reg unload 'HKLM\SM_DU_TB' 2>$null | Out-Null }
|
||||
Log 'taskbar auto-hide set for default user'
|
||||
|
||||
# --- escape policies (machine-wide; reverted at teardown) ---
|
||||
$sys='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,8 +17,16 @@ function Assert { param([string]$Name,[bool]$Cond)
|
||||
if (-not (([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
|
||||
[Security.Principal.WindowsBuiltInRole]::Administrator))) { throw 'must run elevated (WIM mount).' }
|
||||
|
||||
# Discard any stale WIM mounts from a prior aborted run (CI reuses the runner) and
|
||||
# use a fresh, unique mount dir — a leftover mount makes Mount-WindowsImage fail with
|
||||
# "attempted to mount to a directory that is not empty." Mirrors build.ps1 Stage 0.
|
||||
Get-WindowsImage -Mounted -EA SilentlyContinue | ForEach-Object {
|
||||
Dismount-WindowsImage -Path $_.MountPath -Discard -EA SilentlyContinue | Out-Null
|
||||
}
|
||||
Clear-WindowsCorruptMountPoint -EA SilentlyContinue | Out-Null
|
||||
|
||||
$img = Mount-DiskImage -ImagePath $IsoPath -PassThru
|
||||
$mount = Join-Path $env:TEMP 'sm-assert-wim'
|
||||
$mount = Join-Path $env:TEMP ('sm-assert-wim-' + [guid]::NewGuid().ToString('N'))
|
||||
$null = New-Item -ItemType Directory -Force $mount
|
||||
try {
|
||||
$drive = ($img | Get-Volume).DriveLetter + ':'
|
||||
@@ -43,10 +51,29 @@ try {
|
||||
Assert 'Welcome exe baked into WIM' (Test-Path $welcomeExe)
|
||||
$welcomeFlavours = Get-ChildItem (Join-Path $mount 'Program Files\SilverOS\Welcome\flavours') -Filter '*.json' -EA SilentlyContinue
|
||||
Assert 'Welcome flavours baked (>=1 .json)' ($welcomeFlavours.Count -ge 1)
|
||||
$welcomeCatalog = Join-Path $mount 'Program Files\SilverOS\Welcome\apps\catalog.json'
|
||||
Assert 'Welcome app catalog baked' (Test-Path $welcomeCatalog)
|
||||
}
|
||||
} finally { Dismount-WindowsImage -Path $mount -Discard | Out-Null }
|
||||
}
|
||||
} finally { Dismount-DiskImage -ImagePath $IsoPath | 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
|
||||
}
|
||||
|
||||
Write-Host "`n$($fail) assertion(s) failed."
|
||||
exit $fail
|
||||
|
||||
72
windows/tests/Collector.Tests.ps1
Normal file
72
windows/tests/Collector.Tests.ps1
Normal file
@@ -0,0 +1,72 @@
|
||||
#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 'preconfig round-trips via chunked FirstLogonCommands' {
|
||||
# Gather the echo'd base64 chunks in Order, concatenate, strip whitespace, decode.
|
||||
$chunks = [regex]::Matches($script:xml, 'echo ([A-Za-z0-9+/=]+)') | ForEach-Object { $_.Groups[1].Value }
|
||||
$b64 = ($chunks -join '')
|
||||
$json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b64)) | ConvertFrom-Json
|
||||
$json.flavour | Should -Be 'developer'
|
||||
$json.bitlocker.pin | Should -Be '246810'
|
||||
}
|
||||
It 'has no specialize pass anymore' { $script:xml | Should -Not -Match 'pass="specialize"' }
|
||||
It 'creates the preconfig dir + decodes it at first logon' {
|
||||
$script:xml | Should -Match 'ProgramData\\SilverMetal'
|
||||
$script:xml | Should -Match 'FromBase64String'
|
||||
$script:xml | Should -Match 'preconfig\.json'
|
||||
}
|
||||
It 'launches the toolbox in FirstLogonCommands' { $script:xml | Should -Match 'SilverOS\.Welcome\.App\.exe' }
|
||||
}
|
||||
44
windows/welcome/runtime/webview2/README.md
Normal file
44
windows/welcome/runtime/webview2/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Fixed-version WebView2 runtime (vendored)
|
||||
|
||||
The SilverOS Welcome wizard is a MAUI Blazor Hybrid app — it needs the **Microsoft
|
||||
Edge WebView2 Runtime**. IoT Enterprise LTSC images frequently ship **without** it,
|
||||
and even when present, the Evergreen runtime adds first-boot cold-start cost (registry
|
||||
probe; on-demand install if absent). To make first boot fast *and* air-gapped, we bake
|
||||
a **fixed-version** runtime here and point the app at it via
|
||||
`WEBVIEW2_BROWSER_EXECUTABLE_FOLDER` (see `MauiProgram.cs`).
|
||||
|
||||
## What goes in this folder
|
||||
|
||||
The **extracted** contents of a "Microsoft Edge WebView2 Fixed Version" distribution —
|
||||
i.e. this directory must directly contain `msedgewebview2.exe` (plus its sibling DLLs,
|
||||
`*.pak`, locales, etc.). The build (`windows/installer/build.ps1`,
|
||||
`Copy-WelcomePayload`) detects `msedgewebview2.exe` and copies the whole folder to
|
||||
`C:\Program Files\SilverOS\Welcome\webview2\` inside the image.
|
||||
|
||||
```
|
||||
windows/welcome/runtime/webview2/
|
||||
├── README.md <- this file (the only thing committed)
|
||||
├── msedgewebview2.exe <- you add these
|
||||
├── *.dll
|
||||
├── *.pak
|
||||
└── ...
|
||||
```
|
||||
|
||||
## How to obtain it
|
||||
|
||||
1. Download the **Fixed Version** (x64) CAB from the official WebView2 distribution
|
||||
page: <https://developer.microsoft.com/microsoft-edge/webview2/> → "Fixed Version".
|
||||
Match the channel/arch to the target (x64, since the app publishes `win-x64`).
|
||||
2. Expand the CAB and copy the inner runtime folder's contents here so that
|
||||
`msedgewebview2.exe` sits directly in this directory.
|
||||
3. Pin the version in `windows/installer/inputs.manifest.json` alongside the other
|
||||
baked inputs (SBOM hygiene).
|
||||
|
||||
## If you skip this
|
||||
|
||||
The build does **not** fail — it logs a warning and the image relies on whatever
|
||||
Evergreen runtime is present at first boot. Fine for a quick VM smoke test; **not**
|
||||
recommended for shipped LTSC media (risk of a blank/hung wizard and slower cold start).
|
||||
|
||||
> The runtime binaries are **not** committed (large, Microsoft-redistributable, version-
|
||||
> pinned per build). Only this README is tracked.
|
||||
@@ -5,12 +5,54 @@
|
||||
xmlns:components="clr-namespace:SilverOS.Welcome.App.Components;assembly=SilverOS.Welcome.UI"
|
||||
x:Class="SilverOS.Welcome.App.MainPage">
|
||||
|
||||
<BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html"
|
||||
BlazorWebViewInitialized="OnBlazorInitialized"
|
||||
UrlLoading="OnUrlLoading">
|
||||
<BlazorWebView.RootComponents>
|
||||
<RootComponent Selector="#app" ComponentType="{x:Type components:Routes}" />
|
||||
</BlazorWebView.RootComponents>
|
||||
</BlazorWebView>
|
||||
<!--
|
||||
A native MAUI splash sits ON TOP of the BlazorWebView. MAUI controls render
|
||||
immediately when the window is shown — they do NOT wait on WebView2/.NET JIT —
|
||||
so the user sees branded "loading" within the first frame instead of a blank
|
||||
window for the seconds it takes WebView2 to cold-start and Blazor to boot.
|
||||
The overlay is dismissed in MainPage.xaml.cs once WV2 finishes its first
|
||||
navigation (the index.html splash then carries the eye through Blazor's boot).
|
||||
-->
|
||||
<Grid BackgroundColor="#0b0f14">
|
||||
|
||||
<BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html"
|
||||
BlazorWebViewInitialized="OnBlazorInitialized"
|
||||
UrlLoading="OnUrlLoading">
|
||||
<BlazorWebView.RootComponents>
|
||||
<RootComponent Selector="#app" ComponentType="{x:Type components:Routes}" />
|
||||
</BlazorWebView.RootComponents>
|
||||
</BlazorWebView>
|
||||
|
||||
<Grid x:Name="SplashOverlay"
|
||||
BackgroundColor="#0b0f14"
|
||||
InputTransparent="False">
|
||||
<VerticalStackLayout HorizontalOptions="Center"
|
||||
VerticalOptions="Center"
|
||||
Spacing="22">
|
||||
<Label Text="SilverOS"
|
||||
HorizontalOptions="Center"
|
||||
FontFamily="OpenSansRegular"
|
||||
FontSize="42"
|
||||
FontAutoScalingEnabled="False"
|
||||
TextColor="#e8edf5" />
|
||||
<Label Text="WELCOME"
|
||||
HorizontalOptions="Center"
|
||||
FontSize="13"
|
||||
CharacterSpacing="8"
|
||||
TextColor="#00d4ff" />
|
||||
<ActivityIndicator IsRunning="True"
|
||||
Color="#00d4ff"
|
||||
HeightRequest="34"
|
||||
WidthRequest="34"
|
||||
HorizontalOptions="Center"
|
||||
Margin="0,10,0,0" />
|
||||
<Label Text="Preparing your setup…"
|
||||
HorizontalOptions="Center"
|
||||
FontSize="14"
|
||||
TextColor="#8fa4bc" />
|
||||
</VerticalStackLayout>
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
</ContentPage>
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace SilverOS.Welcome.App;
|
||||
|
||||
public partial class MainPage : ContentPage
|
||||
{
|
||||
bool _splashDismissed;
|
||||
|
||||
public MainPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -20,7 +22,13 @@ public partial class MainPage : ContentPage
|
||||
{
|
||||
var wv = e.WebView; // Microsoft.UI.Xaml.Controls.WebView2
|
||||
wv.NavigationCompleted += (a, b) =>
|
||||
{
|
||||
Diag.Log($"WV2 NavigationCompleted ok={b.IsSuccess} status={b.WebErrorStatus}");
|
||||
// First completed navigation = the WebView has content on screen.
|
||||
// Drop the native splash so the (visually identical) in-page splash
|
||||
// carries through Blazor's final boot without a flash of blank.
|
||||
if (b.IsSuccess) DismissSplash();
|
||||
};
|
||||
if (wv.CoreWebView2 is not null)
|
||||
wv.CoreWebView2.ProcessFailed += (a, b) =>
|
||||
Diag.Log("WV2 ProcessFailed: " + b.ProcessFailedKind);
|
||||
@@ -29,6 +37,26 @@ public partial class MainPage : ContentPage
|
||||
#endif
|
||||
}
|
||||
|
||||
// Fade the native splash out once, then collapse it so it never intercepts input.
|
||||
void DismissSplash()
|
||||
{
|
||||
if (_splashDismissed) return;
|
||||
_splashDismissed = true;
|
||||
MainThread.BeginInvokeOnMainThread(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await SplashOverlay.FadeTo(0, 250, Easing.CubicOut);
|
||||
}
|
||||
catch { /* fade is cosmetic — never block on it */ }
|
||||
finally
|
||||
{
|
||||
SplashOverlay.IsVisible = false;
|
||||
SplashOverlay.InputTransparent = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void OnUrlLoading(object? sender, UrlLoadingEventArgs e)
|
||||
=> Diag.Log("UrlLoading: " + e.Url);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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;
|
||||
@@ -16,6 +18,23 @@ public static class MauiProgram
|
||||
Directory.CreateDirectory(wv2);
|
||||
Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", wv2);
|
||||
|
||||
// Cold-start + air-gap: prefer a fixed-version WebView2 runtime baked next to
|
||||
// the app (build.ps1 stages it under .\webview2). This removes two first-boot
|
||||
// costs at once: (1) the Evergreen-runtime registry probe, and (2) the risk
|
||||
// that IoT Enterprise LTSC ships WITHOUT WebView2 entirely — in which case the
|
||||
// wizard would block/blank waiting on an on-demand install. If the baked folder
|
||||
// is absent (VM/dev test), fall through to whatever Evergreen runtime exists.
|
||||
var fixedRuntime = Path.Combine(AppContext.BaseDirectory, "webview2");
|
||||
if (File.Exists(Path.Combine(fixedRuntime, "msedgewebview2.exe")))
|
||||
{
|
||||
Environment.SetEnvironmentVariable("WEBVIEW2_BROWSER_EXECUTABLE_FOLDER", fixedRuntime);
|
||||
Diag.Log("WebView2: using baked fixed-version runtime at " + fixedRuntime);
|
||||
}
|
||||
else
|
||||
{
|
||||
Diag.Log("WebView2: no baked runtime found; relying on installed Evergreen");
|
||||
}
|
||||
|
||||
var builder = MauiApp.CreateBuilder();
|
||||
builder
|
||||
.UseMauiApp<App>()
|
||||
@@ -31,18 +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<IAccountService, AccountService>();
|
||||
builder.Services.AddSingleton<IAppCatalog, AppCatalog>();
|
||||
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>(),
|
||||
hardeningDir));
|
||||
sp.GetRequiredService<IAppInstaller>()));
|
||||
builder.Services.AddSingleton<IPreconfigStore>(_ => new PreconfigStore(@"C:\ProgramData\SilverMetal"));
|
||||
builder.Services.AddScoped<WizardState>();
|
||||
|
||||
return builder.Build();
|
||||
|
||||
@@ -25,6 +25,16 @@
|
||||
<WindowsPackageType>None</WindowsPackageType>
|
||||
|
||||
<SupportedOSPlatformVersion>10.0.19041.0</SupportedOSPlatformVersion>
|
||||
|
||||
<!--
|
||||
Cold-start: precompile to ReadyToRun so the self-contained image does not
|
||||
JIT the whole app on first launch (this wizard runs exactly once, on the
|
||||
slowest possible "fresh OS, cold disk" path, so first-run JIT is pure cost).
|
||||
R2R only — trimming/NativeAOT are NOT safe for MAUI Blazor Hybrid (heavy
|
||||
reflection in Blazor + DI). Larger binaries are fine: the payload is baked
|
||||
into the image, never downloaded. Only takes effect on `dotnet publish -r win-x64`.
|
||||
-->
|
||||
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -303,6 +303,58 @@ h1:focus { outline: none; }
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* ── Boot splash (pre-Blazor; mirrors the native MAUI splash) ───────── */
|
||||
.sm-boot {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.4rem;
|
||||
background: var(--clr-void);
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.sm-boot-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 2.6rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--clr-text-hi);
|
||||
}
|
||||
|
||||
.sm-boot-kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.55em;
|
||||
text-indent: 0.55em; /* balance the trailing letter-spacing */
|
||||
color: var(--clr-accent);
|
||||
}
|
||||
|
||||
.sm-boot-spinner {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 3px solid var(--clr-border-hi);
|
||||
border-top-color: var(--clr-accent);
|
||||
border-radius: 50%;
|
||||
animation: sm-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sm-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.sm-boot-text {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.9rem;
|
||||
color: var(--clr-text-mid);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sm-boot-spinner { animation: none; }
|
||||
}
|
||||
|
||||
/* ── Loading / error states ─────────────────────────────────────────── */
|
||||
.loading {
|
||||
font-family: var(--font-mono);
|
||||
|
||||
@@ -15,7 +15,21 @@
|
||||
|
||||
<div class="status-bar-safe-area"></div>
|
||||
|
||||
<div id="app">Loading...</div>
|
||||
<!--
|
||||
In-page boot splash. Lives INSIDE #app so Blazor wipes it automatically the
|
||||
moment the root component first renders. Styled to match the native MAUI
|
||||
splash (same void bg + electric-ice accent), so handoff native -> webview ->
|
||||
Blazor reads as one continuous loading screen rather than three flashes.
|
||||
Inline-styled on the wrapper so it shows even before app.css paints.
|
||||
-->
|
||||
<div id="app" style="background:#0b0f14">
|
||||
<div class="sm-boot">
|
||||
<div class="sm-boot-title">SilverOS</div>
|
||||
<div class="sm-boot-kicker">WELCOME</div>
|
||||
<div class="sm-boot-spinner" aria-hidden="true"></div>
|
||||
<div class="sm-boot-text">Preparing your setup…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
|
||||
@@ -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("'", "''");
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
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);
|
||||
// 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);
|
||||
|
||||
@@ -1,40 +1,21 @@
|
||||
using System.Text.Json;
|
||||
using SilverOS.Welcome.Core.Flavours;
|
||||
using SilverOS.Welcome.Core.Apps;
|
||||
namespace SilverOS.Welcome.Core.Apply;
|
||||
|
||||
public sealed class ApplyService(IProcessRunner runner, IAccountService accounts,
|
||||
IBitLockerService bitlocker, IBootstrapService bootstrap, 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("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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +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.
|
||||
await Ps($"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);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
namespace SilverOS.Welcome.Core.Apply;
|
||||
public interface IAccountService { Task CreateAccountsAsync(string user, string password, string adminPassword, CancellationToken ct = default); }
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace SilverOS.Welcome.Core.Apply;
|
||||
public interface IBootstrapService
|
||||
{
|
||||
Task RevertKioskAsync(CancellationToken ct = default);
|
||||
Task TearDownAsync(string bootstrapUser, CancellationToken ct = default);
|
||||
}
|
||||
37
windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalog.cs
Normal file
37
windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalog.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SilverOS.Welcome.Core.Apps;
|
||||
|
||||
public sealed record LoadedCatalog(IReadOnlyList<AppCatalogEntry> All)
|
||||
{
|
||||
// Essentials (offered to all roles) first, then the role's own apps. Stable, de-duped by id.
|
||||
public IReadOnlyList<AppCatalogEntry> AppsForRole(string role) =>
|
||||
All.Where(a => a.Roles.Contains("essentials") || a.Roles.Contains(role))
|
||||
.GroupBy(a => a.Id).Select(g => g.First())
|
||||
.OrderByDescending(a => a.Group == "essentials").ThenBy(a => a.Name).ToList();
|
||||
|
||||
public IReadOnlyList<string> DefaultSelectionForRole(string role) =>
|
||||
AppsForRole(role).Where(a => a.DefaultFor.Contains("essentials") || a.DefaultFor.Contains(role))
|
||||
.Select(a => a.Id).ToList();
|
||||
}
|
||||
|
||||
public sealed class AppCatalog : IAppCatalog
|
||||
{
|
||||
private sealed record CatalogFile(int SchemaVersion, IReadOnlyList<AppCatalogEntry>? Apps);
|
||||
|
||||
public LoadedCatalog Load(string directory)
|
||||
{
|
||||
var path = Path.Combine(directory, "catalog.json");
|
||||
if (!File.Exists(path)) return new LoadedCatalog(Array.Empty<AppCatalogEntry>());
|
||||
try
|
||||
{
|
||||
var f = JsonSerializer.Deserialize<CatalogFile>(File.ReadAllText(path), AppCatalogEntry.JsonOptions);
|
||||
return new LoadedCatalog(f?.Apps ?? Array.Empty<AppCatalogEntry>());
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A bad catalog must never block onboarding — degrade to "no extra apps".
|
||||
return new LoadedCatalog(Array.Empty<AppCatalogEntry>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SilverOS.Welcome.Core.Apps;
|
||||
|
||||
public sealed record AppSource
|
||||
{
|
||||
public string? Winget { get; init; }
|
||||
// Future: public string? Mirror { get; init; } // swappable to a curated mirror.
|
||||
}
|
||||
|
||||
public sealed record AppCatalogEntry
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string Name { get; init; } = "";
|
||||
public string Description { get; init; } = "";
|
||||
public AppSource Source { get; init; } = new();
|
||||
public string Group { get; init; } = "";
|
||||
public IReadOnlyList<string> Roles { get; init; } = Array.Empty<string>();
|
||||
public IReadOnlyList<string> DefaultFor { get; init; } = Array.Empty<string>();
|
||||
public string? Configure { get; init; }
|
||||
|
||||
public static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
}
|
||||
114
windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs
Normal file
114
windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using SilverOS.Welcome.Core.Apply;
|
||||
|
||||
namespace SilverOS.Welcome.Core.Apps;
|
||||
|
||||
public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppInstaller
|
||||
{
|
||||
// Best-effort diagnostic log (winget resolution, bootstrap output, per-app results).
|
||||
// Lives under ProgramData so it survives + is readable post-install. Never throws.
|
||||
private static readonly string LogPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "SilverMetal", "appinstall.log");
|
||||
|
||||
private static void Log(string msg)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
|
||||
File.AppendAllText(LogPath, $"{DateTime.Now:HH:mm:ss.fff} {msg}{Environment.NewLine}");
|
||||
}
|
||||
catch { /* logging is best-effort */ }
|
||||
}
|
||||
|
||||
private static string Snip(string? s) =>
|
||||
string.IsNullOrWhiteSpace(s) ? "" : s.Trim().Replace("\r", " ").Replace("\n", " ") is var t && t.Length > 300 ? t[..300] : t;
|
||||
|
||||
public async Task<IReadOnlyList<AppInstallResult>> InstallAsync(
|
||||
IReadOnlyList<AppCatalogEntry> apps, IProgress<ApplyProgress> progress, CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<AppInstallResult>();
|
||||
if (apps.Count == 0) return results;
|
||||
|
||||
Log($"InstallAsync: {apps.Count} app(s) requested: {string.Join(", ", apps.Select(a => a.Id))}");
|
||||
|
||||
// App installs are non-critical: a missing/broken winget (e.g. offline IoT LTSC) must
|
||||
// NEVER fail onboarding. Resolve winget defensively; if it can't be found, skip installs.
|
||||
var winget = await ResolveWingetAsync(progress, ct);
|
||||
if (winget is null)
|
||||
{
|
||||
Log($"winget UNAVAILABLE -> skipping all {apps.Count} app(s)");
|
||||
progress.Report(new($"App installer unavailable - skipping {apps.Count} app(s)", 80));
|
||||
foreach (var app in apps) results.Add(new AppInstallResult(app.Id, false));
|
||||
return results;
|
||||
}
|
||||
|
||||
var i = 0;
|
||||
foreach (var app in apps)
|
||||
{
|
||||
i++;
|
||||
progress.Report(new($"Installing {app.Name} ({i}/{apps.Count})", 80));
|
||||
var ok = false;
|
||||
var id = app.Source.Winget;
|
||||
if (!string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
var r = await TryRunAsync(winget,
|
||||
$"install --id {id} --silent --accept-package-agreements --accept-source-agreements --disable-interactivity",
|
||||
ct);
|
||||
ok = r.ExitCode == 0;
|
||||
Log($"install {id}: exit={r.ExitCode} ok={ok} err={Snip(r.StdErr)}");
|
||||
if (ok && !string.IsNullOrWhiteSpace(app.Configure))
|
||||
{
|
||||
var script = Path.Combine(appsDir, "configure", app.Configure);
|
||||
// best-effort: configuration failure does not mark the install as failed
|
||||
await TryRunAsync("powershell.exe",
|
||||
$"-NoProfile -ExecutionPolicy Bypass -File \"{script}\"", ct);
|
||||
}
|
||||
}
|
||||
results.Add(new AppInstallResult(app.Id, ok));
|
||||
}
|
||||
Log($"InstallAsync done: {results.Count(r => r.Installed)}/{results.Count} installed");
|
||||
return results;
|
||||
}
|
||||
|
||||
// Find a usable winget. winget (App Installer) is absent from IoT Enterprise LTSC, and even
|
||||
// when present it ships as a WindowsApps execution alias that Process.Start can't always launch
|
||||
// by bare name. Returns the invocation target ("winget" or a full path), or null if unavailable.
|
||||
private async Task<string?> ResolveWingetAsync(IProgress<ApplyProgress> progress, CancellationToken ct)
|
||||
{
|
||||
// 1) Already launchable by name (on PATH for this process)?
|
||||
var p1 = await TryRunAsync("winget", "--version", ct);
|
||||
Log($"winget probe (PATH): exit={p1.ExitCode} out={Snip(p1.StdOut)}");
|
||||
if (p1.ExitCode == 0) return "winget";
|
||||
|
||||
// 2) Provision App Installer via the bundled bootstrap (or registered package), then re-probe.
|
||||
progress.Report(new("Preparing app installer", 68));
|
||||
var bootstrap = Path.Combine(appsDir, "bootstrap-winget.ps1");
|
||||
var b = await TryRunAsync("powershell.exe",
|
||||
$"-NoProfile -ExecutionPolicy Bypass -Command \"if (Test-Path '{bootstrap}') {{ & '{bootstrap}' }} else {{ " +
|
||||
"Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe -EA SilentlyContinue }}\"",
|
||||
ct);
|
||||
Log($"bootstrap-winget: exit={b.ExitCode} out={Snip(b.StdOut)} err={Snip(b.StdErr)}");
|
||||
var p2 = await TryRunAsync("winget", "--version", ct);
|
||||
Log($"winget probe (post-bootstrap): exit={p2.ExitCode} out={Snip(p2.StdOut)}");
|
||||
if (p2.ExitCode == 0) return "winget";
|
||||
|
||||
// 3) Fall back to the WindowsApps execution-alias path (bare-name launch can fail under
|
||||
// UseShellExecute=false even when winget is installed).
|
||||
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var aliased = Path.Combine(local, "Microsoft", "WindowsApps", "winget.exe");
|
||||
var aliasExists = File.Exists(aliased);
|
||||
var p3Exit = aliasExists ? (await TryRunAsync(aliased, "--version", ct)).ExitCode : -1;
|
||||
Log($"winget alias path '{aliased}': exists={aliasExists} probe={p3Exit}");
|
||||
if (aliasExists && p3Exit == 0) return aliased;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Launching a missing executable throws Win32Exception ("cannot find the file specified")
|
||||
// rather than returning a non-zero code. Treat any launch failure as a failed run so the
|
||||
// installer's continue-on-failure logic covers it and onboarding never crashes.
|
||||
private async Task<ProcessResult> TryRunAsync(string file, string args, CancellationToken ct)
|
||||
{
|
||||
try { return await runner.RunAsync(file, args, ct); }
|
||||
catch (Exception ex) { return new ProcessResult(-1, "", ex.Message); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace SilverOS.Welcome.Core.Apps;
|
||||
|
||||
public interface IAppCatalog
|
||||
{
|
||||
LoadedCatalog Load(string directory);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using SilverOS.Welcome.Core.Apply;
|
||||
|
||||
namespace SilverOS.Welcome.Core.Apps;
|
||||
|
||||
public sealed record AppInstallResult(string Id, bool Installed);
|
||||
|
||||
public interface IAppInstaller
|
||||
{
|
||||
Task<IReadOnlyList<AppInstallResult>> InstallAsync(
|
||||
IReadOnlyList<AppCatalogEntry> apps, IProgress<ApplyProgress> progress, CancellationToken ct = default);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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"); }
|
||||
}
|
||||
@@ -1,8 +1,23 @@
|
||||
@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
|
||||
@inject SilverOS.Welcome.Core.Apply.IProcessRunner ProcessRunner
|
||||
|
||||
@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">
|
||||
@@ -40,13 +55,13 @@
|
||||
<FlavourStep Flavours="_flavours" OnSelected="StateHasChanged" />
|
||||
break;
|
||||
case 2:
|
||||
<AccountStep OnValidityChanged="@(v => { _accountValid = v; StateHasChanged(); })" />
|
||||
<AppsStep Apps="_catalog.AppsForRole(State.Flavour?.Id ?? string.Empty)" />
|
||||
break;
|
||||
case 3:
|
||||
<PrefsStep />
|
||||
break;
|
||||
case 4:
|
||||
<ApplyStep OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" />
|
||||
<ApplyStep AutoStart="_autoApply" OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" />
|
||||
break;
|
||||
case 5:
|
||||
<DoneStep />
|
||||
@@ -69,31 +84,53 @@
|
||||
@(_currentStep == _stepTitles.Length - 2 ? "Apply" : "Next")
|
||||
</button>
|
||||
}
|
||||
else if (_currentStep == _stepTitles.Length - 1)
|
||||
{
|
||||
<button class="btn-primary" @onclick="RestartNow">Restart now</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly string[] _stepTitles = { "Welcome", "Flavour", "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(
|
||||
AppContext.BaseDirectory, "flavours");
|
||||
|
||||
// Apps catalog dir: baked alongside the exe at publish time.
|
||||
private static readonly string AppsDir = Path.Combine(
|
||||
AppContext.BaseDirectory, "apps");
|
||||
|
||||
private LoadedCatalog _catalog = new(Array.Empty<AppCatalogEntry>());
|
||||
|
||||
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>();
|
||||
|
||||
private async Task RestartNow()
|
||||
{
|
||||
await ProcessRunner.RunAsync("cmd.exe", "/c shutdown /r /t 5", default);
|
||||
}
|
||||
|
||||
private bool CanGoNext => _currentStep switch
|
||||
{
|
||||
1 => State.Flavour is not null,
|
||||
2 => _accountValid,
|
||||
// 2 = Apps step is always valid (never blocks Next).
|
||||
_ => true
|
||||
};
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadFlavours();
|
||||
protected override Task OnInitializedAsync()
|
||||
{
|
||||
LoadFlavours();
|
||||
SeedFromPreconfig();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task LoadFlavours()
|
||||
{
|
||||
@@ -102,6 +139,7 @@
|
||||
try
|
||||
{
|
||||
_flavours = FlavourLoader.Load(FlavoursDir);
|
||||
_catalog = AppCatalog.Load(AppsDir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -114,10 +152,55 @@
|
||||
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: the collector already captured account + flavour, so
|
||||
// skip Welcome/Flavour and land on the Apps step (pre-checked with the flavour's
|
||||
// defaults) so the user can review/adjust the app selection before applying. From
|
||||
// there it's Apps -> Prefs -> Apply -> Done. (No auto-apply: the picker is the point.)
|
||||
_currentStep = 2;
|
||||
}
|
||||
|
||||
private void ReRunSetup()
|
||||
{
|
||||
_toolboxHome = false;
|
||||
_currentStep = 0;
|
||||
}
|
||||
|
||||
void Next()
|
||||
{
|
||||
if (_currentStep < _stepTitles.Length - 1)
|
||||
_currentStep++;
|
||||
|
||||
// On entering the Apps step, seed the per-role default selection once.
|
||||
if (_currentStep == 2 && State.SelectedApps.Count == 0 && State.Flavour is not null)
|
||||
foreach (var id in _catalog.DefaultSelectionForRole(State.Flavour.Id))
|
||||
State.SelectedApps.Add(id);
|
||||
}
|
||||
|
||||
void Back()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +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">
|
||||
@@ -46,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…";
|
||||
@@ -67,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
|
||||
@@ -81,13 +99,16 @@
|
||||
StateHasChanged();
|
||||
await OnRunningChanged.InvokeAsync(true);
|
||||
|
||||
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 =>
|
||||
{
|
||||
@@ -105,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();
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
@using SilverOS.Welcome.Core.Apps
|
||||
@inject WizardState State
|
||||
|
||||
<div class="step apps-step">
|
||||
<h1>Choose your apps</h1>
|
||||
<p class="step-subtitle">We'll install these during setup. The SilverLABS Stack (browser, VPN, keys) is already included.</p>
|
||||
|
||||
@foreach (var grp in _groups)
|
||||
{
|
||||
<h3 class="apps-group">@GroupTitle(grp.Key)</h3>
|
||||
<div class="apps-grid">
|
||||
@foreach (var app in grp)
|
||||
{
|
||||
<label class="app-card @(State.SelectedApps.Contains(app.Id) ? "selected" : "")">
|
||||
<input type="checkbox" checked="@State.SelectedApps.Contains(app.Id)"
|
||||
@onchange="e => Toggle(app.Id, (bool)e.Value!)" />
|
||||
<span class="app-name">@app.Name</span>
|
||||
<span class="app-desc">@app.Description</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<AppCatalogEntry> Apps { get; set; } = Array.Empty<AppCatalogEntry>();
|
||||
|
||||
private IEnumerable<IGrouping<string, AppCatalogEntry>> _groups =>
|
||||
Apps.GroupBy(a => a.Group).OrderByDescending(g => g.Key == "essentials");
|
||||
|
||||
private static string GroupTitle(string g) => g switch
|
||||
{
|
||||
"essentials" => "Essentials",
|
||||
"developer" => "Developer tools",
|
||||
"journalist" => "Journalist tools",
|
||||
"daily-driver" => "Everyday apps",
|
||||
"privacy-max" => "Privacy tools",
|
||||
_ => g
|
||||
};
|
||||
|
||||
void Toggle(string id, bool on)
|
||||
{
|
||||
if (on) State.SelectedApps.Add(id); else State.SelectedApps.Remove(id);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
@using QRCoder
|
||||
@inject SilverOS.Welcome.Core.Apply.IProcessRunner ProcessRunner
|
||||
|
||||
<div class="step done-step">
|
||||
<h1>All Done!</h1>
|
||||
@@ -27,8 +26,6 @@
|
||||
</small></p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button class="btn-primary btn-restart" @onclick="RestartNow">Restart Now</button>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
@@ -56,9 +53,4 @@
|
||||
catch { /* QR is best-effort; the key text still shows */ }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestartNow()
|
||||
{
|
||||
await ProcessRunner.RunAsync("cmd.exe", "/c shutdown /r /t 5", CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ namespace SilverOS.Welcome.App.Components;
|
||||
public sealed class WizardState
|
||||
{
|
||||
public FlavourManifest? Flavour { get; set; }
|
||||
public string Username { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
public string AdminPassword { get; set; } = "";
|
||||
|
||||
// Apps step: ids of catalog apps the user chose to install.
|
||||
public HashSet<string> SelectedApps { get; set; } = new();
|
||||
|
||||
public string BitLockerPin { get; set; } = "";
|
||||
|
||||
// Prefs step
|
||||
|
||||
@@ -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).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using SilverOS.Welcome.Core.Apps;
|
||||
using Xunit;
|
||||
|
||||
public class AppCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void Deserializes_a_catalog_entry()
|
||||
{
|
||||
var json = """
|
||||
{ "id":"vscodium","name":"VSCodium","description":"Telemetry-free VS Code.",
|
||||
"source":{"winget":"VSCodium.VSCodium"},"group":"developer",
|
||||
"roles":["developer"],"defaultFor":["developer"],"configure":null }
|
||||
""";
|
||||
var e = JsonSerializer.Deserialize<AppCatalogEntry>(json, AppCatalogEntry.JsonOptions)!;
|
||||
Assert.Equal("vscodium", e.Id);
|
||||
Assert.Equal("VSCodium.VSCodium", e.Source.Winget);
|
||||
Assert.Contains("developer", e.Roles);
|
||||
Assert.Contains("developer", e.DefaultFor);
|
||||
}
|
||||
}
|
||||
|
||||
public class AppCatalogLoaderTests
|
||||
{
|
||||
static string WriteCatalog(string body)
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "smcat-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(Path.Combine(dir, "catalog.json"), body);
|
||||
return dir;
|
||||
}
|
||||
|
||||
const string Body = """
|
||||
{ "schemaVersion":1, "apps":[
|
||||
{"id":"tb","name":"Thunderbird","source":{"winget":"Mozilla.Thunderbird"},"group":"essentials","roles":["essentials"],"defaultFor":["essentials"]},
|
||||
{"id":"vscodium","name":"VSCodium","source":{"winget":"VSCodium.VSCodium"},"group":"developer","roles":["developer"],"defaultFor":["developer"]},
|
||||
{"id":"rider","name":"Rider","source":{"winget":"JetBrains.Rider"},"group":"developer","roles":["developer"],"defaultFor":[]}
|
||||
]}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void AppsForRole_returns_essentials_plus_role()
|
||||
{
|
||||
var c = new AppCatalog().Load(WriteCatalog(Body));
|
||||
var ids = c.AppsForRole("developer").Select(a => a.Id).ToList();
|
||||
Assert.Contains("tb", ids);
|
||||
Assert.Contains("vscodium", ids);
|
||||
Assert.Contains("rider", ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSelection_only_pre_checks_defaultFor()
|
||||
{
|
||||
var c = new AppCatalog().Load(WriteCatalog(Body));
|
||||
var def = c.DefaultSelectionForRole("developer");
|
||||
Assert.Contains("tb", def);
|
||||
Assert.Contains("vscodium", def);
|
||||
Assert.DoesNotContain("rider", def);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_catalog_returns_empty_not_throw()
|
||||
{
|
||||
var c = new AppCatalog().Load(Path.Combine(Path.GetTempPath(), "nope-" + Guid.NewGuid().ToString("N")));
|
||||
Assert.Empty(c.All);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
using SilverOS.Welcome.Core.Apps;
|
||||
using SilverOS.Welcome.Core.Apply;
|
||||
|
||||
public class AppInstallerTests
|
||||
{
|
||||
static AppCatalogEntry App(string id, string winget, string? cfg = null) =>
|
||||
new() { Id = id, Name = id, Source = new AppSource { Winget = winget }, Configure = cfg };
|
||||
|
||||
static Mock<IProcessRunner> Runner(int exit = 0)
|
||||
{
|
||||
var m = new Mock<IProcessRunner>();
|
||||
m.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(exit, "", ""));
|
||||
return m;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Installs_each_selected_app_via_winget()
|
||||
{
|
||||
var run = Runner();
|
||||
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(0, "v1.8", ""));
|
||||
var sut = new AppInstaller(run.Object, "C:\\apps");
|
||||
var res = await sut.InstallAsync(new[] { App("vscodium", "VSCodium.VSCodium") },
|
||||
new Progress<ApplyProgress>(_ => { }));
|
||||
run.Verify(r => r.RunAsync("winget", It.Is<string>(s =>
|
||||
s.Contains("install") && s.Contains("VSCodium.VSCodium") && s.Contains("--silent")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
Assert.True(res.Single().Installed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstraps_winget_when_absent()
|
||||
{
|
||||
var run = Runner();
|
||||
// Any winget probe (bare name OR the WindowsApps alias path) reports absent.
|
||||
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(1, "", "not found"));
|
||||
var sut = new AppInstaller(run.Object, "C:\\apps");
|
||||
await sut.InstallAsync(new[] { App("tb", "Mozilla.Thunderbird") }, new Progress<ApplyProgress>(_ => { }));
|
||||
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
|
||||
s.Contains("DesktopAppInstaller")), It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_app_failure_does_not_stop_the_rest()
|
||||
{
|
||||
var run = new Mock<IProcessRunner>();
|
||||
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(0, "v1.8", ""));
|
||||
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("Bad.App")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(1, "", "fail"));
|
||||
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("Good.App")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(0, "", ""));
|
||||
var sut = new AppInstaller(run.Object, "C:\\apps");
|
||||
var res = await sut.InstallAsync(new[] { App("bad", "Bad.App"), App("good", "Good.App") },
|
||||
new Progress<ApplyProgress>(_ => { }));
|
||||
Assert.False(res.First(r => r.Id == "bad").Installed);
|
||||
Assert.True(res.First(r => r.Id == "good").Installed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Winget_launch_exception_does_not_crash_apply_and_skips_installs()
|
||||
{
|
||||
// On IoT LTSC winget is absent, so Process.Start throws Win32Exception instead of
|
||||
// returning a non-zero code. The installer must swallow it, skip installs, and NOT throw.
|
||||
var run = new Mock<IProcessRunner>();
|
||||
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new System.ComponentModel.Win32Exception("The system cannot find the file specified."));
|
||||
run.Setup(r => r.RunAsync("powershell.exe", It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(0, "", ""));
|
||||
var sut = new AppInstaller(run.Object, "C:\\apps");
|
||||
|
||||
var res = await sut.InstallAsync(new[] { App("vscodium", "VSCodium.VSCodium"), App("git", "Git.Git") },
|
||||
new Progress<ApplyProgress>(_ => { }));
|
||||
|
||||
Assert.Equal(2, res.Count);
|
||||
Assert.All(res, r => Assert.False(r.Installed));
|
||||
// never attempted an install once winget was unresolved
|
||||
run.Verify(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("install --id")), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task App_install_exception_is_isolated_from_the_rest()
|
||||
{
|
||||
var run = new Mock<IProcessRunner>();
|
||||
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(0, "v1.8", ""));
|
||||
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("Bad.App")), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new System.ComponentModel.Win32Exception("boom"));
|
||||
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("Good.App")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(0, "", ""));
|
||||
var sut = new AppInstaller(run.Object, "C:\\apps");
|
||||
|
||||
var res = await sut.InstallAsync(new[] { App("bad", "Bad.App"), App("good", "Good.App") },
|
||||
new Progress<ApplyProgress>(_ => { }));
|
||||
|
||||
Assert.False(res.First(r => r.Id == "bad").Installed);
|
||||
Assert.True(res.First(r => r.Id == "good").Installed);
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
using Moq;
|
||||
using SilverOS.Welcome.Core.Apply;
|
||||
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 sut = new ApplyService(
|
||||
runner: new ProcessRunner(),
|
||||
accounts: acct.Object,
|
||||
bitlocker: bl.Object,
|
||||
bootstrap: boot.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");
|
||||
|
||||
// ---- 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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,90 @@
|
||||
using Moq;
|
||||
using SilverOS.Welcome.Core.Apply;
|
||||
using SilverOS.Welcome.Core.Apps;
|
||||
using SilverOS.Welcome.Core.Flavours;
|
||||
using Xunit;
|
||||
|
||||
public class ApplyServiceTests
|
||||
{
|
||||
private static Mock<IAppInstaller> NoApps()
|
||||
{
|
||||
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>());
|
||||
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 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, "C:\\hard");
|
||||
var flavour = new FlavourManifest { Id="daily-driver", Hardening = new HardeningSpec { Modules = new[]{"00"} } };
|
||||
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap");
|
||||
var 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","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, "C:\\hard");
|
||||
var req = new ApplyRequest(new FlavourManifest{ Hardening=new HardeningSpec{Modules=new[]{"00"}}}, "a","b","c","123456","sm-bootstrap");
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.RunAsync(req, new Progress<ApplyProgress>(_ => {})));
|
||||
boot.Verify(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,32 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using SilverOS.Welcome.App.Components;
|
||||
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
|
||||
{
|
||||
// The component loads the catalog from AppContext.BaseDirectory/apps; no catalog.json
|
||||
// is staged in the test bin, so the real AppCatalog degrades to an empty list — which is
|
||||
// exactly what these tests want (no apps selected → empty Apps on the request).
|
||||
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()
|
||||
{
|
||||
@@ -17,17 +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);
|
||||
}
|
||||
@@ -41,16 +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()
|
||||
{
|
||||
@@ -60,10 +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);
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
using Moq;
|
||||
using SilverOS.Welcome.Core.Apply;
|
||||
|
||||
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 sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.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");
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user