41 Commits

Author SHA1 Message Date
dfae1f136b Merge pull request 'fix(build): drop invalid --no-incremental from dotnet publish' (#33) from fix/clean-publish-flag into main
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (push) Failing after 9s
2026-06-10 22:26:28 +00:00
sysadmin
74e48aa1e5 fix(build): drop invalid --no-incremental from dotnet publish (MSB1001)
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 8s
dotnet publish rejects --no-incremental (it's a dotnet build switch) -> MSB1001 Unknown
switch -> build failed. The bin/obj wipe alone forces the clean recompile we need.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:26:08 +01:00
a6ac6ce355 Merge pull request 'fix(build): clean compile before publish (CI shipped a stale toolbox DLL)' (#32) from fix/ci-clean-publish into main
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (push) Failing after 31s
2026-06-10 22:21:30 +00:00
sysadmin
9832121dbb fix(build): clean compile before publish (CI shipped stale Core.dll)
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 1m31s
The deployed toolbox Core.dll was timestamped BEFORE its own build ran -- the CI runner's
incremental build reused a cached SilverOS.Welcome.Core.dll, so source fixes (e.g. the winget
bootstrap brace fix) never reached the published exe. Wipe all bin/obj under welcome/ and pass
--no-incremental so every build is a clean compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:21:04 +01:00
d0a5925652 Merge pull request 'fix(apps): winget bootstrap never ran (unbalanced-brace parse error) — the real apps-skip cause' (#31) from fix/winget-bootstrap-brace into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 5m32s
2026-06-10 21:44:32 +00:00
sysadmin
e91c4de7ed fix(apps): winget bootstrap never ran (unbalanced-brace parse error in inline cmd)
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 36s
appinstall.log on the VM showed: bootstrap-winget exit=1 'Unexpected token }'. The
inline -Command was built from an interpolated string ($"...{{...}}" -> {/}) concatenated
with a NON-interpolated string whose '}}' stayed literal, so the emitted PowerShell ended
in '}}' and failed to parse -> the bootstrap (and thus winget install) never executed ->
all apps skipped on every run, regardless of network. Invoke the bootstrap .ps1 file
directly instead (it self-checks + installs winget online); fall back to the inbox
re-register only when the script is absent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:44:05 +01:00
51ab88b1f8 Merge pull request 'fix(toolbox): move Done 'Restart now' button to the footer-right (was clipped)' (#30) from fix/done-restart-footer into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 5m41s
2026-06-10 18:12:19 +00:00
sysadmin
709744d533 feat(apps): AppInstaller writes a diagnostic log (winget resolve + bootstrap + per-app)
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 2s
Writes C:\ProgramData\SilverMetal\appinstall.log (best-effort) so a post-install mount
shows exactly where app installs fail: winget probe results, bootstrap-winget output,
and per-app winget exit codes. Makes the no-apps-installed failure diagnosable instead
of inferred.
2026-06-10 19:12:11 +01:00
sysadmin
ddd8784b56 fix(toolbox): move Done 'Restart now' to footer-right (was clipped in content)
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 33s
The in-content Restart button overflowed its fixed width. Move it into the wizard
footer's right slot (where Next/Apply sits) as a btn-primary; Routes owns the restart
shutdown now, DoneStep just shows the recovery key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 19:06:53 +01:00
226a823c68 Merge pull request 'fix: track driver .exe (NetKVM inject) + winget online bootstrap — the two app-install blockers' (#29) from fix/driver-exe-and-winget into main
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (push) Failing after 4m0s
2026-06-10 16:22:52 +00:00
sysadmin
67befa56df fix(build): track driver .exe files (gitignore *.exe dropped netkvmp.exe -> DISM rejected NetKVM)
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 5m49s
The global .gitignore '*.exe' rule silently excluded netkvmp.exe + netkvmco.exe when the
NetKVM driver was committed, so only inf/sys/cat shipped. netkvm.inf REQUIRES netkvmp.exe
([SourceDisksFiles] + netkvmp.CopyFiles), so Add-WindowsDriver failed every build with 'the
driver package could not be installed' -> no virtio NIC driver -> no VM network. Un-ignore
windows/drivers/** and force-add the referenced binaries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:22:07 +01:00
sysadmin
13df66d137 feat(apps): bootstrap-winget downloads + installs the App Installer online (LTSC lacks it) 2026-06-10 17:21:27 +01:00
541a17c792 Merge pull request 'ci(windows): free build working set before the persist copy (persist OOM)' (#28) from ci/free-before-persist into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 6m24s
2026-06-10 15:35:35 +00:00
sysadmin
9fa613b8c1 ci(windows): free build working set before persist copy (oscdimg OK, persist OOM)
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 45s
Build got through the ISO repack but failed copying the 5GB ISO to C:\silvermetal\out
('not enough space'): the build's working set (extracted ISO tree + expanded install.wim
+ 5GB base ISO) fills the single-volume runner, leaving <5GB for the persist copy. The
image grew again with the injected driver. Delete RUNNER_TEMP\smbuild + base.iso (no
longer needed post-build/validate) right before the copy to reclaim ~10GB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 16:35:01 +01:00
8f61d5fb61 Merge pull request 'fix(build): driver inject non-fatal + ForceUnsigned (NetKVM rejected, bricked build)' (#27) from fix/driver-inject-resilient into main
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (push) Failing after 7m24s
2026-06-10 13:41:42 +00:00
sysadmin
09e1f94b7d fix(build): driver inject non-fatal + ForceUnsigned + .gitattributes binary
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 7m35s
Add-WindowsDriver rejected the virtio NetKVM driver during offline servicing and
aborted the whole build. A driver issue must not brick the image: wrap it in try/catch
(warn + continue) and add -ForceUnsigned to bypass the offline-inject signature check
(the driver is WHQL-signed and loads at boot regardless). Add .gitattributes marking
driver/binary files as binary so the runner checkout never EOL-normalizes them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:41:22 +01:00
8ceb38c3dd Merge pull request 'fix(collector): button footer + inject virtio NIC driver (HVCI network)' (#26) from fix/collector-button-layout into main
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (push) Failing after 4m46s
2026-06-10 13:32:59 +00:00
sysadmin
a169d2a452 feat(build): inject virtio-net (NetKVM) driver for HVCI-compatible VM networking
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 4m56s
The privacy hardening enables HVCI (Memory Integrity), which blocks the legacy
e1000 NIC driver (E1G6032E.sys) -> no network in the VM, so winget app installs
silently skip. virtio-net's NetKVM driver is WHQL-signed + HVCI-compatible. Staged
from virtio-win (w11/amd64) under windows/drivers/netkvm/; build.ps1 already auto-
injects any *.inf under windows/drivers/ into install.wim. Pair with a virtio NIC on
the VM (already switched). Lets apps actually install under hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:32:48 +01:00
sysadmin
20743e9b54 fix(collector): move Finish/Cancel into a clean footer (were overlapping Confirm PIN) 2026-06-10 14:29:52 +01:00
75f97778f8 Merge pull request 'feat(toolbox): first-run lands on the Apps picker (not silent auto-apply)' (#25) from fix/first-run-apps-picker into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 6m13s
2026-06-10 13:11:42 +00:00
sysadmin
18475fa731 feat(toolbox): first-run lands on the Apps picker (not silent auto-apply)
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 6m14s
Hands-on VM testing showed auto-apply skipped the app picker entirely -- the user
couldn't review/adjust apps before install. Land first-run on the Apps step instead
(pre-checked with the collector flavour's defaults); the user adjusts then walks
Apps -> Prefs -> Apply -> Done. The collector already owns account + flavour, so
Welcome/Flavour are skipped. Reverses the earlier auto-apply behavior per operator
feedback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:11:28 +01:00
04a6f6eabb Merge pull request 'fix(collector): carry preconfig via chunked FirstLogonCommands (specialize Path too long)' (#24) from fix/answer-file-specialize-length into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 7m34s
2026-06-10 09:35:34 +00:00
sysadmin
7e99d7e304 fix(collector): carry preconfig via chunked FirstLogonCommands (specialize Path was too long -> answer file invalid)
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 5m44s
2026-06-10 10:34:47 +01:00
731ae88adf Merge pull request 'fix(collector): launch via Setup\CmdLine (collector was bypassed into Setup)' (#23) from fix/collector-launch-via-setup-cmdline into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 5m47s
2026-06-10 09:14:19 +00:00
sysadmin
fce4b77bd6 fix(collector): launch via Setup\CmdLine (was bypassed) + WinPE diagnostics
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 4m8s
The boot.wim Setup\CmdLine override (legacy-Setup forcing) is authoritative over
winpeshl.ini, so it launched setup.exe directly and the collector never ran -- the
VM went straight to the old sm-bootstrap unattended install. Repoint Setup\CmdLine
at the collector (cmd /c X:\sm\Start-Collector.cmd); the collector still launches the
legacy X:\sources\setup.exe itself. Add wpeinit + an on-screen banner, and write any
collector/WinForms-load failure to X:\sm\collector-error.txt shown on the console
before falling back, so we can diagnose WinForms-in-WinPE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:14:08 +01:00
3538f43267 Merge pull request 'ci(windows): free disk space before build (fixes oscdimg out-of-space)' (#22) from ci/free-disk-before-build into main
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (push) Failing after 24s
2026-06-10 08:50:14 +00:00
sysadmin
7eec584a66 ci(windows): free disk space before build (clear prior ISO output)
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 21s
The boot.wim now carries WinPE-NetFx/PowerShell (collector), growing the image ~0.4GB,
and each build persists a ~5GB ISO to C:\silvermetal\out. On the single-volume runner
that accumulation starved oscdimg ('Insufficient disk space'). Clear prior output +
stale smbuild work dirs at job start so free space self-heals each run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:50:00 +01:00
c154e70495 Merge pull request 'feat: WinPE pre-config collector + simplified first-boot toolbox (SP1)' (#21) from docs/winpe-preconfig-collector into main
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (push) Failing after 3m51s
2026-06-10 08:40:57 +00:00
sysadmin
e6c292da25 ci(windows): install ADK WinPE add-on so boot.wim collector can be staged
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 7m26s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:38:13 +01:00
sysadmin
6c96e92fa5 fix(collector): drop 'essentials' from flavour radios (it's the baseline role, not a flavour)
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 5m18s
2026-06-10 09:35:59 +01:00
sysadmin
a82ca271a0 feat(build): scrub Panther unattend + assert collector baked into boot.wim
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:28:45 +01:00
bd215cba54 Merge pull request 'perf(welcome): cut first-boot cold-start + add loading affordance' (#20) from fix/welcome-cold-start into main
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (push) Failing after 21s
Reviewed-on: #20
2026-06-10 08:27:18 +00:00
sysadmin
084dd6a1d7 fix(collector): pre-launch XML parse-check (fail to default) + resolve setup.exe path 2026-06-10 09:25:57 +01:00
sysadmin
9d05a4a223 feat(build): stage WinPE collector into boot.wim (winpeshl + WinPE-NetFx/PowerShell) with SM_UNATTENDED fallback
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:22:14 +01:00
sysadmin
5bc345b1bd feat(toolbox): first-run auto-applies the collected preconfig (no manual walkthrough) 2026-06-10 09:19:11 +01:00
sysadmin
e88e476cd6 feat(toolbox): drop Account step, pre-seed from preconfig, run-once vs toolbox-home
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:12:39 +01:00
sysadmin
2730b29cb6 refactor(toolbox): drop unused IProcessRunner from ApplyService ctor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 09:08:14 +01:00
sysadmin
30a168e853 perf(welcome): cut first-boot cold-start + add loading affordance
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 4m46s
The Welcome wizard showed nothing until WebView2 cold-started and Blazor
booted, so the whole startup cost presented as a blank window long enough
that operators thought first boot had failed.

- Native MAUI splash overlay (renders in the first frame, no WebView2/JIT
  dependency) + a visually identical in-page splash inside #app, so the
  native -> webview -> Blazor handoff reads as one continuous loading
  screen. Fades out on first successful WV2 NavigationCompleted.
- PublishReadyToRun=true (publish-only) to remove first-run JIT on the
  one-shot cold-disk path. R2R header verified present after publish.
- Fixed-version WebView2 runtime baked offline next to the exe (build.ps1
  stages it, app points WEBVIEW2_BROWSER_EXECUTABLE_FOLDER at it). Removes
  the Evergreen registry probe and the LTSC "no WebView2 at all" risk flagged
  in welcome-app-spec.md; air-gap friendly. Absent => falls back to Evergreen.
- De-flash launch: drop the `cmd /c` wrapper and add -WindowStyle Hidden in
  autounattend FirstLogonCommands (kills the console flash + one process).

Verified: Release build clean, win-x64 self-contained publish succeeds with
R2R confirmed, 38/38 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:06:02 +01:00
sysadmin
f3d66fb9d3 refactor(toolbox): Apply is apps+bitlocker only (account via Setup, hardening via SetupComplete)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:04:09 +01:00
sysadmin
bd1e2885df feat(toolbox): preconfig store (load fail-open, clear-pin, configured marker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 08:56:32 +01:00
sysadmin
42d86734b0 feat(collector): answer-file generator (real account, no sm-bootstrap, embedded preconfig) 2026-06-10 08:51:35 +01:00
47 changed files with 1640 additions and 675 deletions

11
.gitattributes vendored Normal file
View 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

View File

@@ -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
View File

@@ -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/**

View File

@@ -1,5 +1,68 @@
#Requires -Version 5.1
$ErrorActionPreference='SilentlyContinue'
# Register the inbox App Installer if present, else nothing to do (offline image w/o it).
# 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" }
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

View 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)
}

View 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('&','&amp;').Replace('<','&lt;').Replace('>','&gt;') }
$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>
"@
}

View 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

View File

@@ -0,0 +1,2 @@
[LaunchApps]
%SYSTEMDRIVE%\sm\Start-Collector.cmd

Binary file not shown.

View 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"

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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>

View File

@@ -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 {
@@ -143,6 +160,12 @@ function Invoke-PublishWelcome {
Write-Stage 'Stage 3b: publish SilverOS Welcome app (win-x64 self-contained)'
$proj = Join-Path $WindowsDir 'welcome\src\SilverOS.Welcome.App'
$out = Join-Path $WorkDir 'welcome-publish'
# Force a CLEAN compile. The CI runner reuses build artifacts across runs, and dotnet's
# incremental build has shipped a STALE SilverOS.Welcome.Core.dll (old code despite fixed
# source) -- so wipe every bin/obj under welcome/ before publishing (a clean tree forces a
# full recompile; note `dotnet publish` does NOT accept --no-incremental).
Get-ChildItem (Join-Path $WindowsDir 'welcome') -Recurse -Directory -EA SilentlyContinue |
Where-Object { $_.Name -in 'bin', 'obj' } | Remove-Item -Recurse -Force -EA SilentlyContinue
& dotnet publish $proj -c Release -f net9.0-windows10.0.19041.0 -r win-x64 --self-contained true -o $out
if ($LASTEXITCODE -ne 0) { throw 'Welcome app dotnet publish failed' }
Write-Host " Published to: $out"
@@ -183,6 +206,22 @@ function Copy-WelcomePayload {
} 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)) {
@@ -221,7 +260,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.

View File

@@ -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

View File

@@ -56,6 +56,20 @@ try {
}
} finally { Dismount-WindowsImage -Path $mount -Discard | Out-Null }
}
# boot.wim must carry the WinPE collector + winpeshl (the pre-config front-end).
$bootwim = "$drive\sources\boot.wim"
Assert 'boot.wim present' (Test-Path $bootwim)
if (Test-Path $bootwim) {
$bmount = Join-Path $env:TEMP ('sm-assert-boot-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force $bmount | Out-Null
Mount-WindowsImage -ImagePath $bootwim -Index 2 -Path $bmount -ReadOnly | Out-Null
try {
Assert 'collector staged in boot.wim' (Test-Path (Join-Path $bmount 'sm\Collector.ps1'))
Assert 'winpeshl.ini set' (Test-Path (Join-Path $bmount 'Windows\System32\winpeshl.ini'))
Assert 'answer-file generator staged' (Test-Path (Join-Path $bmount 'sm\New-SmAnswerFile.ps1'))
} finally { Dismount-WindowsImage -Path $bmount -Discard | Out-Null; Remove-Item $bmount -Recurse -Force -EA SilentlyContinue }
}
} finally {
Dismount-DiskImage -ImagePath $IsoPath | Out-Null
Remove-Item $mount -Recurse -Force -EA SilentlyContinue

View File

@@ -30,3 +30,43 @@ Describe 'Test-SmComputerName' {
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' }
}

View 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.

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Flavours;
using SilverOS.Welcome.Core.Preconfig;
using SilverOS.Welcome.App.Components;
namespace SilverOS.Welcome.App;
@@ -17,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>()
@@ -32,22 +50,16 @@ public static class MauiProgram
builder.Logging.AddDebug();
#endif
var hardeningDir = @"C:\Windows\Setup\Scripts\hardening";
builder.Services.AddSingleton<IProcessRunner, ProcessRunner>();
builder.Services.AddSingleton<IFlavourLoader, FlavourLoader>();
builder.Services.AddSingleton<IAppCatalog, AppCatalog>();
builder.Services.AddSingleton<IAccountService, AccountService>();
builder.Services.AddSingleton<IBitLockerService, BitLockerService>();
builder.Services.AddSingleton<IBootstrapService, BootstrapService>();
var appsDir = Path.Combine(AppContext.BaseDirectory, "apps");
builder.Services.AddSingleton<IAppInstaller>(sp => new AppInstaller(sp.GetRequiredService<IProcessRunner>(), appsDir));
builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
sp.GetRequiredService<IProcessRunner>(),
sp.GetRequiredService<IAccountService>(),
sp.GetRequiredService<IBitLockerService>(),
sp.GetRequiredService<IBootstrapService>(),
sp.GetRequiredService<IAppInstaller>(),
hardeningDir));
sp.GetRequiredService<IAppInstaller>()));
builder.Services.AddSingleton<IPreconfigStore>(_ => new PreconfigStore(@"C:\ProgramData\SilverMetal"));
builder.Services.AddScoped<WizardState>();
return builder.Build();

View File

@@ -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>

View File

@@ -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);

View File

@@ -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.

View File

@@ -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("'", "''");
}

View File

@@ -1,6 +1,6 @@
using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Flavours;
using SilverOS.Welcome.Core.Apps;
namespace SilverOS.Welcome.Core.Apply;
public sealed record ApplyRequest(FlavourManifest Flavour, string Username, string Password,
string AdminPassword, string BitLockerPin, string BootstrapUser,
IReadOnlyList<AppCatalogEntry> Apps);
// Toolbox model: the account is created by Windows Setup (WinPE collector), and hardening
// runs from SetupComplete. Apply only installs apps + enrols BitLocker.
public sealed record ApplyRequest(FlavourManifest Flavour, string BitLockerPin, IReadOnlyList<AppCatalogEntry> Apps);

View File

@@ -1,45 +1,21 @@
using System.Text.Json;
using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Flavours;
namespace SilverOS.Welcome.Core.Apply;
public sealed class ApplyService(IProcessRunner runner, IAccountService accounts,
IBitLockerService bitlocker, IBootstrapService bootstrap, IAppInstaller installer,
string hardeningDir) : IApplyService
// Toolbox Apply pipeline: apps -> bitlocker -> done.
// Account creation moved to Windows Setup (WinPE collector); OS hardening runs from
// SetupComplete; sm-bootstrap teardown is owned by Setup, not the toolbox.
public sealed class ApplyService(IBitLockerService bitlocker, IAppInstaller installer) : IApplyService
{
public async Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default)
{
progress.Report(new("Applying hardening", 10));
// Pass modules as a single bare CSV token (e.g. 00,03,05).
// powershell.exe -File receives single-quoted tokens as one literal string, not an array,
// so Invoke-Hardening.ps1 accepts [string]$Modules and splits on ',' internally.
var mods = string.Join(",", req.Flavour.Hardening.Modules);
var pjson = JsonSerializer.Serialize(req.Flavour.Hardening.Params).Replace("\"", "\\\"");
var script = Path.Combine(hardeningDir, "Invoke-Hardening.ps1");
var res = await runner.RunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -File \"{script}\" -Modules {mods} -ParamsJson \"{pjson}\"", ct);
if (res.ExitCode != 0)
progress.Report(new("Installing apps", 30));
await installer.InstallAsync(req.Apps, progress, ct); // continue-on-failure; never throws
if (!string.IsNullOrWhiteSpace(req.BitLockerPin))
{
// Only expose exit code + first non-empty stderr line (capped) — never raw full stderr.
var firstLine = res.StdErr
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault()?.Trim() ?? string.Empty;
if (firstLine.Length > 200) firstLine = firstLine[..200];
throw new InvalidOperationException($"Hardening failed (exit {res.ExitCode}): {firstLine}");
progress.Report(new("Encrypting the disk", 75));
await bitlocker.EnableAsync(req.BitLockerPin, ct);
}
progress.Report(new("Creating your account", 55));
await accounts.CreateAccountsAsync(req.Username, req.Password, req.AdminPassword, ct);
progress.Report(new("Installing apps", 70));
await installer.InstallAsync(req.Apps, progress, ct); // continue-on-failure; never throws
progress.Report(new("Encrypting the disk", 75));
await bitlocker.EnableAsync(req.BitLockerPin, ct);
progress.Report(new("Finishing up", 95));
await bootstrap.RevertKioskAsync(ct); // revert kiosk before account deletion (SID must still resolve)
await bootstrap.TearDownAsync(req.BootstrapUser, ct); // last — only after success
progress.Report(new("Done", 100));
}
}

View File

@@ -1,63 +0,0 @@
namespace SilverOS.Welcome.Core.Apply;
public sealed class BootstrapService(IProcessRunner runner) : IBootstrapService
{
// Lockdown revert is BEST-EFFORT (like TearDownAsync): -EA SilentlyContinue throughout.
// Don't fail the apply over a missing WMI class / key. Must run BEFORE TearDownAsync.
public async Task RevertKioskAsync(CancellationToken ct = default)
{
// Disable the Keyboard Filter rules so the real end-user's Win key / task-switch /
// Alt+F4 etc. work again (Explorer is already the shell — nothing to undo there).
await Ps(
"$c='root\\\\standardcimv2\\\\embedded';" +
"foreach($k in @('Win','Win+L','Ctrl+Esc','Ctrl+Win+F','Win+R','Alt+Tab','Ctrl+Shift+Esc','Alt+F4')){" +
"$p=Get-CimInstance -Namespace $c -ClassName WEKF_PredefinedKey -Filter \"Id='$k'\" -EA SilentlyContinue;" +
"if($p){$p.Enabled=$false; Set-CimInstance -InputObject $p -EA SilentlyContinue}" +
"}",
ct);
// Revert escape policies set by Configure-Kiosk.ps1.
await Ps(
"$s='HKLM:\\\\SOFTWARE\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Policies\\\\System';" +
"Remove-ItemProperty $s -Name DisableTaskMgr,DisableLockWorkstation,HideFastUserSwitching -EA SilentlyContinue;" +
// Restore SECURE UAC for the real end-user (the kiosk auto-approved unsigned elevation).
"Set-ItemProperty $s -Name ConsentPromptBehaviorAdmin -Value 2 -Type DWord -EA SilentlyContinue;" +
"Set-ItemProperty $s -Name PromptOnSecureDesktop -Value 1 -Type DWord -EA SilentlyContinue",
ct);
}
// Teardown is BEST-EFFORT (unlike Account/BitLocker which are strict): the answer file's
// AutoLogon LogonCount=1 already neutralises auto-logon after the first logon (Windows clears
// AutoAdminLogon itself), so these Winlogon cleanups must not fail the whole apply. The op that
// matters — removing the sm-bootstrap account — runs regardless and is tolerant too.
public async Task TearDownAsync(string bootstrapUser, CancellationToken ct = default)
{
const string w = "'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon'";
await Ps($"Set-ItemProperty -Path {w} -Name AutoAdminLogon -Value '0' -EA SilentlyContinue; " +
$"Remove-ItemProperty -Path {w} -Name DefaultPassword -EA SilentlyContinue; " +
$"Remove-ItemProperty -Path {w} -Name DefaultUserName -EA SilentlyContinue; " +
$"Remove-ItemProperty -Path {w} -Name DefaultDomainName -EA SilentlyContinue", ct);
var u = Esc(bootstrapUser);
// Best-effort in-session removal (usually no-ops — you can't delete the account
// you're logged in as), THEN defer the real removal to a SYSTEM startup task that
// runs on next boot, when sm-bootstrap is no longer logged on. It removes the
// account + profile, then unregisters itself.
// Disable immediately (in-session, takes effect at once so the account is unusable
// and shows as disabled), then best-effort delete; the deferred task does the real
// delete on next boot when it isn't logged on.
await Ps($"Disable-LocalUser -Name '{u}' -EA SilentlyContinue; Remove-LocalUser -Name '{u}' -EA SilentlyContinue", ct);
var cleanup =
$"Remove-LocalUser -Name '{u}' -ErrorAction SilentlyContinue; " +
$"Get-CimInstance Win32_UserProfile | Where-Object {{ $_.LocalPath -like '*\\{u}' }} | Remove-CimInstance -ErrorAction SilentlyContinue; " +
"Unregister-ScheduledTask -TaskName 'SilverMetalBootstrapCleanup' -Confirm:$false -ErrorAction SilentlyContinue";
var b64 = Convert.ToBase64String(System.Text.Encoding.Unicode.GetBytes(cleanup));
// Register-ScheduledTask (not schtasks.exe) — schtasks /tr caps at 261 chars and
// silently failed with the encoded payload, so the task was never created.
await Ps("$a=New-ScheduledTaskAction -Execute 'powershell.exe' -Argument " +
$"'-NoProfile -ExecutionPolicy Bypass -EncodedCommand {b64}'; " +
"$t=New-ScheduledTaskTrigger -AtStartup; " +
"$p=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest; " +
"Register-ScheduledTask -TaskName 'SilverMetalBootstrapCleanup' -Action $a -Trigger $t -Principal $p -Force | Out-Null", ct);
}
private static string Esc(string s) => s.Replace("'", "''");
private Task Ps(string s, CancellationToken ct) =>
runner.RunAsync("powershell.exe", $"-NoProfile -ExecutionPolicy Bypass -Command \"{s}\"", ct);
}

View File

@@ -1,2 +0,0 @@
namespace SilverOS.Welcome.Core.Apply;
public interface IAccountService { Task CreateAccountsAsync(string user, string password, string adminPassword, CancellationToken ct = default); }

View File

@@ -1,6 +0,0 @@
namespace SilverOS.Welcome.Core.Apply;
public interface IBootstrapService
{
Task RevertKioskAsync(CancellationToken ct = default);
Task TearDownAsync(string bootstrapUser, CancellationToken ct = default);
}

View File

@@ -4,17 +4,38 @@ 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;
@@ -33,6 +54,7 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn
$"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);
@@ -43,6 +65,7 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn
}
results.Add(new AppInstallResult(app.Id, ok));
}
Log($"InstallAsync done: {results.Count(r => r.Installed)}/{results.Count} installed");
return results;
}
@@ -52,22 +75,34 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn
private async Task<string?> ResolveWingetAsync(IProgress<ApplyProgress> progress, CancellationToken ct)
{
// 1) Already launchable by name (on PATH for this process)?
if ((await TryRunAsync("winget", "--version", ct)).ExitCode == 0) return "winget";
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.
// 2) Provision App Installer, then re-probe. Run the bootstrap SCRIPT FILE directly
// (it checks for winget and installs it online if absent). Invoking the .ps1 file
// avoids an inline -Command (a prior inline if/else had an unbalanced-brace parse bug
// from a non-interpolated string, so the bootstrap never actually ran).
progress.Report(new("Preparing app installer", 68));
var bootstrap = Path.Combine(appsDir, "bootstrap-winget.ps1");
await TryRunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -Command \"if (Test-Path '{bootstrap}') {{ & '{bootstrap}' }} else {{ " +
"Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe -EA SilentlyContinue }}\"",
ct);
if ((await TryRunAsync("winget", "--version", ct)).ExitCode == 0) return "winget";
var b = File.Exists(bootstrap)
? await TryRunAsync("powershell.exe", $"-NoProfile -ExecutionPolicy Bypass -File \"{bootstrap}\"", ct)
: await TryRunAsync("powershell.exe",
"-NoProfile -ExecutionPolicy Bypass -Command \"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");
if (File.Exists(aliased) && (await TryRunAsync(aliased, "--version", ct)).ExitCode == 0) return aliased;
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;
}

View File

@@ -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
}

View File

@@ -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
};
}

View File

@@ -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"); }
}

View File

@@ -1,10 +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">
@@ -45,15 +58,12 @@
<AppsStep Apps="_catalog.AppsForRole(State.Flavour?.Id ?? string.Empty)" />
break;
case 3:
<AccountStep OnValidityChanged="@(v => { _accountValid = v; StateHasChanged(); })" />
break;
case 4:
<PrefsStep />
break;
case 5:
<ApplyStep OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" />
case 4:
<ApplyStep AutoStart="_autoApply" OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" />
break;
case 6:
case 5:
<DoneStep />
break;
}
@@ -66,7 +76,7 @@
@onclick="Back">
Back
</button>
@if (_currentStep < _stepTitles.Length - 1 && _currentStep != 5)
@if (_currentStep < _stepTitles.Length - 1 && _currentStep != 4)
{
<button class="btn-primary"
disabled="@(!CanGoNext)"
@@ -74,11 +84,16 @@
@(_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", "Apps", "Account", "Prefs", "Apply", "Done" };
private static readonly string[] _stepTitles = { "Welcome", "Flavour", "Apps", "Prefs", "Apply", "Done" };
// Flavours dir: baked alongside the exe at publish time.
private static readonly string FlavoursDir = Path.Combine(
@@ -93,19 +108,29 @@
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 = Apps step is always valid (never blocks Next).
3 => _accountValid,
_ => true
};
protected override Task OnInitializedAsync() => LoadFlavours();
protected override Task OnInitializedAsync()
{
LoadFlavours();
SeedFromPreconfig();
return Task.CompletedTask;
}
private Task LoadFlavours()
{
@@ -127,6 +152,46 @@
return Task.CompletedTask;
}
// Runs AFTER flavours + catalog are loaded (order matters): decides run-mode and,
// on a first run, pre-seeds wizard state from the WinPE collector's choices.
private void SeedFromPreconfig()
{
var pre = PreconfigStore.Load();
if (PreconfigStore.IsConfigured())
{
// Already ran once -> open the minimal toolbox-home landing, never auto-apply.
_toolboxHome = true;
return;
}
if (pre is null)
return; // fail-open: no preconfig -> normal wizard with flavour defaults.
// Match the collector's flavour by id; fall back to the loaded default if absent.
State.Flavour = _flavours.FirstOrDefault(f => f.Id == pre.Flavour)
?? _flavours.FirstOrDefault(f => f.IsDefault)
?? _flavours.FirstOrDefault();
foreach (var id in _catalog.DefaultSelectionForRole(pre.Flavour))
State.SelectedApps.Add(id);
if (pre.Bitlocker.Enable && !string.IsNullOrEmpty(pre.Bitlocker.Pin))
State.BitLockerPin = pre.Bitlocker.Pin;
// First run with a preconfig: 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)

View File

@@ -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);
}
}

View File

@@ -1,6 +1,8 @@
@using SilverOS.Welcome.Core.Apps
@using SilverOS.Welcome.Core.Preconfig
@inject IApplyService ApplyService
@inject IAppCatalog AppCatalog
@inject IPreconfigStore PreconfigStore
@inject WizardState State
<div class="step apply-step">
@@ -48,8 +50,10 @@
@code {
[Parameter] public EventCallback OnComplete { get; set; }
[Parameter] public EventCallback<bool> OnRunningChanged { get; set; }
[Parameter] public bool AutoStart { get; set; }
private bool _running;
private bool _autoStarted;
private bool _complete;
private int _percent;
private string _stageLabel = "Preparing…";
@@ -69,6 +73,18 @@
: single[..ErrorDisplayMaxLength] + "…";
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// First-run auto-apply: when the host jumps straight to this step with AutoStart,
// kick off the same apply the Start button would, exactly once. The manual path
// (AutoStart=false) is untouched.
if (firstRender && AutoStart && !_autoStarted)
{
_autoStarted = true;
await StartAsync();
}
}
public async Task StartAsync()
{
// Re-entrancy guard: prevent a second overlapping apply if already running
@@ -86,13 +102,12 @@
var apps = AppCatalog.Load(Path.Combine(AppContext.BaseDirectory, "apps"))
.All.Where(a => State.SelectedApps.Contains(a.Id)).ToList();
// D1: Apply is now apps+bitlocker only (account via Setup, hardening via SetupComplete).
// D2 owns the full UI rewire (run-mode / preseed); this passes the 3-arg request from
// existing State fields so the app keeps compiling.
var req = new ApplyRequest(
Flavour: State.Flavour!,
Username: State.Username,
Password: State.Password,
AdminPassword: State.AdminPassword,
BitLockerPin: State.BitLockerPin,
BootstrapUser: "sm-bootstrap",
Apps: apps);
var progress = new Progress<ApplyProgress>(p =>
@@ -111,6 +126,12 @@
_complete = true;
_running = false;
_percent = 100;
// Apply succeeded: wipe the BitLocker pin from the preconfig and stamp the
// configured marker so the next launch opens toolbox-home instead of re-applying.
PreconfigStore.ClearPin();
PreconfigStore.MarkConfigured();
StateHasChanged();
await OnRunningChanged.InvokeAsync(false);
await OnComplete.InvokeAsync();

View File

@@ -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);
}
}

View File

@@ -9,9 +9,6 @@ public sealed class WizardState
// Apps step: ids of catalog apps the user chose to install.
public HashSet<string> SelectedApps { get; set; } = new();
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public string AdminPassword { get; set; } = "";
public string BitLockerPin { get; set; } = "";
// Prefs step

View File

@@ -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).");
}
}

View File

@@ -1,121 +0,0 @@
using Moq;
using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Flavours;
using Xunit;
/// <summary>
/// Real integration test: proves that ApplyService passes -Modules with the correct
/// encoding so that Invoke-Hardening.ps1's subset filter actually works through the
/// real ProcessStartInfo / PowerShell boundary.
///
/// SAFETY: only harmless dummy .ps1 files are executed — never the real 0*.ps1 hardening
/// modules. Invoke-Hardening.ps1 is copied into a temp dir and run against dummy stubs.
/// </summary>
public class ApplyServiceHardeningIntegrationTests
{
/// <summary>Walk up from the test binary to find the repo root (same as ShippedFlavoursTests).</summary>
private static string HardeningDir()
{
var d = AppContext.BaseDirectory;
while (d is not null && !Directory.Exists(Path.Combine(d, "windows", "hardening")))
d = Directory.GetParent(d)?.FullName;
return Path.Combine(d!, "windows", "hardening");
}
[Fact]
public async Task Subset_filter_runs_only_requested_modules_via_real_powershell()
{
// ---- Arrange: set up a temp sandbox ----
var tmp = Path.Combine(Path.GetTempPath(), $"sm_integ_{Guid.NewGuid():N}");
Directory.CreateDirectory(tmp);
try
{
// Copy the REAL Invoke-Hardening.ps1 (the one we just patched) into the temp dir.
var realInvoke = Path.Combine(HardeningDir(), "Invoke-Hardening.ps1");
File.Copy(realInvoke, Path.Combine(tmp, "Invoke-Hardening.ps1"));
// Create harmless dummy module stubs. Each just appends its prefix to ran.txt.
var ranFile = Path.Combine(tmp, "ran.txt").Replace("\\", "\\\\");
foreach (var (prefix, name) in new[] {
("00", "00-a.ps1"),
("03", "03-b.ps1"),
("05", "05-c.ps1"),
("07", "07-d.ps1"),
})
{
// Single quotes around prefix so the string itself is written, not executed.
await File.WriteAllTextAsync(
Path.Combine(tmp, name),
$"'RAN {prefix}' | Out-File -Append \"{ranFile.Replace("\\\\", "\\\\")}\"");
}
// Dummy Verify script — no-op so Invoke-Hardening.ps1's Verify step succeeds.
await File.WriteAllTextAsync(
Path.Combine(tmp, "Verify-SilverMetalWindows.ps1"),
"# no-op verify");
// ---- Arrange: mocked services so apply completes without touching real OS ----
var acct = new Mock<IAccountService>();
acct.Setup(a => a.CreateAccountsAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var bl = new Mock<IBitLockerService>();
bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var boot = new Mock<IBootstrapService>();
boot.Setup(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var installer = new Mock<IAppInstaller>();
installer.Setup(i => i.InstallAsync(
It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<AppInstallResult>());
var sut = new ApplyService(
runner: new ProcessRunner(),
accounts: acct.Object,
bitlocker: bl.Object,
bootstrap: boot.Object,
installer: installer.Object,
hardeningDir: tmp);
// Flavour requests modules 00 and 05 only — 03 and 07 must be skipped.
var flavour = new FlavourManifest
{
Id = "test",
Hardening = new HardeningSpec { Modules = new[] { "00", "05" } }
};
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap", Array.Empty<AppCatalogEntry>());
// ---- Act ----
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
// ---- Assert: ran.txt should contain only 00 and 05 markers ----
Assert.True(File.Exists(Path.Combine(tmp, "ran.txt")),
"ran.txt was not created — no module ran at all (subset filter matched nothing)");
var ran = await File.ReadAllTextAsync(Path.Combine(tmp, "ran.txt"));
Assert.Contains("RAN 00", ran, StringComparison.Ordinal);
Assert.Contains("RAN 05", ran, StringComparison.Ordinal);
Assert.DoesNotContain("RAN 03", ran, StringComparison.Ordinal);
Assert.DoesNotContain("RAN 07", ran, StringComparison.Ordinal);
// ---- Assert: the rest of the apply pipeline also completed ----
acct.Verify(a => a.CreateAccountsAsync(
"alice", "pw", "adminpw", It.IsAny<CancellationToken>()), Times.Once);
bl.Verify(b => b.EnableAsync("123456", It.IsAny<CancellationToken>()), Times.Once);
boot.Verify(b => b.TearDownAsync("sm-bootstrap", It.IsAny<CancellationToken>()), Times.Once);
}
finally
{
// Clean up — ignore errors (locked files etc.) to avoid masking test failure.
try { Directory.Delete(tmp, recursive: true); } catch { /* ignore */ }
}
}
}

View File

@@ -15,39 +15,76 @@ public class ApplyServiceTests
return installer;
}
private static FlavourManifest Flavour() =>
new() { Id = "daily-driver", Hardening = new HardeningSpec { Modules = new[] { "00" } } };
[Fact]
public async Task Runs_modules_then_accounts_then_bitlocker_then_bootstrap_last()
public async Task Runs_apps_then_bitlocker_when_pin_supplied()
{
var order = new List<string>();
var run = new Mock<IProcessRunner>();
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string,string,CancellationToken>((_, a, _) => { if (a.Contains("Invoke-Hardening")) order.Add("modules"); })
.ReturnsAsync(new ProcessResult(0, "", ""));
var acct = new Mock<IAccountService>(); acct.Setup(a => a.CreateAccountsAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("accounts")).Returns(Task.CompletedTask);
var bl = new Mock<IBitLockerService>(); bl.Setup(b => b.EnableAsync(It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
var boot = new Mock<IBootstrapService>(); boot.Setup(b => b.TearDownAsync(It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("bootstrap")).Returns(Task.CompletedTask);
var installer = NoApps(); installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),It.IsAny<IProgress<ApplyProgress>>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty<AppInstallResult>());
var bl = new Mock<IBitLockerService>();
bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
var installer = NoApps();
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty<AppInstallResult>());
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, installer.Object, "C:\\hard");
var flavour = new FlavourManifest { Id="daily-driver", Hardening = new HardeningSpec { Modules = new[]{"00"} } };
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap", System.Array.Empty<AppCatalogEntry>());
var sut = new ApplyService(bl.Object, installer.Object);
var req = new ApplyRequest(Flavour(), "123456", System.Array.Empty<AppCatalogEntry>());
var progress = new List<string>();
await sut.RunAsync(req, new Progress<ApplyProgress>(p => progress.Add(p.Stage)));
Assert.Equal(new[]{"modules","accounts","apps","bitlocker","bootstrap"}, order);
Assert.Contains("Applying hardening", progress);
Assert.Equal(new[] { "apps", "bitlocker" }, order);
Assert.Contains("Installing apps", progress);
Assert.Contains("Done", progress);
}
[Fact]
public async Task Does_not_tear_down_bootstrap_if_account_creation_fails()
public async Task Empty_pin_skips_bitlocker()
{
var run = new Mock<IProcessRunner>(); run.Setup(r => r.RunAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).ReturnsAsync(new ProcessResult(0,"",""));
var acct = new Mock<IAccountService>(); acct.Setup(a => a.CreateAccountsAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("boom"));
var bl = new Mock<IBitLockerService>(); var boot = new Mock<IBootstrapService>();
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, NoApps().Object, "C:\\hard");
var req = new ApplyRequest(new FlavourManifest{ Hardening=new HardeningSpec{Modules=new[]{"00"}}}, "a","b","c","123456","sm-bootstrap", System.Array.Empty<AppCatalogEntry>());
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.RunAsync(req, new Progress<ApplyProgress>(_ => {})));
boot.Verify(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
var order = new List<string>();
var run = new Mock<IProcessRunner>();
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "", ""));
var bl = new Mock<IBitLockerService>();
bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
var installer = NoApps();
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty<AppInstallResult>());
var sut = new ApplyService(bl.Object, installer.Object);
var req = new ApplyRequest(Flavour(), "", System.Array.Empty<AppCatalogEntry>());
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
Assert.Equal(new[] { "apps" }, order);
bl.Verify(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Installs_the_requested_apps()
{
var run = new Mock<IProcessRunner>();
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "", ""));
var bl = new Mock<IBitLockerService>();
var installer = NoApps();
var apps = new[] { new AppCatalogEntry { Id = "firefox", Name = "Firefox" } };
var sut = new ApplyService(bl.Object, installer.Object);
var req = new ApplyRequest(Flavour(), "123456", apps);
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
installer.Verify(i => i.InstallAsync(apps, It.IsAny<IProgress<ApplyProgress>>(),
It.IsAny<CancellationToken>()), Times.Once);
bl.Verify(b => b.EnableAsync("123456", It.IsAny<CancellationToken>()), Times.Once);
}
}

View File

@@ -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>()));
}
}

View File

@@ -7,6 +7,7 @@ using SilverOS.Welcome.App.Components.Steps;
using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Flavours;
using SilverOS.Welcome.Core.Preconfig;
using Xunit;
public class ApplyStepTests : TestContext
@@ -17,6 +18,19 @@ public class ApplyStepTests : TestContext
private static void AddCatalog(IServiceCollection services) =>
services.AddSingleton<IAppCatalog>(new AppCatalog());
// ApplyStep injects IPreconfigStore to clear the pin + mark configured after a
// successful apply; a no-op fake keeps these UI tests off the real filesystem.
private static void AddPreconfig(IServiceCollection services) =>
services.AddSingleton<IPreconfigStore>(new FakePreconfigStore());
private sealed class FakePreconfigStore : IPreconfigStore
{
public Preconfig? Load() => null;
public void ClearPin() { }
public bool IsConfigured() => false;
public void MarkConfigured() { }
}
[Fact]
public async Task Calls_apply_with_the_wizard_selections()
{
@@ -24,18 +38,16 @@ public class ApplyStepTests : TestContext
var state = new WizardState
{
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } },
Username = "alice",
Password = "pw",
AdminPassword = "apw",
BitLockerPin = "123456"
};
Services.AddSingleton(state);
Services.AddSingleton(apply.Object);
AddCatalog(Services);
AddPreconfig(Services);
var cut = RenderComponent<ApplyStep>();
await cut.InvokeAsync(() => cut.Instance.StartAsync());
apply.Verify(a => a.RunAsync(
It.Is<ApplyRequest>(r => r.Username == "alice" && r.Flavour.Id == "daily-driver"),
It.Is<ApplyRequest>(r => r.BitLockerPin == "123456" && r.Flavour.Id == "daily-driver"),
It.IsAny<IProgress<ApplyProgress>>(),
It.IsAny<CancellationToken>()), Times.Once);
}
@@ -49,17 +61,45 @@ public class ApplyStepTests : TestContext
var state = new WizardState
{
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } },
Username = "alice", Password = "pw", AdminPassword = "apw", BitLockerPin = "123456"
BitLockerPin = "123456"
};
Services.AddSingleton(state);
Services.AddSingleton(apply.Object);
AddCatalog(Services);
AddPreconfig(Services);
var completed = false;
var cut = RenderComponent<ApplyStep>(p => p.Add(s => s.OnComplete, EventCallback.Factory.Create(this, () => { completed = true; })));
await cut.InvokeAsync(() => cut.Instance.StartAsync());
Assert.True(completed);
}
[Fact]
public void AutoStart_triggers_apply_once_without_a_button_click()
{
var apply = new Mock<IApplyService>();
apply.Setup(a => a.RunAsync(It.IsAny<ApplyRequest>(), It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var state = new WizardState
{
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } },
BitLockerPin = "123456"
};
Services.AddSingleton(state);
Services.AddSingleton(apply.Object);
AddCatalog(Services);
AddPreconfig(Services);
// AutoStart=true should fire StartAsync from OnAfterRenderAsync on first render,
// with no Start button click.
var cut = RenderComponent<ApplyStep>(p => p.Add(s => s.AutoStart, true));
cut.WaitForAssertion(() =>
apply.Verify(a => a.RunAsync(
It.Is<ApplyRequest>(r => r.Flavour.Id == "daily-driver"),
It.IsAny<IProgress<ApplyProgress>>(),
It.IsAny<CancellationToken>()), Times.Once));
}
[Fact]
public async Task Shows_error_and_retry_button_when_apply_fails()
{
@@ -69,11 +109,12 @@ public class ApplyStepTests : TestContext
var state = new WizardState
{
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } },
Username = "alice", Password = "pw", AdminPassword = "apw", BitLockerPin = "123456"
BitLockerPin = "123456"
};
Services.AddSingleton(state);
Services.AddSingleton(apply.Object);
AddCatalog(Services);
AddPreconfig(Services);
var cut = RenderComponent<ApplyStep>();
await cut.InvokeAsync(() => cut.Instance.StartAsync());
Assert.Contains("Module 03 failed", cut.Markup);

View File

@@ -1,107 +0,0 @@
using Moq;
using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Apps;
public class BootstrapServiceRevertKioskTests
{
private static Mock<IProcessRunner> Ok()
{
var m = new Mock<IProcessRunner>();
m.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "", ""));
return m;
}
private static Mock<IProcessRunner> Fail()
{
var m = new Mock<IProcessRunner>();
m.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(1, "", "the operation failed"));
return m;
}
[Fact]
public async Task RevertKioskAsync_is_best_effort_and_does_not_throw_on_nonzero_exit()
{
// Kiosk revert is best-effort (like TearDownAsync): a non-zero exit must NOT
// fail the apply — the real user still gets Explorer regardless of WESL state.
var ex = await Record.ExceptionAsync(() =>
new BootstrapService(Fail().Object).RevertKioskAsync());
Assert.Null(ex);
}
[Fact]
public async Task RevertKioskAsync_disables_keyboard_filter_rules()
{
var run = Ok();
await new BootstrapService(run.Object).RevertKioskAsync();
// First call: disable the Keyboard Filter predefined-key blocks for the real user.
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("WEKF_PredefinedKey") &&
s.Contains("Enabled=$false")),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task RevertKioskAsync_reverts_escape_policies()
{
var run = Ok();
await new BootstrapService(run.Object).RevertKioskAsync();
// Second call: policy revert — must remove the three escape policy values.
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("Remove-ItemProperty") &&
s.Contains("DisableTaskMgr") &&
s.Contains("DisableLockWorkstation") &&
s.Contains("HideFastUserSwitching")),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ApplyService_calls_revert_kiosk_before_teardown()
{
var order = new List<string>();
var run = new Mock<IProcessRunner>();
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string, string, CancellationToken>((_, a, _) =>
{
if (a.Contains("Invoke-Hardening")) order.Add("modules");
})
.ReturnsAsync(new ProcessResult(0, "", ""));
var acct = new Mock<IAccountService>();
acct.Setup(a => a.CreateAccountsAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback(() => order.Add("accounts"))
.Returns(Task.CompletedTask);
var bl = new Mock<IBitLockerService>();
bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback(() => order.Add("bitlocker"))
.Returns(Task.CompletedTask);
var boot = new Mock<IBootstrapService>();
boot.Setup(b => b.RevertKioskAsync(It.IsAny<CancellationToken>()))
.Callback(() => order.Add("revert-kiosk"))
.Returns(Task.CompletedTask);
boot.Setup(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback(() => order.Add("teardown"))
.Returns(Task.CompletedTask);
var installer = new Mock<IAppInstaller>();
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(System.Array.Empty<AppInstallResult>());
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, installer.Object, "C:\\hard");
var flavour = new SilverOS.Welcome.Core.Flavours.FlavourManifest
{
Id = "daily-driver",
Hardening = new SilverOS.Welcome.Core.Flavours.HardeningSpec { Modules = new[] { "00" } }
};
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap", System.Array.Empty<AppCatalogEntry>());
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
// revert-kiosk must precede teardown so the sm-bootstrap SID still resolves.
Assert.Equal(new[] { "modules", "accounts", "bitlocker", "revert-kiosk", "teardown" }, order);
}
}

View File

@@ -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());
}
}