109 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
sysadmin
72e401113a feat(collector): WinPE input validation helpers + Pester tests 2026-06-10 08:45:58 +01:00
sysadmin
27a08ac1ab docs(welcome): WinPE pre-config collector implementation plan (SP1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 08:37:15 +01:00
sysadmin
59418e37c8 docs(welcome): WinPE pre-config collector design spec (SP1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 08:30:48 +01:00
72fa329ddd Merge pull request 'fix(apps): winget launch failure no longer crashes Apply' (#19) from fix/winget-launch-resilience into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 6m30s
2026-06-10 00:24:02 +00:00
sysadmin
3daa770584 fix(apps): winget launch failure no longer crashes Apply
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 4m44s
On IoT LTSC winget is absent, so Process.Start('winget') throws Win32Exception
('cannot find the file specified') rather than returning non-zero. That throw
propagated out of InstallAsync and failed the entire Apply ('Configuration failed').

AppInstaller is now fully exception-safe: a TryRunAsync wrapper converts launch
throws into a failed run, winget is resolved defensively (PATH -> bootstrap+re-probe
-> WindowsApps alias path) and when unavailable the installer skips apps and marks
them not-installed instead of throwing. Per-app launch throws are isolated too.
Two new tests cover probe-throws-skips and per-app-throw-isolated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 01:23:49 +01:00
f6dac0fdfd Merge pull request 'fix(ci): ISO-assert discards stale WIM mount (was blocking ISO persist)' (#18) from fix/iso-assert-stale-mount into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 5m45s
2026-06-10 00:02:42 +00:00
sysadmin
4268a337f3 fix(ci): ISO-assert discards stale WIM mount + asserts app catalog baked
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 5m55s
Assert-IsoStructure.ps1 reused a fixed mount dir; a prior aborted run left a WIM
mounted there, so Mount-WindowsImage failed with 'directory is not empty' and the
persist-to-stable-path step was skipped (no ISO deployed). Now discards stale mounts
+ clears corrupt mount points + uses a unique per-run mount dir (mirrors build.ps1
Stage 0), and removes the dir after. Also asserts apps/catalog.json baked into the WIM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 01:02:31 +01:00
129b8741fd Merge pull request 'feat(welcome): per-role app recipes in the first-boot wizard' (#17) from feat/app-recipes into main
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (push) Failing after 5m27s
2026-06-09 23:54:29 +00:00
sysadmin
260023a1a5 feat(apps): catalog.json + chromium configure + winget bootstrap + build staging
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 6m36s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:41:18 +01:00
sysadmin
3c7654f128 feat(apps): install selected apps during Apply (after accounts, before BitLocker)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:36:54 +01:00
sysadmin
de18ee8dd2 feat(apps): insert Apps step after Flavour + seed per-role defaults
Insert AppsStep as wizard index 2 (renumbering Account/Prefs/Apply/Done
to 3-6), load the app catalog alongside flavours, seed the per-role
default selection on entering the step, and register IAppCatalog in DI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:27:18 +01:00
sysadmin
993d4028a2 feat(apps): AppsStep grouped checkboxes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:27:11 +01:00
sysadmin
52d7187991 feat(apps): WizardState.SelectedApps
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:27:06 +01:00
sysadmin
cd3808de64 feat(apps): winget install engine (bootstrap + per-app + configure, continue-on-failure)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:22:19 +01:00
sysadmin
18eb42324a feat(apps): AppCatalog loader + role filtering 2026-06-10 00:18:58 +01:00
sysadmin
bfb53bd295 feat(apps): AppCatalogEntry record + test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:16:10 +01:00
sysadmin
1e59029e53 docs(welcome): app-recipes implementation plan 2026-06-10 00:14:30 +01:00
sysadmin
58d261cc6b style(hardening): ascii-ify em-dash in kernel-credential comment 2026-06-10 00:14:30 +01:00
sysadmin
583ed4400c docs(welcome): role app-recipes design spec
Per-role app-install picker for the Welcome wizard: catalog.json + AppsStep + winget
install engine (phased, swappable source for a future curated mirror). Stack stays
auto-installed; picker adds role apps + privacy-trimmed essentials. Approved in
brainstorming. Next: writing-plans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:58:37 +01:00
efdf5888ac Merge pull request 'fix(kiosk): keyboard filter covers admins + taskbar auto-hide + instant sm-bootstrap disable' (#16) from fix/kiosk-lockdown-polish into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 5m43s
2026-06-09 22:36:42 +00:00
sysadmin
e83ce6bcf0 fix(kiosk): keyboard filter covers admins + taskbar auto-hide + disable sm-bootstrap in-session
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 5m0s
Live e2e: in the sm-bootstrap session the taskbar showed and Win/Start worked.
- Keyboard Filter EXEMPTS administrators by default and sm-bootstrap is an admin, so
  Win/Start/Alt-Tab etc. were never blocked. Set WEKF_Settings
  DisableKeyboardFilterForAdministrators=false so the filter applies to it.
- Auto-hide the taskbar (default-user StuckRects3, inherited by sm-bootstrap) so it
  doesn't peek over the fullscreen wizard.
- TearDownAsync now Disable-LocalUser's sm-bootstrap in-session (immediate) so it's
  unusable at once; the deferred SYSTEM task still deletes it on next boot (SAM-confirmed
  the delete works now).

Verified: Configure-Kiosk parses under Windows PowerShell 5.1 (ASCII-clean); welcome 29/29.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:30:43 +01:00
sysadmin
6124448003 fix(first-boot): branding-online parse crash (em-dash/encoding) + bootstrap cleanup task + recovery QR
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 4m47s
Found by reading the unencrypted VM disk after run #7:
1. Online branding never ran: Apply-Branding.ps1 had a UTF-8 em-dash in a Write-Warning
   STRING; Windows PowerShell 5.1 (SetupComplete) reads .ps1 as ANSI, mangled it, broke
   the string terminator -> whole script failed to parse -> lock/login/wallpaper branding
   never re-applied. Fix: ASCII-ify the em-dash AND save the branding scripts UTF-8-with-BOM
   so PS5.1 always decodes them correctly (verified parses under PS5.1 + PS7).
2. sm-bootstrap never removed: TearDownAsync used schtasks /tr with an inline -EncodedCommand,
   which silently fails past the ~261-char /tr limit, so the cleanup task was never created
   (confirmed NO_TASK on disk). Fix: Register-ScheduledTask (no length limit).
3. Done step: show a QR code of the BitLocker recovery key (QRCoder) for phone backup, and
   lay key+QR side-by-side so the Restart button no longer overflows below the fold.

Verified: welcome solution builds, 29/29 tests; branding Pester 6/6 unit (offline-integration
needs elevation, runs in CI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:41:30 +01:00
5f0df87405 Merge pull request 'fix(welcome): BitLocker PIN first-boot + recovery-key display + FlavourStep Next' (#14) from feat/wizard-recipes into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 6m3s
Reviewed-on: #14
2026-06-09 21:05:40 +00:00
sysadmin
a3623b1fbb fix(welcome): BitLocker PIN works first boot (drop -SkipHardwareTest) + show recovery key
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 7m5s
- BitLocker: remove -SkipHardwareTest so BitLocker validates the TPM+PIN unseal via
  its hardware test on the next reboot (the wizard's end-of-flow reboot) before
  encrypting — fixes the E_FVE_SECURE_BOOT_CHANGED / PCR-11 drop-to-recovery on the
  first post-enroll boot. The PIN now works first time instead of needing recovery.
- Done step now DISPLAYS the 48-digit BitLocker recovery key (read from the file the
  enrollment saves) with a 'save this' warning — previously it was never surfaced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:57:47 +01:00
94de283495 Merge pull request 'fix(first-boot): online branding re-apply + deferred sm-bootstrap cleanup' (#13) from fix/branding-online-and-bootstrap-cleanup into main
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (push) Failing after 6m19s
Reviewed-on: #13
2026-06-09 20:57:29 +00:00
sysadmin
6d6eb2cdc8 fix(welcome): FlavourStep notifies host on select so Next enables immediately
WIP on local branch feat/wizard-recipes (NOT pushed) — holding per operator while
more wizard changes (role app-recipes) are designed.
2026-06-09 21:45:20 +01:00
sysadmin
daac231148 fix(first-boot): re-apply personalization branding online + defer sm-bootstrap cleanup
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 5m37s
VM e2e findings on the real-user desktop:
1. Lock/login screen + wallpaper NOT branded (OEM About WAS) — Windows resets the
   offline-baked personalization (PersonalizationCSP / default-user wallpaper / FVE)
   during OOBE, same class as the UAC reset. Fix: stage windows/branding/ into the
   image and re-run Apply-Branding -Mode Online from SetupComplete (post-OOBE, as
   SYSTEM) where it sticks. OEM About re-asserted harmlessly.
2. sm-bootstrap account still present after onboarding — TearDownAsync's in-session
   Remove-LocalUser no-ops (can't delete the account you're logged in as). Fix: keep
   the best-effort in-session attempt, but DEFER the real removal to a SYSTEM
   AtStartup scheduled task that runs on next boot (sm-bootstrap not logged on),
   removes the account + Win32_UserProfile, then deletes itself.

(Network 'no adapter' in the VM was a Proxmox NIC-model regression to virtio — fixed
by switching the VM to Intel e1000; not a SilverMetal change.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:27:24 +01:00
66ab2e1aed Merge pull request 'fix(bitlocker): add recovery-password protector + save key (TPM+PIN-only was unrecoverable)' (#12) from fix/bitlocker-recovery-key into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 5m22s
Reviewed-on: #12
2026-06-09 20:24:36 +00:00
sysadmin
3f1ea6aa63 fix(bitlocker): add recovery-password protector + save the key (was unrecoverable)
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 6m17s
VM e2e: full wizard ran end-to-end and enrolled TPM+PIN, but BitLockerService only
created TPM+PIN with NO recovery protector — a forgotten/mistyped PIN bricks the
drive (hit exactly that on the VM). Add a RecoveryPassword protector and save the
48-digit key to ProgramData AND the unencrypted EFI System Partition (readable even
when the OS volume is locked, e.g. for offline recovery/verification).

PRODUCT TODO (follow-up): escrow the recovery key to SilverSync + display it in the
wizard's Done step so the end-user records it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:15:49 +01:00
2dc7882483 Merge pull request 'fix(kiosk): pivot to Explorer + policy lockdown (WebView2 blank as SL shell)' (#11) from fix/kiosk-explorer-lockdown into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 9m41s
2026-06-09 18:00:15 +00:00
sysadmin
e3b010530c fix(kiosk): pivot to Explorer + policy lockdown (WebView2 wizard renders blank as the SL shell)
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 7m31s
5th VM e2e: with the kiosk fully working mechanically (SL engages, silent UAC,
app launches fullscreen as the shell), the MAUI/WebView2 wizard STILL renders
blank — WebView2 never initializes when the app is the bare Shell Launcher shell
with no Explorer (the same app rendered fine in the earlier build launched with
Explorer present). Operator decision: pivot.

- autounattend.xml: restore FirstLogonCommands to launch the wizard elevated over
  the normal (Explorer) first-logon session — where WebView2 works.
- Configure-Kiosk.ps1: drop Shell-Launcher-as-shell entirely; keep the lockdown —
  Keyboard Filter (Win/Start/lock/task-switch/Task-Mgr/Alt+F4), DisableTaskMgr /
  LockWorkstation / FastUserSwitch, and silent-elevation UAC. The wizard runs
  fullscreen-topmost over the locked-down Explorer (covers the taskbar).
- RevertKioskAsync: disable the Keyboard Filter rules for the real user (no SL to
  undo); keep escape-policy + secure-UAC restore. Tests updated.

Keeps the diagnostics from #10 (welcome.log) to confirm the wizard renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:52:15 +01:00
3c072a4edb Merge pull request 'fix(welcome): harden kiosk chrome + WebView2 diagnostics (4th e2e — blank wizard)' (#10) from fix/welcome-blank-instrument into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 4m56s
2026-06-09 17:18:08 +00:00
sysadmin
d54a5cb8db fix(kiosk): re-assert UAC auto-approve online (OOBE resets the offline bake)
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 5m6s
4th e2e showed a UAC consent prompt for the unsigned Welcome app — the offline-baked
ConsentPromptBehaviorAdmin=0 is reset by Windows during OOBE. Re-assert it (and
PromptOnSecureDesktop=0) ONLINE in Configure-Kiosk.ps1, which runs right before the
sm-bootstrap autologon, so 'Start-Process -Verb RunAs' elevates silently. RevertKioskAsync
restores SECURE UAC (ConsentPromptBehaviorAdmin=2, PromptOnSecureDesktop=1) for the real user.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:12:57 +01:00
sysadmin
159cea0019 fix(welcome): harden kiosk chrome + add startup/WebView2 diagnostics
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 5m29s
4th VM e2e: kiosk shell engages + the app launches fullscreen, but the Blazor
wizard renders BLANK and the kiosk chrome didn't apply (title bar present) — the
app didn't crash, so there's no log to read. Two changes:

1) ApplyKioskChrome made defensive (null-guard HWND/AppWindow, FullScreen presenter
   only, returns bool) and wrapped in try/catch at the call site, so a chrome
   failure can never stall app/WebView startup (the likely cause of the blank).
2) Always-on file log at C:\ProgramData\SilverMetal\welcome.log: app ctor, window
   create, chrome result, unhandled exceptions, and the BlazorWebView/WebView2
   lifecycle (Initialized, NavigationCompleted, ProcessFailed). If the wizard is
   still blank next run, this pinpoints whether WebView2 env creation failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:09:24 +01:00
6d90f41f8f Merge pull request 'fix(kiosk): Start-Process -FilePath not -LiteralPath (3rd e2e — kiosk now engages)' (#9) from fix/kiosk-start-process-filepath into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 6m0s
2026-06-09 16:47:35 +00:00
sysadmin
37bfbae2e2 fix(kiosk): Start-Process uses -FilePath, not -LiteralPath (app never launched)
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 4m42s
3rd VM e2e: Shell Launcher now ENGAGES (kiosk shell up, no Explorer), but the
launcher's 'Start-Process -LiteralPath ...' errored — Start-Process has no
-LiteralPath parameter (that was an unvalidated review tweak; the proven form
is -FilePath). So the kiosk shell ran but the Welcome app never started. Revert
both the launcher and the RunOnce fallback to -FilePath. Single-quote escaping
of the path is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:42:21 +01:00
09216d6cfb Merge pull request 'fix(kiosk): WESL DefaultAction is sint32 not uint32 (2nd e2e finding)' (#8) from fix/kiosk-defaultaction-type into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 5m21s
2026-06-09 15:46:29 +00:00
sysadmin
cc6369e3b3 fix(kiosk): WESL DefaultAction is sint32, not uint32 (config failed -> fail-open, no kiosk)
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 4m39s
2nd VM e2e: Shell Launcher config still failed with 'Type mismatch for parameter
DefaultAction'. WESL_UserSetting.SetCustomShell/SetDefaultShell take sint32 (Int32)
DefaultAction, but we passed [uint32]0. The fail-open rollback worked (no brick,
booted to Explorer) but the kiosk never engaged. Pass [int32]0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 16:40:09 +01:00
863be56d15 Merge pull request 'fix(kiosk): WESL class-level calls — first-boot reboot loop found in VM e2e' (#7) from fix/kiosk-wesl-classlevel into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 4m47s
2026-06-09 15:11:00 +00:00
sysadmin
45939e1e9f fix(kiosk): call WESL_UserSetting methods class-level (was bricking first boot)
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 4m45s
VM e2e caught a reboot loop: Configure-Kiosk used `Invoke-CimMethod -InputObject $wesl`
for SetDefaultShell/SetCustomShell, but WESL_UserSetting exposes STATIC methods and
Get-CimInstance returns null — so those calls threw "InputObject is null" while the
class-level SetEnabled($true) had already succeeded. Result: Shell Launcher enabled with
NO shell configured -> every logon (incl. OOBE defaultuser0) gets a broken shell -> the
"Why did my PC restart?" OOBE loop.

Fix: call SetEnabled/SetDefaultShell/SetCustomShell all class-level (-Namespace/-ClassName).
Setting the DEFAULT shell to explorer.exe is what keeps OOBE/normal logons alive; only
sm-bootstrap gets the kiosk launcher. Added GetCustomShell verification + a fail-open
rollback (SetEnabled false + RunOnce launch of the Welcome app) so a WMI hiccup can never
brick the box again. Same class-level fix applied to BootstrapService.RevertKioskAsync.

Found via VM 102 disk logs (silvermetal-firstboot.log + silvermetal-kiosk.log).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 16:05:28 +01:00
864c99edcd Merge pull request 'SilverMetal Windows: first-boot experience &amp; branding' (#6) from feat/first-boot-branding into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 4m35s
2026-06-09 14:30:04 +00:00
sysadmin
4e46f81f3e Merge remote-tracking branch 'origin/main' into feat/first-boot-branding
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 4m35s
# Conflicts:
#	windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs
2026-06-09 15:24:40 +01:00
sysadmin
bc847ea6d9 fix(build): discard stale image mounts at startup + ephemeral CI WorkDir
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 4m54s
A prior aborted build left a DISM image mounted in the fixed WorkDir,
locking install.wim and breaking the Stage 2 extract clean-up. Add a
Stage 0 that discards any orphaned SilverMetal mounts + loaded hives
before recreating the work dirs, and run CI in an ephemeral per-job
RUNNER_TEMP WorkDir so concurrent/aborted runs can't collide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 15:14:48 +01:00
sysadmin
500e21f186 ci(branding): force Pester 5 + use v5 config object (fix -Output ambiguity vs Pester 3)
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 46s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 15:10:52 +01:00
sysadmin
9e9af94dfd fix(branding): opaque ARGB/ABGR accent DWORDs; fix stage labels + stale launch comments
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 43s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:05:50 +01:00
sysadmin
83ee152277 feat(welcome): frosted glass-card framing for wizard on void wall
Replace the full-viewport .wizard rule with a fixed-inset frosted-glass card
(backdrop-filter blur, semi-transparent surface, rounded corners, inset shadow)
so the Mercury void/gradient wall shows behind and around the card. Adds
@keyframes sm-rise entrance animation and a body::after SILVERMETAL wordmark
watermark on the wall. Targets the real .wizard class in SilverOS.Welcome.UI/
Components/Routes.razor — no App-project markup touched.
2026-06-09 14:51:48 +01:00
sysadmin
f314dccf53 fix(welcome): remove dead Phase C artifacts (silvermetal.css, App-project layout edits)
Delete wwwroot/css/silvermetal.css (sm-* selectors targeting no markup),
remove its <link> from index.html, and restore App-project stock Layout/Pages
components (MainLayout, NavMenu, Home) to their pre-Phase-C state at 2d8b651.
These files are unused dead-code — the wizard shell lives in SilverOS.Welcome.UI.
2026-06-09 14:51:40 +01:00
sysadmin
65de29c58b chore(welcome): remove stock template nav from kiosk shell 2026-06-09 14:41:29 +01:00
sysadmin
395e86137b feat(welcome): Hybrid glass-card shell in MainLayout 2026-06-09 14:40:49 +01:00
sysadmin
bb7b4b0fed feat(welcome): SilverMetal void/cyan glass stylesheet 2026-06-09 14:40:15 +01:00
sysadmin
64ae04d56c feat(welcome): borderless fullscreen non-closable kiosk window 2026-06-09 14:39:30 +01:00
sysadmin
2d8b651e34 fix(kiosk): re-fetch WESL after enable, robust launcher quoting, intent comments
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:36:14 +01:00
sysadmin
ee2d6fd8f2 feat(kiosk): revert kiosk (shell launcher + escapes) on wizard success 2026-06-09 14:29:07 +01:00
sysadmin
c14fcf67b1 feat(kiosk): drop FirstLogonCommands launch (Shell Launcher owns launch) 2026-06-09 14:27:37 +01:00
sysadmin
a8d7522a70 feat(kiosk): configure kiosk from SetupComplete before first logon 2026-06-09 14:27:13 +01:00
sysadmin
f199981cf1 feat(build): enable kiosk features offline + stage Configure-Kiosk.ps1 2026-06-09 14:26:57 +01:00
sysadmin
f00ef19578 feat(kiosk): Configure-Kiosk.ps1 (Shell Launcher v2 + Keyboard Filter + escapes) 2026-06-09 14:26:33 +01:00
sysadmin
4ff12ab543 fix(branding): guard reg unload, set ErrorAction in libs, accent field rename, test hive unload + assertions 2026-06-09 14:23:50 +01:00
sysadmin
6aa963f024 docs(tests): document branding test suite + elevation requirement
ci(branding): run branding Pester suite before Build packed ISO step
2026-06-09 14:14:13 +01:00
sysadmin
bd5d82f6b4 feat(build): wire branding into Invoke-ServiceWim (offline hive bake) 2026-06-09 14:13:30 +01:00
sysadmin
50856b8f28 feat(branding): Apply-Branding orchestrator (offline/online) + placeholder assets 2026-06-09 14:12:45 +01:00
sysadmin
320b4c675a feat(branding): OEM/lockscreen/desktop/bitlocker layer writers + tests 2026-06-09 14:10:17 +01:00
sysadmin
7de5262c43 feat(branding): registry helper + Pester harness 2026-06-09 14:08:34 +01:00
sysadmin
73d6611ab5 feat(branding): manifest + module skeleton for SilverMetal Windows branding 2026-06-09 14:06:46 +01:00
sysadmin
e4241f7f59 docs(windows): first-boot branding implementation plan
Phased TDD plan: A (branding module, hardware-free), B (kiosk: Shell
Launcher v2 + Keyboard Filter), C (MAUI fullscreen glass presentation),
D (build integration + VM e2e). Bite-sized tasks with complete code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:59:29 +01:00
sysadmin
66e7fd4ae8 docs(windows): first-boot experience & branding design spec
Design for SilverMetal Windows first-boot: declarative branding build
(4 layers baked offline into the WIM, shared dual-mode module), hardened
onboarding kiosk (Shell Launcher v2 + Keyboard Filter for the one-time
sm-bootstrap session), and the Hybrid fullscreen glass-card presentation
for the Welcome app. Fills the empty Invoke-Brand stub (M4 branding).

Approved in brainstorming. Next: writing-plans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:53:58 +01:00
a9c26d842d Merge pull request 'fix(welcome): eject optical install media before BitLocker enrollment' (#5) from feat/welcome-app into main
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (push) Failing after 35s
Reviewed-on: #5
2026-06-09 12:26:25 +00:00
394804f379 Merge pull request 'feat(welcome): SilverOS Welcome first-logon wizard (flavour engine + apply orchestrator + MAUI UI + image bake)' (#4) from feat/welcome-app into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 4m40s
Reviewed-on: #4
2026-06-09 10:31:34 +00:00
82 changed files with 6381 additions and 509 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 - name: Checkout
uses: actions/checkout@v4 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) - name: Ensure Windows ADK (oscdimg)
shell: pwsh shell: pwsh
run: | run: |
@@ -58,6 +74,28 @@ jobs:
} }
if (-not (Test-Path $deploy)) { throw 'ADK Deployment Tools install failed.' } 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 - name: Setup .NET 9 SDK
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
@@ -108,11 +146,31 @@ jobs:
} }
"path=$dst" >> $env:GITHUB_OUTPUT "path=$dst" >> $env:GITHUB_OUTPUT
- name: Test branding module (Pester)
shell: pwsh
run: |
# Windows ships Pester 3.x; force Pester 5 (the tests use v5 syntax).
Set-PSRepository PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue
if (-not (Get-Module -ListAvailable Pester | Where-Object { $_.Version -ge [version]'5.0.0' })) {
Install-Module Pester -MinimumVersion 5.0 -Scope CurrentUser -Force -SkipPublisherCheck
}
Get-Module Pester | Remove-Module -Force -ErrorAction SilentlyContinue
Import-Module Pester -MinimumVersion 5.0 -Force
Write-Host "Using Pester $((Get-Module Pester).Version)"
# v5 configuration object — avoids the v3/-Output param ambiguity.
$cfg = New-PesterConfiguration
$cfg.Run.Path = 'windows/tests/Branding.Tests.ps1'
$cfg.Run.PassThru = $true
$cfg.Output.Verbosity = 'Detailed'
$r = Invoke-Pester -Configuration $cfg
if ($r.FailedCount -gt 0) { throw "$($r.FailedCount) branding test(s) failed" }
- name: Build packed ISO - name: Build packed ISO
shell: pwsh shell: pwsh
run: | run: |
.\windows\installer\build.ps1 ` .\windows\installer\build.ps1 `
-SourceIso '${{ steps.iso.outputs.path }}' ` -SourceIso '${{ steps.iso.outputs.path }}' `
-WorkDir "$env:RUNNER_TEMP\smbuild" `
-OutputIso "$env:RUNNER_TEMP\out\SilverMetal-Enhanced-Windows.iso" -OutputIso "$env:RUNNER_TEMP\out\SilverMetal-Enhanced-Windows.iso"
- name: Validate baked payload (offline assertions) - name: Validate baked payload (offline assertions)
@@ -126,6 +184,15 @@ jobs:
# RUNNER_TEMP is per-job/ephemeral. Keep the latest validated build at a # 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. # 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 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 Copy-Item "$env:RUNNER_TEMP\out\*" 'C:\silvermetal\out\' -Force
Get-ChildItem 'C:\silvermetal\out' | ForEach-Object { Write-Host $_.Name } Get-ChildItem 'C:\silvermetal\out' | ForEach-Object { Write-Host $_.Name }

7
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# Brainstorming / design scratch (mockups, companion state) — durable specs live in docs/
.superpowers/
# Build outputs # Build outputs
build/output/ build/output/
build/cache/ build/cache/
@@ -65,3 +68,7 @@ coverage/
# SBOM intermediates (final SBOMs are committed; intermediates are not) # SBOM intermediates (final SBOMs are committed; intermediates are not)
sbom/work/ 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

@@ -0,0 +1,68 @@
#Requires -Version 5.1
# Provision winget (the App Installer) when absent. Windows IoT Enterprise LTSC
# ships WITHOUT the inbox Microsoft.DesktopAppInstaller package, so re-registering
# it is not enough - we download and install it (plus dependencies) online at apply
# time. Best-effort and idempotent: exit 0 if winget ends up available, else 1.
$ErrorActionPreference = 'SilentlyContinue'
function Test-Winget {
return [bool](Get-Command winget -ErrorAction SilentlyContinue)
}
# Fast path 1: winget already on PATH.
if (Test-Winget) { exit 0 }
# Fast path 2: an inbox App Installer package is present - just re-register it.
Get-AppxPackage -AllUsers Microsoft.DesktopAppInstaller |
ForEach-Object { Add-AppxPackage -DisableDevelopmentMode -Register "$($_.InstallLocation)\AppXManifest.xml" }
if (Test-Winget) { exit 0 }
# Slow path: download + install the App Installer and its dependencies online.
$temp = $env:TEMP
$bundlePath = Join-Path $temp 'Microsoft.DesktopAppInstaller.msixbundle'
$vclibsPath = Join-Path $temp 'Microsoft.VCLibs.x64.14.00.Desktop.appx'
$uixamlNupkg = Join-Path $temp 'microsoft.ui.xaml.2.8.6.nupkg'
$uixamlExtract = Join-Path $temp 'uixaml.2.8.6'
$uixamlAppx = Join-Path $uixamlExtract 'tools\AppX\x64\Release\Microsoft.UI.Xaml.2.8.appx'
$bundleUrl = 'https://aka.ms/getwinget'
$vclibsUrl = 'https://aka.ms/Microsoft.VCLibs.x64.14.00.Desktop.appx'
$uixamlUrl = 'https://globalcdn.nuget.org/packages/microsoft.ui.xaml.2.8.6.nupkg'
function Get-File {
param([string]$Url, [string]$Destination)
try {
Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing
return (Test-Path $Destination)
} catch {
return $false
}
}
# Download the App Installer bundle (required).
if (-not (Get-File -Url $bundleUrl -Destination $bundlePath)) { exit 1 }
# Download the VCLibs desktop dependency (required).
if (-not (Get-File -Url $vclibsUrl -Destination $vclibsPath)) { exit 1 }
# Download the UI.Xaml 2.8 nuget package (a .zip) and extract the appx from it.
if (-not (Get-File -Url $uixamlUrl -Destination $uixamlNupkg)) { exit 1 }
try {
if (Test-Path $uixamlExtract) { Remove-Item -Path $uixamlExtract -Recurse -Force }
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory($uixamlNupkg, $uixamlExtract)
} catch {
exit 1
}
if (-not (Test-Path $uixamlAppx)) { exit 1 }
# Install order: VCLibs dependency, then UI.Xaml dependency, then the bundle with
# both supplied as DependencyPath. Per-user Add-AppxPackage (toolbox runs as the
# real admin user at first logon).
Add-AppxPackage -Path $vclibsPath
Add-AppxPackage -Path $uixamlAppx
Add-AppxPackage -Path $bundlePath -DependencyPath $vclibsPath, $uixamlAppx
# Final re-check.
if (Test-Winget) { exit 0 }
exit 1

285
windows/apps/catalog.json Normal file
View File

@@ -0,0 +1,285 @@
{
"schemaVersion": 1,
"apps": [
{
"id": "thunderbird",
"name": "Thunderbird",
"description": "Open-source email, calendar, and chat client.",
"source": { "winget": "Mozilla.Thunderbird" },
"group": "essentials",
"roles": ["essentials"],
"defaultFor": ["essentials"],
"configure": null
},
{
"id": "vlc",
"name": "VLC",
"description": "Plays virtually any audio or video file.",
"source": { "winget": "VideoLAN.VLC" },
"group": "essentials",
"roles": ["essentials"],
"defaultFor": ["essentials"],
"configure": null
},
{
"id": "7zip",
"name": "7-Zip",
"description": "High-ratio archive extractor and compressor.",
"source": { "winget": "7zip.7zip" },
"group": "essentials",
"roles": ["essentials"],
"defaultFor": ["essentials"],
"configure": null
},
{
"id": "libreoffice",
"name": "LibreOffice",
"description": "Full office suite for documents, sheets, and slides.",
"source": { "winget": "TheDocumentFoundation.LibreOffice" },
"group": "essentials",
"roles": ["essentials"],
"defaultFor": ["essentials"],
"configure": null
},
{
"id": "ungoogled-chromium",
"name": "ungoogled-chromium",
"description": "Chromium with Google integration stripped.",
"source": { "winget": "eloston.ungoogled-chromium" },
"group": "essentials",
"roles": ["essentials"],
"defaultFor": [],
"configure": "ungoogled-chromium.ps1"
},
{
"id": "keepassxc",
"name": "KeePassXC",
"description": "Offline, encrypted password manager.",
"source": { "winget": "KeePassXCTeam.KeePassXC" },
"group": "essentials",
"roles": ["essentials", "journalist"],
"defaultFor": ["journalist"],
"configure": null
},
{
"id": "vscodium",
"name": "VSCodium",
"description": "Telemetry-free VS Code build.",
"source": { "winget": "VSCodium.VSCodium" },
"group": "developer",
"roles": ["developer"],
"defaultFor": ["developer"],
"configure": null
},
{
"id": "git",
"name": "Git",
"description": "Distributed version control system.",
"source": { "winget": "Git.Git" },
"group": "developer",
"roles": ["developer"],
"defaultFor": ["developer"],
"configure": null
},
{
"id": "dotnet-sdk-9",
"name": ".NET 9 SDK",
"description": "SDK for building .NET 9 applications.",
"source": { "winget": "Microsoft.DotNet.SDK.9" },
"group": "developer",
"roles": ["developer"],
"defaultFor": ["developer"],
"configure": null
},
{
"id": "nodejs-lts",
"name": "Node.js LTS",
"description": "JavaScript runtime, long-term-support release.",
"source": { "winget": "OpenJS.NodeJS.LTS" },
"group": "developer",
"roles": ["developer"],
"defaultFor": ["developer"],
"configure": null
},
{
"id": "windows-terminal",
"name": "Windows Terminal",
"description": "Modern tabbed terminal for the shell.",
"source": { "winget": "Microsoft.WindowsTerminal" },
"group": "developer",
"roles": ["developer"],
"defaultFor": ["developer"],
"configure": null
},
{
"id": "powershell-7",
"name": "PowerShell 7",
"description": "Cross-platform PowerShell shell.",
"source": { "winget": "Microsoft.PowerShell" },
"group": "developer",
"roles": ["developer"],
"defaultFor": ["developer"],
"configure": null
},
{
"id": "claude-desktop",
"name": "Claude Desktop",
"description": "Anthropic Claude desktop client.",
"source": { "winget": "Anthropic.Claude" },
"group": "developer",
"roles": ["developer"],
"defaultFor": ["developer"],
"configure": null
},
{
"id": "visual-studio-2022",
"name": "Visual Studio 2022",
"description": "Full-featured IDE, Community edition.",
"source": { "winget": "Microsoft.VisualStudio.2022.Community" },
"group": "developer",
"roles": ["developer"],
"defaultFor": [],
"configure": null
},
{
"id": "jetbrains-rider",
"name": "JetBrains Rider",
"description": "Cross-platform .NET IDE.",
"source": { "winget": "JetBrains.Rider" },
"group": "developer",
"roles": ["developer"],
"defaultFor": [],
"configure": null
},
{
"id": "docker-desktop",
"name": "Docker Desktop",
"description": "Container build and run environment.",
"source": { "winget": "Docker.DockerDesktop" },
"group": "developer",
"roles": ["developer"],
"defaultFor": [],
"configure": null
},
{
"id": "google-chrome",
"name": "Google Chrome",
"description": "Google's web browser.",
"source": { "winget": "Google.Chrome" },
"group": "developer",
"roles": ["developer"],
"defaultFor": [],
"configure": null
},
{
"id": "postgresql",
"name": "PostgreSQL",
"description": "Relational database server.",
"source": { "winget": "PostgreSQL.PostgreSQL" },
"group": "developer",
"roles": ["developer"],
"defaultFor": [],
"configure": null
},
{
"id": "bruno",
"name": "Bruno",
"description": "Offline-first API client.",
"source": { "winget": "Bruno.Bruno" },
"group": "developer",
"roles": ["developer"],
"defaultFor": [],
"configure": null
},
{
"id": "veracrypt",
"name": "VeraCrypt",
"description": "On-the-fly disk and volume encryption.",
"source": { "winget": "IDRIX.VeraCrypt" },
"group": "journalist",
"roles": ["journalist", "privacy-max"],
"defaultFor": ["journalist"],
"configure": null
},
{
"id": "joplin",
"name": "Joplin",
"description": "Encrypted, open-source note-taking app.",
"source": { "winget": "Joplin.Joplin" },
"group": "journalist",
"roles": ["journalist"],
"defaultFor": ["journalist"],
"configure": null
},
{
"id": "obs-studio",
"name": "OBS Studio",
"description": "Screen recording and live streaming.",
"source": { "winget": "OBSProject.OBSStudio" },
"group": "journalist",
"roles": ["journalist"],
"defaultFor": [],
"configure": null
},
{
"id": "standard-notes",
"name": "Standard Notes",
"description": "End-to-end encrypted notes app.",
"source": { "winget": "StandardNotes.StandardNotes" },
"group": "journalist",
"roles": ["journalist"],
"defaultFor": [],
"configure": null
},
{
"id": "signal",
"name": "Signal",
"description": "Encrypted private messaging.",
"source": { "winget": "OpenWhisperSystems.Signal" },
"group": "journalist",
"roles": ["journalist"],
"defaultFor": [],
"configure": null
},
{
"id": "tor-browser",
"name": "Tor Browser",
"description": "Anonymous browsing over the Tor network.",
"source": { "winget": "TorProject.TorBrowser" },
"group": "journalist",
"roles": ["journalist"],
"defaultFor": [],
"configure": null
},
{
"id": "spotify",
"name": "Spotify",
"description": "Music and podcast streaming.",
"source": { "winget": "Spotify.Spotify" },
"group": "daily-driver",
"roles": ["daily-driver"],
"defaultFor": [],
"configure": null
},
{
"id": "zoom",
"name": "Zoom",
"description": "Video conferencing client.",
"source": { "winget": "Zoom.Zoom" },
"group": "daily-driver",
"roles": ["daily-driver"],
"defaultFor": [],
"configure": null
},
{
"id": "discord",
"name": "Discord",
"description": "Voice, video, and text chat.",
"source": { "winget": "Discord.Discord" },
"group": "daily-driver",
"roles": ["daily-driver"],
"defaultFor": [],
"configure": null
}
]
}

View File

@@ -0,0 +1,7 @@
#Requires -Version 5.1
$ErrorActionPreference='SilentlyContinue'
$pol='HKLM:\SOFTWARE\Policies\Chromium'
New-Item $pol -Force | Out-Null
New-ItemProperty $pol -Name 'ForceGoogleSafeSearch' -Value 1 -PropertyType DWord -Force | Out-Null
$ext="$pol\ExtensionInstallSources"; New-Item $ext -Force | Out-Null
New-ItemProperty $ext -Name '1' -Value 'https://chrome.google.com/webstore/*' -PropertyType String -Force | Out-Null

View File

@@ -0,0 +1,160 @@
#Requires -Version 5.1
<#
.SYNOPSIS Apply SilverMetal Windows branding (4 layers), offline (WIM) or online.
.DESCRIPTION
Offline: reg-load the mounted image's SOFTWARE + default NTUSER hives, write
values, stage assets, reg-unload. Online: write live HKLM + default-user hive.
Design: ../docs/superpowers/specs/2026-06-09-first-boot-branding-design.md
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][ValidateSet('Offline','Online')][string]$Mode,
[string]$MountPath, # required for Offline
[string]$Manifest = "$PSScriptRoot\branding.manifest.json",
[string]$AssetsDir = "$PSScriptRoot\assets",
[switch]$PassThru
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. "$PSScriptRoot\lib\BrandingLayers.ps1"
function Write-Stage { param($m) Write-Host "==> $m" -ForegroundColor Cyan }
if ($Mode -eq 'Offline' -and -not $MountPath) { throw 'Offline mode requires -MountPath.' }
$m = Get-Content $Manifest -Raw | ConvertFrom-Json
# Destination paths (image-relative for offline, live for online).
$winRoot = if ($Mode -eq 'Offline') { $MountPath } else { 'C:' }
$logoDest = Join-Path $winRoot 'Windows\System32\oemlogo.bmp'
$lockDir = Join-Path $winRoot 'Windows\Web\Screen\SilverMetal'
$wallDir = Join-Path $winRoot 'Windows\Web\Wallpaper\SilverMetal'
$themeDest = Join-Path $winRoot 'Windows\Resources\Themes'
$lockLive = 'C:\Windows\Web\Screen\SilverMetal\' + $m.lockScreen.image
$wallLive = 'C:\Windows\Web\Wallpaper\SilverMetal\' + $m.desktop.wallpaper
$logoLive = 'C:\Windows\System32\oemlogo.bmp'
# --- stage assets ---
Write-Stage "Stage branding assets ($Mode)"
New-Item -ItemType Directory -Force $lockDir,$wallDir,$themeDest,(Split-Path $logoDest) | Out-Null
Copy-Item (Join-Path $AssetsDir $m.oem.logo) $logoDest -Force
Copy-Item (Join-Path $AssetsDir $m.lockScreen.image) (Join-Path $lockDir $m.lockScreen.image) -Force
Copy-Item (Join-Path $AssetsDir $m.desktop.wallpaper)(Join-Path $wallDir $m.desktop.wallpaper) -Force
Copy-Item (Join-Path $AssetsDir $m.desktop.theme) (Join-Path $themeDest $m.desktop.theme) -Force
$result = [ordered]@{ OemApplied=$false; LockScreenApplied=$false; DesktopApplied=$false; BitLockerApplied=$false }
function Invoke-WithHive {
param([string]$HivePath,[string]$Name,[scriptblock]$Body)
& reg load "HKLM\$Name" $HivePath | Out-Null
if ($LASTEXITCODE -ne 0) { throw "reg load $Name ($HivePath) failed" }
try { & $Body "Registry::HKEY_LOCAL_MACHINE\$Name" }
finally {
[gc]::Collect(); Start-Sleep -Milliseconds 500
& reg unload "HKLM\$Name" | Out-Null
if ($LASTEXITCODE -ne 0) { Write-Warning "reg unload $Name failed ($LASTEXITCODE) -- hive may be leaked" }
}
}
if ($Mode -eq 'Offline') {
$swHive = Join-Path $MountPath 'Windows\System32\config\SOFTWARE'
$duHive = Join-Path $MountPath 'Users\Default\NTUSER.DAT'
Invoke-WithHive $swHive 'SM_BRAND_SW' {
param($sw)
Write-Stage 'OEM About'; Set-OemInformation -SoftwareRoot $sw -Manifest $m -LogoPath $logoLive; $result.OemApplied=$true
Write-Stage 'Lock screen'; Set-LockScreen -SoftwareRoot $sw -ImagePath $lockLive -Lock:$m.lockScreen.lock; $result.LockScreenApplied=$true
Write-Stage 'BitLocker preboot';Set-BitLockerPreboot -SoftwareRoot $sw -Manifest $m; $result.BitLockerApplied=$true
}
Invoke-WithHive $duHive 'SM_BRAND_DU' {
param($du)
Write-Stage 'Desktop'; Set-DesktopBranding -DefaultUserRoot $du -Manifest $m -WallpaperPath $wallLive; $result.DesktopApplied=$true
}
} else {
Set-OemInformation -SoftwareRoot 'HKLM:\SOFTWARE' -Manifest $m -LogoPath $logoLive; $result.OemApplied=$true
Set-LockScreen -SoftwareRoot 'HKLM:\SOFTWARE' -ImagePath $lockLive -Lock:$m.lockScreen.lock; $result.LockScreenApplied=$true
Set-BitLockerPreboot -SoftwareRoot 'HKLM:\SOFTWARE' -Manifest $m; $result.BitLockerApplied=$true
Invoke-WithHive 'C:\Users\Default\NTUSER.DAT' 'SM_BRAND_DU' {
param($du) Set-DesktopBranding -DefaultUserRoot $du -Manifest $m -WallpaperPath $wallLive; $result.DesktopApplied=$true
}
}
Write-Host 'Branding applied.' -ForegroundColor Green
if ($PassThru) { [pscustomobject]$result }

View File

@@ -0,0 +1,11 @@
# SilverMetal Windows branding (shared, dual-mode)
`Apply-Branding.ps1` writes the four branding layers either OFFLINE into a
mounted WIM (`-Mode Offline -MountPath <mount>`, used by `installer/build.ps1`
`Invoke-Brand`) or ONLINE onto the running system (`-Mode Online`, self-apply).
Strings live in `branding.manifest.json`; images in `assets/`. See
`docs/superpowers/specs/2026-06-09-first-boot-branding-design.md`.
Layers: BitLocker pre-boot recovery message, lock-screen image (locked),
desktop wallpaper+theme (changeable), OEM About.

View File

@@ -0,0 +1,11 @@
# Branding assets
Placeholder void/cyan assets until the brand identity is finalised
(`shared/branding/README.md`). Replace in place; keep filenames.
| File | Spec | Used by |
|------|------|---------|
| `oemlogo.bmp` | 120×120 24-bit BMP | OEM About logo |
| `lockscreen.jpg` | display-resolution JPG, dark | Lock/sign-in |
| `wallpaper.jpg` | display-resolution JPG, dark | Desktop |
| `SilverMetal.theme` | Windows .theme (dark + cyan accent) | Desktop theme |

View File

@@ -0,0 +1,8 @@
[Theme]
DisplayName=SilverMetal
[Control Panel\Desktop]
Wallpaper=%SystemRoot%\Web\Wallpaper\SilverMetal\wallpaper.jpg
WallpaperStyle=10
[VisualStyles]
SystemMode=Dark
AppMode=Dark

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,26 @@
{
"schemaVersion": 1,
"productName": "SilverMetal Windows",
"oem": {
"manufacturer": "SilverLABS",
"model": "SilverMetal Windows",
"supportUrl": "https://silverlabs.uk",
"supportHours": "24/7 community + paid SLA",
"logo": "oemlogo.bmp"
},
"bitlocker": {
"recoveryMessage": "SilverMetal Windows. Locked out? silverlabs.uk",
"recoveryUrl": "https://silverlabs.uk"
},
"lockScreen": {
"image": "lockscreen.jpg",
"lock": true
},
"desktop": {
"wallpaper": "wallpaper.jpg",
"theme": "SilverMetal.theme",
"accentColor": "00d4ff",
"darkMode": true,
"lockWallpaper": false
}
}

View File

@@ -0,0 +1,130 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. "$PSScriptRoot\RegistryHelpers.ps1"
function Set-OemInformation {
param([Parameter(Mandatory)][string]$SoftwareRoot,[Parameter(Mandatory)]$Manifest,[Parameter(Mandatory)][string]$LogoPath)
$sub = 'Microsoft\Windows\CurrentVersion\OEMInformation'
Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'Manufacturer' -Type String -Value $Manifest.oem.manufacturer
Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'Model' -Type String -Value $Manifest.oem.model
Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'SupportURL' -Type String -Value $Manifest.oem.supportUrl
Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'SupportHours' -Type String -Value $Manifest.oem.supportHours
Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'Logo' -Type String -Value $LogoPath
}
function Set-LockScreen {
param([Parameter(Mandatory)][string]$SoftwareRoot,[Parameter(Mandatory)][string]$ImagePath,[bool]$Lock=$true)
# Per-device modern lock-screen image (reliable on Enterprise/IoT).
$csp = 'Microsoft\Windows\CurrentVersion\PersonalizationCSP'
Set-SmRegValue -Root $SoftwareRoot -SubKey $csp -Name 'LockScreenImagePath' -Type String -Value $ImagePath
Set-SmRegValue -Root $SoftwareRoot -SubKey $csp -Name 'LockScreenImageUrl' -Type String -Value $ImagePath
Set-SmRegValue -Root $SoftwareRoot -SubKey $csp -Name 'LockScreenImageStatus' -Type DWord -Value 1
if ($Lock) {
$pol = 'Policies\Microsoft\Windows\Personalization'
Set-SmRegValue -Root $SoftwareRoot -SubKey $pol -Name 'LockScreenImage' -Type String -Value $ImagePath
Set-SmRegValue -Root $SoftwareRoot -SubKey $pol -Name 'NoChangingLockScreen' -Type DWord -Value 1
}
}
function Set-DesktopBranding {
param([Parameter(Mandatory)][string]$DefaultUserRoot,[Parameter(Mandatory)]$Manifest,[Parameter(Mandatory)][string]$WallpaperPath)
Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Control Panel\Desktop' -Name 'WallPaper' -Type String -Value $WallpaperPath
Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Control Panel\Desktop' -Name 'WallpaperStyle' -Type String -Value '10' # fill
if ($Manifest.desktop.darkMode) {
$p = 'Software\Microsoft\Windows\CurrentVersion\Themes\Personalize'
Set-SmRegValue -Root $DefaultUserRoot -SubKey $p -Name 'AppsUseLightTheme' -Type DWord -Value 0
Set-SmRegValue -Root $DefaultUserRoot -SubKey $p -Name 'SystemUsesLightTheme' -Type DWord -Value 0
}
# Accent (cyan). DWM uses fully-opaque DWORDs with DIFFERENT byte orders:
# ColorizationColor = 0xAARRGGBB (ARGB); AccentColor = 0xAABBGGRR (ABGR).
# Manifest holds the plain RGB hex (source of truth); derive both, alpha=FF.
# NOTE: exact accent rendering is VM-verified (plan §9 soft spot).
$rgb = $Manifest.desktop.accentColor.TrimStart('#')
$r = [Convert]::ToInt32($rgb.Substring(0,2),16)
$g = [Convert]::ToInt32($rgb.Substring(2,2),16)
$b = [Convert]::ToInt32($rgb.Substring(4,2),16)
$argb = [int](0xFF000000 -bor ($r -shl 16) -bor ($g -shl 8) -bor $b) # ColorizationColor
$abgr = [int](0xFF000000 -bor ($b -shl 16) -bor ($g -shl 8) -bor $r) # AccentColor
Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\DWM' -Name 'AccentColor' -Type DWord -Value $abgr
Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\DWM' -Name 'ColorizationColor' -Type DWord -Value $argb
if (-not $Manifest.desktop.lockWallpaper) { return }
Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\CurrentVersion\Policies\ActiveDesktop' -Name 'NoChangingWallPaper' -Type DWord -Value 1
}
function Set-BitLockerPreboot {
param([Parameter(Mandatory)][string]$SoftwareRoot,[Parameter(Mandatory)]$Manifest)
# GPO "Configure pre-boot recovery message and URL" (ADMX VolumeEncryption).
# NOTE: only the BitLocker RECOVERY screen is customisable; the normal PIN-entry
# screen text is fixed Windows UI. Exact value names are asserted by the read-back
# test; if a name is wrong the offline-apply verify (Task A4) catches it.
$fve = 'Policies\Microsoft\FVE'
Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'UseCustomRecoveryMessage' -Type DWord -Value 1
Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'RecoveryMessage' -Type String -Value $Manifest.bitlocker.recoveryMessage
Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'UseCustomRecoveryUrl' -Type DWord -Value 1
Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'RecoveryUrl' -Type String -Value $Manifest.bitlocker.recoveryUrl
}

View File

@@ -0,0 +1,34 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Write a registry value under an arbitrary hive root (a live HKLM:/HKCU: path
# OR a loaded offline hive exposed as a PSDrive). Creates intermediate keys.
function Set-SmRegValue {
param(
[Parameter(Mandatory)][string]$Root,
[Parameter(Mandatory)][string]$SubKey,
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][ValidateSet('String','ExpandString','DWord','Binary')][string]$Type,
[Parameter(Mandatory)]$Value
)
$key = Join-Path $Root $SubKey
if (-not (Test-Path $key)) { New-Item -Path $key -Force | Out-Null }
New-ItemProperty -Path $key -Name $Name -PropertyType $Type -Value $Value -Force | Out-Null
}

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,36 @@
#Requires -Version 5.1
# Pure validation helpers for the WinPE collector. No WinForms dependency so they
# are unit-testable headless. Each returns [pscustomobject]@{ Ok=[bool]; Message=[string] }.
function New-SmResult([bool]$ok, [string]$msg = '') { [pscustomobject]@{ Ok = $ok; Message = $msg } }
$script:SmReserved = @('administrator','guest','system','defaultaccount','wdagutilityaccount','sm-bootstrap')
function Test-SmUsername([string]$name) {
if ([string]::IsNullOrWhiteSpace($name)) { return New-SmResult $false 'Username is required.' }
if ($name.Length -gt 20) { return New-SmResult $false 'Username must be 20 characters or fewer.' }
if ($script:SmReserved -contains $name.ToLower()) { return New-SmResult $false 'That username is reserved.' }
if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]*$') { return New-SmResult $false 'Username has illegal characters.' }
New-SmResult $true
}
function Test-SmPassword([string]$pw, [string]$confirm) {
if ([string]::IsNullOrEmpty($pw)) { return New-SmResult $false 'Password is required.' }
if ($pw.Length -lt 8) { return New-SmResult $false 'Password must be at least 8 characters.' }
if ($pw -ne $confirm) { return New-SmResult $false 'Passwords do not match.' }
New-SmResult $true
}
function Test-SmPin([string]$pin, [string]$confirm) {
if ($pin -notmatch '^[0-9]+$') { return New-SmResult $false 'PIN must be numeric.' }
if ($pin.Length -lt 6) { return New-SmResult $false 'PIN must be at least 6 digits.' }
if ($pin -ne $confirm) { return New-SmResult $false 'PINs do not match.' }
New-SmResult $true
}
function Test-SmComputerName([string]$name) {
if ([string]::IsNullOrWhiteSpace($name)) { return New-SmResult $false 'Computer name is required.' }
if ($name.Length -gt 15) { return New-SmResult $false 'Computer name must be 15 characters or fewer.' }
if ($name -notmatch '^[A-Za-z0-9-]+$') { return New-SmResult $false 'Computer name: letters, digits, hyphens only.' }
New-SmResult $true
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,716 @@
# WinPE Pre-Config Collector Implementation Plan (SP1)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A branded WinPE collector runs before Windows Setup, captures identity + install-shaping choices, generates the answer file so Setup creates the real local-admin account natively (no `sm-bootstrap`), and hands the rest (flavour, BitLocker PIN, app defaults) to a simplified run-once-then-persist first-boot toolbox.
**Architecture:** Pure-PowerShell collector logic (validation + answer-file generation) that is unit-tested headless with Pester, wrapped by a WinForms shell launched from `boot.wim` via `winpeshl.ini`. The collector writes a generated answer file plus a base64-embedded `preconfig.json` carried into the installed OS via the `specialize` pass. The existing MAUI Welcome app is trimmed into the toolbox: account creation, `sm-bootstrap` teardown, and the heavy kiosk are removed; a `PreconfigLoader` pre-seeds state and Apply becomes `apps -> bitlocker -> done`.
**Tech Stack:** PowerShell 5.1 + WinForms (WinPE `WinPE-NetFx`/`WinPE-PowerShell`), Pester v5, .NET 9 / C# (SilverOS.Welcome), xUnit + Moq, DISM (`Add-WindowsPackage`), `oscdimg`.
**Spec:** [`../specs/2026-06-10-winpe-preconfig-collector-design.md`](../specs/2026-06-10-winpe-preconfig-collector-design.md)
**Branch:** `docs/winpe-preconfig-collector` (spec committed at `59418e3`). Implementation continues on this branch (rename/PR at the end is fine).
**Conventions:**
- Pester runs v5 under `pwsh` via a config object (the CI pattern): `New-PesterConfiguration`; `$cfg.Run.Path = '...'`; `Invoke-Pester -Configuration $cfg`. Run locally: `pwsh -NoProfile -Command "$c=New-PesterConfiguration; $c.Run.Path='windows/tests/Collector.Tests.ps1'; $c.Output.Verbosity='Detailed'; Invoke-Pester -Configuration $c"` (install Pester 5 first if absent: `Install-Module Pester -MinimumVersion 5.0 -Scope CurrentUser -Force -SkipPublisherCheck`).
- PowerShell files run by Windows PE / Windows-PowerShell 5.1 must be **ASCII / UTF-8-with-BOM** and contain **no em-dashes or smart quotes** (mojibake breaks parsing — a repeat bug in this repo). Use ASCII hyphens.
- C# tests: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release`.
- The collector logic must be split so the **testable parts have no WinForms dependency** (Pester can't load WinForms reliably headless and we don't want UI in unit tests).
---
## File structure
```
windows/collector/
Test-SmInput.ps1 # pure validation functions (no WinForms) -- Pester-tested
New-SmAnswerFile.ps1 # pure answer-file generator (no WinForms) -- Pester-tested
Collector.ps1 # WinForms shell: builds the form, calls the two above, launches Setup
Start-Collector.cmd # winpeshl entry point -> powershell Collector.ps1 (with fallback)
winpeshl.ini # tells WinPE to run Start-Collector.cmd instead of Setup
assets/sm-logo.png # collector branding (optional; form degrades without it)
windows/tests/
Collector.Tests.ps1 # Pester: Test-SmInput + New-SmAnswerFile
windows/welcome/src/SilverOS.Welcome.Core/Preconfig/
Preconfig.cs # record + JsonOptions
IPreconfigStore.cs / PreconfigStore.cs # load + clear-pin + configured-marker
windows/welcome/tests/SilverOS.Welcome.Tests/
PreconfigTests.cs # xUnit for PreconfigStore
```
Modified: `windows/installer/build.ps1` (Stage 2b boot.wim), `windows/welcome/src/SilverOS.Welcome.Core/Apply/{ApplyRequest,ApplyService}.cs`, `windows/welcome/src/SilverOS.Welcome.UI/Components/{Routes.razor,WizardState.cs}`, `windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor`, `windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs`, `windows/installer/autounattend/SetupComplete` flow, `windows/tests/Assert-IsoStructure.ps1`.
---
## Phase A — Collector validation logic (Pester TDD)
### Task A1: `Test-SmInput` validation functions
**Files:**
- Create: `windows/collector/Test-SmInput.ps1`
- Create: `windows/tests/Collector.Tests.ps1`
- [ ] **Step 1: Write the failing Pester tests** (`windows/tests/Collector.Tests.ps1`)
```powershell
#Requires -Version 5.1
. (Join-Path $PSScriptRoot '..\collector\Test-SmInput.ps1')
Describe 'Test-SmUsername' {
It 'accepts a simple username' { (Test-SmUsername 'jamie').Ok | Should -BeTrue }
It 'rejects empty' { (Test-SmUsername '').Ok | Should -BeFalse }
It 'rejects reserved name' { (Test-SmUsername 'Administrator').Ok | Should -BeFalse }
It 'rejects illegal chars' { (Test-SmUsername 'a\b').Ok | Should -BeFalse }
It 'rejects > 20 chars' { (Test-SmUsername ('x'*21)).Ok| Should -BeFalse }
}
Describe 'Test-SmPassword' {
It 'accepts matching 8+ char password' { (Test-SmPassword 'Sup3rPass!' 'Sup3rPass!').Ok | Should -BeTrue }
It 'rejects mismatch' { (Test-SmPassword 'a' 'b').Ok | Should -BeFalse }
It 'rejects < 8 chars' { (Test-SmPassword 'short' 'short').Ok | Should -BeFalse }
}
Describe 'Test-SmPin' {
It 'accepts 6-digit matching pin' { (Test-SmPin '246810' '246810').Ok | Should -BeTrue }
It 'rejects < 6 digits' { (Test-SmPin '123' '123').Ok | Should -BeFalse }
It 'rejects non-numeric' { (Test-SmPin 'abcdef' 'abcdef').Ok | Should -BeFalse }
It 'rejects mismatch' { (Test-SmPin '246810' '999999').Ok | Should -BeFalse }
}
Describe 'Test-SmComputerName' {
It 'accepts a valid name' { (Test-SmComputerName 'SILVER-01').Ok | Should -BeTrue }
It 'rejects empty' { (Test-SmComputerName '').Ok | Should -BeFalse }
It 'rejects > 15 chars' { (Test-SmComputerName ('A'*16)).Ok | Should -BeFalse }
It 'rejects illegal chars' { (Test-SmComputerName 'bad name').Ok | Should -BeFalse }
}
```
- [ ] **Step 2: Run to verify it fails**
Run: `pwsh -NoProfile -Command "$c=New-PesterConfiguration; $c.Run.Path='windows/tests/Collector.Tests.ps1'; $c.Output.Verbosity='Detailed'; Invoke-Pester -Configuration $c"`
Expected: FAIL — `Test-SmUsername` not recognized.
- [ ] **Step 3: Implement** (`windows/collector/Test-SmInput.ps1`, ASCII + UTF-8-BOM)
```powershell
#Requires -Version 5.1
# Pure validation helpers for the WinPE collector. No WinForms dependency so they
# are unit-testable headless. Each returns [pscustomobject]@{ Ok=[bool]; Message=[string] }.
function New-SmResult([bool]$ok, [string]$msg = '') { [pscustomobject]@{ Ok = $ok; Message = $msg } }
$script:SmReserved = @('administrator','guest','system','defaultaccount','wdagutilityaccount','sm-bootstrap')
function Test-SmUsername([string]$name) {
if ([string]::IsNullOrWhiteSpace($name)) { return New-SmResult $false 'Username is required.' }
if ($name.Length -gt 20) { return New-SmResult $false 'Username must be 20 characters or fewer.' }
if ($script:SmReserved -contains $name.ToLower()) { return New-SmResult $false 'That username is reserved.' }
if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]*$') { return New-SmResult $false 'Username has illegal characters.' }
New-SmResult $true
}
function Test-SmPassword([string]$pw, [string]$confirm) {
if ([string]::IsNullOrEmpty($pw)) { return New-SmResult $false 'Password is required.' }
if ($pw.Length -lt 8) { return New-SmResult $false 'Password must be at least 8 characters.' }
if ($pw -ne $confirm) { return New-SmResult $false 'Passwords do not match.' }
New-SmResult $true
}
function Test-SmPin([string]$pin, [string]$confirm) {
if ($pin -notmatch '^[0-9]+$') { return New-SmResult $false 'PIN must be numeric.' }
if ($pin.Length -lt 6) { return New-SmResult $false 'PIN must be at least 6 digits.' }
if ($pin -ne $confirm) { return New-SmResult $false 'PINs do not match.' }
New-SmResult $true
}
function Test-SmComputerName([string]$name) {
if ([string]::IsNullOrWhiteSpace($name)) { return New-SmResult $false 'Computer name is required.' }
if ($name.Length -gt 15) { return New-SmResult $false 'Computer name must be 15 characters or fewer.' }
if ($name -notmatch '^[A-Za-z0-9-]+$') { return New-SmResult $false 'Computer name: letters, digits, hyphens only.' }
New-SmResult $true
}
```
- [ ] **Step 4: Run to verify it passes**
Run the same Pester command. Expected: PASS (all `Test-Sm*` contexts green).
- [ ] **Step 5: Commit**
```bash
git add windows/collector/Test-SmInput.ps1 windows/tests/Collector.Tests.ps1
git commit -m "feat(collector): WinPE input validation helpers + Pester tests"
```
---
## Phase B — Answer-file generator (Pester TDD)
### Task B1: `New-SmAnswerFile`
**Files:**
- Create: `windows/collector/New-SmAnswerFile.ps1`
- Modify (append): `windows/tests/Collector.Tests.ps1`
- [ ] **Step 1: Append failing tests** to `windows/tests/Collector.Tests.ps1`
```powershell
. (Join-Path $PSScriptRoot '..\collector\New-SmAnswerFile.ps1')
Describe 'New-SmAnswerFile' {
$cfg = @{
DisplayName = 'Jamie'
Username = 'jamie'
Password = 'Sup3rPass!'
ComputerName = 'SILVER-01'
InputLocale = '0809:00000809'
SystemLocale = 'en-GB'
UiLanguage = 'en-US'
UserLocale = 'en-GB'
Flavour = 'developer'
BitLockerEnable = $true
BitLockerPin = '246810'
}
$xml = New-SmAnswerFile @cfg
$doc = [xml]$xml
It 'is valid XML' { { [xml]$xml } | Should -Not -Throw }
It 'creates the real account in Administrators' {
$xml | Should -Match '<Name>jamie</Name>'
$xml | Should -Match '<Group>Administrators</Group>'
}
It 'does NOT contain sm-bootstrap' { $xml | Should -Not -Match 'sm-bootstrap' }
It 'sets AutoLogon once as the user' {
$xml | Should -Match '<LogonCount>1</LogonCount>'
$xml | Should -Match '<Username>jamie</Username>'
}
It 'sets the computer name' { $xml | Should -Match '<ComputerName>SILVER-01</ComputerName>' }
It 'keeps WillWipeDisk for disk 0' { $xml | Should -Match '<WillWipeDisk>true</WillWipeDisk>' }
It 'embeds a base64 preconfig write in specialize' {
$xml | Should -Match 'preconfig\.json'
$xml | Should -Match 'FromBase64String'
}
It 'embedded preconfig round-trips with the flavour and pin' {
$m = [regex]::Match($xml, "FromBase64String\('([^']+)'\)")
$m.Success | Should -BeTrue
$json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($m.Groups[1].Value)) | ConvertFrom-Json
$json.flavour | Should -Be 'developer'
$json.bitlocker.pin | Should -Be '246810'
}
It 'launches the toolbox in FirstLogonCommands' { $xml | Should -Match 'SilverOS\.Welcome\.App\.exe' }
}
```
- [ ] **Step 2: Run to verify it fails** (same Pester command). Expected: FAIL — `New-SmAnswerFile` not defined.
- [ ] **Step 3: Implement** (`windows/collector/New-SmAnswerFile.ps1`, ASCII + UTF-8-BOM)
```powershell
#Requires -Version 5.1
# Pure generator: collected values -> Windows Setup answer-file XML string.
# No WinForms dependency (unit-testable). Mirrors the legacy autounattend.xml but with
# ONE real local-admin account (no sm-bootstrap) and an embedded preconfig.json that a
# specialize-pass command writes to C:\ProgramData\SilverMetal\preconfig.json.
function New-SmAnswerFile {
param(
[string]$DisplayName, [string]$Username, [string]$Password,
[string]$ComputerName,
[string]$InputLocale = '0809:00000809', [string]$SystemLocale = 'en-GB',
[string]$UiLanguage = 'en-US', [string]$UserLocale = 'en-GB',
[string]$Flavour, [bool]$BitLockerEnable = $false, [string]$BitLockerPin = ''
)
# Build the carried-forward config and base64-embed it.
$pre = [ordered]@{
schemaVersion = 1
flavour = $Flavour
bitlocker = [ordered]@{ enable = [bool]$BitLockerEnable; pin = $BitLockerPin }
apps = [ordered]@{ useFlavourDefaults = $true }
}
$preJson = ($pre | ConvertTo-Json -Depth 6 -Compress)
$preB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($preJson))
# XML-escape user-supplied strings.
function Esc([string]$s) { [Security.SecurityElement]::Escape($s) }
$dn = Esc $DisplayName; $un = Esc $Username; $pw = Esc $Password; $cn = Esc $ComputerName
# specialize command: recreate dir + decode the embedded preconfig to C:.
$writePre = "powershell -NoProfile -ExecutionPolicy Bypass -Command "" New-Item -ItemType Directory -Force 'C:\ProgramData\SilverMetal' | Out-Null; [IO.File]::WriteAllBytes('C:\ProgramData\SilverMetal\preconfig.json', [Convert]::FromBase64String('$preB64')) """
@"
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="windowsPE">
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<SetupUILanguage><UILanguage>$UiLanguage</UILanguage></SetupUILanguage>
<InputLocale>$InputLocale</InputLocale><SystemLocale>$SystemLocale</SystemLocale>
<UILanguage>$UiLanguage</UILanguage><UserLocale>$UserLocale</UserLocale>
</component>
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<DiskConfiguration>
<WillShowUI>OnError</WillShowUI>
<Disk wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<DiskID>0</DiskID><WillWipeDisk>true</WillWipeDisk>
<CreatePartitions>
<CreatePartition wcm:action="add"><Order>1</Order><Type>EFI</Type><Size>300</Size></CreatePartition>
<CreatePartition wcm:action="add"><Order>2</Order><Type>MSR</Type><Size>16</Size></CreatePartition>
<CreatePartition wcm:action="add"><Order>3</Order><Type>Primary</Type><Extend>true</Extend></CreatePartition>
</CreatePartitions>
<ModifyPartitions>
<ModifyPartition wcm:action="add"><Order>1</Order><PartitionID>1</PartitionID><Label>System</Label><Format>FAT32</Format></ModifyPartition>
<ModifyPartition wcm:action="add"><Order>2</Order><PartitionID>2</PartitionID></ModifyPartition>
<ModifyPartition wcm:action="add"><Order>3</Order><PartitionID>3</PartitionID><Label>Windows</Label><Format>NTFS</Format><Letter>C</Letter></ModifyPartition>
</ModifyPartitions>
</Disk>
</DiskConfiguration>
<ImageInstall><OSImage>
<InstallTo><DiskID>0</DiskID><PartitionID>3</PartitionID></InstallTo>
<InstallFrom><MetaData wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Key>/IMAGE/INDEX</Key><Value>1</Value></MetaData></InstallFrom>
</OSImage></ImageInstall>
<UserData><AcceptEula>true</AcceptEula></UserData>
</component>
</settings>
<settings pass="specialize">
<component name="Microsoft-Windows-Deployment" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<RunSynchronous>
<RunSynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<Order>1</Order>
<Path>$([Security.SecurityElement]::Escape($writePre))</Path>
<Description>Write SilverMetal preconfig</Description>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<InputLocale>$InputLocale</InputLocale><SystemLocale>$SystemLocale</SystemLocale>
<UILanguage>$UiLanguage</UILanguage><UILanguageFallback>$UiLanguage</UILanguageFallback><UserLocale>$UserLocale</UserLocale>
</component>
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<OOBE><HideEULAPage>true</HideEULAPage><HideOEMRegistrationScreen>true</HideOEMRegistrationScreen><HideOnlineAccountScreens>true</HideOnlineAccountScreens><HideLocalAccountScreen>true</HideLocalAccountScreen><HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE><ProtectYourPC>3</ProtectYourPC></OOBE>
<UserAccounts><LocalAccounts>
<LocalAccount wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<Name>$un</Name><Group>Administrators</Group><DisplayName>$dn</DisplayName>
<Password><Value>$pw</Value><PlainText>true</PlainText></Password>
</LocalAccount>
</LocalAccounts></UserAccounts>
<AutoLogon><Enabled>true</Enabled><LogonCount>1</LogonCount><Username>$un</Username><Password><Value>$pw</Value><PlainText>true</PlainText></Password></AutoLogon>
<ComputerName>$cn</ComputerName>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<Order>1</Order>
<CommandLine>cmd /c powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath 'C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe' -Verb RunAs"</CommandLine>
<Description>Launch SilverMetal toolbox (run-once)</Description>
</SynchronousCommand>
</FirstLogonCommands>
<RegisteredOwner>SilverMetal</RegisteredOwner><RegisteredOrganization>SilverLABS</RegisteredOrganization>
</component>
</settings>
</unattend>
"@
}
```
> Note: `ComputerName` is set in `oobeSystem` here for simplicity; if 24H2 ignores it there, move it to a `specialize` `Microsoft-Windows-Shell-Setup` component in a follow-up — the test only asserts the element is present.
- [ ] **Step 4: Run to verify it passes** (same Pester command). Expected: PASS (all `New-SmAnswerFile` assertions, incl. base64 round-trip).
- [ ] **Step 5: Commit**
```bash
git add windows/collector/New-SmAnswerFile.ps1 windows/tests/Collector.Tests.ps1
git commit -m "feat(collector): answer-file generator (real account, no sm-bootstrap, embedded preconfig)"
```
---
## Phase C — Preconfig consumer in the toolbox (xUnit TDD)
### Task C1: `Preconfig` record + `PreconfigStore`
**Files:**
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Preconfig/Preconfig.cs`
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Preconfig/IPreconfigStore.cs`
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Preconfig/PreconfigStore.cs`
- Create test: `windows/welcome/tests/SilverOS.Welcome.Tests/PreconfigTests.cs`
- [ ] **Step 1: Write failing tests** (`PreconfigTests.cs`)
```csharp
using System.IO;
using System.Text.Json;
using SilverOS.Welcome.Core.Preconfig;
using Xunit;
public class PreconfigTests
{
static string TempDir()
{
var d = Path.Combine(Path.GetTempPath(), "smpre-" + System.Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(d);
return d;
}
const string Sample = """
{ "schemaVersion":1, "flavour":"developer",
"bitlocker":{"enable":true,"pin":"246810"},
"apps":{"useFlavourDefaults":true} }
""";
[Fact]
public void Loads_flavour_and_pin()
{
var dir = TempDir();
File.WriteAllText(Path.Combine(dir, "preconfig.json"), Sample);
var p = new PreconfigStore(dir).Load();
Assert.NotNull(p);
Assert.Equal("developer", p!.Flavour);
Assert.True(p.Bitlocker.Enable);
Assert.Equal("246810", p.Bitlocker.Pin);
Assert.True(p.Apps.UseFlavourDefaults);
}
[Fact]
public void Missing_or_bad_file_returns_null_not_throw()
{
Assert.Null(new PreconfigStore(TempDir()).Load()); // missing
var dir = TempDir();
File.WriteAllText(Path.Combine(dir, "preconfig.json"), "{ not json");
Assert.Null(new PreconfigStore(dir).Load()); // corrupt
}
[Fact]
public void ClearPin_rewrites_without_the_pin()
{
var dir = TempDir();
File.WriteAllText(Path.Combine(dir, "preconfig.json"), Sample);
var store = new PreconfigStore(dir);
store.ClearPin();
var reread = store.Load();
Assert.True(string.IsNullOrEmpty(reread!.Bitlocker.Pin));
Assert.Equal("developer", reread.Flavour); // rest preserved
}
[Fact]
public void Configured_marker_roundtrips()
{
var dir = TempDir();
var store = new PreconfigStore(dir);
Assert.False(store.IsConfigured());
store.MarkConfigured();
Assert.True(store.IsConfigured());
}
}
```
- [ ] **Step 2: Run to verify it fails**
Run: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release --filter Preconfig`
Expected: FAIL — types not defined.
- [ ] **Step 3: Implement the records + store**
`Preconfig.cs`:
```csharp
using System.Text.Json;
namespace SilverOS.Welcome.Core.Preconfig;
public sealed record BitlockerConfig { public bool Enable { get; init; } public string? Pin { get; init; } }
public sealed record AppsConfig { public bool UseFlavourDefaults { get; init; } = true; public IReadOnlyList<string>? Selected { get; init; } }
public sealed record Preconfig
{
public int SchemaVersion { get; init; } = 1;
public string Flavour { get; init; } = "";
public BitlockerConfig Bitlocker { get; init; } = new();
public AppsConfig Apps { get; init; } = new();
public static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
}
```
`IPreconfigStore.cs`:
```csharp
namespace SilverOS.Welcome.Core.Preconfig;
public interface IPreconfigStore
{
Preconfig? Load(); // null if missing/corrupt (fail-open)
void ClearPin(); // rewrite preconfig without the BitLocker pin
bool IsConfigured(); // configured marker present?
void MarkConfigured(); // write the configured marker
}
```
`PreconfigStore.cs`:
```csharp
using System.Text.Json;
namespace SilverOS.Welcome.Core.Preconfig;
public sealed class PreconfigStore(string dir) : IPreconfigStore
{
private string File_ => Path.Combine(dir, "preconfig.json");
private string Marker => Path.Combine(dir, "configured");
public Preconfig? Load()
{
try
{
if (!File.Exists(File_)) return null;
return JsonSerializer.Deserialize<Preconfig>(File.ReadAllText(File_), Preconfig.JsonOptions);
}
catch (JsonException) { return null; } // fail-open
}
public void ClearPin()
{
var p = Load();
if (p is null) return;
var stripped = p with { Bitlocker = p.Bitlocker with { Pin = null } };
File.WriteAllText(File_, JsonSerializer.Serialize(stripped, Preconfig.JsonOptions));
}
public bool IsConfigured() => File.Exists(Marker);
public void MarkConfigured() { Directory.CreateDirectory(dir); File.WriteAllText(Marker, "1"); }
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release --filter Preconfig`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add windows/welcome/src/SilverOS.Welcome.Core/Preconfig windows/welcome/tests/SilverOS.Welcome.Tests/PreconfigTests.cs
git commit -m "feat(toolbox): preconfig store (load fail-open, clear-pin, configured marker)"
```
---
## Phase D — Trim the toolbox + consume preconfig
> This phase removes account creation, `sm-bootstrap` teardown, and the heavy kiosk, and rewires Apply to `apps -> bitlocker -> done`, pre-seeded from preconfig. It touches several existing tests — update them as specified.
### Task D1: Slim `ApplyRequest` + `ApplyService` to apps + bitlocker
**Files:**
- Modify: `windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs`
- Modify: `windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs`
- Modify tests: `ApplyServiceTests.cs`, `ApplyServicesTests.cs`, `ApplyServiceHardeningIntegrationTests.cs`, `BootstrapServiceRevertKioskTests.cs`, `ApplyStepTests.cs`
- [ ] **Step 1: Read the current files** (`ApplyRequest.cs`, `ApplyService.cs`) to see the exact ctor/params and progress stages. The current `ApplyService` order is hardening -> accounts -> apps -> bitlocker -> RevertKiosk -> TearDown -> done; `ApplyRequest` is `(FlavourManifest Flavour, string Username, string Password, string AdminPassword, string BitLockerPin, string BootstrapUser, IReadOnlyList<AppCatalogEntry> Apps)`.
- [ ] **Step 2: Update the failing tests first** — change `ApplyServiceTests` ordering expectation to the new pipeline and drop account/teardown assertions.
New `ApplyRequest.cs`:
```csharp
using SilverOS.Welcome.Core.Flavours;
using SilverOS.Welcome.Core.Apps;
namespace SilverOS.Welcome.Core.Apply;
// Toolbox model: account is created by Windows Setup (WinPE collector), and hardening
// runs from SetupComplete. Apply only installs apps + enrols BitLocker.
public sealed record ApplyRequest(FlavourManifest Flavour, string BitLockerPin, IReadOnlyList<AppCatalogEntry> Apps);
```
In `ApplyServiceTests.cs`, the ordering test becomes (replace the old `modules/accounts/apps/bitlocker/bootstrap` sequence):
```csharp
// Apply now: apps -> bitlocker. No accounts/hardening/teardown in the toolbox.
Assert.Equal(new[] { "apps", "bitlocker" }, order);
```
Remove (delete) `BootstrapServiceRevertKioskTests.cs` and `ApplyServiceHardeningIntegrationTests.cs` (the kiosk revert + in-Apply hardening are gone). Update any `new ApplyRequest(...)` call sites to the 3-arg form `new ApplyRequest(flavour, "", apps)` / with a pin where relevant.
- [ ] **Step 3: Run to verify failure**`dotnet test ... -c Release` fails to compile (ApplyRequest arity changed). Good (red).
- [ ] **Step 4: Implement the slimmed `ApplyService`**
Rewrite `ApplyService` to:
```csharp
using SilverOS.Welcome.Core.Apps;
namespace SilverOS.Welcome.Core.Apply;
public sealed class ApplyService(IProcessRunner runner, IBitLockerService bitlocker, IAppInstaller installer) : IApplyService
{
public async Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default)
{
progress.Report(new("Installing apps", 30));
await installer.InstallAsync(req.Apps, progress, ct); // continue-on-failure; never throws
if (!string.IsNullOrWhiteSpace(req.BitLockerPin))
{
progress.Report(new("Encrypting the disk", 75));
await bitlocker.EnableAsync(req.BitLockerPin, ct);
}
progress.Report(new("Done", 100));
}
}
```
Delete the now-unused `IAccountService`/`AccountService`/`IBootstrapService`/`BootstrapService` (and their tests `AccountStepTests.cs` if it only tests account creation — keep if it tests the removed step UI, but that step is deleted in D2). Keep `IProcessRunner`/`ProcessRunner`/`BitLockerService`/`IAppInstaller`.
- [ ] **Step 5: Run to verify pass**`dotnet test windows/welcome/SilverOS.Welcome.sln -c Release`. Fix remaining call sites until green.
- [ ] **Step 6: Commit**
```bash
git add windows/welcome
git commit -m "refactor(toolbox): Apply is apps+bitlocker only (account via Setup, hardening via SetupComplete)"
```
### Task D2: Remove Account step + pre-seed from preconfig + run-mode
**Files:**
- Modify: `windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor`
- Delete: `windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/AccountStep.razor` (+ `AccountStepTests.cs`)
- Modify: `windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor`
- Modify: `windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs`
- [ ] **Step 1:** Remove the Account step from `Routes.razor` (delete its case + title; renumber: Welcome, Flavour, Apps, Prefs, Apply, Done). Inject `IPreconfigStore` (registered in MauiProgram pointing at `C:\ProgramData\SilverMetal`). On init: `var pre = PreconfigStore.Load();` if non-null and `!IsConfigured()`, pre-seed `State.Flavour` (match by id from loaded flavours), seed `State.SelectedApps` from `DefaultSelectionForRole(pre.Flavour)`, set `State.BitLockerPin = pre.Bitlocker.Pin` when `pre.Bitlocker.Enable`. If `IsConfigured()`, start on a minimal **toolbox-home** view (a simple page with a "Re-run setup" button) instead of auto-advancing.
- [ ] **Step 2:** `ApplyStep.razor` builds `new ApplyRequest(State.Flavour!, State.BitLockerPin, apps)` (3-arg). After a successful Apply: `PreconfigStore.ClearPin(); PreconfigStore.MarkConfigured();`.
- [ ] **Step 3:** `MauiProgram.cs` — register `IPreconfigStore`:
```csharp
builder.Services.AddSingleton<IPreconfigStore>(_ => new PreconfigStore(@"C:\ProgramData\SilverMetal"));
```
and update the `ApplyService` factory to the new 3-arg ctor `new ApplyService(runner, bitlocker, installer)` (drop accounts/bootstrap/hardeningDir). Remove the `IAccountService`/`IBootstrapService` registrations.
- [ ] **Step 4: Build** `dotnet build windows/welcome/src/SilverOS.Welcome.App -c Release` -> 0 errors; `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release` -> green (update/delete `FlavourStepTests`/`ApplyStepTests` as needed for the new wiring; keep their real assertions).
- [ ] **Step 5: Commit**
```bash
git add windows/welcome
git commit -m "feat(toolbox): drop Account step, pre-seed from preconfig, run-once vs toolbox-home"
```
---
## Phase E — Build wiring + SetupComplete scrub + ISO assertions
### Task E1: Stage the collector into boot.wim + winpeshl
**Files:**
- Modify: `windows/installer/build.ps1` (`Invoke-ForceLegacySetup`, around lines 103-135)
- Create: `windows/collector/Start-Collector.cmd`, `windows/collector/winpeshl.ini`, `windows/collector/Collector.ps1`
- [ ] **Step 1:** Create `Start-Collector.cmd` (ASCII):
```bat
@echo off
REM WinPE entry point. SM_UNATTENDED=1 -> skip UI and launch Setup with the default answer file (CI).
if "%SM_UNATTENDED%"=="1" (
start /wait X:\sources\setup.exe /unattend:X:\autounattend.xml
exit /b 0
)
powershell -NoProfile -ExecutionPolicy Bypass -File X:\sm\Collector.ps1
if errorlevel 1 (
REM Collector failed/cancelled -> fall back to the default answer file so install still proceeds.
start /wait X:\sources\setup.exe /unattend:X:\autounattend.xml
)
exit /b 0
```
- [ ] **Step 2:** Create `winpeshl.ini` (ASCII):
```ini
[LaunchApps]
%SYSTEMDRIVE%\sm\Start-Collector.cmd
```
- [ ] **Step 3:** Create `Collector.ps1` — the WinForms shell. It dot-sources `Test-SmInput.ps1` + `New-SmAnswerFile.ps1` from `X:\sm\`, shows a branded full-screen form collecting {DisplayName, Username, Password+confirm, ComputerName, locale (default), flavour (radio list), BitLocker enable + PIN+confirm}, validates each with the `Test-Sm*` functions (block OK until valid), then on Finish: `$xml = New-SmAnswerFile @collected; Set-Content X:\sm\unattend.generated.xml $xml -Encoding UTF8; Start-Process X:\sources\setup.exe "/unattend:X:\sm\unattend.generated.xml" -Wait`. On Cancel: `exit 1` (Start-Collector falls back). Wrap the whole body in try/catch that `exit 1` on any error. (UI code is not unit-tested; the validated logic + generator are.)
- [ ] **Step 4:** In `build.ps1` `Invoke-ForceLegacySetup`, after copying `autounattend.xml` into the boot mount, also: add the WinPE optional components and stage the collector. Insert inside the `try` (after line 120), before the `reg load`:
```powershell
# Add WinPE .NET + PowerShell so the collector (WinForms) can run.
$adk = 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Windows Preinstallation Environment\amd64\WinPE_OCs'
foreach ($oc in @('WinPE-WMI.cab','WinPE-NetFx.cab','WinPE-Scripting.cab','WinPE-PowerShell.cab')) {
$cab = Join-Path $adk $oc
if (Test-Path $cab) { Add-WindowsPackage -Path $bootmnt -PackagePath $cab | Out-Null }
else { Write-Warning " WinPE OC missing on runner: $cab (collector needs the ADK WinPE add-on)" }
}
# Stage the collector + winpeshl so WinPE launches it instead of Setup.
$smDir = Join-Path $bootmnt 'sm'; $null = New-Item -ItemType Directory -Force $smDir
Copy-Item (Join-Path $PSScriptRoot '..\collector\*') $smDir -Recurse -Force
Copy-Item (Join-Path $smDir 'winpeshl.ini') (Join-Path $bootmnt 'Windows\System32\winpeshl.ini') -Force
```
Keep the existing `reg add ... CmdLine` (it is the fallback path / legacy-setup forcing). Leave the static `autounattend.xml` copy in place (default/fallback answer file).
- [ ] **Step 5: Parse-lint** `build.ps1`, `Collector.ps1`, `Start-Collector.cmd`:
```
pwsh -NoProfile -Command "[void][System.Management.Automation.Language.Parser]::ParseFile('C:\Staging\Source\SilverMetal\windows\installer\build.ps1',[ref]$null,[ref]$null); [void][System.Management.Automation.Language.Parser]::ParseFile('C:\Staging\Source\SilverMetal\windows\collector\Collector.ps1',[ref]$null,[ref]$null); 'ok'"
```
Expected: `ok`.
- [ ] **Step 6: Commit**
```bash
git add windows/collector windows/installer/build.ps1
git commit -m "feat(build): stage WinPE collector into boot.wim (winpeshl + WinPE-NetFx/PowerShell) with SM_UNATTENDED fallback"
```
### Task E2: SetupComplete Panther scrub + ISO assertions
**Files:**
- Modify: the SetupComplete script staged by build (find it: `windows/hardening/` `SetupComplete.cmd` or where build writes `C:\Windows\Setup\Scripts\SetupComplete.cmd`)
- Modify: `windows/tests/Assert-IsoStructure.ps1`
- [ ] **Step 1:** Add a scrub line near the end of the SetupComplete flow (after hardening, runs as SYSTEM):
```bat
del /f /q "%WINDIR%\Panther\unattend.xml" 2>nul
del /f /q "%WINDIR%\Panther\Unattend\unattend.xml" 2>nul
```
(Find the actual SetupComplete generator in build.ps1 / hardening and append these two lines.)
- [ ] **Step 2:** In `Assert-IsoStructure.ps1`, add boot.wim collector assertions. After the install.wim block, add a boot.wim check:
```powershell
# boot.wim must carry the WinPE collector + winpeshl.
$bootwim = "$drive\sources\boot.wim"
Assert 'boot.wim present' (Test-Path $bootwim)
if (Test-Path $bootwim) {
$bmount = Join-Path $env:TEMP ('sm-assert-boot-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force $bmount | Out-Null
Mount-WindowsImage -ImagePath $bootwim -Index 2 -Path $bmount -ReadOnly | Out-Null
try {
Assert 'collector staged in boot.wim' (Test-Path (Join-Path $bmount 'sm\Collector.ps1'))
Assert 'winpeshl.ini set' (Test-Path (Join-Path $bmount 'Windows\System32\winpeshl.ini'))
} finally { Dismount-WindowsImage -Path $bmount -Discard | Out-Null; Remove-Item $bmount -Recurse -Force -EA SilentlyContinue }
}
```
Also assert the staged toolbox no longer ships the bootstrap teardown: `Assert 'no sm-bootstrap in answer file' (-not (Select-String -Path "$drive\autounattend.xml" -Pattern 'sm-bootstrap' -Quiet))`.
- [ ] **Step 3: Parse-lint** Assert-IsoStructure.ps1 (same ParseFile check). Expected `ok`.
- [ ] **Step 4: Commit**
```bash
git add windows/tests/Assert-IsoStructure.ps1 windows/hardening
git commit -m "feat(build): scrub Panther unattend + assert collector baked into boot.wim"
```
---
## Phase F — Verify + PR
### Task F1: Full test + parity + PR
- [ ] **Step 1:** `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release` -> all green.
- [ ] **Step 2:** Run the collector Pester: `pwsh -NoProfile -Command "$c=New-PesterConfiguration; $c.Run.Path='windows/tests/Collector.Tests.ps1'; $c.Output.Verbosity='Detailed'; $r=Invoke-Pester -Configuration $c -PassThru; if($r.FailedCount){throw $r.FailedCount}"` -> 0 failures.
- [ ] **Step 3:** `dotnet build windows/welcome/src/SilverOS.Welcome.App -c Release` -> 0 errors.
- [ ] **Step 4:** Use `superpowers:finishing-a-development-branch` to open the PR (`docs/winpe-preconfig-collector` -> main). In the PR note the **runner prerequisite**: the CI Windows runner needs the **ADK WinPE add-on** (`WinPE_OCs` cabs) for `Add-WindowsPackage` to succeed; without it the collector isn't added and boot.wim assertions fail. The end-to-end (collector form in WinPE -> native account -> toolbox run-once) is verified on the **next VM cycle**.
---
## Self-review notes (author)
- **Spec coverage:** collector scope (§2) -> A1/E1 (fields) ; WinForms UI (§2,§4a) -> E1 Collector.ps1 ; single admin (§2,§4b) -> B1 ; toolbox run-once+persist (§2,§4d) -> D2 ; handoff generated-answer-file + base64 (§2,§5) -> B1 ; hardening canonical in SetupComplete (§2) -> D1 (Apply drops hardening) + E2 ; preconfig contract (§4c) -> C1 ; build wiring (§4e) -> E1 ; fallback/SM_UNATTENDED (§4e,§6) -> E1 Start-Collector ; error handling fail-open (§6) -> C1 (loader) + E1 (collector fallback) ; security scrub + pin-clear (§7) -> E2 + C1/D2 ; testing (§8) -> A1/B1/C1 + E2 (Assert-IsoStructure) ; phasing (§9) -> SP2/SP3 left out.
- **Placeholder scan:** none — the testable units carry full code; integration tasks carry the exact code/diffs. The one soft spot (Collector.ps1 WinForms body) is intentionally described not pasted, since UI isn't unit-tested and its logic delegates to the fully-specified `Test-Sm*`/`New-SmAnswerFile`; the executor builds the form against those contracts.
- **Type consistency:** `Test-SmUsername/Password/Pin/ComputerName` (A1) reused by Collector.ps1 (E1). `New-SmAnswerFile` param names (B1) = the hashtable Collector.ps1 splats. `Preconfig`/`BitlockerConfig`/`AppsConfig` + `PreconfigStore.Load/ClearPin/IsConfigured/MarkConfigured` (C1) used in D2. `ApplyRequest(Flavour,BitLockerPin,Apps)` (D1) used in ApplyStep (D2). `IAppInstaller.InstallAsync` unchanged.
- **Known risk flagged in-place:** ADK WinPE add-on on the runner (E1 warning + F1 PR note); `ComputerName` pass placement (B1 note).

View File

@@ -0,0 +1,730 @@
# Wizard App Recipes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a per-role app-install picker to the SilverOS Welcome wizard — choosing a role shows grouped, pre-checked app checkboxes (role tools + privacy-trimmed essentials) that get installed via winget during Apply.
**Architecture:** A JSON app catalog (staged into the image like flavours) loaded by a Core loader mirroring `FlavourLoader`; a new `AppsStep` Blazor step writing selected ids into `WizardState`; an `AppInstaller` Core service that bootstraps winget and runs `winget install` per selected app (continue-on-failure) plus optional `configure` scripts; wired into `ApplyService` after the Stack and before BitLocker.
**Tech Stack:** .NET 9 / C# (SilverOS.Welcome.Core + .UI), MAUI Blazor, winget, xUnit + Moq, System.Text.Json.
**Spec:** [`../specs/2026-06-09-wizard-app-recipes-design.md`](../specs/2026-06-09-wizard-app-recipes-design.md)
**Branch:** `feat/app-recipes` (spec committed at `583ed44`).
**Conventions (match existing code):**
- Loaders mirror `FlavourLoader` (`Core/Flavours/FlavourLoader.cs`) + `FlavourManifest.JsonOptions` (case-insensitive, comments, trailing commas).
- `IProcessRunner.RunAsync(string file, string args, CancellationToken ct)``ProcessResult(ExitCode, StdOut, StdErr)` with `.EnsureSuccess(op)`.
- Services are `AddSingleton` in `MauiProgram.cs`; `ApplyService` is built via a factory (lines 40-45) and takes a directory string (`hardeningDir`).
- Tests: xUnit + Moq in `windows/welcome/tests/SilverOS.Welcome.Tests`. Run with `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release`.
- The app namespace root is `SilverOS.Welcome.App` even in the `.UI`/`.Core` projects (see existing files).
---
## Phase A — App catalog (Core)
### Task A1: AppCatalogEntry + AppCatalog records
**Files:**
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalogEntry.cs`
- Test: `windows/welcome/tests/SilverOS.Welcome.Tests/AppCatalogTests.cs`
- [ ] **Step 1: Write the failing test**
`AppCatalogTests.cs`:
```csharp
using System.Text.Json;
using SilverOS.Welcome.Core.Apps;
using Xunit;
public class AppCatalogTests
{
[Fact]
public void Deserializes_a_catalog_entry()
{
var json = """
{ "id":"vscodium","name":"VSCodium","description":"Telemetry-free VS Code.",
"source":{"winget":"VSCodium.VSCodium"},"group":"developer",
"roles":["developer"],"defaultFor":["developer"],"configure":null }
""";
var e = JsonSerializer.Deserialize<AppCatalogEntry>(json, AppCatalogEntry.JsonOptions)!;
Assert.Equal("vscodium", e.Id);
Assert.Equal("VSCodium.VSCodium", e.Source.Winget);
Assert.Contains("developer", e.Roles);
Assert.Contains("developer", e.DefaultFor);
}
}
```
- [ ] **Step 2: Run to verify it fails**
Run: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release --filter AppCatalogTests`
Expected: FAIL — `AppCatalogEntry` not defined.
- [ ] **Step 3: Implement the records**
`AppCatalogEntry.cs`:
```csharp
using System.Text.Json;
namespace SilverOS.Welcome.Core.Apps;
public sealed record AppSource
{
public string? Winget { get; init; }
// Future: public string? Mirror { get; init; } // swappable to a curated mirror.
}
public sealed record AppCatalogEntry
{
public string Id { get; init; } = "";
public string Name { get; init; } = "";
public string Description { get; init; } = "";
public AppSource Source { get; init; } = new();
public string Group { get; init; } = "";
public IReadOnlyList<string> Roles { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> DefaultFor { get; init; } = Array.Empty<string>();
public string? Configure { get; init; }
public static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release --filter AppCatalogTests`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalogEntry.cs windows/welcome/tests/SilverOS.Welcome.Tests/AppCatalogTests.cs
git commit -m "feat(apps): AppCatalogEntry record + test"
```
---
### Task A2: AppCatalog loader + role filtering
**Files:**
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalog.cs`
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppCatalog.cs`
- Modify: `windows/welcome/tests/SilverOS.Welcome.Tests/AppCatalogTests.cs` (append)
- [ ] **Step 1: Write failing tests (append)**
```csharp
public class AppCatalogLoaderTests
{
static string WriteCatalog(string body)
{
var dir = Path.Combine(Path.GetTempPath(), "smcat-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "catalog.json"), body);
return dir;
}
const string Body = """
{ "schemaVersion":1, "apps":[
{"id":"tb","name":"Thunderbird","source":{"winget":"Mozilla.Thunderbird"},"group":"essentials","roles":["essentials"],"defaultFor":["essentials"]},
{"id":"vscodium","name":"VSCodium","source":{"winget":"VSCodium.VSCodium"},"group":"developer","roles":["developer"],"defaultFor":["developer"]},
{"id":"rider","name":"Rider","source":{"winget":"JetBrains.Rider"},"group":"developer","roles":["developer"],"defaultFor":[]}
]}
""";
[Fact]
public void AppsForRole_returns_essentials_plus_role()
{
var c = new AppCatalog().Load(WriteCatalog(Body));
var ids = c.AppsForRole("developer").Select(a => a.Id).ToList();
Assert.Contains("tb", ids); // essentials (all roles)
Assert.Contains("vscodium", ids); // developer
Assert.Contains("rider", ids); // developer (offered, not default)
}
[Fact]
public void DefaultSelection_only_pre_checks_defaultFor()
{
var c = new AppCatalog().Load(WriteCatalog(Body));
var def = c.DefaultSelectionForRole("developer");
Assert.Contains("tb", def);
Assert.Contains("vscodium", def);
Assert.DoesNotContain("rider", def);
}
[Fact]
public void Missing_catalog_returns_empty_not_throw()
{
var c = new AppCatalog().Load(Path.Combine(Path.GetTempPath(), "nope-" + Guid.NewGuid().ToString("N")));
Assert.Empty(c.All);
}
}
```
- [ ] **Step 2: Run to verify failure**`AppCatalog` not defined.
- [ ] **Step 3: Implement loader**
`IAppCatalog.cs`:
```csharp
namespace SilverOS.Welcome.Core.Apps;
public interface IAppCatalog
{
LoadedCatalog Load(string directory);
}
```
`AppCatalog.cs`:
```csharp
using System.Text.Json;
namespace SilverOS.Welcome.Core.Apps;
public sealed record LoadedCatalog(IReadOnlyList<AppCatalogEntry> All)
{
// Essentials (offered to all roles) first, then the role's own apps. Stable, de-duped by id.
public IReadOnlyList<AppCatalogEntry> AppsForRole(string role) =>
All.Where(a => a.Roles.Contains("essentials") || a.Roles.Contains(role))
.GroupBy(a => a.Id).Select(g => g.First())
.OrderByDescending(a => a.Group == "essentials").ThenBy(a => a.Name).ToList();
public IReadOnlyList<string> DefaultSelectionForRole(string role) =>
AppsForRole(role).Where(a => a.DefaultFor.Contains("essentials") || a.DefaultFor.Contains(role))
.Select(a => a.Id).ToList();
}
public sealed class AppCatalog : IAppCatalog
{
private sealed record CatalogFile(int SchemaVersion, IReadOnlyList<AppCatalogEntry>? Apps);
public LoadedCatalog Load(string directory)
{
var path = Path.Combine(directory, "catalog.json");
if (!File.Exists(path)) return new LoadedCatalog(Array.Empty<AppCatalogEntry>());
try
{
var f = JsonSerializer.Deserialize<CatalogFile>(File.ReadAllText(path), AppCatalogEntry.JsonOptions);
return new LoadedCatalog(f?.Apps ?? Array.Empty<AppCatalogEntry>());
}
catch (JsonException)
{
// A bad catalog must never block onboarding — degrade to "no extra apps".
return new LoadedCatalog(Array.Empty<AppCatalogEntry>());
}
}
}
```
> Note: `IAppCatalog.Load` returns `LoadedCatalog`; the test calls `new AppCatalog().Load(dir)` then `.AppsForRole`/`.DefaultSelectionForRole`/`.All`.
- [ ] **Step 4: Run to verify pass** → all AppCatalog tests green.
- [ ] **Step 5: Commit**
```bash
git add windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalog.cs windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppCatalog.cs windows/welcome/tests/SilverOS.Welcome.Tests/AppCatalogTests.cs
git commit -m "feat(apps): AppCatalog loader + role filtering"
```
---
## Phase B — Install engine (Core)
### Task B1: IAppInstaller + AppInstaller (winget)
**Files:**
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppInstaller.cs`
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs`
- Test: `windows/welcome/tests/SilverOS.Welcome.Tests/AppInstallerTests.cs`
- [ ] **Step 1: Write failing tests**
`AppInstallerTests.cs`:
```csharp
using Moq;
using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Apply;
public class AppInstallerTests
{
static AppCatalogEntry App(string id, string winget, string? cfg = null) =>
new() { Id = id, Name = id, Source = new AppSource { Winget = winget }, Configure = cfg };
static Mock<IProcessRunner> Runner(int exit = 0) {
var m = new Mock<IProcessRunner>();
m.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(exit, "", ""));
return m;
}
[Fact]
public async Task Installs_each_selected_app_via_winget()
{
var run = Runner();
// winget present -> no bootstrap needed
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "v1.8", ""));
var sut = new AppInstaller(run.Object, "C:\\apps");
var res = await sut.InstallAsync(new[] { App("vscodium", "VSCodium.VSCodium") },
new Progress<ApplyProgress>(_ => { }));
run.Verify(r => r.RunAsync("winget", It.Is<string>(s =>
s.Contains("install") && s.Contains("VSCodium.VSCodium") && s.Contains("--silent")),
It.IsAny<CancellationToken>()), Times.Once);
Assert.True(res.Single().Installed);
}
[Fact]
public async Task Bootstraps_winget_when_absent()
{
var run = Runner();
// winget --version fails -> absent -> bootstrap path runs powershell
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(1, "", "not found"));
var sut = new AppInstaller(run.Object, "C:\\apps");
await sut.InstallAsync(new[] { App("tb", "Mozilla.Thunderbird") }, new Progress<ApplyProgress>(_ => { }));
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("DesktopAppInstaller")), It.IsAny<CancellationToken>()), Times.AtLeastOnce);
}
[Fact]
public async Task One_app_failure_does_not_stop_the_rest()
{
var run = new Mock<IProcessRunner>();
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "v1.8", ""));
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("Bad.App")), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(1, "", "fail"));
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("Good.App")), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "", ""));
var sut = new AppInstaller(run.Object, "C:\\apps");
var res = await sut.InstallAsync(new[] { App("bad","Bad.App"), App("good","Good.App") },
new Progress<ApplyProgress>(_ => { }));
Assert.False(res.First(r => r.Id == "bad").Installed);
Assert.True(res.First(r => r.Id == "good").Installed);
}
}
```
- [ ] **Step 2: Run to verify failure**`AppInstaller`/`IAppInstaller` not defined.
- [ ] **Step 3: Implement the installer**
`IAppInstaller.cs`:
```csharp
using SilverOS.Welcome.Core.Apply;
namespace SilverOS.Welcome.Core.Apps;
public sealed record AppInstallResult(string Id, bool Installed);
public interface IAppInstaller
{
Task<IReadOnlyList<AppInstallResult>> InstallAsync(
IReadOnlyList<AppCatalogEntry> apps, IProgress<ApplyProgress> progress, CancellationToken ct = default);
}
```
`AppInstaller.cs`:
```csharp
using SilverOS.Welcome.Core.Apply;
namespace SilverOS.Welcome.Core.Apps;
public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppInstaller
{
public async Task<IReadOnlyList<AppInstallResult>> InstallAsync(
IReadOnlyList<AppCatalogEntry> apps, IProgress<ApplyProgress> progress, CancellationToken ct = default)
{
var results = new List<AppInstallResult>();
if (apps.Count == 0) return results;
await EnsureWingetAsync(ct);
var i = 0;
foreach (var app in apps)
{
i++;
progress.Report(new($"Installing {app.Name} ({i}/{apps.Count})", 80));
var ok = false;
var id = app.Source.Winget;
if (!string.IsNullOrWhiteSpace(id))
{
var r = await runner.RunAsync("winget",
$"install --id {id} --silent --accept-package-agreements --accept-source-agreements --disable-interactivity", ct);
ok = r.ExitCode == 0;
if (ok && !string.IsNullOrWhiteSpace(app.Configure))
{
var script = Path.Combine(appsDir, "configure", app.Configure);
await runner.RunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -File \"{script}\"", ct); // best-effort config
}
}
results.Add(new AppInstallResult(app.Id, ok));
}
return results;
}
// winget (App Installer) is NOT in IoT Enterprise LTSC. Detect, and if absent, provision it.
private async Task EnsureWingetAsync(CancellationToken ct)
{
var probe = await runner.RunAsync("winget", "--version", ct);
if (probe.ExitCode == 0) return;
var bootstrap = Path.Combine(appsDir, "bootstrap-winget.ps1");
await runner.RunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -Command \"if (Test-Path '{bootstrap}') {{ & '{bootstrap}' }} else {{ " +
"Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe -EA SilentlyContinue }}\"", ct);
}
}
```
> The test asserts a `powershell.exe` call containing `DesktopAppInstaller` during bootstrap — the
> inline command above contains it. The real `bootstrap-winget.ps1` (staged) does the robust
> install; this keeps the engine testable without the script present.
- [ ] **Step 4: Run to verify pass** → AppInstaller tests green.
- [ ] **Step 5: Commit**
```bash
git add windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppInstaller.cs windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs windows/welcome/tests/SilverOS.Welcome.Tests/AppInstallerTests.cs
git commit -m "feat(apps): winget install engine (bootstrap + per-app + configure, continue-on-failure)"
```
---
## Phase C — Wizard step (UI)
### Task C1: WizardState.SelectedApps
**Files:**
- Modify: `windows/welcome/src/SilverOS.Welcome.UI/Components/WizardState.cs`
- [ ] **Step 1: Add the property**
In `WizardState`, after `BitLockerPin`:
```csharp
// Apps step: ids of catalog apps the user chose to install.
public HashSet<string> SelectedApps { get; set; } = new();
```
- [ ] **Step 2: Build** `dotnet build windows/welcome/src/SilverOS.Welcome.UI -c Release` → 0 errors.
- [ ] **Step 3: Commit**
```bash
git add windows/welcome/src/SilverOS.Welcome.UI/Components/WizardState.cs
git commit -m "feat(apps): WizardState.SelectedApps"
```
---
### Task C2: AppsStep.razor
**Files:**
- Create: `windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/AppsStep.razor`
- [ ] **Step 1: Create the step**
`AppsStep.razor`:
```razor
@using SilverOS.Welcome.Core.Apps
@inject WizardState State
<div class="step apps-step">
<h1>Choose your apps</h1>
<p class="step-subtitle">We'll install these during setup. The SilverLABS Stack (browser, VPN, keys) is already included.</p>
@foreach (var grp in _groups)
{
<h3 class="apps-group">@GroupTitle(grp.Key)</h3>
<div class="apps-grid">
@foreach (var app in grp)
{
<label class="app-card @(State.SelectedApps.Contains(app.Id) ? "selected" : "")">
<input type="checkbox" checked="@State.SelectedApps.Contains(app.Id)"
@onchange="e => Toggle(app.Id, (bool)e.Value!)" />
<span class="app-name">@app.Name</span>
<span class="app-desc">@app.Description</span>
</label>
}
</div>
}
</div>
@code {
[Parameter] public IReadOnlyList<AppCatalogEntry> Apps { get; set; } = Array.Empty<AppCatalogEntry>();
private IEnumerable<IGrouping<string, AppCatalogEntry>> _groups =>
Apps.GroupBy(a => a.Group).OrderByDescending(g => g.Key == "essentials");
private static string GroupTitle(string g) => g switch
{
"essentials" => "Essentials",
"developer" => "Developer tools",
"journalist" => "Journalist tools",
"daily-driver" => "Everyday apps",
"privacy-max" => "Privacy tools",
_ => g
};
void Toggle(string id, bool on)
{
if (on) State.SelectedApps.Add(id); else State.SelectedApps.Remove(id);
}
}
```
- [ ] **Step 2: Build** the UI project → 0 errors.
- [ ] **Step 3: Commit**
```bash
git add windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/AppsStep.razor
git commit -m "feat(apps): AppsStep grouped checkboxes"
```
---
### Task C3: Wire AppsStep into Routes (after Flavour)
**Files:**
- Modify: `windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor`
Read `Routes.razor` first. The wizard is a fixed-index switch with `_stepTitles`. Insert "Apps"
as step index 2 (between Flavour=1 and Account, which becomes 3), and load the catalog like flavours.
- [ ] **Step 1: Add the catalog field + load**
In `Routes.razor` `@code`, alongside the flavour fields, inject + load the catalog:
```razor
@inject IAppCatalog AppCatalog
```
```csharp
private LoadedCatalog _catalog = new(Array.Empty<AppCatalogEntry>());
private static readonly string AppsDir = Path.Combine(AppContext.BaseDirectory, "apps");
```
In `LoadFlavours()` (or `OnInitializedAsync`), after flavours load:
```csharp
_catalog = AppCatalog.Load(AppsDir);
```
- [ ] **Step 2: Update `_stepTitles` and the switch**
Change titles to: `{ "Welcome", "Flavour", "Apps", "Account", "Prefs", "Apply", "Done" }`.
Insert the Apps case and renumber the rest:
```razor
case 2:
<AppsStep Apps="_catalog.AppsForRole(State.Flavour?.Id ?? string.Empty)" />
break;
case 3:
<AccountStep OnValidityChanged="@(v => { _accountValid = v; StateHasChanged(); })" />
break;
case 4:
<PrefsStep />
break;
case 5:
<ApplyStep OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" />
break;
case 6:
<DoneStep />
break;
```
Update `CanGoNext` indices (Flavour stays 1; Account becomes 3): the `2 => _accountValid` case becomes `3 => _accountValid`, and the Apply step index in `Next()`/`_applyRunning` guard (`_currentStep != 4``_currentStep != 5`).
- [ ] **Step 3: Pre-select defaults when entering the Apps step**
In `FlavourStep`'s selection (or when advancing to Apps), seed `State.SelectedApps` from the catalog defaults. Simplest: in `Routes` `Next()`, when moving **into** step 2 and `SelectedApps` is empty, seed it:
```csharp
void Next()
{
if (_currentStep < _stepTitles.Length - 1) _currentStep++;
if (_currentStep == 2 && State.SelectedApps.Count == 0 && State.Flavour is not null)
foreach (var id in _catalog.DefaultSelectionForRole(State.Flavour.Id)) State.SelectedApps.Add(id);
}
```
- [ ] **Step 4: Build** the UI project → 0 errors.
- [ ] **Step 5: Commit**
```bash
git add windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor
git commit -m "feat(apps): insert Apps step after Flavour + seed per-role defaults"
```
---
## Phase D — Apply integration
### Task D1: ApplyRequest.SelectedApps + ApplyService + DI
**Files:**
- Modify: `windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs`
- Modify: `windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs`
- Modify: `windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor`
- Modify: `windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs`
- Modify: `windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs` (and any other ApplyService test mocks)
- [ ] **Step 1: Extend ApplyRequest**
`ApplyRequest.cs`:
```csharp
using SilverOS.Welcome.Core.Flavours;
using SilverOS.Welcome.Core.Apps;
namespace SilverOS.Welcome.Core.Apply;
public sealed record ApplyRequest(FlavourManifest Flavour, string Username, string Password,
string AdminPassword, string BitLockerPin, string BootstrapUser,
IReadOnlyList<AppCatalogEntry> Apps);
```
- [ ] **Step 2: Inject IAppInstaller into ApplyService + run after Stack, before BitLocker**
`ApplyService.cs` ctor — add `IAppInstaller installer`:
```csharp
public sealed class ApplyService(IProcessRunner runner, IAccountService accounts,
IBitLockerService bitlocker, IBootstrapService bootstrap, IAppInstaller installer, string hardeningDir) : IApplyService
```
After `CreateAccountsAsync` and before `bitlocker.EnableAsync`:
```csharp
progress.Report(new("Installing apps", 70));
await installer.InstallAsync(req.Apps, progress, ct); // continue-on-failure; never throws
```
- [ ] **Step 3: Update MauiProgram DI**
`MauiProgram.cs`: add (near the other singletons)
```csharp
builder.Services.AddSingleton<IAppCatalog, AppCatalog>();
var appsDir = Path.Combine(AppContext.BaseDirectory, "apps");
builder.Services.AddSingleton<IAppInstaller>(sp => new AppInstaller(sp.GetRequiredService<IProcessRunner>(), appsDir));
```
And add `installer` to the `ApplyService` factory:
```csharp
builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
sp.GetRequiredService<IProcessRunner>(),
sp.GetRequiredService<IAccountService>(),
sp.GetRequiredService<IBitLockerService>(),
sp.GetRequiredService<IBootstrapService>(),
sp.GetRequiredService<IAppInstaller>(),
hardeningDir));
```
- [ ] **Step 4: ApplyStep passes the selected entries**
`ApplyStep.razor` — where it builds `ApplyRequest`, resolve the selected ids to entries and pass them. Inject `IAppCatalog`, load once, then:
```csharp
[Inject] IAppCatalog AppCatalog { get; set; } = default!;
// ...
var apps = AppCatalog.Load(Path.Combine(AppContext.BaseDirectory, "apps"))
.All.Where(a => State.SelectedApps.Contains(a.Id)).ToList();
var req = new ApplyRequest(State.Flavour!, State.Username, State.Password,
State.AdminPassword, State.BitLockerPin, "sm-bootstrap", apps);
```
- [ ] **Step 5: Fix the existing ApplyService test mocks**
In `ApplyServiceTests.cs` / `ApplyServicesTests.cs` / `ApplyServiceHardeningIntegrationTests.cs`: every `new ApplyService(...)` gains a mock `IAppInstaller` arg, and every `new ApplyRequest(...)` gains a final `Array.Empty<AppCatalogEntry>()` (or a small list). Add an installer mock:
```csharp
var installer = new Mock<IAppInstaller>();
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(), It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<AppInstallResult>());
```
and a callback to record ordering if those tests assert order (insert `"apps"` between `"accounts"` and `"bitlocker"` in the expected sequence).
- [ ] **Step 6: Run the whole suite**
Run: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release`
Expected: PASS (all green, including updated ordering assertions).
- [ ] **Step 7: Commit**
```bash
git add windows/welcome
git commit -m "feat(apps): install selected apps during Apply (after Stack, before BitLocker)"
```
---
## Phase E — Catalog data + build staging
### Task E1: catalog.json + configure + bootstrap + build.ps1 staging
**Files:**
- Create: `windows/apps/catalog.json`
- Create: `windows/apps/configure/ungoogled-chromium.ps1`
- Create: `windows/apps/bootstrap-winget.ps1`
- Modify: `windows/installer/build.ps1`
- [ ] **Step 1: Write `catalog.json`** (the §4 table from the spec, all entries, UTF-8 no-BOM is fine — JSON read by .NET)
Create `windows/apps/catalog.json` with `schemaVersion:1` and the full `apps` array exactly per the spec §4 table (id/name/description/source.winget/group/roles/defaultFor), with `eloston.ungoogled-chromium` carrying `"configure":"ungoogled-chromium.ps1"`. (Claude Code CLI entry: omit from v1 or give `"source":{}` and skip — winget-only engine ignores empty source.)
- [ ] **Step 2: `configure/ungoogled-chromium.ps1`** — enable the Web Store + safe search via Chromium policy (HKLM\SOFTWARE\Policies\Chromium):
```powershell
#Requires -Version 5.1
$ErrorActionPreference='SilentlyContinue'
$pol='HKLM:\SOFTWARE\Policies\Chromium'
New-Item $pol -Force | Out-Null
New-ItemProperty $pol -Name 'ForceGoogleSafeSearch' -Value 1 -PropertyType DWord -Force | Out-Null
$ext="$pol\ExtensionInstallSources"; New-Item $ext -Force | Out-Null
New-ItemProperty $ext -Name '1' -Value 'https://chrome.google.com/webstore/*' -PropertyType String -Force | Out-Null
```
- [ ] **Step 3: `bootstrap-winget.ps1`** — robust App Installer provisioning:
```powershell
#Requires -Version 5.1
$ErrorActionPreference='SilentlyContinue'
# Register the inbox App Installer if present, else nothing to do (offline image w/o it).
Get-AppxPackage -AllUsers Microsoft.DesktopAppInstaller |
ForEach-Object { Add-AppxPackage -DisableDevelopmentMode -Register "$($_.InstallLocation)\AppXManifest.xml" }
```
- [ ] **Step 4: Stage `windows/apps/` in build.ps1**
In `Invoke-ServiceWim`, where the Welcome payload is staged (the `Copy-WelcomePayload` area), add the apps dir next to flavours:
```powershell
# Stage the app catalog + configure/bootstrap scripts next to the Welcome app.
$appsDest = Join-Path $dest 'apps'
$null = New-Item -ItemType Directory -Force $appsDest
Copy-Item (Join-Path $WindowsDir 'apps\*') $appsDest -Recurse -Force
```
(where `$dest` is `C:\Program Files\SilverOS\Welcome`, same var the flavours copy uses).
- [ ] **Step 5: Parse-lint + verify JSON**
Run: `pwsh -NoProfile -Command "Get-Content windows/apps/catalog.json -Raw | ConvertFrom-Json | Out-Null; 'json ok'"`
Run: `pwsh -NoProfile -Command "[void][System.Management.Automation.Language.Parser]::ParseFile('C:\Staging\Source\SilverMetal\windows\installer\build.ps1',[ref]$null,[ref]$null); 'ps ok'"`
Expected: `json ok`, `ps ok`.
- [ ] **Step 6: Commit**
```bash
git add windows/apps windows/installer/build.ps1
git commit -m "feat(apps): catalog.json + chromium configure + winget bootstrap + build staging"
```
---
## Phase F — Verify
### Task F1: Full build + test
- [ ] **Step 1**: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release` → all pass.
- [ ] **Step 2**: `dotnet build windows/welcome/src/SilverOS.Welcome.App -f net9.0-windows10.0.19041.0 -c Release` → 0 errors (the full MAUI app compiles with the new step + DI).
- [ ] **Step 3**: Use `superpowers:finishing-a-development-branch` to open the PR (`feat/app-recipes` → main). The catalog/install actually exercised end-to-end is verified on the **next VM run** (or hardware): pick a role → Apps step shows grouped pre-checked apps → Apply installs them via winget. Note in the PR: winget needs network (the VM's HVCI-blocked NIC means in-VM install verification needs the virtio rig or hardware).
---
## Self-review notes (author)
- **Spec coverage**: catalog schema (§3a) → A1/A2 + E1; loader/filter (§3b) → A2; AppsStep (§3c) → C2/C3; AppInstaller winget+bootstrap+configure+continue-on-failure (§3d) → B1; per-role lists (§4) → E1 catalog.json; build staging (§5) → E1; error handling (§6) → A2 (missing catalog), B1 (per-app failure), E1 (bootstrap); testing (§7) → A1/A2/B1 + D1 ordering. WDAC caveat (§2) is a Done-step note (carried as a follow-up — surfaced in PR/Done summary, no code gate).
- **Type consistency**: `AppCatalogEntry`, `AppSource.Winget`, `LoadedCatalog.AppsForRole/DefaultSelectionForRole/All`, `IAppCatalog.Load`, `IAppInstaller.InstallAsync``AppInstallResult(Id,Installed)`, `ApplyRequest(...Apps)`, `WizardState.SelectedApps` — used consistently across A→F.
- **Known integration risk flagged in-place**: Routes step-index renumbering (C3) is the fiddly part — the plan renumbers the switch, `CanGoNext`, and the Apply-step guard explicitly; the executor must read Routes.razor first (it's stated).

View File

@@ -0,0 +1,180 @@
# SilverMetal Windows — First-Boot Experience & Branding
> **Status**: design — 2026-06-09. Approved in brainstorming. Fills the `Invoke-Brand`
> stub in [`installer/build.ps1`](../../../installer/build.ps1) (M4 branding milestone) and
> adds the hardened onboarding kiosk + branded first-boot presentation.
> Bound by [`../../../iso-builder.md`](../../../iso-builder.md), [`../../../hardening-spec.md`](../../../hardening-spec.md),
> and the product principles in [`../../../../docs/`](../../../../docs).
## 1. Goal
Give SilverMetal Windows one cohesive, declaratively-built identity across every surface a
user sees from power-on to desktop, and present the existing SilverOS Welcome onboarding
wizard as a hardened, escape-proof, branded first-boot experience.
Everything is **baked declaratively** — offline registry/file servicing of the WIM in
`build.ps1` plus a SYSTEM configuration step at end-of-setup. **No VM capture / golden-image**
(that idea remains parked).
## 2. Scope — three components, one initiative
| # | Component | Where it runs |
|---|---|---|
| **A** | **Declarative branding** — the four brand layers | Offline (WIM) *and* online (self-apply) — shared module |
| **B** | **Hardened kiosk** — Shell Launcher v2 + Keyboard Filter for the one-time `sm-bootstrap` session | Build / OOBE path only |
| **C** | **First-boot presentation** — Welcome app as fullscreen Hybrid glass card | MAUI Welcome app |
Out of scope: renaming the `SilverOS.*` app/namespace/paths to SilverMetal (tracked as a
separate follow-up); a graphical OEM pre-boot/boot-logo splash (Secure Boot — out, per
earlier brainstorm); bit-identical reproducibility (non-goal per `iso-builder.md §5`).
## 3. Decisions locked in brainstorming
- **Presentation**: Hybrid — fullscreen branded backdrop + centered frosted-glass card.
- **Kiosk**: hardened via Shell Launcher v2 (per-user → only `sm-bootstrap`), escapes disabled.
- **Branding**: all four layers — BitLocker pre-boot message, lock/sign-in, desktop
wallpaper+theme, OEM About. Custom bootloader / firmware logo OUT (Secure Boot).
- **Build**: declarative (`build.ps1` + offline registry). VM used only to design/verify visuals.
- **Aesthetic**: dark "void" canvas, cyan (`#00d4ff`) core mark, teal-green (`#00e5a0`)
secondary. Mockup: [`.superpowers/mockups/02-branding-layers.html`](../../../../.superpowers/mockups/02-branding-layers.html)
and `01-presentation-model.html`.
- **Name shown to users**: **SilverMetal Windows** on every branding surface. (The Enhanced
line is hardened Windows, not our own OS, so "SilverOS" would overclaim. The `SilverOS.*`
app strings are working-title leftovers → separate rename follow-up.)
- **Content**: support URL = `https://silverlabs.uk` (until a dedicated domain is locked);
OEM Model = generic `SilverMetal Windows`; BitLocker recovery message = minimal, URL-only.
- **Code structure**: split — shared dual-mode **branding** module + build-only **kiosk**.
## 4. Component A — Declarative branding (`windows/branding/`)
A shared, dual-mode module, mirroring the `hardening/` "write once, used by ISO + self-apply"
pattern.
```
windows/branding/
├── Apply-Branding.ps1 # -Mode Offline -MountPath <wim mount> | -Mode Online
├── branding.manifest.json # all strings (names, URLs, OEM fields) — single source of truth
├── assets/
│ ├── lockscreen.jpg
│ ├── wallpaper.jpg
│ ├── oemlogo.bmp # ~120x120 OEM About logo
│ └── SilverMetal.theme # dark + cyan accent .theme
└── README.md
```
- **Offline mode**: `reg load` the mounted image's `SOFTWARE` and `C:\Users\Default\NTUSER.DAT`
hives, write values, `reg unload` (with the `[gc]::Collect()` + sleep guard already used
elsewhere in `build.ps1`). Stage asset files into the mounted image.
- **Online mode**: write live `HKLM` / default-user hive and copy assets to the running system.
Same value set both ways.
### Layers, mechanism, lock policy
| Layer | Registry / file | Locked? |
|---|---|---|
| **1 · BitLocker pre-boot** | `SOFTWARE\Policies\Microsoft\FVE` — pre-boot recovery message + URL policy values. Message ≈ "SilverMetal Windows. Locked out? silverlabs.uk". | n/a (firmware) |
| **2 · Lock / sign-in** | `SOFTWARE\…\PersonalizationCSP` lock-screen image (per-device reliable path) **and** `SOFTWARE\Policies\Microsoft\Windows\Personalization\NoChangingLockScreen=1`. Stage `lockscreen.jpg` to `C:\Windows\Web\Screen\SilverMetal\`. | **Locked** |
| **3 · Wallpaper + theme** | default-user `NTUSER.DAT`: `Control Panel\Desktop\WallPaper` (+ `WallpaperStyle`), dark mode (`…\Themes\Personalize\AppsUseLightTheme=0`, `SystemUsesLightTheme=0`), cyan accent. Stage `wallpaper.jpg` + `SilverMetal.theme`. Applies to **every new account** incl. the real end user. | **Changeable** |
| **4 · OEM About** | `SOFTWARE\Microsoft\Windows\CurrentVersion\OEMInformation`: `Manufacturer=SilverLABS`, `Model=SilverMetal Windows`, `SupportURL=https://silverlabs.uk`, `Logo=<oemlogo.bmp path>`. | n/a |
### Honest limitation — BitLocker pre-boot (Layer 1)
Only the BitLocker **recovery** screen's message + URL are customizable. The normal
**PIN-entry** screen text ("Enter the PIN to unlock this drive") is fixed Windows UI and
**cannot** be branded. The mockup's branded PIN title is aspirational; Layer 1's real
deliverable is the recovery message + URL only. Exact `FVE` value names are pinned during
implementation (the M1 hardening `02-data-at-rest.ps1` already touches `FVE` for PIN enrolment).
## 5. Component B — Hardened kiosk (build-only)
Locks the ephemeral `sm-bootstrap` onboarding session so the user cannot escape the wizard.
The `sm-bootstrap` account, AutoLogon, and teardown already exist
([`autounattend.xml`](../../../installer/autounattend/autounattend.xml),
[`SetupComplete.cmd`](../../../installer/oem/SetupComplete.cmd), the Welcome app's `ApplyService`).
### Offline (in `build.ps1`)
- `DISM /Enable-Feature /All` for `Client-EmbeddedShellLauncher` (Shell Launcher v2) and
`Client-KeyboardFilter`, applied to the mounted WIM. (Both ship in IoT Enterprise LTSC.)
- Stage `windows/installer/oem/Configure-Kiosk.ps1` into `C:\Windows\Setup\Scripts\`.
### At end-of-setup (`SetupComplete.cmd`, runs as SYSTEM, after accounts exist, before first logon)
`Configure-Kiosk.ps1`:
- **Shell Launcher v2** (WMI `WESL_UserSetting`, online-only — hence configured here, not
offline): default shell = `explorer.exe`; `sm-bootstrap`'s shell = a small launcher that
starts the Welcome app **elevated** (reuses the baked UAC auto-approve:
`ConsentPromptBehaviorAdmin=0`). With no Explorer in that session there is **no taskbar and
no Start menu** — the escape the operator saw is structurally gone.
- **Keyboard Filter** (WMI `WEKF_PredefinedKey` / `WEKF_Settings`): block Win, Win+L,
Ctrl+Esc, and similar shell hotkeys; `DisableKeyboardFilterForAdministrators=false`.
- **Security-screen / escape policies**: `DisableTaskMgr=1`, `DisableLockWorkstation=1`,
hide fast-user-switching and Log off. Applied scoped to the `sm-bootstrap` session and
reverted at teardown (so the real user is unaffected).
### Interaction with the existing flow
- The `autounattend.xml` `FirstLogonCommands` app-launch is now **redundant and removed**
Shell Launcher launches the Welcome app as the session shell.
- `SetupComplete.cmd` keeps its existing "defer hardening to Welcome when the app is present"
branch; it gains the `Configure-Kiosk.ps1` call.
### Teardown (Welcome app `ApplyService`, on wizard success)
Already deletes `sm-bootstrap` + removes AutoLogon. **Adds**: remove the `sm-bootstrap` WESL
custom-shell entry, revert the escape policies, (optionally clear Keyboard Filter rules). The
features remain enabled but inert. The real end-user account then logs in to a normal,
branded Explorer desktop.
## 6. Component C — First-boot presentation (MAUI Welcome app)
The Welcome app
([`windows/welcome/src/SilverOS.Welcome.App`](../../../welcome/src/SilverOS.Welcome.App)) is
MAUI Blazor (WebView2). Today its window is the plain default and `MainLayout` is the stock
template.
### Native — window chrome
In the Windows lifecycle handler, customize the WinUI `AppWindow`:
- `OverlappedPresenter` with border + title bar off, not resizable / minimizable / maximizable;
use the FullScreen presenter so it covers the whole display.
- Non-closable (suppress/ignore close); Alt+F4 is additionally blocked by the Keyboard Filter.
This is the only native requirement — it removes the title bar and makes the app own the screen.
### Visual — Blazor + CSS only
The Hybrid look (full-bleed branded backdrop + centered frosted-glass card) is rendered
**entirely in the WebView with CSS** (`backdrop-filter: blur(...)` over the in-WebView wall),
exactly as the mockup demonstrates. **No OS-level Mica/Acrylic** — in a Shell-Launcher kiosk
there is no desktop behind the app to blur, so OS backdrop buys nothing.
Work: restyle `MainLayout` and the wizard step shell from the stock MAUI template to the brand
identity — branded backdrop, centered glass card, step rail, cyan/teal accents, the type and
motion direction from the SilverLABS aesthetic. The step components' logic is unchanged.
## 7. Build-flow wiring — what changes
1. `installer/build.ps1` `Invoke-Brand` → call `branding\Apply-Branding.ps1 -Mode Offline -MountPath $mount` and stage assets (inside the existing WIM-mounted block).
2. `installer/build.ps1` → new offline step: enable Shell Launcher + Keyboard Filter features; stage `Configure-Kiosk.ps1`.
3. `installer/oem/SetupComplete.cmd` → invoke `Configure-Kiosk.ps1` before first logon.
4. `installer/autounattend/autounattend.xml` → remove the `FirstLogonCommands` Welcome launch.
5. `welcome/...` → fullscreen borderless window + Hybrid CSS shell; `ApplyService` → kiosk teardown.
## 8. Testing
- **Branding module**: Pester tests for `Apply-Branding.ps1` — offline (load a throwaway hive
into a temp mount, apply, assert each value + asset staged) and online (apply, read back,
revert). Runs on the Windows runner; no hardware needed.
- **Kiosk + presentation**: the existing VM e2e harness (SLAB01 VM 102, `_stageiso.py` /
`_pverun.py` / `_shot.py`). Boot the built ISO → assert: no taskbar / Start in the bootstrap
session, Task Manager / Win+L / Ctrl+Alt+Del options blocked, the glass card is fullscreen,
the wizard completes → real user logs into a branded Explorer desktop with wallpaper, accent,
locked lock-screen, and correct OEM About.
- **Honest scope**: per `iso-builder.md §5`, "reproducible" = pinned inputs + recorded tool
versions + output SHA-256 + SBOM; bit-identical rebuild stays a documented stretch goal.
## 9. Open items (resolve during implementation, not blocking)
1. Pin exact `FVE` pre-boot recovery-message value names against the base media.
2. Confirm `PersonalizationCSP` vs `Policies\…\Personalization\LockScreenImage` reliability on
IoT Enterprise LTSC 24H2/25H2; pick the one that survives a clean OOBE.
3. Decide whether `Configure-Kiosk.ps1` sets the `sm-bootstrap` shell to the app directly
(with `requireAdministrator` manifest) or to an elevating launcher script — pick the
robust one during the elevation spike.
4. Final logo asset (`oemlogo.bmp`, lock-screen, wallpaper) — placeholder void/cyan mark used
until brand identity is finalized (`shared/branding/README.md` is still "to be defined").
5. Separate follow-up: rename `SilverOS.*` app / namespace / install path to SilverMetal.

View File

@@ -0,0 +1,166 @@
# SilverOS Welcome — Role App Recipes
> **Status**: design — 2026-06-09. Approved in brainstorming. Adds a per-role app-install
> picker to the first-logon Welcome wizard. Builds on the wizard in
> `windows/welcome/` and the flavour model in `windows/flavours/`.
## 1. Goal
Make the wizard's role/flavour selection *do something*: after choosing a role, the user
sees a grouped, pre-checked list of apps to install during onboarding — role-relevant tools
plus privacy-trimmed essentials — and the chosen apps are installed as part of Apply.
## 2. Decisions (locked in brainstorming)
- **Install engine: winget, phased.** v1 uses winget (`winget install --id <id> --silent`).
IoT Enterprise LTSC ships *without* winget, so the engine bootstraps the App Installer at
apply time. Every catalog app has a winget id. The catalog's per-app `source` object is
**swappable** to a curated SilverMetal mirror later (the air-gap/privacy end-state) without
changing the UI or engine contract.
- **Stack stays auto-installed.** The SilverLABS Stack (`SilverBrowser`=ungoogled-chromium
rebrand, `SilverVPN`, `SilverKeys`, …) continues to install automatically via each flavour's
`appSet` and is **not** in the picker (shown as "included"). The picker adds **role apps**
plus **opt-in privacy-trimmed third-party** apps (incl. vanilla ungoogled-chromium and a
Thunderbird email option).
- **Per-role lists + Essentials defaults** as in §4 (operator-approved; editable in the JSON).
- **WDAC caveat**: Developer/Daily-Driver run app-control in *audit* (apps run); Privacy-Max/
Journalist run *enforce* (third-party apps blocked until allow-listed). v1 installs anyway and
surfaces a clear note for enforce-mode roles; full WDAC allow-listing is a follow-up.
## 3. Architecture (four small units)
```
windows/apps/catalog.json # the app catalog (staged into the image like flavours/)
SilverOS.Welcome.Core/Apps/
AppCatalog.cs # record + loader (mirrors FlavourManifest)
AppCatalogEntry.cs
IAppInstaller.cs / AppInstaller.cs # winget bootstrap + per-app install + configure
SilverOS.Welcome.UI/Components/Steps/
AppsStep.razor # new wizard step: grouped checkboxes
WizardState.cs # + SelectedApps (ids)
ApplyService.cs # calls IAppInstaller after the Stack/hardening
```
### 3a. Catalog schema (`catalog.json`)
```json
{
"schemaVersion": 1,
"apps": [
{
"id": "vscodium",
"name": "VSCodium",
"description": "Telemetry-free VS Code build.",
"source": { "winget": "VSCodium.VSCodium" },
"group": "developer",
"roles": ["developer"],
"defaultFor": ["developer"],
"configure": null
},
{
"id": "ungoogled-chromium",
"name": "ungoogled-chromium",
"description": "Chromium with Google integration stripped.",
"source": { "winget": "eloston.ungoogled-chromium" },
"group": "essentials",
"roles": ["essentials"],
"defaultFor": [],
"configure": "ungoogled-chromium.ps1"
}
]
}
```
- `roles`: which roles are *offered* this app (`essentials` = all roles).
- `defaultFor`: roles where the checkbox is **pre-checked**.
- `source.winget`: the winget id (only `source` key in v1; a future `mirror` key is additive).
- `configure`: optional post-install script (relative to `windows/apps/configure/`), e.g. the
ungoogled-chromium policy that enables the Chrome Web Store + sets safe search/suggestions.
### 3b. AppCatalog loader
Mirrors `FlavourManifest`/`IFlavourLoader`: `AppCatalogEntry` record + `AppCatalog.Load(dir)`
reading `catalog.json` with the same `JsonSerializerOptions` (case-insensitive, comments,
trailing commas). A pure function `AppsForRole(role)` returns the entries to display grouped,
and `DefaultSelectionForRole(role)` returns the pre-checked ids.
### 3c. AppsStep (new wizard step, after Flavour)
- Renders **Essentials** group first, then the chosen role's group; each app a checkbox with
name + description; pre-checked from `DefaultSelectionForRole`.
- Writes the set of selected ids into `WizardState.SelectedApps`.
- Always valid (zero apps is allowed) — Next is never blocked. Notifies the host like the other
steps (so Next state is correct immediately — same `OnSelected`/`StateHasChanged` pattern as
the FlavourStep fix).
- `Routes.razor` gains a step between Flavour (1) and Account; step indices/titles shift by one.
### 3d. AppInstaller (Apply-step engine)
`IAppInstaller.InstallAsync(IReadOnlyList<AppCatalogEntry> apps, IProgress<ApplyProgress>, ct)`:
1. **Bootstrap winget** if absent: install `Microsoft.DesktopAppInstaller` + deps
(`Microsoft.VCLibs…`, `Microsoft.UI.Xaml…`) via `Add-AppxProvisionedPackage`/staged msix.
2. **Per app**: `winget install --id <source.winget> --silent --accept-package-agreements
--accept-source-agreements`; capture exit code; **continue on failure** (a bad app must not
fail onboarding); run `configure` script if present and the install succeeded.
3. Return a per-app result list (id → installed/failed); ApplyService stows a summary for the
Done step ("Installed N of M apps; failed: …").
Runs **after** hardening + Stack + accounts, **before** BitLocker (so encryption is last), via a
new `progress.Report(new("Installing apps", …))` stage.
## 4. Catalog contents (v1 — editable)
| Group | App | winget id | default-checked for |
|---|---|---|---|
| essentials | Thunderbird | `Mozilla.Thunderbird` | all |
| essentials | VLC | `VideoLAN.VLC` | all |
| essentials | 7-Zip | `7zip.7zip` | all |
| essentials | LibreOffice | `TheDocumentFoundation.LibreOffice` | all |
| essentials | ungoogled-chromium | `eloston.ungoogled-chromium` | — (opt-in; configured) |
| essentials | KeePassXC | `KeePassXCTeam.KeePassXC` | — |
| developer | VSCodium | `VSCodium.VSCodium` | developer |
| developer | Git | `Git.Git` | developer |
| developer | .NET 9 SDK | `Microsoft.DotNet.SDK.9` | developer |
| developer | Node.js LTS | `OpenJS.NodeJS.LTS` | developer |
| developer | Windows Terminal | `Microsoft.WindowsTerminal` | developer |
| developer | PowerShell 7 | `Microsoft.PowerShell` | developer |
| developer | Claude Desktop | `Anthropic.Claude` | developer |
| developer | Visual Studio 2022 | `Microsoft.VisualStudio.2022.Community` | — |
| developer | JetBrains Rider | `JetBrains.Rider` | — |
| developer | Docker Desktop | `Docker.DockerDesktop` | — |
| developer | Claude Code (CLI) | *(npm `@anthropic-ai/claude-code`, needs Node)* | — |
| developer | Google Chrome | `Google.Chrome` | — |
| developer | PostgreSQL | `PostgreSQL.PostgreSQL` | — |
| developer | Bruno (API client) | `Bruno.Bruno` | — |
| journalist | VeraCrypt | `IDRIX.VeraCrypt` | journalist |
| journalist | KeePassXC | `KeePassXCTeam.KeePassXC` | journalist |
| journalist | Joplin | `Joplin.Joplin` | journalist |
| journalist | OBS Studio | `OBSProject.OBSStudio` | — |
| journalist | Standard Notes | `StandardNotes.StandardNotes` | — |
| journalist | Signal | `OpenWhisperSystems.Signal` | — |
| journalist | Tor Browser | `TorProject.TorBrowser` | — |
| daily-driver | Spotify | `Spotify.Spotify` | — |
| daily-driver | Zoom | `Zoom.Zoom` | — |
| daily-driver | Discord | `Discord.Discord` | — |
| privacy-max | VeraCrypt | `IDRIX.VeraCrypt` | — |
> Claude Code (CLI) installs via npm, not winget — modelled with a `source.npm` variant the
> engine handles separately (and only if Node is selected/present). Listed but lower priority.
## 5. Build wiring
- `build.ps1`: stage `windows/apps/` (catalog.json + configure/) into the image
(`C:\Program Files\SilverOS\Welcome\apps\` next to the flavours), same as flavours.
- The Welcome app reads the catalog from `AppContext.BaseDirectory\apps\catalog.json`.
## 6. Error handling
- Catalog missing/!parse → the Apps step shows an empty/"no extra apps" state and onboarding
continues (never blocks).
- winget bootstrap fails (offline) → log it, skip the install stage with a Done-step note;
onboarding still completes.
- Per-app install failure → recorded, surfaced in the Done summary, never throws.
## 7. Testing
- `AppCatalog` deserialization + `AppsForRole`/`DefaultSelectionForRole` unit tests (xUnit).
- `AppInstaller` against a fake `IProcessRunner`: asserts winget-bootstrap when absent, the exact
`winget install` invocation per selected app, continue-on-failure, and configure-script run.
- `AppsStep` selection/validity (bUnit, matching the existing step tests' style).
## 8. Out of scope (follow-ups)
- Curated SilverMetal mirror (the `source.mirror` end-state) + signing for WDAC-enforce.
- WDAC allow-listing of installed apps for Privacy-Max/Journalist enforce mode.
- Per-app version pinning / update policy.

View File

@@ -0,0 +1,223 @@
# SilverMetal WinPE Pre-Config Collector
> **Status**: design — 2026-06-10. Approved in brainstorming. Adds a branded WinPE
> collector that runs *before* Windows Setup, captures identity + install-shaping
> choices, generates the answer file so Setup creates the real account natively
> (eliminating the `sm-bootstrap` account + its teardown), and hands the rest to a
> simplified run-once-then-persist first-boot toolbox (the existing MAUI Welcome app,
> trimmed). Builds on `windows/installer/build.ps1`, `windows/installer/autounattend/`,
> and `windows/welcome/` (the MAUI wizard).
## 1. Goal
Let the user pre-configure the installation **before** the unattended installer runs.
A WinPE collector gathers **identity + install-shaping** choices, writes them into a
generated answer file (native account, computer name, locale, auto-logon) plus a
carried-forward config file (flavour, BitLocker PIN, app defaults). Windows Setup then
creates the real local administrator itself — so the first-boot app no longer creates
an account or removes a bootstrap user, and becomes a **config/toolbox**: it applies the
remaining post-install config once (apps, BitLocker), shows the recovery key, then
persists as a launchable tool.
## 2. Decisions (locked in brainstorming)
- **Collector scope = identity + install shaping.** It owns: the user **account**
(display name, username, password), **computer name**, **locale/keyboard**,
**flavour**, and the **BitLocker** choice + PIN. The toolbox owns **apps and ongoing
config**; hardening runs headless via `SetupComplete.cmd` (see the last bullet).
- **Collector UI = PowerShell + WinForms.** WinPE cannot run the MAUI/WebView2 app (no
Edge/Chromium, no modern .NET), so the collector is a separate, branded full-screen
WinForms form. `WinPE-NetFx` + `WinPE-PowerShell` (and dependencies) are added to
`boot.wim`.
- **Single real admin account.** One local **Administrators** account that the user
defines — replacing both the ephemeral `sm-bootstrap` *and* today's "daily user +
SilverOS Admin" split. (The two-account split can return later as a flavour option;
out of scope here.)
- **Toolbox = run-once apply, then persist.** On first logon (as the real user) it
auto-runs once, applies the collected config (apps, BitLocker PIN, flavour config),
shows progress + the BitLocker recovery key, reaches Done — then stays installed as a
launchable "SilverMetal" app. The heavy kiosk lockdown (Shell Launcher / Keyboard
Filter / `sm-bootstrap` teardown) is dropped.
- **Handoff = generated answer file + embedded config (Approach 1).** Setup keeps owning
the disk. The collector generates the answer file and embeds the non-OS config as
base64 that a `specialize`-pass command writes to `C:` once it exists.
- **Hardening is canonical in SetupComplete.** Hardening runs headless from
`SetupComplete.cmd` as SYSTEM (as the answer file already intends). The toolbox Apply
focuses on **apps + BitLocker** — no duplicate hardening — so hardening is guaranteed
even if the toolbox is closed. (A read-only hardening-status view in the toolbox is SP2.)
## 3. Architecture / flow
```
Boot ISO -> WinPE (boot.wim)
winpeshl.ini -> SilverMetal Collector (PowerShell + WinForms)
collects: account . computer name . locale . flavour . BitLocker PIN
-> New-SmAnswerFile -> X:\sm\unattend.generated.xml (embeds preconfig.json base64)
-> setup.exe /unattend:X:\sm\unattend.generated.xml
Windows Setup:
windowsPE pass : wipe disk0 (WillWipeDisk) -> install LTSC index 1 -> locale
specialize pass: decode embedded preconfig.json -> C:\ProgramData\SilverMetal\preconfig.json
oobeSystem pass: create REAL local admin -> AutoLogon(once) -> ComputerName
SetupComplete.cmd (SYSTEM): hardening (canonical) + scrub C:\Windows\Panther\unattend.xml
First logon (real user, auto, once):
Toolbox run-once -> read preconfig -> install apps + enrol BitLocker
-> show recovery key -> Done -> clear PIN -> set "configured" marker
Subsequent launches -> toolbox-home mode
```
## 4. Components
### 4a. WinPE collector (`windows/collector/`)
- **Launch**: `boot.wim` `winpeshl.ini` runs `X:\sm\Start-Collector.cmd` (instead of the
default Setup shell), which calls `powershell -ExecutionPolicy Bypass -File X:\sm\Collector.ps1`.
- **UI** (`Collector.ps1`): a branded full-screen WinForms form (dark theme + SilverMetal
logo) with fields:
- Account: display name, username, password + confirm.
- Computer name.
- Locale / keyboard (defaults to today's en-GB / `0809:00000809`).
- Flavour (the existing flavours, read from a small bundled list).
- BitLocker: enable toggle + PIN + confirm (or skip).
- A **"Use defaults"** fast path that fills sensible defaults so a user can click through.
- **Validation** (`Test-SmInput.ps1`, separated from the WinForms shell so it is
unit-testable headless): username rules (non-empty, valid local-account chars, not a
reserved name), password present + confirm-match (+ minimum length), PIN numeric +
length (>= 6) + confirm-match when BitLocker enabled, computer-name rules
(<= 15 chars, valid NetBIOS charset).
- **Output**: calls `New-SmAnswerFile` then launches Setup with the generated file.
### 4b. Answer-file generator (`windows/collector/New-SmAnswerFile.ps1`)
Pure function: collected values (a hashtable) -> answer XML string. Mirrors the current
`autounattend.xml` structure with these differences:
- **windowsPE**: keep `DiskConfiguration` (wipe disk 0, EFI/MSR/Primary) + `ImageInstall`
index 1; locale from the collector.
- **oobeSystem**: emit **one** `LocalAccount` in `Administrators` (the user's
account); `AutoLogon` Enabled with `LogonCount=1` as that user; `ComputerName`;
`Microsoft-Windows-International-Core` locale; the existing `OOBE` hide-pages block.
**No `sm-bootstrap`.**
- **specialize**: a `RunSynchronousCommand` that base64-decodes the embedded
`preconfig.json` to `C:\ProgramData\SilverMetal\preconfig.json` (creating the dir).
- **FirstLogonCommands**: launch the toolbox elevated (as today, but as the real user).
- The **scrub** runs from `SetupComplete.cmd` (SYSTEM, end of Setup — guaranteed after
account creation): it deletes `C:\Windows\Panther\unattend.xml` and the cached answer
copy.
### 4c. `preconfig.json` contract
Written to `C:\ProgramData\SilverMetal\preconfig.json`:
```json
{
"schemaVersion": 1,
"flavour": "developer",
"bitlocker": { "enable": true, "pin": "246810" },
"apps": { "useFlavourDefaults": true }
}
```
- `flavour`: the chosen flavour id (drives the toolbox's app defaults + flavour config).
- `bitlocker.pin`: consumed by the toolbox to enrol TPM+PIN, then **the field is cleared**
(rewritten without `pin`) once enrolment succeeds.
- `apps`: `useFlavourDefaults:true` (toolbox pre-checks the flavour defaults) or an
explicit `selected: ["id", ...]` list (future: collector app picking — not in SP1; SP1
always emits `useFlavourDefaults:true`).
### 4d. Simplified first-boot toolbox (`windows/welcome/`, trimmed)
- **Remove**: the Account step + `AccountService` account creation; `BootstrapService`
`sm-bootstrap` teardown; the heavy kiosk lockdown (`Configure-Kiosk.ps1` Keyboard
Filter / DisableTaskMgr / Shell Launcher path). Branding stays (baked offline; online
re-apply unaffected).
- **Add**: a `PreconfigLoader` that reads `C:\ProgramData\SilverMetal\preconfig.json` and
pre-seeds `WizardState` (flavour, app selection = flavour defaults, BitLocker PIN). On
**first run** (no "configured" marker) the toolbox auto-applies: apps install +
BitLocker enrol (using the preconfig PIN) + recovery-key display + Done; then it clears
the PIN from preconfig and writes a `configured` marker
(`C:\ProgramData\SilverMetal\configured`).
- **Apply pipeline** (trimmed): `apps -> bitlocker -> done`. No account step, no
hardening (SetupComplete owns it), no teardown. Idempotent / re-runnable.
- **Persist**: the app stays installed with a Start-menu shortcut "SilverMetal".
Subsequent launches (marker present) open **toolbox-home** (a simple landing page; rich
ongoing-config surfaces are SP2).
- **Run mode selection**: marker present -> toolbox-home; marker absent + preconfig
present -> first-run auto-apply; neither -> toolbox-home with flavour defaults
(fail-open).
### 4e. `build.ps1` changes
- **Stage 2b (boot.wim servicing)**: in addition to forcing legacy Setup, add the WinPE
optional components (`WinPE-NetFx`, `WinPE-PowerShell`, and their deps `WinPE-WMI`,
`WinPE-Scripting`) via `Add-WindowsPackage`; copy `windows/collector/*` to the mounted
boot.wim at `\sm\`; write `winpeshl.ini` to launch `\sm\Start-Collector.cmd`.
- **Fallback**: keep the static `autounattend.xml` on the ISO as the **default** answer
file. The collector uses it as its template and as the fallback if cancelled. An env/marker
`SM_UNATTENDED=1` (or a build flag) makes `Start-Collector.cmd` skip the UI and launch
Setup with the static default — so **CI ISO builds remain non-interactive**.
## 5. Data flow / handoff
Single source of truth = the collector's captured values. From them:
- **OS-native fields** (account, computer name, locale, auto-logon) -> the generated
answer file -> applied by Setup.
- **Non-OS config** (flavour, BitLocker PIN, app defaults) -> base64-embedded in the
answer file -> written to `C:\ProgramData\SilverMetal\preconfig.json` in the
`specialize` pass -> read by the toolbox.
No extra partition; Setup still owns disk partitioning (kept simplest + safest).
## 6. Error handling (must never brick boot)
- **Collector cancelled / crashes** -> launch Setup with the bundled **default** answer
file (sensible defaults) so install still proceeds. The collector wraps its UI in
try/catch and always has a path to Setup.
- **Generated XML fails validation** (schema/parse check before launch) -> use the
default answer file.
- **`preconfig.json` missing / corrupt** at first boot -> toolbox proceeds with
**flavour defaults** (fail-open, mirroring the app-catalog loader).
- **winget unavailable** -> already handled (graceful skip; PR #19).
- **BitLocker enrol fails** -> toolbox surfaces it but still reaches Done (apps/onboarding
not blocked); PIN retained in preconfig only on failure so a re-run can retry.
## 7. Security
- **Local account only** (no Microsoft account / no cloud key escrow) — unchanged.
- **Account password** transits the generated answer file briefly; `SetupComplete.cmd`
**scrubs `C:\Windows\Panther\unattend.xml`** (and the cached copy) after account
creation.
- **BitLocker PIN** transits `preconfig.json`; the toolbox **clears it after enrolment**.
- **Residual (documented):** plaintext secrets exist transiently in WinPE memory and on
the freshly-formatted disk until the scrub/clear runs. Acceptable for a local,
operator-present install; a future hardening pass could DPAPI-wrap the PIN or prompt it
interactively at first logon instead.
## 8. Testing
- **Pester — `New-SmAnswerFile`**: given inputs, the XML contains the real
`LocalAccount` in Administrators, `AutoLogon` once as that user, the `ComputerName`,
the locale, the `specialize` base64-write command, and the Panther scrub; and contains
**no `sm-bootstrap`**. Base64 round-trips back to the original `preconfig.json`.
- **Pester — `Test-SmInput`**: username/password/PIN/computer-name validation rules
(valid + each invalid case).
- **xUnit — toolbox `PreconfigLoader`**: parses a sample, fails open to flavour defaults
on missing/corrupt, clears the PIN after a simulated enrol, honours the `configured`
marker for run-mode selection.
- **`Assert-IsoStructure.ps1`**: boot.wim contains `winpeshl.ini` + `\sm\Collector.ps1`;
WinPE-NetFx present (e.g. `Get-WindowsPackage` shows the package or a marker file); the
toolbox payload no longer references `sm-bootstrap`.
- **VM e2e**: collector form renders in WinPE -> fill in account/flavour/PIN -> install ->
first logon auto-applies (apps skipped if no network, BitLocker per env) -> recovery key
shown -> Done -> relaunch shows toolbox-home.
## 9. Scope / phasing
- **SP1 (this spec):** WinPE collector (account, flavour, computer name, locale, BitLocker
PIN) + answer-file generation + `preconfig.json` handoff + simplified run-once-then-persist
toolbox; remove `sm-bootstrap` + account step + heavy kiosk; hardening canonical in
SetupComplete.
- **SP2 (later):** rich toolbox-home — ongoing config surfaces (re-run/extend apps, change
settings, view hardening status).
- **SP3 (later):** install-time disk target / partitioning + BitLocker pre-provisioning on
the blank drive (the deferred "maximal" collector scope).
## 10. Out of scope (SP1)
- App selection inside the collector (SP1 emits `useFlavourDefaults:true`; the toolbox
remains the place to pick apps).
- The two-account (daily + admin) split — SP1 is single admin.
- Disk target / partition UI; BitLocker pre-provision in WinPE.
- DPAPI-wrapping or interactive-at-first-logon secret handling (noted as a future
hardening option in §7).

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

@@ -33,6 +33,6 @@ New-Item $ki -Force | Out-Null
Set-ItemProperty $ki -Name DeviceEnumerationPolicy -Type DWord -Value 0 # block until authorized Set-ItemProperty $ki -Name DeviceEnumerationPolicy -Type DWord -Value 0 # block until authorized
# TODO-M1: confirm msinfo32 reports VBS=Running + Credential Guard + HVCI after reboot; # TODO-M1: confirm msinfo32 reports VBS=Running + Credential Guard + HVCI after reboot;
# confirm whether Kernel DMA Protection shows On (IVRS bit) open question §8. # confirm whether Kernel DMA Protection shows On (IVRS bit) -- open question §8.
Write-Host ' [D] policy set (VBS/HVCI/CredGuard/LSA-PPL/DMA). Effective after reboot.' Write-Host ' [D] policy set (VBS/HVCI/CredGuard/LSA-PPL/DMA). Effective after reboot.'

View File

@@ -101,8 +101,8 @@
</LocalAccounts> </LocalAccounts>
</UserAccounts> </UserAccounts>
<!-- <!--
AutoLogon: logs in as sm-bootstrap exactly once so that FirstLogonCommands AutoLogon: logs in as sm-bootstrap exactly once so FirstLogonCommands can
can launch the Welcome wizard. After the wizard completes successfully, launch the Welcome wizard. After the wizard completes successfully,
ApplyService removes the AutoAdminLogon registry values and deletes ApplyService removes the AutoAdminLogon registry values and deletes
sm-bootstrap, so the one-time session cannot be re-entered. sm-bootstrap, so the one-time session cannot be re-entered.
--> -->
@@ -113,16 +113,24 @@
<Password><Value>bootstrap-OneTime!</Value><PlainText>true</PlainText></Password> <Password><Value>bootstrap-OneTime!</Value><PlainText>true</PlainText></Password>
</AutoLogon> </AutoLogon>
<!-- <!--
FirstLogonCommands: launch the Welcome wizard ELEVATED (full admin token). Launch the Welcome wizard ELEVATED over the (locked-down) Explorer session.
The offline UAC auto-approve policy baked into the image (ConsentPromptBehaviorAdmin=0, Explorer stays the shell so the MAUI/WebView2 wizard renders (it does NOT
PromptOnSecureDesktop=0) means Start-Process -Verb RunAs silently elevates without render when launched as a bare Shell Launcher shell). Configure-Kiosk.ps1
a UAC prompt during this ephemeral sm-bootstrap session. The sm-bootstrap account bakes the silent-elevation UAC policy + the lockdown (Keyboard Filter,
is torn down by ApplyService on wizard completion. 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> <FirstLogonCommands>
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"> <SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<Order>1</Order> <Order>1</Order>
<CommandLine>cmd /c powershell -NoProfile -ExecutionPolicy Bypass -Command &quot;Start-Process -FilePath 'C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe' -Verb RunAs&quot;</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> <Description>Launch SilverOS Welcome elevated</Description>
</SynchronousCommand> </SynchronousCommand>
</FirstLogonCommands> </FirstLogonCommands>

View File

@@ -53,6 +53,23 @@ $m = Get-Content $Manifest -Raw | ConvertFrom-Json
$isoRoot = Join-Path $WorkDir 'iso' # writable copy of ISO contents $isoRoot = Join-Path $WorkDir 'iso' # writable copy of ISO contents
$mount = Join-Path $WorkDir 'mount' # install.wim mount point $mount = Join-Path $WorkDir 'mount' # install.wim mount point
$bootmnt = Join-Path $WorkDir 'bootmnt' # boot.wim mount point $bootmnt = Join-Path $WorkDir 'bootmnt' # boot.wim mount point
# --- 0. Discard stale state from a prior interrupted build -----------------
# An aborted run can leave a DISM image mounted (locking install.wim/boot.wim)
# or registry hives loaded, which breaks the Stage 2 extract clean-up with
# "the process cannot access the file ... because it is being used by another
# process". Discard anything of ours before (re)creating the work dirs. Match
# by 'silvermetal' so orphans from any prior WorkDir are cleaned too.
Write-Stage 'Stage 0: discard stale SilverMetal image mounts / hives from prior runs'
Get-WindowsImage -Mounted -ErrorAction SilentlyContinue |
Where-Object { $_.ImagePath -match 'silvermetal' -or $_.MountPath -match 'silvermetal' } |
ForEach-Object {
Write-Host " discarding stale mount: $($_.MountPath)"
Dismount-WindowsImage -Path $_.MountPath -Discard -ErrorAction SilentlyContinue | Out-Null
}
foreach ($h in 'SM_BRAND_SW','SM_BRAND_DU','SM_OFFLINE','SM_BOOT') { & reg unload "HKLM\$h" 2>$null | Out-Null }
if (Test-Path $WorkDir) { Remove-Item $WorkDir -Recurse -Force -ErrorAction SilentlyContinue }
$null = New-Item -ItemType Directory -Force -Path $WorkDir,$mount,$bootmnt,(Split-Path $OutputIso) $null = New-Item -ItemType Directory -Force -Path $WorkDir,$mount,$bootmnt,(Split-Path $OutputIso)
# --- 1. Verify input ------------------------------------------------------- # --- 1. Verify input -------------------------------------------------------
@@ -101,8 +118,25 @@ function Invoke-ForceLegacySetup {
# unreliable when setup is launched via the CmdLine override (legacy Setup # unreliable when setup is launched via the CmdLine override (legacy Setup
# otherwise still shows the language page). # otherwise still shows the language page).
Copy-Item (Join-Path $PSScriptRoot 'autounattend\autounattend.xml') (Join-Path $bootmnt 'autounattend.xml') -Force 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' } # Add WinPE .NET + PowerShell so the collector (WinForms) can run in WinPE.
$cmdline = "$setup /unattend:X:\autounattend.xml" $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' $hive = Join-Path $bootmnt 'Windows\System32\config\SYSTEM'
& reg load 'HKLM\SM_BOOT' $hive | Out-Null & reg load 'HKLM\SM_BOOT' $hive | Out-Null
try { try {
@@ -126,6 +160,12 @@ function Invoke-PublishWelcome {
Write-Stage 'Stage 3b: publish SilverOS Welcome app (win-x64 self-contained)' Write-Stage 'Stage 3b: publish SilverOS Welcome app (win-x64 self-contained)'
$proj = Join-Path $WindowsDir 'welcome\src\SilverOS.Welcome.App' $proj = Join-Path $WindowsDir 'welcome\src\SilverOS.Welcome.App'
$out = Join-Path $WorkDir 'welcome-publish' $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 & 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' } if ($LASTEXITCODE -ne 0) { throw 'Welcome app dotnet publish failed' }
Write-Host " Published to: $out" Write-Host " Published to: $out"
@@ -155,6 +195,33 @@ function Copy-WelcomePayload {
} else { } else {
Write-Warning " No *.json flavour files found in $flavoursDir -- image will ship with no flavours." Write-Warning " No *.json flavour files found in $flavoursDir -- image will ship with no flavours."
} }
# Stage the app catalog + configure/bootstrap scripts next to the Welcome app
# (mirrors the flavours copy above): catalog.json, configure\*.ps1, bootstrap-winget.ps1.
$appsDest = Join-Path $dest 'apps'
$null = New-Item -ItemType Directory -Force $appsDest
$appsDir = Join-Path $WindowsDir 'apps'
if (Test-Path $appsDir) {
Copy-Item (Join-Path $appsDir '*') $appsDest -Recurse -Force
Write-Host " Copied app catalog + scripts to $appsDest"
} else {
Write-Warning " No apps dir found at $appsDir -- image will ship with no app catalog."
}
# Stage the fixed-version WebView2 runtime, if vendored, next to the app.
# Cold-start + air-gap: a fixed-version runtime is just files (no installer
# step at first boot) and removes the dependency on whether IoT Enterprise LTSC
# ships WebView2 at all. Operator populates windows\welcome\runtime\webview2\
# with an EXTRACTED "Microsoft Edge WebView2 Fixed Version" distribution (the
# folder that contains msedgewebview2.exe) -- handled like the drivers dir:
# absent is allowed (VM/dev test), in which case the app falls back to Evergreen.
$wv2Src = Join-Path $WindowsDir 'welcome\runtime\webview2'
if (Test-Path (Join-Path $wv2Src 'msedgewebview2.exe')) {
$wv2Dest = Join-Path $dest 'webview2'
$null = New-Item -ItemType Directory -Force $wv2Dest
Copy-Item (Join-Path $wv2Src '*') $wv2Dest -Recurse -Force
Write-Host " Staged fixed-version WebView2 runtime to $wv2Dest"
} else {
Write-Warning " No fixed-version WebView2 runtime at $wv2Src (expected msedgewebview2.exe) -- image will rely on the Evergreen runtime being present at first boot. See windows\welcome\runtime\webview2\README.md."
}
# --- Guard: verify the payload actually landed in the mounted image ------- # --- Guard: verify the payload actually landed in the mounted image -------
$stagedExe = Join-Path $dest 'SilverOS.Welcome.App.exe' $stagedExe = Join-Path $dest 'SilverOS.Welcome.App.exe'
if (-not (Test-Path $stagedExe)) { if (-not (Test-Path $stagedExe)) {
@@ -164,6 +231,10 @@ function Copy-WelcomePayload {
if (-not $stagedFlavours) { if (-not $stagedFlavours) {
throw "Welcome bake failed: no flavour manifests staged in '$destFlavours'. Add *.json files under windows/flavours/ or the installed wizard will have no flavour choices." throw "Welcome bake failed: no flavour manifests staged in '$destFlavours'. Add *.json files under windows/flavours/ or the installed wizard will have no flavour choices."
} }
$stagedCatalog = Join-Path $appsDest 'catalog.json'
if (-not (Test-Path $stagedCatalog)) {
throw "Welcome bake failed: app catalog.json missing from image (expected at '$stagedCatalog'). Add windows/apps/catalog.json or the wizard's Apps step will be empty."
}
Write-Host " Welcome payload staged at $dest" Write-Host " Welcome payload staged at $dest"
} }
@@ -189,9 +260,22 @@ function Invoke-ServiceWim {
# Drivers (GPD Pocket 4 pack) -- skipped silently if dir empty (e.g. VM test). # Drivers (GPD Pocket 4 pack) -- skipped silently if dir empty (e.g. VM test).
$drv = Join-Path $WindowsDir 'drivers' $drv = Join-Path $WindowsDir 'drivers'
if ((Get-ChildItem $drv -Recurse -Filter *.inf -EA SilentlyContinue)) { 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)' } } else { Write-Host ' no .inf drivers staged (ok for VM test)' }
# Kiosk features (Shell Launcher v2 + Keyboard Filter) — IoT Enterprise LTSC.
if ($env:SILVERMETAL_WELCOME_ENABLED -ne '0') {
Write-Host ' enabling Shell Launcher + Keyboard Filter features'
Enable-WindowsOptionalFeature -Path $mount -FeatureName Client-EmbeddedShellLauncher -All -NoRestart | Out-Null
Enable-WindowsOptionalFeature -Path $mount -FeatureName Client-KeyboardFilter -All -NoRestart | Out-Null
}
# Debloat: remove provisioned appx listed in debloat/appx-remove.txt (best-effort). # Debloat: remove provisioned appx listed in debloat/appx-remove.txt (best-effort).
$list = Join-Path $WindowsDir 'debloat\appx-remove.txt' $list = Join-Path $WindowsDir 'debloat\appx-remove.txt'
if (Test-Path $list) { if (Test-Path $list) {
@@ -208,18 +292,30 @@ function Invoke-ServiceWim {
$scripts = Join-Path $mount 'Windows\Setup\Scripts' $scripts = Join-Path $mount 'Windows\Setup\Scripts'
$null = New-Item -ItemType Directory -Force $scripts, (Join-Path $scripts 'hardening') $null = New-Item -ItemType Directory -Force $scripts, (Join-Path $scripts 'hardening')
Copy-Item (Join-Path $PSScriptRoot 'oem\SetupComplete.cmd') $scripts -Force Copy-Item (Join-Path $PSScriptRoot 'oem\SetupComplete.cmd') $scripts -Force
Copy-Item (Join-Path $PSScriptRoot 'oem\Configure-Kiosk.ps1') $scripts -Force
Copy-Item (Join-Path $WindowsDir 'hardening\*') (Join-Path $scripts 'hardening') -Recurse -Force Copy-Item (Join-Path $WindowsDir 'hardening\*') (Join-Path $scripts 'hardening') -Recurse -Force
# Stage the branding module so SetupComplete.cmd can re-apply branding ONLINE
# (Windows resets the offline personalization bake during OOBE).
$brandDest = Join-Path $scripts 'branding'
$null = New-Item -ItemType Directory -Force $brandDest
Copy-Item (Join-Path $WindowsDir 'branding\*') $brandDest -Recurse -Force
# Stage Welcome app + flavours while the WIM is still mounted. # Stage Welcome app + flavours while the WIM is still mounted.
Copy-WelcomePayload Copy-WelcomePayload
# Bake the four branding layers into the offline hives (must be inside the mount).
Write-Stage 'Stage 3d: bake SilverMetal branding (OEM/lockscreen/desktop/bitlocker)'
& (Join-Path $WindowsDir 'branding\Apply-Branding.ps1') -Mode Offline -MountPath $mount
if ($LASTEXITCODE -ne 0) { throw 'branding apply failed' }
# Bake offline UAC auto-approve policy so the Welcome wizard (launched via # Bake offline UAC auto-approve policy so the Welcome wizard (launched via
# Start-Process -Verb RunAs in FirstLogonCommands) silently elevates during # Shell Launcher v2 (Configure-Kiosk.ps1) as the sm-bootstrap shell, which
# the ephemeral sm-bootstrap session without a UAC prompt. # elevates the app) silently elevates during the ephemeral sm-bootstrap
# session without a UAC prompt.
# UAC stays enabled (EnableLUA=1); the wizard's hardening re-tightens the # UAC stays enabled (EnableLUA=1); the wizard's hardening re-tightens the
# policy for the daily user. Only applies when Welcome is enabled. # policy for the daily user. Only applies when Welcome is enabled.
if ($env:SILVERMETAL_WELCOME_ENABLED -ne '0') { if ($env:SILVERMETAL_WELCOME_ENABLED -ne '0') {
Write-Stage 'Stage 3d: bake offline UAC auto-approve policy (silent elevation for sm-bootstrap)' Write-Stage 'Stage 3e: bake offline UAC auto-approve policy (silent elevation for sm-bootstrap)'
$hive = Join-Path $mount 'Windows\System32\config\SOFTWARE' $hive = Join-Path $mount 'Windows\System32\config\SOFTWARE'
& reg load HKLM\SM_OFFLINE "$hive" | Out-Null & reg load HKLM\SM_OFFLINE "$hive" | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'reg load SOFTWARE hive failed' } if ($LASTEXITCODE -ne 0) { throw 'reg load SOFTWARE hive failed' }
@@ -247,7 +343,13 @@ function Invoke-InjectUnattend {
} }
# --- 5. Brand -------------------------------------------------------------- # --- 5. Brand --------------------------------------------------------------
function Invoke-Brand { Write-Stage 'Stage 5: branding'; Write-Warning ' deferred to M4.' } # NOTE: branding edits the OFFLINE hives, so it must run while the WIM is still
# mounted. We therefore call it from inside Invoke-ServiceWim (see Step 2), and
# this stage just asserts the staged result for the SBOM/log.
function Invoke-Brand {
Write-Stage 'Stage 5: branding (applied during WIM servicing)'
Write-Host ' branding layers baked via branding\Apply-Branding.ps1 -Mode Offline'
}
# --- 6. Repack ------------------------------------------------------------- # --- 6. Repack -------------------------------------------------------------
function Invoke-Repack { function Invoke-Repack {

View File

@@ -0,0 +1,64 @@
#Requires -Version 5.1
<#
.SYNOPSIS Lock down the one-time sm-bootstrap onboarding session.
.DESCRIPTION
Runs from SetupComplete.cmd as SYSTEM, after accounts exist, before first logon.
Explorer stays the session shell so the MAUI/WebView2 Welcome wizard RENDERS
(it does not render when launched as a bare Shell Launcher shell with no
Explorer). The wizard is launched fullscreen-topmost by autounattend
FirstLogonCommands; this script applies the lockdown around it:
- Keyboard Filter: block Win/Start, lock, task-switch and Task-Manager hotkeys
- DisableTaskMgr / DisableLockWorkstation / HideFastUserSwitching
- silent-elevation UAC policy (so the unsigned wizard elevates with no prompt)
All reverted by the Welcome app's ApplyService on wizard success, so the real
end-user gets a normal, secure desktop.
#>
[CmdletBinding()]
param([string]$BootstrapUser='sm-bootstrap')
Set-StrictMode -Version Latest
$ErrorActionPreference='Stop'
$log='C:\Windows\Setup\Scripts\silvermetal-kiosk.log'
function Log($m){ "$(Get-Date -f s) $m" | Add-Content $log }
Log 'configuring onboarding lockdown (Explorer shell + policy)'
# --- Keyboard Filter: block shell/escape hotkeys for the locked-down session ---
Enable-WindowsOptionalFeature -Online -FeatureName Client-KeyboardFilter -NoRestart -ErrorAction SilentlyContinue | Out-Null
$kf='root\standardcimv2\embedded'
# CRITICAL: by default the Keyboard Filter EXEMPTS administrators, and sm-bootstrap is an
# admin -> Win/Start/etc. were NOT blocked. Turn that exemption off so the filter applies.
$adm=Get-CimInstance -Namespace $kf -ClassName WEKF_Settings -Filter "Name='DisableKeyboardFilterForAdministrators'" -ErrorAction SilentlyContinue
if($adm){ $adm.Value='false'; Set-CimInstance -InputObject $adm -ErrorAction SilentlyContinue }
foreach($combo in 'Win','Win+L','Ctrl+Esc','Ctrl+Win+F','Win+R','Alt+Tab','Ctrl+Shift+Esc','Alt+F4'){
$p=Get-CimInstance -Namespace $kf -ClassName WEKF_PredefinedKey -Filter "Id='$combo'" -ErrorAction SilentlyContinue
if($p){ $p.Enabled=$true; Set-CimInstance -InputObject $p -ErrorAction SilentlyContinue }
}
Log 'keyboard filter rules enabled (admins included)'
# --- Hide the taskbar for the locked-down session (auto-hide in the default-user hive,
# which the sm-bootstrap profile inherits). The fullscreen wizard covers it, but
# auto-hide stops it peeking. StuckRects3 byte 8: 0x03 = auto-hide on. ---
try {
& reg load 'HKLM\SM_DU_TB' 'C:\Users\Default\NTUSER.DAT' 2>$null | Out-Null
$sr='HKLM:\SM_DU_TB\Software\Microsoft\Windows\CurrentVersion\Explorer\StuckRects3'
New-Item $sr -Force | Out-Null
$bytes=[byte[]](0x30,0x00,0x00,0x00,0x28,0x00,0x00,0x00,0x03,0x00,0x00,0x00,0x30,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00)
Set-ItemProperty $sr -Name 'Settings' -Value $bytes -Type Binary
} catch {} finally { [gc]::Collect(); Start-Sleep -Milliseconds 300; & reg unload 'HKLM\SM_DU_TB' 2>$null | Out-Null }
Log 'taskbar auto-hide set for default user'
# --- escape policies (machine-wide; reverted at teardown) ---
$sys='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
New-Item $sys -Force | Out-Null
Set-ItemProperty $sys -Name DisableTaskMgr -Value 1 -Type DWord
Set-ItemProperty $sys -Name DisableLockWorkstation -Value 1 -Type DWord
Set-ItemProperty $sys -Name HideFastUserSwitching -Value 1 -Type DWord
# Silent elevation for the FirstLogonCommands 'Start-Process -Verb RunAs' launch:
# the offline-baked UAC auto-approve is RESET by Windows during OOBE, so re-assert
# it online here (before the autologon). Otherwise a UAC consent prompt appears for
# the unsigned Welcome app. Restored to SECURE UAC at teardown for the real user.
Set-ItemProperty $sys -Name ConsentPromptBehaviorAdmin -Value 0 -Type DWord
Set-ItemProperty $sys -Name PromptOnSecureDesktop -Value 0 -Type DWord
Log 'escape policies + UAC auto-approve set; lockdown ready'

View File

@@ -14,11 +14,29 @@ set HARD=C:\Windows\Setup\Scripts\hardening
echo [%DATE% %TIME%] SilverMetal first-boot start >> "%LOG%" echo [%DATE% %TIME%] SilverMetal first-boot start >> "%LOG%"
REM Re-apply branding ONLINE (lock screen / wallpaper / OEM / FVE). Windows resets
REM the offline-baked personalization during OOBE, so re-assert it here (post-OOBE,
REM as SYSTEM) where it sticks. Idempotent with the offline bake.
if exist "%~dp0branding\Apply-Branding.ps1" (
echo [%DATE% %TIME%] re-applying SilverMetal branding (online) >> "%LOG%"
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0branding\Apply-Branding.ps1" -Mode Online >> "%LOG%" 2>&1
)
if exist "C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe" (
echo [%DATE% %TIME%] configuring onboarding kiosk >> "%LOG%"
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Configure-Kiosk.ps1" >> "%LOG%" 2>&1
)
if exist "C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe" ( if exist "C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe" (
echo [%DATE% %TIME%] hardening deferred to SilverOS Welcome >> "%LOG%" echo [%DATE% %TIME%] hardening deferred to SilverOS Welcome >> "%LOG%"
) else ( ) else (
powershell -NoProfile -ExecutionPolicy Bypass -File "%HARD%\Invoke-Hardening.ps1" >> "%LOG%" 2>&1 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%" echo [%DATE% %TIME%] SilverMetal first-boot done >> "%LOG%"
exit /b 0 exit /b 0

View File

@@ -17,8 +17,16 @@ function Assert { param([string]$Name,[bool]$Cond)
if (-not (([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( if (-not (([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator))) { throw 'must run elevated (WIM mount).' } [Security.Principal.WindowsBuiltInRole]::Administrator))) { throw 'must run elevated (WIM mount).' }
# Discard any stale WIM mounts from a prior aborted run (CI reuses the runner) and
# use a fresh, unique mount dir — a leftover mount makes Mount-WindowsImage fail with
# "attempted to mount to a directory that is not empty." Mirrors build.ps1 Stage 0.
Get-WindowsImage -Mounted -EA SilentlyContinue | ForEach-Object {
Dismount-WindowsImage -Path $_.MountPath -Discard -EA SilentlyContinue | Out-Null
}
Clear-WindowsCorruptMountPoint -EA SilentlyContinue | Out-Null
$img = Mount-DiskImage -ImagePath $IsoPath -PassThru $img = Mount-DiskImage -ImagePath $IsoPath -PassThru
$mount = Join-Path $env:TEMP 'sm-assert-wim' $mount = Join-Path $env:TEMP ('sm-assert-wim-' + [guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Force $mount $null = New-Item -ItemType Directory -Force $mount
try { try {
$drive = ($img | Get-Volume).DriveLetter + ':' $drive = ($img | Get-Volume).DriveLetter + ':'
@@ -43,10 +51,29 @@ try {
Assert 'Welcome exe baked into WIM' (Test-Path $welcomeExe) Assert 'Welcome exe baked into WIM' (Test-Path $welcomeExe)
$welcomeFlavours = Get-ChildItem (Join-Path $mount 'Program Files\SilverOS\Welcome\flavours') -Filter '*.json' -EA SilentlyContinue $welcomeFlavours = Get-ChildItem (Join-Path $mount 'Program Files\SilverOS\Welcome\flavours') -Filter '*.json' -EA SilentlyContinue
Assert 'Welcome flavours baked (>=1 .json)' ($welcomeFlavours.Count -ge 1) Assert 'Welcome flavours baked (>=1 .json)' ($welcomeFlavours.Count -ge 1)
$welcomeCatalog = Join-Path $mount 'Program Files\SilverOS\Welcome\apps\catalog.json'
Assert 'Welcome app catalog baked' (Test-Path $welcomeCatalog)
} }
} finally { Dismount-WindowsImage -Path $mount -Discard | Out-Null } } finally { Dismount-WindowsImage -Path $mount -Discard | Out-Null }
} }
} finally { Dismount-DiskImage -ImagePath $IsoPath | Out-Null }
# boot.wim must carry the WinPE collector + winpeshl (the pre-config front-end).
$bootwim = "$drive\sources\boot.wim"
Assert 'boot.wim present' (Test-Path $bootwim)
if (Test-Path $bootwim) {
$bmount = Join-Path $env:TEMP ('sm-assert-boot-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force $bmount | Out-Null
Mount-WindowsImage -ImagePath $bootwim -Index 2 -Path $bmount -ReadOnly | Out-Null
try {
Assert 'collector staged in boot.wim' (Test-Path (Join-Path $bmount 'sm\Collector.ps1'))
Assert 'winpeshl.ini set' (Test-Path (Join-Path $bmount 'Windows\System32\winpeshl.ini'))
Assert 'answer-file generator staged' (Test-Path (Join-Path $bmount 'sm\New-SmAnswerFile.ps1'))
} finally { Dismount-WindowsImage -Path $bmount -Discard | Out-Null; Remove-Item $bmount -Recurse -Force -EA SilentlyContinue }
}
} finally {
Dismount-DiskImage -ImagePath $IsoPath | Out-Null
Remove-Item $mount -Recurse -Force -EA SilentlyContinue
}
Write-Host "`n$($fail) assertion(s) failed." Write-Host "`n$($fail) assertion(s) failed."
exit $fail exit $fail

View File

@@ -0,0 +1,88 @@
#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' }
Describe 'Set-SmRegValue' {
BeforeAll {
. "$PSScriptRoot\..\branding\lib\RegistryHelpers.ps1"
$script:root = 'HKCU:\Software\SilverMetalTest'
if (Test-Path $script:root) { Remove-Item $script:root -Recurse -Force }
}
AfterAll {
if (Test-Path $script:root) { Remove-Item $script:root -Recurse -Force }
}
It 'creates the key path and writes a string value' {
Set-SmRegValue -Root $script:root -SubKey 'A\B' -Name 'Greeting' -Type String -Value 'hi'
(Get-ItemProperty "$script:root\A\B").Greeting | Should -Be 'hi'
}
It 'writes a dword value' {
Set-SmRegValue -Root $script:root -SubKey 'A' -Name 'Flag' -Type DWord -Value 1
(Get-ItemProperty "$script:root\A").Flag | Should -Be 1
}
}
Describe 'Branding layer writers' {
BeforeAll {
. "$PSScriptRoot\..\branding\lib\BrandingLayers.ps1"
$script:sw = 'HKCU:\Software\SilverMetalTest\SW'
$script:du = 'HKCU:\Software\SilverMetalTest\DU'
$script:m = Get-Content "$PSScriptRoot\..\branding\branding.manifest.json" -Raw | ConvertFrom-Json
}
AfterAll {
Remove-Item 'HKCU:\Software\SilverMetalTest' -Recurse -Force -ErrorAction SilentlyContinue
}
It 'writes OEM About info' {
Set-OemInformation -SoftwareRoot $script:sw -Manifest $script:m -LogoPath 'C:\Windows\System32\oemlogo.bmp'
$k = Get-ItemProperty "$script:sw\Microsoft\Windows\CurrentVersion\OEMInformation"
$k.Manufacturer | Should -Be 'SilverLABS'
$k.Model | Should -Be 'SilverMetal Windows'
$k.SupportURL | Should -Be 'https://silverlabs.uk'
$k.SupportHours | Should -Be '24/7 community + paid SLA'
$k.Logo | Should -Be 'C:\Windows\System32\oemlogo.bmp'
}
It 'writes a locked lock-screen image' {
Set-LockScreen -SoftwareRoot $script:sw -ImagePath 'C:\Windows\Web\Screen\SilverMetal\lockscreen.jpg' -Lock $true
(Get-ItemProperty "$script:sw\Microsoft\Windows\CurrentVersion\PersonalizationCSP").LockScreenImageStatus | Should -Be 1
(Get-ItemProperty "$script:sw\Policies\Microsoft\Windows\Personalization").NoChangingLockScreen | Should -Be 1
}
It 'writes desktop wallpaper + dark mode into the default-user root' {
Set-DesktopBranding -DefaultUserRoot $script:du -Manifest $script:m -WallpaperPath 'C:\Windows\Web\Wallpaper\SilverMetal\wallpaper.jpg'
(Get-ItemProperty "$script:du\Control Panel\Desktop").WallPaper | Should -Be 'C:\Windows\Web\Wallpaper\SilverMetal\wallpaper.jpg'
(Get-ItemProperty "$script:du\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize").AppsUseLightTheme | Should -Be 0
}
It 'writes the BitLocker pre-boot recovery message policy' {
Set-BitLockerPreboot -SoftwareRoot $script:sw -Manifest $script:m
$k = Get-ItemProperty "$script:sw\Policies\Microsoft\FVE"
$k.UseCustomRecoveryMessage | Should -Be 1
$k.RecoveryMessage | Should -Be 'SilverMetal Windows. Locked out? silverlabs.uk'
$k.RecoveryUrl | Should -Be 'https://silverlabs.uk'
}
}
Describe 'Apply-Branding -Mode Offline' {
BeforeAll {
# Build a throwaway "mount" tree with empty SOFTWARE + default NTUSER hives.
$script:mount = Join-Path $env:TEMP ("sm-brandtest-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force "$script:mount\Windows\System32\config","$script:mount\Users\Default" | Out-Null
# Create two empty hive files by loading/unloading a fresh key.
foreach ($h in @("$script:mount\Windows\System32\config\SOFTWARE","$script:mount\Users\Default\NTUSER.DAT")) {
reg load 'HKLM\SM_SEED' (New-Item -ItemType File -Force $h | Select-Object -Expand FullName) 2>$null | Out-Null
}
& reg unload 'HKLM\SM_SEED' 2>$null | Out-Null
# The above seed is best-effort; if reg can't init an empty file, the apply
# script creates the hives via reg load of the path it expects.
}
AfterAll { Remove-Item $script:mount -Recurse -Force -ErrorAction SilentlyContinue }
It 'applies all layers into the offline SOFTWARE hive and reports success' {
$r = & "$PSScriptRoot\..\branding\Apply-Branding.ps1" -Mode Offline -MountPath $script:mount -PassThru
$r.OemApplied | Should -BeTrue
$r.LockScreenApplied | Should -BeTrue
$r.DesktopApplied | Should -BeTrue
$r.BitLockerApplied | Should -BeTrue
}
}

View File

@@ -0,0 +1,72 @@
#Requires -Version 5.1
BeforeAll {
. (Join-Path $PSScriptRoot '..\collector\Test-SmInput.ps1')
}
Describe 'Test-SmUsername' {
It 'accepts a simple username' { (Test-SmUsername 'jamie').Ok | Should -BeTrue }
It 'rejects empty' { (Test-SmUsername '').Ok | Should -BeFalse }
It 'rejects reserved name' { (Test-SmUsername 'Administrator').Ok | Should -BeFalse }
It 'rejects illegal chars' { (Test-SmUsername 'a\b').Ok | Should -BeFalse }
It 'rejects > 20 chars' { (Test-SmUsername ('x'*21)).Ok| Should -BeFalse }
}
Describe 'Test-SmPassword' {
It 'accepts matching 8+ char password' { (Test-SmPassword 'Sup3rPass!' 'Sup3rPass!').Ok | Should -BeTrue }
It 'rejects mismatch' { (Test-SmPassword 'a' 'b').Ok | Should -BeFalse }
It 'rejects < 8 chars' { (Test-SmPassword 'short' 'short').Ok | Should -BeFalse }
}
Describe 'Test-SmPin' {
It 'accepts 6-digit matching pin' { (Test-SmPin '246810' '246810').Ok | Should -BeTrue }
It 'rejects < 6 digits' { (Test-SmPin '123' '123').Ok | Should -BeFalse }
It 'rejects non-numeric' { (Test-SmPin 'abcdef' 'abcdef').Ok | Should -BeFalse }
It 'rejects mismatch' { (Test-SmPin '246810' '999999').Ok | Should -BeFalse }
}
Describe 'Test-SmComputerName' {
It 'accepts a valid name' { (Test-SmComputerName 'SILVER-01').Ok | Should -BeTrue }
It 'rejects empty' { (Test-SmComputerName '').Ok | Should -BeFalse }
It 'rejects > 15 chars' { (Test-SmComputerName ('A'*16)).Ok | Should -BeFalse }
It 'rejects illegal chars' { (Test-SmComputerName 'bad name').Ok | Should -BeFalse }
}
Describe 'New-SmAnswerFile' {
BeforeAll {
. (Join-Path $PSScriptRoot '..\collector\New-SmAnswerFile.ps1')
$cfg = @{
DisplayName = 'Jamie'; Username = 'jamie'; Password = 'Sup3rPass!'
ComputerName = 'SILVER-01'
InputLocale = '0809:00000809'; SystemLocale = 'en-GB'; UiLanguage = 'en-US'; UserLocale = 'en-GB'
Flavour = 'developer'; BitLockerEnable = $true; BitLockerPin = '246810'
}
$script:xml = New-SmAnswerFile @cfg
}
It 'is valid XML' { { [xml]$script:xml } | Should -Not -Throw }
It 'creates the real account in Administrators' {
$script:xml | Should -Match '<Name>jamie</Name>'
$script:xml | Should -Match '<Group>Administrators</Group>'
}
It 'does NOT contain sm-bootstrap' { $script:xml | Should -Not -Match 'sm-bootstrap' }
It 'sets AutoLogon once as the user' {
$script:xml | Should -Match '<LogonCount>1</LogonCount>'
$script:xml | Should -Match '<Username>jamie</Username>'
}
It 'sets the computer name' { $script:xml | Should -Match '<ComputerName>SILVER-01</ComputerName>' }
It 'keeps WillWipeDisk for disk 0' { $script:xml | Should -Match '<WillWipeDisk>true</WillWipeDisk>' }
It 'preconfig round-trips via chunked FirstLogonCommands' {
# Gather the echo'd base64 chunks in Order, concatenate, strip whitespace, decode.
$chunks = [regex]::Matches($script:xml, 'echo ([A-Za-z0-9+/=]+)') | ForEach-Object { $_.Groups[1].Value }
$b64 = ($chunks -join '')
$json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b64)) | ConvertFrom-Json
$json.flavour | Should -Be 'developer'
$json.bitlocker.pin | Should -Be '246810'
}
It 'has no specialize pass anymore' { $script:xml | Should -Not -Match 'pass="specialize"' }
It 'creates the preconfig dir + decodes it at first logon' {
$script:xml | Should -Match 'ProgramData\\SilverMetal'
$script:xml | Should -Match 'FromBase64String'
$script:xml | Should -Match 'preconfig\.json'
}
It 'launches the toolbox in FirstLogonCommands' { $script:xml | Should -Match 'SilverOS\.Welcome\.App\.exe' }
}

View File

@@ -12,3 +12,9 @@ Verification gates for a SilverMetal Enhanced — Windows build
The telemetry-leak test is the honesty gate: it documents the minimum-feasible The telemetry-leak test is the honesty gate: it documents the minimum-feasible
Microsoft contact that remains, per design-principle #2. Microsoft contact that remains, per design-principle #2.
## Branding.Tests.ps1
Pester v5 unit + offline-integration tests for `windows/branding/`.
**Requires an elevated shell** (the offline-integration test uses `reg load`).
Run: `pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1"`.

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

@@ -1,14 +1,42 @@
namespace SilverOS.Welcome.App; namespace SilverOS.Welcome.App;
public partial class App : Application public partial class App : Application
{ {
public App() public App()
{ {
Diag.Log("App ctor");
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
Diag.Log("UNHANDLED: " + (e.ExceptionObject as Exception)?.ToString());
TaskScheduler.UnobservedTaskException += (s, e) =>
{
Diag.Log("UNOBSERVED TASK: " + e.Exception);
e.SetObserved();
};
InitializeComponent(); InitializeComponent();
} }
protected override Window CreateWindow(IActivationState? activationState) protected override Window CreateWindow(IActivationState? activationState)
{ {
return new Window(new MainPage()) { Title = "SilverOS.Welcome.App" }; Diag.Log("CreateWindow");
var window = new Window(new MainPage()) { Title = "SilverMetal Windows" };
#if WINDOWS
// Apply kiosk chrome once the native window handler exists. Wrapped so a
// chrome failure can NEVER break app/WebView startup (a previous version
// threw here and left the wizard blank). HandlerChanged can fire before the
// HWND is ready, so ApplyKioskChrome null-guards and re-applies safely.
window.HandlerChanged += (s, e) =>
{
try
{
if (window.Handler?.PlatformView is Microsoft.UI.Xaml.Window native)
{
var ok = native.ApplyKioskChrome();
Diag.Log($"ApplyKioskChrome applied={ok}");
}
}
catch (Exception ex) { Diag.Log("ApplyKioskChrome FAILED: " + ex); }
};
#endif
return window;
} }
} }

View File

@@ -0,0 +1,24 @@
namespace SilverOS.Welcome.App;
// Lightweight always-on file logger for first-boot diagnosis. The Welcome app
// runs as the kiosk shell with no console and (in Release) no debugger, so when
// something fails silently (blank WebView, chrome not applied) there is nowhere
// to look. This writes to a world-writable ProgramData path that survives the
// session and can be read off the disk image.
public static class Diag
{
const string Dir = @"C:\ProgramData\SilverMetal";
const string Path = Dir + @"\welcome.log";
static readonly object _lock = new();
public static void Log(string msg)
{
try
{
System.IO.Directory.CreateDirectory(Dir);
lock (_lock)
System.IO.File.AppendAllText(Path, $"{DateTime.Now:HH:mm:ss.fff} {msg}{Environment.NewLine}");
}
catch { /* logging must never throw */ }
}
}

View File

@@ -1,14 +1,58 @@
<?xml version="1.0" encoding="utf-8" ?> <?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:SilverOS.Welcome.App" xmlns:local="clr-namespace:SilverOS.Welcome.App"
xmlns:components="clr-namespace:SilverOS.Welcome.App.Components;assembly=SilverOS.Welcome.UI" xmlns:components="clr-namespace:SilverOS.Welcome.App.Components;assembly=SilverOS.Welcome.UI"
x:Class="SilverOS.Welcome.App.MainPage"> x:Class="SilverOS.Welcome.App.MainPage">
<BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html"> <!--
<BlazorWebView.RootComponents> A native MAUI splash sits ON TOP of the BlazorWebView. MAUI controls render
<RootComponent Selector="#app" ComponentType="{x:Type components:Routes}" /> immediately when the window is shown — they do NOT wait on WebView2/.NET JIT —
</BlazorWebView.RootComponents> so the user sees branded "loading" within the first frame instead of a blank
</BlazorWebView> 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> </ContentPage>

View File

@@ -1,9 +1,62 @@
namespace SilverOS.Welcome.App; using Microsoft.AspNetCore.Components.WebView;
namespace SilverOS.Welcome.App;
public partial class MainPage : ContentPage public partial class MainPage : ContentPage
{ {
bool _splashDismissed;
public MainPage() public MainPage()
{ {
InitializeComponent(); InitializeComponent();
Diag.Log("MainPage ctor");
} }
// Fires once the platform WebView2 is created. If this never appears in the log,
// WebView2 environment creation failed (the real cause of a blank wizard).
void OnBlazorInitialized(object? sender, BlazorWebViewInitializedEventArgs e)
{
Diag.Log("BlazorWebViewInitialized");
#if WINDOWS
try
{
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);
}
catch (Exception ex) { Diag.Log("WV2 hook failed: " + ex.Message); }
#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

@@ -1,6 +1,8 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SilverOS.Welcome.Core.Apply; using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Flavours; using SilverOS.Welcome.Core.Flavours;
using SilverOS.Welcome.Core.Preconfig;
using SilverOS.Welcome.App.Components; using SilverOS.Welcome.App.Components;
namespace SilverOS.Welcome.App; namespace SilverOS.Welcome.App;
@@ -16,6 +18,23 @@ public static class MauiProgram
Directory.CreateDirectory(wv2); Directory.CreateDirectory(wv2);
Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", 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(); var builder = MauiApp.CreateBuilder();
builder builder
.UseMauiApp<App>() .UseMauiApp<App>()
@@ -31,18 +50,16 @@ public static class MauiProgram
builder.Logging.AddDebug(); builder.Logging.AddDebug();
#endif #endif
var hardeningDir = @"C:\Windows\Setup\Scripts\hardening";
builder.Services.AddSingleton<IProcessRunner, ProcessRunner>(); builder.Services.AddSingleton<IProcessRunner, ProcessRunner>();
builder.Services.AddSingleton<IFlavourLoader, FlavourLoader>(); builder.Services.AddSingleton<IFlavourLoader, FlavourLoader>();
builder.Services.AddSingleton<IAccountService, AccountService>(); builder.Services.AddSingleton<IAppCatalog, AppCatalog>();
builder.Services.AddSingleton<IBitLockerService, BitLockerService>(); 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( builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
sp.GetRequiredService<IProcessRunner>(),
sp.GetRequiredService<IAccountService>(),
sp.GetRequiredService<IBitLockerService>(), sp.GetRequiredService<IBitLockerService>(),
sp.GetRequiredService<IBootstrapService>(), sp.GetRequiredService<IAppInstaller>()));
hardeningDir)); builder.Services.AddSingleton<IPreconfigStore>(_ => new PreconfigStore(@"C:\ProgramData\SilverMetal"));
builder.Services.AddScoped<WizardState>(); builder.Services.AddScoped<WizardState>();
return builder.Build(); return builder.Build();

View File

@@ -0,0 +1,27 @@
#if WINDOWS
using Microsoft.UI.Windowing;
using Microsoft.UI;
using WinRT.Interop;
namespace SilverOS.Welcome.App;
public static class WindowExtensions
{
// Borderless, fullscreen, non-closable kiosk window. Returns true when applied.
// Defensive: HandlerChanged can fire before the HWND/AppWindow is ready, so we
// bail out cleanly and rely on a later HandlerChanged to apply it.
public static bool ApplyKioskChrome(this Microsoft.UI.Xaml.Window winuiWindow)
{
var hwnd = WindowNative.GetWindowHandle(winuiWindow);
if (hwnd == IntPtr.Zero) return false;
var id = Win32Interop.GetWindowIdFromWindow(hwnd);
var appWindow = AppWindow.GetFromWindowId(id);
if (appWindow is null) return false;
// FullScreen presenter is borderless (no title bar) by nature — simpler and
// more reliable than toggling OverlappedPresenter border/title-bar flags.
appWindow.SetPresenter(AppWindowPresenterKind.FullScreen);
appWindow.Closing += (s, e) => e.Cancel = true;
return true;
}
}
#endif

View File

@@ -25,6 +25,16 @@
<WindowsPackageType>None</WindowsPackageType> <WindowsPackageType>None</WindowsPackageType>
<SupportedOSPlatformVersion>10.0.19041.0</SupportedOSPlatformVersion> <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> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -139,6 +139,19 @@ body {
min-height: 100vh; min-height: 100vh;
} }
body::after {
content: "SILVERMETAL";
position: fixed;
right: 26px;
bottom: 16px;
font-family: var(--font-ui);
font-weight: 700;
font-size: 13px;
letter-spacing: 4px;
color: rgba(255, 255, 255, 0.16);
pointer-events: none;
}
/* ── Blazor error overlay (keep readable) ──────────────────────────── */ /* ── Blazor error overlay (keep readable) ──────────────────────────── */
#blazor-error-ui { #blazor-error-ui {
background: #1a0a0a; background: #1a0a0a;
@@ -200,9 +213,23 @@ h1:focus { outline: none; }
.wizard { .wizard {
display: grid; display: grid;
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto;
min-height: 100vh; position: fixed;
max-width: 760px; inset: 5vh 7vw; /* float as a card inset from the wall edges */
margin: 0 auto; max-width: 1040px;
margin: 0 auto; /* center horizontally within the inset box */
background: rgba(16, 22, 31, 0.55);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 18px;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.12);
overflow: hidden; /* clip header/footer corners to the radius */
animation: sm-rise 0.5s var(--ease-out) both;
}
@keyframes sm-rise {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: none; }
} }
/* ── Step indicator ─────────────────────────────────────────────────── */ /* ── Step indicator ─────────────────────────────────────────────────── */
@@ -276,6 +303,58 @@ h1:focus { outline: none; }
gap: 1rem; 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 / error states ─────────────────────────────────────────── */
.loading { .loading {
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -850,3 +929,38 @@ h1:focus { outline: none; }
padding-left: env(safe-area-inset-left); padding-left: env(safe-area-inset-left);
} }
} }
/* ── BitLocker recovery key (Done step) ─────────────────────────────── */
.done-step { display: flex; flex-direction: column; align-items: flex-start; }
.recovery-panel {
margin: 0.75rem 0;
padding: 0.85rem 1rem;
border: 1px solid var(--clr-accent);
border-radius: var(--radius-sm, 8px);
background: var(--clr-accent-glow, rgba(0,212,255,0.10));
width: 100%;
}
.recovery-panel h3 { margin: 0 0 0.35rem; color: var(--clr-accent); font-family: var(--font-mono); font-size: 1rem; }
.recovery-lead { margin: 0 0 0.5rem; color: var(--clr-text-lo); font-size: 0.85rem; }
.recovery-row { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
.recovery-qr {
width: 132px; height: 132px;
background: #fff; padding: 6px; border-radius: var(--radius-sm, 8px);
flex: 0 0 auto;
}
.recovery-key {
flex: 1 1 14rem;
font-family: var(--font-mono);
font-size: 1.0rem;
letter-spacing: 0.04em;
color: var(--clr-text-hi);
background: rgba(0,0,0,0.30);
padding: 0.6rem 0.85rem;
border-radius: var(--radius-sm, 8px);
white-space: pre-wrap;
word-break: break-all;
user-select: all;
margin: 0;
}
.recovery-note { color: var(--clr-text-lo); margin: 0.5rem 0 0; }
.done-step .btn-restart { margin-top: 1rem; align-self: flex-start; }

View File

@@ -15,7 +15,21 @@
<div class="status-bar-safe-area"></div> <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"> <div id="blazor-error-ui">
An unhandled error has occurred. 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,4 +1,6 @@
using SilverOS.Welcome.Core.Flavours; using SilverOS.Welcome.Core.Flavours;
using SilverOS.Welcome.Core.Apps;
namespace SilverOS.Welcome.Core.Apply; namespace SilverOS.Welcome.Core.Apply;
public sealed record ApplyRequest(FlavourManifest Flavour, string Username, string Password, // Toolbox model: the account is created by Windows Setup (WinPE collector), and hardening
string AdminPassword, string BitLockerPin, string BootstrapUser); // runs from SetupComplete. Apply only installs apps + enrols BitLocker.
public sealed record ApplyRequest(FlavourManifest Flavour, string BitLockerPin, IReadOnlyList<AppCatalogEntry> Apps);

View File

@@ -1,39 +1,21 @@
using System.Text.Json; using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Flavours;
namespace SilverOS.Welcome.Core.Apply; namespace SilverOS.Welcome.Core.Apply;
public sealed class ApplyService(IProcessRunner runner, IAccountService accounts, // Toolbox Apply pipeline: apps -> bitlocker -> done.
IBitLockerService bitlocker, IBootstrapService bootstrap, string hardeningDir) : IApplyService // 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) public async Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default)
{ {
progress.Report(new("Applying hardening", 10)); progress.Report(new("Installing apps", 30));
// Pass modules as a single bare CSV token (e.g. 00,03,05). await installer.InstallAsync(req.Apps, progress, ct); // continue-on-failure; never throws
// 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. if (!string.IsNullOrWhiteSpace(req.BitLockerPin))
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)
{ {
// Only expose exit code + first non-empty stderr line (capped) — never raw full stderr. progress.Report(new("Encrypting the disk", 75));
var firstLine = res.StdErr await bitlocker.EnableAsync(req.BitLockerPin, ct);
.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("Creating your account", 55));
await accounts.CreateAccountsAsync(req.Username, req.Password, req.AdminPassword, ct);
progress.Report(new("Encrypting the disk", 75));
await bitlocker.EnableAsync(req.BitLockerPin, ct);
progress.Report(new("Finishing up", 95));
await bootstrap.TearDownAsync(req.BootstrapUser, ct); // last — only after success
progress.Report(new("Done", 100)); progress.Report(new("Done", 100));
} }
} }

View File

@@ -31,13 +31,28 @@ public sealed class BitLockerService(IProcessRunner runner) : IBitLockerService
"$p=ConvertTo-SecureString '", p, "' -AsPlainText -Force; ", "$p=ConvertTo-SecureString '", p, "' -AsPlainText -Force; ",
"$v=Get-BitLockerVolume -MountPoint $mp; ", "$v=Get-BitLockerVolume -MountPoint $mp; ",
"if ($v.VolumeStatus -eq 'FullyDecrypted') { ", "if ($v.VolumeStatus -eq 'FullyDecrypted') { ",
"Enable-BitLocker -MountPoint $mp -EncryptionMethod XtsAes256 -TpmAndPinProtector -Pin $p -SkipHardwareTest } ", // NO -SkipHardwareTest: let BitLocker run its hardware test on the next reboot so it
// VALIDATES the TPM+PIN unseal against the real boot measurements before encrypting.
// -SkipHardwareTest seals immediately against possibly-wrong PCRs -> drops to recovery
// on first boot (E_FVE_SECURE_BOOT_CHANGED, PCR 11). The wizard's end-of-flow reboot
// is that validation pass, so the PIN works on first boot instead of bouncing.
"Enable-BitLocker -MountPoint $mp -EncryptionMethod XtsAes256 -TpmAndPinProtector -Pin $p } ",
"elseif (-not ($v.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'TpmPin' })) { ", "elseif (-not ($v.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'TpmPin' })) { ",
"Add-BitLockerKeyProtector -MountPoint $mp -TpmAndPinProtector -Pin $p }; ", "Add-BitLockerKeyProtector -MountPoint $mp -TpmAndPinProtector -Pin $p }; ",
"$kp=(Get-BitLockerVolume -MountPoint $mp).KeyProtector; ", "$kp=(Get-BitLockerVolume -MountPoint $mp).KeyProtector; ",
"if ($kp | Where-Object { $_.KeyProtectorType -eq 'TpmPin' }) { ", "if ($kp | Where-Object { $_.KeyProtectorType -eq 'TpmPin' }) { ",
"$kp | Where-Object { $_.KeyProtectorType -eq 'Tpm' } | ForEach-Object { ", "$kp | Where-Object { $_.KeyProtectorType -eq 'Tpm' } | ForEach-Object { ",
"Remove-BitLockerKeyProtector -MountPoint $mp -KeyProtectorId $_.KeyProtectorId | Out-Null } }; ", "Remove-BitLockerKeyProtector -MountPoint $mp -KeyProtectorId $_.KeyProtectorId | Out-Null } }; ",
// 4. Add a RECOVERY-PASSWORD protector so a forgotten/mistyped PIN is recoverable —
// TPM+PIN alone is an unrecoverable brick. Save the 48-digit key to ProgramData
// AND to the unencrypted EFI System Partition (readable even when the OS volume is
// locked). PRODUCT TODO: escrow to SilverSync + show it in the wizard's Done step.
"if (-not ((Get-BitLockerVolume -MountPoint $mp).KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' })) { ",
"Add-BitLockerKeyProtector -MountPoint $mp -RecoveryPasswordProtector | Out-Null }; ",
"$rp=((Get-BitLockerVolume -MountPoint $mp).KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' } | Select-Object -First 1).RecoveryPassword; ",
"if ($rp) { New-Item -ItemType Directory -Force 'C:\\ProgramData\\SilverMetal' | Out-Null; ",
"Set-Content -Path 'C:\\ProgramData\\SilverMetal\\bitlocker-recovery.txt' -Value $rp; ",
"try { mountvol Q: /S; Set-Content -Path 'Q:\\SilverMetal-Recovery.txt' -Value $rp; mountvol Q: /D } catch {} }; ",
// Outcome check: fail loudly (non-zero exit) if a TPM+PIN protector is not present at // Outcome check: fail loudly (non-zero exit) if a TPM+PIN protector is not present at
// the end — this is what actually matters (a benign non-terminating warning alone // the end — this is what actually matters (a benign non-terminating warning alone
// must not pass, and a real failure must not stay silent). // must not pass, and a real failure must not stay silent).

View File

@@ -1,21 +0,0 @@
namespace SilverOS.Welcome.Core.Apply;
public sealed class BootstrapService(IProcessRunner runner) : IBootstrapService
{
// 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);
await Ps($"Remove-LocalUser -Name '{u}' -EA SilentlyContinue", 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,2 +0,0 @@
namespace SilverOS.Welcome.Core.Apply;
public interface IBootstrapService { Task TearDownAsync(string bootstrapUser, CancellationToken ct = default); }

View File

@@ -0,0 +1,37 @@
using System.Text.Json;
namespace SilverOS.Welcome.Core.Apps;
public sealed record LoadedCatalog(IReadOnlyList<AppCatalogEntry> All)
{
// Essentials (offered to all roles) first, then the role's own apps. Stable, de-duped by id.
public IReadOnlyList<AppCatalogEntry> AppsForRole(string role) =>
All.Where(a => a.Roles.Contains("essentials") || a.Roles.Contains(role))
.GroupBy(a => a.Id).Select(g => g.First())
.OrderByDescending(a => a.Group == "essentials").ThenBy(a => a.Name).ToList();
public IReadOnlyList<string> DefaultSelectionForRole(string role) =>
AppsForRole(role).Where(a => a.DefaultFor.Contains("essentials") || a.DefaultFor.Contains(role))
.Select(a => a.Id).ToList();
}
public sealed class AppCatalog : IAppCatalog
{
private sealed record CatalogFile(int SchemaVersion, IReadOnlyList<AppCatalogEntry>? Apps);
public LoadedCatalog Load(string directory)
{
var path = Path.Combine(directory, "catalog.json");
if (!File.Exists(path)) return new LoadedCatalog(Array.Empty<AppCatalogEntry>());
try
{
var f = JsonSerializer.Deserialize<CatalogFile>(File.ReadAllText(path), AppCatalogEntry.JsonOptions);
return new LoadedCatalog(f?.Apps ?? Array.Empty<AppCatalogEntry>());
}
catch (JsonException)
{
// A bad catalog must never block onboarding — degrade to "no extra apps".
return new LoadedCatalog(Array.Empty<AppCatalogEntry>());
}
}
}

View File

@@ -0,0 +1,28 @@
using System.Text.Json;
namespace SilverOS.Welcome.Core.Apps;
public sealed record AppSource
{
public string? Winget { get; init; }
// Future: public string? Mirror { get; init; } // swappable to a curated mirror.
}
public sealed record AppCatalogEntry
{
public string Id { get; init; } = "";
public string Name { get; init; } = "";
public string Description { get; init; } = "";
public AppSource Source { get; init; } = new();
public string Group { get; init; } = "";
public IReadOnlyList<string> Roles { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> DefaultFor { get; init; } = Array.Empty<string>();
public string? Configure { get; init; }
public static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
}

View File

@@ -0,0 +1,118 @@
using SilverOS.Welcome.Core.Apply;
namespace SilverOS.Welcome.Core.Apps;
public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppInstaller
{
// Best-effort diagnostic log (winget resolution, bootstrap output, per-app results).
// Lives under ProgramData so it survives + is readable post-install. Never throws.
private static readonly string LogPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "SilverMetal", "appinstall.log");
private static void Log(string msg)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
File.AppendAllText(LogPath, $"{DateTime.Now:HH:mm:ss.fff} {msg}{Environment.NewLine}");
}
catch { /* logging is best-effort */ }
}
private static string Snip(string? s) =>
string.IsNullOrWhiteSpace(s) ? "" : s.Trim().Replace("\r", " ").Replace("\n", " ") is var t && t.Length > 300 ? t[..300] : t;
public async Task<IReadOnlyList<AppInstallResult>> InstallAsync(
IReadOnlyList<AppCatalogEntry> apps, IProgress<ApplyProgress> progress, CancellationToken ct = default)
{
var results = new List<AppInstallResult>();
if (apps.Count == 0) return results;
Log($"InstallAsync: {apps.Count} app(s) requested: {string.Join(", ", apps.Select(a => a.Id))}");
// App installs are non-critical: a missing/broken winget (e.g. offline IoT LTSC) must
// NEVER fail onboarding. Resolve winget defensively; if it can't be found, skip installs.
var winget = await ResolveWingetAsync(progress, ct);
if (winget is null)
{
Log($"winget UNAVAILABLE -> skipping all {apps.Count} app(s)");
progress.Report(new($"App installer unavailable - skipping {apps.Count} app(s)", 80));
foreach (var app in apps) results.Add(new AppInstallResult(app.Id, false));
return results;
}
var i = 0;
foreach (var app in apps)
{
i++;
progress.Report(new($"Installing {app.Name} ({i}/{apps.Count})", 80));
var ok = false;
var id = app.Source.Winget;
if (!string.IsNullOrWhiteSpace(id))
{
var r = await TryRunAsync(winget,
$"install --id {id} --silent --accept-package-agreements --accept-source-agreements --disable-interactivity",
ct);
ok = r.ExitCode == 0;
Log($"install {id}: exit={r.ExitCode} ok={ok} err={Snip(r.StdErr)}");
if (ok && !string.IsNullOrWhiteSpace(app.Configure))
{
var script = Path.Combine(appsDir, "configure", app.Configure);
// best-effort: configuration failure does not mark the install as failed
await TryRunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -File \"{script}\"", ct);
}
}
results.Add(new AppInstallResult(app.Id, ok));
}
Log($"InstallAsync done: {results.Count(r => r.Installed)}/{results.Count} installed");
return results;
}
// Find a usable winget. winget (App Installer) is absent from IoT Enterprise LTSC, and even
// when present it ships as a WindowsApps execution alias that Process.Start can't always launch
// by bare name. Returns the invocation target ("winget" or a full path), or null if unavailable.
private async Task<string?> ResolveWingetAsync(IProgress<ApplyProgress> progress, CancellationToken ct)
{
// 1) Already launchable by name (on PATH for this process)?
var p1 = await TryRunAsync("winget", "--version", ct);
Log($"winget probe (PATH): exit={p1.ExitCode} out={Snip(p1.StdOut)}");
if (p1.ExitCode == 0) return "winget";
// 2) Provision App Installer, 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");
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");
var aliasExists = File.Exists(aliased);
var p3Exit = aliasExists ? (await TryRunAsync(aliased, "--version", ct)).ExitCode : -1;
Log($"winget alias path '{aliased}': exists={aliasExists} probe={p3Exit}");
if (aliasExists && p3Exit == 0) return aliased;
return null;
}
// Launching a missing executable throws Win32Exception ("cannot find the file specified")
// rather than returning a non-zero code. Treat any launch failure as a failed run so the
// installer's continue-on-failure logic covers it and onboarding never crashes.
private async Task<ProcessResult> TryRunAsync(string file, string args, CancellationToken ct)
{
try { return await runner.RunAsync(file, args, ct); }
catch (Exception ex) { return new ProcessResult(-1, "", ex.Message); }
}
}

View File

@@ -0,0 +1,6 @@
namespace SilverOS.Welcome.Core.Apps;
public interface IAppCatalog
{
LoadedCatalog Load(string directory);
}

View File

@@ -0,0 +1,11 @@
using SilverOS.Welcome.Core.Apply;
namespace SilverOS.Welcome.Core.Apps;
public sealed record AppInstallResult(string Id, bool Installed);
public interface IAppInstaller
{
Task<IReadOnlyList<AppInstallResult>> InstallAsync(
IReadOnlyList<AppCatalogEntry> apps, IProgress<ApplyProgress> progress, CancellationToken ct = default);
}

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,8 +1,23 @@
@using SilverOS.Welcome.App.Components.Steps @using SilverOS.Welcome.App.Components.Steps
@using SilverOS.Welcome.Core.Flavours @using SilverOS.Welcome.Core.Flavours
@using SilverOS.Welcome.Core.Apps
@using SilverOS.Welcome.Core.Preconfig
@inject IFlavourLoader FlavourLoader @inject IFlavourLoader FlavourLoader
@inject IAppCatalog AppCatalog
@inject IPreconfigStore PreconfigStore
@inject WizardState State @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">
<div class="wizard-header"> <div class="wizard-header">
<div class="wizard-steps-indicator"> <div class="wizard-steps-indicator">
@@ -37,16 +52,16 @@
<WelcomeStep /> <WelcomeStep />
break; break;
case 1: case 1:
<FlavourStep Flavours="_flavours" /> <FlavourStep Flavours="_flavours" OnSelected="StateHasChanged" />
break; break;
case 2: case 2:
<AccountStep OnValidityChanged="@(v => { _accountValid = v; StateHasChanged(); })" /> <AppsStep Apps="_catalog.AppsForRole(State.Flavour?.Id ?? string.Empty)" />
break; break;
case 3: case 3:
<PrefsStep /> <PrefsStep />
break; break;
case 4: case 4:
<ApplyStep OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" /> <ApplyStep AutoStart="_autoApply" OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" />
break; break;
case 5: case 5:
<DoneStep /> <DoneStep />
@@ -69,31 +84,53 @@
@(_currentStep == _stepTitles.Length - 2 ? "Apply" : "Next") @(_currentStep == _stepTitles.Length - 2 ? "Apply" : "Next")
</button> </button>
} }
else if (_currentStep == _stepTitles.Length - 1)
{
<button class="btn-primary" @onclick="RestartNow">Restart now</button>
}
</div> </div>
</div> </div>
}
@code { @code {
private static readonly string[] _stepTitles = { "Welcome", "Flavour", "Account", "Prefs", "Apply", "Done" }; private static readonly string[] _stepTitles = { "Welcome", "Flavour", "Apps", "Prefs", "Apply", "Done" };
// Flavours dir: baked alongside the exe at publish time. // Flavours dir: baked alongside the exe at publish time.
private static readonly string FlavoursDir = Path.Combine( private static readonly string FlavoursDir = Path.Combine(
AppContext.BaseDirectory, "flavours"); AppContext.BaseDirectory, "flavours");
// Apps catalog dir: baked alongside the exe at publish time.
private static readonly string AppsDir = Path.Combine(
AppContext.BaseDirectory, "apps");
private LoadedCatalog _catalog = new(Array.Empty<AppCatalogEntry>());
private int _currentStep = 0; private int _currentStep = 0;
private bool _loading = true; private bool _loading = true;
private bool _applyRunning = false; private bool _applyRunning = false;
private bool _accountValid = false; private bool _toolboxHome = false;
private bool _autoApply = false;
private string? _error; private string? _error;
private IReadOnlyList<FlavourManifest> _flavours = Array.Empty<FlavourManifest>(); 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 private bool CanGoNext => _currentStep switch
{ {
1 => State.Flavour is not null, 1 => State.Flavour is not null,
2 => _accountValid, // 2 = Apps step is always valid (never blocks Next).
_ => true _ => true
}; };
protected override Task OnInitializedAsync() => LoadFlavours(); protected override Task OnInitializedAsync()
{
LoadFlavours();
SeedFromPreconfig();
return Task.CompletedTask;
}
private Task LoadFlavours() private Task LoadFlavours()
{ {
@@ -102,6 +139,7 @@
try try
{ {
_flavours = FlavourLoader.Load(FlavoursDir); _flavours = FlavourLoader.Load(FlavoursDir);
_catalog = AppCatalog.Load(AppsDir);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -114,10 +152,55 @@
return Task.CompletedTask; 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() void Next()
{ {
if (_currentStep < _stepTitles.Length - 1) if (_currentStep < _stepTitles.Length - 1)
_currentStep++; _currentStep++;
// On entering the Apps step, seed the per-role default selection once.
if (_currentStep == 2 && State.SelectedApps.Count == 0 && State.Flavour is not null)
foreach (var id in _catalog.DefaultSelectionForRole(State.Flavour.Id))
State.SelectedApps.Add(id);
} }
void Back() void Back()

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,4 +1,8 @@
@using SilverOS.Welcome.Core.Apps
@using SilverOS.Welcome.Core.Preconfig
@inject IApplyService ApplyService @inject IApplyService ApplyService
@inject IAppCatalog AppCatalog
@inject IPreconfigStore PreconfigStore
@inject WizardState State @inject WizardState State
<div class="step apply-step"> <div class="step apply-step">
@@ -46,8 +50,10 @@
@code { @code {
[Parameter] public EventCallback OnComplete { get; set; } [Parameter] public EventCallback OnComplete { get; set; }
[Parameter] public EventCallback<bool> OnRunningChanged { get; set; } [Parameter] public EventCallback<bool> OnRunningChanged { get; set; }
[Parameter] public bool AutoStart { get; set; }
private bool _running; private bool _running;
private bool _autoStarted;
private bool _complete; private bool _complete;
private int _percent; private int _percent;
private string _stageLabel = "Preparing…"; private string _stageLabel = "Preparing…";
@@ -67,6 +73,18 @@
: single[..ErrorDisplayMaxLength] + "…"; : 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() public async Task StartAsync()
{ {
// Re-entrancy guard: prevent a second overlapping apply if already running // Re-entrancy guard: prevent a second overlapping apply if already running
@@ -81,13 +99,16 @@
StateHasChanged(); StateHasChanged();
await OnRunningChanged.InvokeAsync(true); await OnRunningChanged.InvokeAsync(true);
var apps = AppCatalog.Load(Path.Combine(AppContext.BaseDirectory, "apps"))
.All.Where(a => State.SelectedApps.Contains(a.Id)).ToList();
// D1: Apply is now apps+bitlocker only (account via Setup, hardening via SetupComplete).
// D2 owns the full UI rewire (run-mode / preseed); this passes the 3-arg request from
// existing State fields so the app keeps compiling.
var req = new ApplyRequest( var req = new ApplyRequest(
Flavour: State.Flavour!, Flavour: State.Flavour!,
Username: State.Username,
Password: State.Password,
AdminPassword: State.AdminPassword,
BitLockerPin: State.BitLockerPin, BitLockerPin: State.BitLockerPin,
BootstrapUser: "sm-bootstrap"); Apps: apps);
var progress = new Progress<ApplyProgress>(p => var progress = new Progress<ApplyProgress>(p =>
{ {
@@ -105,6 +126,12 @@
_complete = true; _complete = true;
_running = false; _running = false;
_percent = 100; _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(); StateHasChanged();
await OnRunningChanged.InvokeAsync(false); await OnRunningChanged.InvokeAsync(false);
await OnComplete.InvokeAsync(); await OnComplete.InvokeAsync();

View File

@@ -0,0 +1,45 @@
@using SilverOS.Welcome.Core.Apps
@inject WizardState State
<div class="step apps-step">
<h1>Choose your apps</h1>
<p class="step-subtitle">We'll install these during setup. The SilverLABS Stack (browser, VPN, keys) is already included.</p>
@foreach (var grp in _groups)
{
<h3 class="apps-group">@GroupTitle(grp.Key)</h3>
<div class="apps-grid">
@foreach (var app in grp)
{
<label class="app-card @(State.SelectedApps.Contains(app.Id) ? "selected" : "")">
<input type="checkbox" checked="@State.SelectedApps.Contains(app.Id)"
@onchange="e => Toggle(app.Id, (bool)e.Value!)" />
<span class="app-name">@app.Name</span>
<span class="app-desc">@app.Description</span>
</label>
}
</div>
}
</div>
@code {
[Parameter] public IReadOnlyList<AppCatalogEntry> Apps { get; set; } = Array.Empty<AppCatalogEntry>();
private IEnumerable<IGrouping<string, AppCatalogEntry>> _groups =>
Apps.GroupBy(a => a.Group).OrderByDescending(g => g.Key == "essentials");
private static string GroupTitle(string g) => g switch
{
"essentials" => "Essentials",
"developer" => "Developer tools",
"journalist" => "Journalist tools",
"daily-driver" => "Everyday apps",
"privacy-max" => "Privacy tools",
_ => g
};
void Toggle(string id, bool on)
{
if (on) State.SelectedApps.Add(id); else State.SelectedApps.Remove(id);
}
}

View File

@@ -1,14 +1,56 @@
@inject SilverOS.Welcome.Core.Apply.IProcessRunner ProcessRunner @using QRCoder
<div class="step done-step"> <div class="step done-step">
<h1>All Done!</h1> <h1>All Done!</h1>
<p>Your SilverOS device is configured and ready. Click below to restart and start using it.</p> <p class="step-subtitle">Your SilverMetal device is configured and ready.</p>
<button class="btn-primary btn-restart" @onclick="RestartNow">Restart Now</button>
@if (!string.IsNullOrWhiteSpace(_recoveryKey))
{
<div class="recovery-panel">
<h3>⚠ Back up your BitLocker recovery key</h3>
<p class="recovery-lead">
This is the <strong>only</strong> way back into your drive if you forget your PIN.
Scan the code with your phone, or copy the key — keep it somewhere safe and
separate from this device.
</p>
<div class="recovery-row">
@if (_qrDataUri is not null)
{
<img class="recovery-qr" src="@_qrDataUri" alt="BitLocker recovery key QR code" />
}
<pre class="recovery-key">@_recoveryKey</pre>
</div>
<p class="recovery-note"><small>
A copy is saved on this device at <code>C:\ProgramData\SilverMetal\bitlocker-recovery.txt</code>
— you can delete it once you've backed the key up elsewhere.
</small></p>
</div>
}
</div> </div>
@code { @code {
private async Task RestartNow() private string? _recoveryKey;
private string? _qrDataUri;
protected override void OnInitialized()
{ {
await ProcessRunner.RunAsync("cmd.exe", "/c shutdown /r /t 5", CancellationToken.None); try
{
const string path = @"C:\ProgramData\SilverMetal\bitlocker-recovery.txt";
if (File.Exists(path)) _recoveryKey = File.ReadAllText(path).Trim();
}
catch { /* best-effort display */ }
if (!string.IsNullOrWhiteSpace(_recoveryKey))
{
try
{
using var gen = new QRCodeGenerator();
using var data = gen.CreateQrCode(_recoveryKey, QRCodeGenerator.ECCLevel.M);
var png = new PngByteQRCode(data).GetGraphic(6);
_qrDataUri = "data:image/png;base64," + Convert.ToBase64String(png);
}
catch { /* QR is best-effort; the key text still shows */ }
}
} }
} }

View File

@@ -19,13 +19,19 @@
@code { @code {
[Parameter] public IReadOnlyList<FlavourManifest> Flavours { get; set; } = Array.Empty<FlavourManifest>(); [Parameter] public IReadOnlyList<FlavourManifest> Flavours { get; set; } = Array.Empty<FlavourManifest>();
protected override void OnInitialized() /// <summary>Notifies the wizard host when the selection changes so it re-evaluates
/// the Next button (otherwise Next stays disabled until a back/forward re-render).</summary>
[Parameter] public EventCallback OnSelected { get; set; }
protected override async Task OnInitializedAsync()
{ {
State.Flavour ??= Flavours.FirstOrDefault(f => f.IsDefault); State.Flavour ??= Flavours.FirstOrDefault(f => f.IsDefault);
await OnSelected.InvokeAsync();
} }
void Select(FlavourManifest f) async Task Select(FlavourManifest f)
{ {
State.Flavour = f; State.Flavour = f;
await OnSelected.InvokeAsync();
} }
} }

View File

@@ -5,9 +5,10 @@ namespace SilverOS.Welcome.App.Components;
public sealed class WizardState public sealed class WizardState
{ {
public FlavourManifest? Flavour { get; set; } public FlavourManifest? Flavour { get; set; }
public string Username { get; set; } = "";
public string Password { get; set; } = ""; // Apps step: ids of catalog apps the user chose to install.
public string AdminPassword { get; set; } = ""; public HashSet<string> SelectedApps { get; set; } = new();
public string BitLockerPin { get; set; } = ""; public string BitLockerPin { get; set; } = "";
// Prefs step // Prefs step

View File

@@ -9,6 +9,8 @@
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
<!-- Pure-managed QR generator (no System.Drawing/native deps) for the recovery key. -->
<PackageReference Include="QRCoder" Version="1.6.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

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

@@ -0,0 +1,69 @@
using System.IO;
using System.Linq;
using System.Text.Json;
using SilverOS.Welcome.Core.Apps;
using Xunit;
public class AppCatalogTests
{
[Fact]
public void Deserializes_a_catalog_entry()
{
var json = """
{ "id":"vscodium","name":"VSCodium","description":"Telemetry-free VS Code.",
"source":{"winget":"VSCodium.VSCodium"},"group":"developer",
"roles":["developer"],"defaultFor":["developer"],"configure":null }
""";
var e = JsonSerializer.Deserialize<AppCatalogEntry>(json, AppCatalogEntry.JsonOptions)!;
Assert.Equal("vscodium", e.Id);
Assert.Equal("VSCodium.VSCodium", e.Source.Winget);
Assert.Contains("developer", e.Roles);
Assert.Contains("developer", e.DefaultFor);
}
}
public class AppCatalogLoaderTests
{
static string WriteCatalog(string body)
{
var dir = Path.Combine(Path.GetTempPath(), "smcat-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "catalog.json"), body);
return dir;
}
const string Body = """
{ "schemaVersion":1, "apps":[
{"id":"tb","name":"Thunderbird","source":{"winget":"Mozilla.Thunderbird"},"group":"essentials","roles":["essentials"],"defaultFor":["essentials"]},
{"id":"vscodium","name":"VSCodium","source":{"winget":"VSCodium.VSCodium"},"group":"developer","roles":["developer"],"defaultFor":["developer"]},
{"id":"rider","name":"Rider","source":{"winget":"JetBrains.Rider"},"group":"developer","roles":["developer"],"defaultFor":[]}
]}
""";
[Fact]
public void AppsForRole_returns_essentials_plus_role()
{
var c = new AppCatalog().Load(WriteCatalog(Body));
var ids = c.AppsForRole("developer").Select(a => a.Id).ToList();
Assert.Contains("tb", ids);
Assert.Contains("vscodium", ids);
Assert.Contains("rider", ids);
}
[Fact]
public void DefaultSelection_only_pre_checks_defaultFor()
{
var c = new AppCatalog().Load(WriteCatalog(Body));
var def = c.DefaultSelectionForRole("developer");
Assert.Contains("tb", def);
Assert.Contains("vscodium", def);
Assert.DoesNotContain("rider", def);
}
[Fact]
public void Missing_catalog_returns_empty_not_throw()
{
var c = new AppCatalog().Load(Path.Combine(Path.GetTempPath(), "nope-" + Guid.NewGuid().ToString("N")));
Assert.Empty(c.All);
}
}

View File

@@ -0,0 +1,106 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Apply;
public class AppInstallerTests
{
static AppCatalogEntry App(string id, string winget, string? cfg = null) =>
new() { Id = id, Name = id, Source = new AppSource { Winget = winget }, Configure = cfg };
static Mock<IProcessRunner> Runner(int exit = 0)
{
var m = new Mock<IProcessRunner>();
m.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(exit, "", ""));
return m;
}
[Fact]
public async Task Installs_each_selected_app_via_winget()
{
var run = Runner();
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "v1.8", ""));
var sut = new AppInstaller(run.Object, "C:\\apps");
var res = await sut.InstallAsync(new[] { App("vscodium", "VSCodium.VSCodium") },
new Progress<ApplyProgress>(_ => { }));
run.Verify(r => r.RunAsync("winget", It.Is<string>(s =>
s.Contains("install") && s.Contains("VSCodium.VSCodium") && s.Contains("--silent")),
It.IsAny<CancellationToken>()), Times.Once);
Assert.True(res.Single().Installed);
}
[Fact]
public async Task Bootstraps_winget_when_absent()
{
var run = Runner();
// Any winget probe (bare name OR the WindowsApps alias path) reports absent.
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(1, "", "not found"));
var sut = new AppInstaller(run.Object, "C:\\apps");
await sut.InstallAsync(new[] { App("tb", "Mozilla.Thunderbird") }, new Progress<ApplyProgress>(_ => { }));
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("DesktopAppInstaller")), It.IsAny<CancellationToken>()), Times.AtLeastOnce);
}
[Fact]
public async Task One_app_failure_does_not_stop_the_rest()
{
var run = new Mock<IProcessRunner>();
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "v1.8", ""));
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("Bad.App")), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(1, "", "fail"));
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("Good.App")), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "", ""));
var sut = new AppInstaller(run.Object, "C:\\apps");
var res = await sut.InstallAsync(new[] { App("bad", "Bad.App"), App("good", "Good.App") },
new Progress<ApplyProgress>(_ => { }));
Assert.False(res.First(r => r.Id == "bad").Installed);
Assert.True(res.First(r => r.Id == "good").Installed);
}
[Fact]
public async Task Winget_launch_exception_does_not_crash_apply_and_skips_installs()
{
// On IoT LTSC winget is absent, so Process.Start throws Win32Exception instead of
// returning a non-zero code. The installer must swallow it, skip installs, and NOT throw.
var run = new Mock<IProcessRunner>();
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
.ThrowsAsync(new System.ComponentModel.Win32Exception("The system cannot find the file specified."));
run.Setup(r => r.RunAsync("powershell.exe", It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "", ""));
var sut = new AppInstaller(run.Object, "C:\\apps");
var res = await sut.InstallAsync(new[] { App("vscodium", "VSCodium.VSCodium"), App("git", "Git.Git") },
new Progress<ApplyProgress>(_ => { }));
Assert.Equal(2, res.Count);
Assert.All(res, r => Assert.False(r.Installed));
// never attempted an install once winget was unresolved
run.Verify(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("install --id")), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task App_install_exception_is_isolated_from_the_rest()
{
var run = new Mock<IProcessRunner>();
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "v1.8", ""));
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("Bad.App")), It.IsAny<CancellationToken>()))
.ThrowsAsync(new System.ComponentModel.Win32Exception("boom"));
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("Good.App")), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "", ""));
var sut = new AppInstaller(run.Object, "C:\\apps");
var res = await sut.InstallAsync(new[] { App("bad", "Bad.App"), App("good", "Good.App") },
new Progress<ApplyProgress>(_ => { }));
Assert.False(res.First(r => r.Id == "bad").Installed);
Assert.True(res.First(r => r.Id == "good").Installed);
}
}

View File

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

View File

@@ -1,42 +1,90 @@
using Moq; using Moq;
using SilverOS.Welcome.Core.Apply; using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Flavours; using SilverOS.Welcome.Core.Flavours;
using Xunit; using Xunit;
public class ApplyServiceTests public class ApplyServiceTests
{ {
private static Mock<IAppInstaller> NoApps()
{
var installer = new Mock<IAppInstaller>();
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<AppInstallResult>());
return installer;
}
private static FlavourManifest Flavour() =>
new() { Id = "daily-driver", Hardening = new HardeningSpec { Modules = new[] { "00" } } };
[Fact] [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 order = new List<string>();
var run = new Mock<IProcessRunner>(); var run = new Mock<IProcessRunner>();
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())) 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, "", "")); .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>();
var bl = new Mock<IBitLockerService>(); bl.Setup(b => b.EnableAsync(It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask); bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
var boot = new Mock<IBootstrapService>(); boot.Setup(b => b.TearDownAsync(It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("bootstrap")).Returns(Task.CompletedTask); .Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
var installer = NoApps();
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty<AppInstallResult>());
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, "C:\\hard"); var sut = new ApplyService(bl.Object, installer.Object);
var flavour = new FlavourManifest { Id="daily-driver", Hardening = new HardeningSpec { Modules = new[]{"00"} } }; var req = new ApplyRequest(Flavour(), "123456", System.Array.Empty<AppCatalogEntry>());
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap");
var progress = new List<string>(); var progress = new List<string>();
await sut.RunAsync(req, new Progress<ApplyProgress>(p => progress.Add(p.Stage))); await sut.RunAsync(req, new Progress<ApplyProgress>(p => progress.Add(p.Stage)));
Assert.Equal(new[]{"modules","accounts","bitlocker","bootstrap"}, order); Assert.Equal(new[] { "apps", "bitlocker" }, order);
Assert.Contains("Applying hardening", progress); Assert.Contains("Installing apps", progress);
Assert.Contains("Done", progress);
} }
[Fact] [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 order = new List<string>();
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 run = new Mock<IProcessRunner>();
var bl = new Mock<IBitLockerService>(); var boot = new Mock<IBootstrapService>(); run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, "C:\\hard"); .ReturnsAsync(new ProcessResult(0, "", ""));
var req = new ApplyRequest(new FlavourManifest{ Hardening=new HardeningSpec{Modules=new[]{"00"}}}, "a","b","c","123456","sm-bootstrap"); var bl = new Mock<IBitLockerService>();
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.RunAsync(req, new Progress<ApplyProgress>(_ => {}))); bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
boot.Verify(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never); .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; return m;
} }
[Fact]
public async Task AccountService_throws_on_nonzero_exit()
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
new AccountService(Fail().Object).CreateAccountsAsync("alice", "pw", "apw"));
}
[Fact] [Fact]
public async Task BitLockerService_throws_on_nonzero_exit() public async Task BitLockerService_throws_on_nonzero_exit()
{ {
@@ -33,26 +26,6 @@ public class ApplyServicesTests
new BitLockerService(Fail().Object).EnableAsync("123456")); 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] [Fact]
public async Task BitLockerService_enables_tpm_and_pin() 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 => run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("Shell.Application") && s.Contains("Eject")), It.IsAny<CancellationToken>())); 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

@@ -5,11 +5,32 @@ using Microsoft.Extensions.DependencyInjection;
using SilverOS.Welcome.App.Components; using SilverOS.Welcome.App.Components;
using SilverOS.Welcome.App.Components.Steps; using SilverOS.Welcome.App.Components.Steps;
using SilverOS.Welcome.Core.Apply; using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Flavours; using SilverOS.Welcome.Core.Flavours;
using SilverOS.Welcome.Core.Preconfig;
using Xunit; using Xunit;
public class ApplyStepTests : TestContext public class ApplyStepTests : TestContext
{ {
// The component loads the catalog from AppContext.BaseDirectory/apps; no catalog.json
// is staged in the test bin, so the real AppCatalog degrades to an empty list — which is
// exactly what these tests want (no apps selected → empty Apps on the request).
private static void AddCatalog(IServiceCollection services) =>
services.AddSingleton<IAppCatalog>(new AppCatalog());
// ApplyStep injects IPreconfigStore to clear the pin + mark configured after a
// successful apply; a no-op fake keeps these UI tests off the real filesystem.
private static void AddPreconfig(IServiceCollection services) =>
services.AddSingleton<IPreconfigStore>(new FakePreconfigStore());
private sealed class FakePreconfigStore : IPreconfigStore
{
public Preconfig? Load() => null;
public void ClearPin() { }
public bool IsConfigured() => false;
public void MarkConfigured() { }
}
[Fact] [Fact]
public async Task Calls_apply_with_the_wizard_selections() public async Task Calls_apply_with_the_wizard_selections()
{ {
@@ -17,17 +38,16 @@ public class ApplyStepTests : TestContext
var state = new WizardState var state = new WizardState
{ {
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } }, 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(state);
Services.AddSingleton(apply.Object); Services.AddSingleton(apply.Object);
AddCatalog(Services);
AddPreconfig(Services);
var cut = RenderComponent<ApplyStep>(); var cut = RenderComponent<ApplyStep>();
await cut.InvokeAsync(() => cut.Instance.StartAsync()); await cut.InvokeAsync(() => cut.Instance.StartAsync());
apply.Verify(a => a.RunAsync( 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<IProgress<ApplyProgress>>(),
It.IsAny<CancellationToken>()), Times.Once); It.IsAny<CancellationToken>()), Times.Once);
} }
@@ -41,16 +61,45 @@ public class ApplyStepTests : TestContext
var state = new WizardState var state = new WizardState
{ {
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } }, 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(state);
Services.AddSingleton(apply.Object); Services.AddSingleton(apply.Object);
AddCatalog(Services);
AddPreconfig(Services);
var completed = false; var completed = false;
var cut = RenderComponent<ApplyStep>(p => p.Add(s => s.OnComplete, EventCallback.Factory.Create(this, () => { completed = true; }))); var cut = RenderComponent<ApplyStep>(p => p.Add(s => s.OnComplete, EventCallback.Factory.Create(this, () => { completed = true; })));
await cut.InvokeAsync(() => cut.Instance.StartAsync()); await cut.InvokeAsync(() => cut.Instance.StartAsync());
Assert.True(completed); 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] [Fact]
public async Task Shows_error_and_retry_button_when_apply_fails() public async Task Shows_error_and_retry_button_when_apply_fails()
{ {
@@ -60,10 +109,12 @@ public class ApplyStepTests : TestContext
var state = new WizardState var state = new WizardState
{ {
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } }, 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(state);
Services.AddSingleton(apply.Object); Services.AddSingleton(apply.Object);
AddCatalog(Services);
AddPreconfig(Services);
var cut = RenderComponent<ApplyStep>(); var cut = RenderComponent<ApplyStep>();
await cut.InvokeAsync(() => cut.Instance.StartAsync()); await cut.InvokeAsync(() => cut.Instance.StartAsync());
Assert.Contains("Module 03 failed", cut.Markup); Assert.Contains("Module 03 failed", cut.Markup);

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