90 Commits

Author SHA1 Message Date
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
76 changed files with 5865 additions and 504 deletions

View File

@@ -40,6 +40,22 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Free disk space
shell: pwsh
run: |
# Each successful build persists a ~5 GB ISO to C:\silvermetal\out, and the
# boot.wim now carries WinPE-NetFx/PowerShell (bigger image). On the single-volume
# runner that accumulation starves oscdimg ("Insufficient disk space"). Clear the
# prior build output + any stale work dir before building so space self-heals.
$before = [math]::Round((Get-PSDrive C).Free/1GB,1)
Remove-Item 'C:\silvermetal\out\*' -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem 'C:\gitea-runner\workspace' -Directory -ErrorAction SilentlyContinue | ForEach-Object {
$t = Join-Path $_.FullName 'tmp\smbuild'
if (Test-Path $t) { Remove-Item $t -Recurse -Force -ErrorAction SilentlyContinue }
}
$after = [math]::Round((Get-PSDrive C).Free/1GB,1)
Write-Host " C: free ${before}GB -> ${after}GB (cleared prior ISO output + stale smbuild)"
- name: Ensure Windows ADK (oscdimg)
shell: pwsh
run: |
@@ -58,6 +74,28 @@ jobs:
}
if (-not (Test-Path $deploy)) { throw 'ADK Deployment Tools install failed.' }
- name: Ensure Windows ADK WinPE add-on
shell: pwsh
run: |
# build.ps1 (Invoke-ForceLegacySetup) calls Add-WindowsPackage with the
# WinPE_OCs cabs, which only exist if the ADK WinPE add-on is installed.
$ocs = 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Windows Preinstallation Environment\amd64\WinPE_OCs'
if (Test-Path $ocs) {
Write-Host 'ADK WinPE add-on (WinPE_OCs) already present.'; exit 0
}
Write-Host 'Installing ADK WinPE add-on...'
# Prefer winget; fall back to the WinPE add-on web installer.
$ok = $false
try { winget install --id Microsoft.ADKPEAddon -e --accept-source-agreements --accept-package-agreements --silent; $ok = $true } catch {}
if (-not $ok) {
# NOTE: fwlink id is ADK-version-specific; update if the channel rolls.
Invoke-WebRequest 'https://go.microsoft.com/fwlink/?linkid=2289981' -OutFile "$env:TEMP\adkwinpesetup.exe"
Start-Process "$env:TEMP\adkwinpesetup.exe" -ArgumentList '/quiet','/norestart','/features','OptionId.WindowsPreinstallationEnvironment' -Wait
}
# The WinPE collector is a required, core feature of this image, so a missing
# WinPE_OCs dir is a hard build gate (fail fast with a clear message).
if (-not (Test-Path $ocs)) { throw 'ADK WinPE add-on install failed (WinPE_OCs missing)' }
- name: Setup .NET 9 SDK
uses: actions/setup-dotnet@v4
with:
@@ -108,11 +146,31 @@ jobs:
}
"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
shell: pwsh
run: |
.\windows\installer\build.ps1 `
-SourceIso '${{ steps.iso.outputs.path }}' `
-WorkDir "$env:RUNNER_TEMP\smbuild" `
-OutputIso "$env:RUNNER_TEMP\out\SilverMetal-Enhanced-Windows.iso"
- name: Validate baked payload (offline assertions)

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# Brainstorming / design scratch (mockups, companion state) — durable specs live in docs/
.superpowers/
# Build outputs
build/output/
build/cache/

View File

@@ -0,0 +1,5 @@
#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" }

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 + 48))
$form.Controls.Add($btnCancel)
$btnFinish = New-Object Windows.Forms.Button
$btnFinish.Text = 'Finish'
Style-Button $btnFinish $true
$btnFinish.Location = New-Object Drawing.Point(750, ($y + 48))
$form.Controls.Add($btnFinish)
# --- behaviour -------------------------------------------------------------
$btnDefaults.Add_Click({
$txtDisplay.Text = 'SilverMetal User'
$txtUser.Text = 'silver'
$txtComputer.Text = 'SILVER-PC'
# Leave passwords blank on purpose: the user must set them.
$txtPass.Text = ''; $txtPassC.Text = ''
foreach ($rb in $radioFlavours) { $rb.Checked = ($rb.Tag -eq 'daily-driver') }
$chkBitLocker.Checked = $false
$txtPin.Text = ''; $txtPinC.Text = ''
$lblStatus.ForeColor = $colMuted
$lblStatus.Text = 'Defaults applied. Set a password to continue.'
})
$btnCancel.Add_Click({ [Environment]::Exit(1) })
$form.Add_KeyDown({ if ($_.KeyCode -eq 'Escape') { [Environment]::Exit(1) } })
$btnFinish.Add_Click({
$lblStatus.ForeColor = $colError
$rUser = Test-SmUsername $txtUser.Text
if (-not $rUser.Ok) { $lblStatus.Text = $rUser.Message; return }
$rPass = Test-SmPassword $txtPass.Text $txtPassC.Text
if (-not $rPass.Ok) { $lblStatus.Text = $rPass.Message; return }
$rComp = Test-SmComputerName $txtComputer.Text
if (-not $rComp.Ok) { $lblStatus.Text = $rComp.Message; return }
$blEnabled = $chkBitLocker.Checked
$pin = ''
if ($blEnabled) {
$rPin = Test-SmPin $txtPin.Text $txtPinC.Text
if (-not $rPin.Ok) { $lblStatus.Text = $rPin.Message; return }
$pin = $txtPin.Text
}
$flavour = 'daily-driver'
foreach ($rb in $radioFlavours) { if ($rb.Checked) { $flavour = [string]$rb.Tag } }
$display = if ([string]::IsNullOrWhiteSpace($txtDisplay.Text)) { $txtUser.Text } else { $txtDisplay.Text }
$lblStatus.ForeColor = $colAccent
$lblStatus.Text = 'Generating answer file and starting Windows Setup...'
$form.Refresh()
$xml = New-SmAnswerFile -DisplayName $display -Username $txtUser.Text -Password $txtPass.Text `
-ComputerName $txtComputer.Text -Flavour $flavour `
-BitLockerEnable $blEnabled -BitLockerPin $pin
Set-Content -Path 'X:\sm\unattend.generated.xml' -Value $xml -Encoding UTF8
try { [void][xml](Get-Content 'X:\sm\unattend.generated.xml' -Raw) }
catch { [Environment]::Exit(1) } # bad XML -> fall back to default answer file
$setup = if (Test-Path 'X:\sources\setup.exe') { 'X:\sources\setup.exe' } else { 'X:\setup.exe' }
Start-Process -FilePath $setup -ArgumentList '/unattend:X:\sm\unattend.generated.xml' -Wait
[Environment]::Exit(0)
})
[void]$form.ShowDialog()
# If the form closes without Finish/Cancel handling exiting, treat as cancel.
[Environment]::Exit(1)
}
catch {
# Any failure (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).

View File

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

View File

@@ -101,8 +101,8 @@
</LocalAccounts>
</UserAccounts>
<!--
AutoLogon: logs in as sm-bootstrap exactly once so that FirstLogonCommands
can launch the Welcome wizard. After the wizard completes successfully,
AutoLogon: logs in as sm-bootstrap exactly once so FirstLogonCommands can
launch the Welcome wizard. After the wizard completes successfully,
ApplyService removes the AutoAdminLogon registry values and deletes
sm-bootstrap, so the one-time session cannot be re-entered.
-->
@@ -113,16 +113,24 @@
<Password><Value>bootstrap-OneTime!</Value><PlainText>true</PlainText></Password>
</AutoLogon>
<!--
FirstLogonCommands: launch the Welcome wizard ELEVATED (full admin token).
The offline UAC auto-approve policy baked into the image (ConsentPromptBehaviorAdmin=0,
PromptOnSecureDesktop=0) means Start-Process -Verb RunAs silently elevates without
a UAC prompt during this ephemeral sm-bootstrap session. The sm-bootstrap account
is torn down by ApplyService on wizard completion.
Launch the Welcome wizard ELEVATED over the (locked-down) Explorer session.
Explorer stays the shell so the MAUI/WebView2 wizard renders (it does NOT
render when launched as a bare Shell Launcher shell). Configure-Kiosk.ps1
bakes the silent-elevation UAC policy + the lockdown (Keyboard Filter,
DisableTaskMgr, hidden taskbar); the wizard runs fullscreen-topmost on top.
Launch is via a single hidden-window PowerShell (no `cmd /c` wrapper): the
old `cmd /c powershell ...` spawned an extra process AND flashed a visible
console window on the bare first-boot desktop — which itself read as "the
machine is doing something broken" before the wizard appeared. `-WindowStyle
Hidden` + dropping the cmd shim removes that flash and one process off the
critical path. Elevation (-Verb RunAs) is still required for ApplyService
(account/BitLocker/hardening) and is silent thanks to the baked UAC policy.
-->
<FirstLogonCommands>
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<Order>1</Order>
<CommandLine>cmd /c powershell -NoProfile -ExecutionPolicy Bypass -Command &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>
</SynchronousCommand>
</FirstLogonCommands>

View File

@@ -53,6 +53,23 @@ $m = Get-Content $Manifest -Raw | ConvertFrom-Json
$isoRoot = Join-Path $WorkDir 'iso' # writable copy of ISO contents
$mount = Join-Path $WorkDir 'mount' # install.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)
# --- 1. Verify input -------------------------------------------------------
@@ -101,8 +118,25 @@ function Invoke-ForceLegacySetup {
# unreliable when setup is launched via the CmdLine override (legacy Setup
# otherwise still shows the language page).
Copy-Item (Join-Path $PSScriptRoot 'autounattend\autounattend.xml') (Join-Path $bootmnt 'autounattend.xml') -Force
$setup = if (Test-Path (Join-Path $bootmnt 'sources\setup.exe')) { 'X:\sources\setup.exe' } else { 'X:\setup.exe' }
$cmdline = "$setup /unattend:X:\autounattend.xml"
# Add WinPE .NET + PowerShell so the collector (WinForms) can run in WinPE.
$adk = 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Windows Preinstallation Environment\amd64\WinPE_OCs'
foreach ($oc in @('WinPE-WMI.cab','WinPE-NetFx.cab','WinPE-Scripting.cab','WinPE-PowerShell.cab')) {
$cab = Join-Path $adk $oc
if (Test-Path $cab) { Add-WindowsPackage -Path $bootmnt -PackagePath $cab | Out-Null; Write-Host " added WinPE OC: $oc" }
else { Write-Warning " WinPE OC missing on runner: $cab (collector needs the ADK WinPE add-on; boot.wim assertions will fail without it)" }
}
# Stage the collector + winpeshl so WinPE launches it instead of Setup.
$smDir = Join-Path $bootmnt 'sm'; $null = New-Item -ItemType Directory -Force $smDir
Copy-Item (Join-Path $PSScriptRoot '..\collector\*') $smDir -Recurse -Force
Copy-Item (Join-Path $smDir 'winpeshl.ini') (Join-Path $bootmnt 'Windows\System32\winpeshl.ini') -Force
Write-Host " staged collector to boot.wim \sm\ + winpeshl.ini"
# Setup\CmdLine is the WinPE setup-image shell launch and is AUTHORITATIVE over
# winpeshl.ini -- point it at the SilverMetal collector so the pre-config UI runs
# FIRST. The collector then launches the LEGACY setup.exe itself (X:\sources\setup.exe,
# preserving the legacy-Setup bypass) with its generated answer file, or falls back to
# the default autounattend.xml on cancel/error. (Pointing Setup\CmdLine straight at
# setup.exe bypassed the collector entirely -- it won over winpeshl.ini.)
$cmdline = "cmd /c X:\sm\Start-Collector.cmd"
$hive = Join-Path $bootmnt 'Windows\System32\config\SYSTEM'
& reg load 'HKLM\SM_BOOT' $hive | Out-Null
try {
@@ -155,6 +189,33 @@ function Copy-WelcomePayload {
} else {
Write-Warning " No *.json flavour files found in $flavoursDir -- image will ship with no flavours."
}
# Stage the app catalog + configure/bootstrap scripts next to the Welcome app
# (mirrors the flavours copy above): catalog.json, configure\*.ps1, bootstrap-winget.ps1.
$appsDest = Join-Path $dest 'apps'
$null = New-Item -ItemType Directory -Force $appsDest
$appsDir = Join-Path $WindowsDir 'apps'
if (Test-Path $appsDir) {
Copy-Item (Join-Path $appsDir '*') $appsDest -Recurse -Force
Write-Host " Copied app catalog + scripts to $appsDest"
} else {
Write-Warning " No apps dir found at $appsDir -- image will ship with no app catalog."
}
# Stage the fixed-version WebView2 runtime, if vendored, next to the app.
# Cold-start + air-gap: a fixed-version runtime is just files (no installer
# step at first boot) and removes the dependency on whether IoT Enterprise LTSC
# ships WebView2 at all. Operator populates windows\welcome\runtime\webview2\
# with an EXTRACTED "Microsoft Edge WebView2 Fixed Version" distribution (the
# folder that contains msedgewebview2.exe) -- handled like the drivers dir:
# absent is allowed (VM/dev test), in which case the app falls back to Evergreen.
$wv2Src = Join-Path $WindowsDir 'welcome\runtime\webview2'
if (Test-Path (Join-Path $wv2Src 'msedgewebview2.exe')) {
$wv2Dest = Join-Path $dest 'webview2'
$null = New-Item -ItemType Directory -Force $wv2Dest
Copy-Item (Join-Path $wv2Src '*') $wv2Dest -Recurse -Force
Write-Host " Staged fixed-version WebView2 runtime to $wv2Dest"
} else {
Write-Warning " No fixed-version WebView2 runtime at $wv2Src (expected msedgewebview2.exe) -- image will rely on the Evergreen runtime being present at first boot. See windows\welcome\runtime\webview2\README.md."
}
# --- Guard: verify the payload actually landed in the mounted image -------
$stagedExe = Join-Path $dest 'SilverOS.Welcome.App.exe'
if (-not (Test-Path $stagedExe)) {
@@ -164,6 +225,10 @@ function Copy-WelcomePayload {
if (-not $stagedFlavours) {
throw "Welcome bake failed: no flavour manifests staged in '$destFlavours'. Add *.json files under windows/flavours/ or the installed wizard will have no flavour choices."
}
$stagedCatalog = Join-Path $appsDest 'catalog.json'
if (-not (Test-Path $stagedCatalog)) {
throw "Welcome bake failed: app catalog.json missing from image (expected at '$stagedCatalog'). Add windows/apps/catalog.json or the wizard's Apps step will be empty."
}
Write-Host " Welcome payload staged at $dest"
}
@@ -192,6 +257,13 @@ function Invoke-ServiceWim {
Write-Host ' adding drivers'; Add-WindowsDriver -Path $mount -Driver $drv -Recurse | Out-Null
} 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).
$list = Join-Path $WindowsDir 'debloat\appx-remove.txt'
if (Test-Path $list) {
@@ -208,18 +280,30 @@ function Invoke-ServiceWim {
$scripts = Join-Path $mount 'Windows\Setup\Scripts'
$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\Configure-Kiosk.ps1') $scripts -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.
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
# Start-Process -Verb RunAs in FirstLogonCommands) silently elevates during
# the ephemeral sm-bootstrap session without a UAC prompt.
# Shell Launcher v2 (Configure-Kiosk.ps1) as the sm-bootstrap shell, which
# 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
# policy for the daily user. Only applies when Welcome is enabled.
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'
& reg load HKLM\SM_OFFLINE "$hive" | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'reg load SOFTWARE hive failed' }
@@ -247,7 +331,13 @@ function Invoke-InjectUnattend {
}
# --- 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 -------------------------------------------------------------
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%"
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" (
echo [%DATE% %TIME%] hardening deferred to SilverOS Welcome >> "%LOG%"
) else (
powershell -NoProfile -ExecutionPolicy Bypass -File "%HARD%\Invoke-Hardening.ps1" >> "%LOG%" 2>&1
)
REM Plaintext-password hygiene: delete the cached answer file that holds the
REM local account password in clear text. Runs as SYSTEM after accounts exist.
del /f /q "%WINDIR%\Panther\unattend.xml" 2>nul
del /f /q "%WINDIR%\Panther\Unattend\unattend.xml" 2>nul
echo [%DATE% %TIME%] SilverMetal first-boot done >> "%LOG%"
exit /b 0

View File

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

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
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 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();
}
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"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:SilverOS.Welcome.App"
xmlns:components="clr-namespace:SilverOS.Welcome.App.Components;assembly=SilverOS.Welcome.UI"
x:Class="SilverOS.Welcome.App.MainPage">
<BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html">
<BlazorWebView.RootComponents>
<RootComponent Selector="#app" ComponentType="{x:Type components:Routes}" />
</BlazorWebView.RootComponents>
</BlazorWebView>
<!--
A native MAUI splash sits ON TOP of the BlazorWebView. MAUI controls render
immediately when the window is shown — they do NOT wait on WebView2/.NET JIT —
so the user sees branded "loading" within the first frame instead of a blank
window for the seconds it takes WebView2 to cold-start and Blazor to boot.
The overlay is dismissed in MainPage.xaml.cs once WV2 finishes its first
navigation (the index.html splash then carries the eye through Blazor's boot).
-->
<Grid BackgroundColor="#0b0f14">
<BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html"
BlazorWebViewInitialized="OnBlazorInitialized"
UrlLoading="OnUrlLoading">
<BlazorWebView.RootComponents>
<RootComponent Selector="#app" ComponentType="{x:Type components:Routes}" />
</BlazorWebView.RootComponents>
</BlazorWebView>
<Grid x:Name="SplashOverlay"
BackgroundColor="#0b0f14"
InputTransparent="False">
<VerticalStackLayout HorizontalOptions="Center"
VerticalOptions="Center"
Spacing="22">
<Label Text="SilverOS"
HorizontalOptions="Center"
FontFamily="OpenSansRegular"
FontSize="42"
FontAutoScalingEnabled="False"
TextColor="#e8edf5" />
<Label Text="WELCOME"
HorizontalOptions="Center"
FontSize="13"
CharacterSpacing="8"
TextColor="#00d4ff" />
<ActivityIndicator IsRunning="True"
Color="#00d4ff"
HeightRequest="34"
WidthRequest="34"
HorizontalOptions="Center"
Margin="0,10,0,0" />
<Label Text="Preparing your setup…"
HorizontalOptions="Center"
FontSize="14"
TextColor="#8fa4bc" />
</VerticalStackLayout>
</Grid>
</Grid>
</ContentPage>

View File

@@ -1,9 +1,62 @@
namespace SilverOS.Welcome.App;
using Microsoft.AspNetCore.Components.WebView;
namespace SilverOS.Welcome.App;
public partial class MainPage : ContentPage
{
bool _splashDismissed;
public MainPage()
{
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 SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Flavours;
using SilverOS.Welcome.Core.Preconfig;
using SilverOS.Welcome.App.Components;
namespace SilverOS.Welcome.App;
@@ -16,6 +18,23 @@ public static class MauiProgram
Directory.CreateDirectory(wv2);
Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", wv2);
// Cold-start + air-gap: prefer a fixed-version WebView2 runtime baked next to
// the app (build.ps1 stages it under .\webview2). This removes two first-boot
// costs at once: (1) the Evergreen-runtime registry probe, and (2) the risk
// that IoT Enterprise LTSC ships WITHOUT WebView2 entirely — in which case the
// wizard would block/blank waiting on an on-demand install. If the baked folder
// is absent (VM/dev test), fall through to whatever Evergreen runtime exists.
var fixedRuntime = Path.Combine(AppContext.BaseDirectory, "webview2");
if (File.Exists(Path.Combine(fixedRuntime, "msedgewebview2.exe")))
{
Environment.SetEnvironmentVariable("WEBVIEW2_BROWSER_EXECUTABLE_FOLDER", fixedRuntime);
Diag.Log("WebView2: using baked fixed-version runtime at " + fixedRuntime);
}
else
{
Diag.Log("WebView2: no baked runtime found; relying on installed Evergreen");
}
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
@@ -31,18 +50,16 @@ public static class MauiProgram
builder.Logging.AddDebug();
#endif
var hardeningDir = @"C:\Windows\Setup\Scripts\hardening";
builder.Services.AddSingleton<IProcessRunner, ProcessRunner>();
builder.Services.AddSingleton<IFlavourLoader, FlavourLoader>();
builder.Services.AddSingleton<IAccountService, AccountService>();
builder.Services.AddSingleton<IAppCatalog, AppCatalog>();
builder.Services.AddSingleton<IBitLockerService, BitLockerService>();
builder.Services.AddSingleton<IBootstrapService, BootstrapService>();
var appsDir = Path.Combine(AppContext.BaseDirectory, "apps");
builder.Services.AddSingleton<IAppInstaller>(sp => new AppInstaller(sp.GetRequiredService<IProcessRunner>(), appsDir));
builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
sp.GetRequiredService<IProcessRunner>(),
sp.GetRequiredService<IAccountService>(),
sp.GetRequiredService<IBitLockerService>(),
sp.GetRequiredService<IBootstrapService>(),
hardeningDir));
sp.GetRequiredService<IAppInstaller>()));
builder.Services.AddSingleton<IPreconfigStore>(_ => new PreconfigStore(@"C:\ProgramData\SilverMetal"));
builder.Services.AddScoped<WizardState>();
return builder.Build();

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>
<SupportedOSPlatformVersion>10.0.19041.0</SupportedOSPlatformVersion>
<!--
Cold-start: precompile to ReadyToRun so the self-contained image does not
JIT the whole app on first launch (this wizard runs exactly once, on the
slowest possible "fresh OS, cold disk" path, so first-run JIT is pure cost).
R2R only — trimming/NativeAOT are NOT safe for MAUI Blazor Hybrid (heavy
reflection in Blazor + DI). Larger binaries are fine: the payload is baked
into the image, never downloaded. Only takes effect on `dotnet publish -r win-x64`.
-->
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
</PropertyGroup>
<ItemGroup>

View File

@@ -139,6 +139,19 @@ body {
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-ui {
background: #1a0a0a;
@@ -200,9 +213,23 @@ h1:focus { outline: none; }
.wizard {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
max-width: 760px;
margin: 0 auto;
position: fixed;
inset: 5vh 7vw; /* float as a card inset from the wall edges */
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 ─────────────────────────────────────────────────── */
@@ -276,6 +303,58 @@ h1:focus { outline: none; }
gap: 1rem;
}
/* ── Boot splash (pre-Blazor; mirrors the native MAUI splash) ───────── */
.sm-boot {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.4rem;
background: var(--clr-void);
z-index: 2000;
}
.sm-boot-title {
font-family: var(--font-mono);
font-size: 2.6rem;
font-weight: 300;
letter-spacing: -0.02em;
color: var(--clr-text-hi);
}
.sm-boot-kicker {
font-family: var(--font-mono);
font-size: 0.8rem;
letter-spacing: 0.55em;
text-indent: 0.55em; /* balance the trailing letter-spacing */
color: var(--clr-accent);
}
.sm-boot-spinner {
width: 34px;
height: 34px;
border: 3px solid var(--clr-border-hi);
border-top-color: var(--clr-accent);
border-radius: 50%;
animation: sm-spin 0.8s linear infinite;
}
@keyframes sm-spin {
to { transform: rotate(360deg); }
}
.sm-boot-text {
font-family: var(--font-ui);
font-size: 0.9rem;
color: var(--clr-text-mid);
}
@media (prefers-reduced-motion: reduce) {
.sm-boot-spinner { animation: none; }
}
/* ── Loading / error states ─────────────────────────────────────────── */
.loading {
font-family: var(--font-mono);
@@ -850,3 +929,38 @@ h1:focus { outline: none; }
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 id="app">Loading...</div>
<!--
In-page boot splash. Lives INSIDE #app so Blazor wipes it automatically the
moment the root component first renders. Styled to match the native MAUI
splash (same void bg + electric-ice accent), so handoff native -> webview ->
Blazor reads as one continuous loading screen rather than three flashes.
Inline-styled on the wrapper so it shows even before app.css paints.
-->
<div id="app" style="background:#0b0f14">
<div class="sm-boot">
<div class="sm-boot-title">SilverOS</div>
<div class="sm-boot-kicker">WELCOME</div>
<div class="sm-boot-spinner" aria-hidden="true"></div>
<div class="sm-boot-text">Preparing your setup…</div>
</div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.

View File

@@ -1,25 +0,0 @@
namespace SilverOS.Welcome.Core.Apply;
public sealed class AccountService(IProcessRunner runner) : IAccountService
{
public async Task CreateAccountsAsync(string user, string password, string adminPassword, CancellationToken ct = default)
{
// Daily account = Standard User (Users group only — NOT Administrators).
await Ps($"$p=ConvertTo-SecureString '{Esc(password)}' -AsPlainText -Force; " +
$"New-LocalUser -Name '{Esc(user)}' -Password $p -FullName '{Esc(user)}' -AccountNeverExpires; " +
$"Add-LocalGroupMember -Group 'Users' -Member '{Esc(user)}'", "Daily account creation", ct);
// Separate elevation account.
await Ps($"$a=ConvertTo-SecureString '{Esc(adminPassword)}' -AsPlainText -Force; " +
$"New-LocalUser -Name 'SilverOS Admin' -Password $a -AccountNeverExpires; " +
$"Add-LocalGroupMember -Group 'Administrators' -Member 'SilverOS Admin'", "Admin account creation", ct);
}
// $ErrorActionPreference='Stop' turns the (otherwise non-terminating) cmdlet errors into a
// non-zero exit so EnsureSuccess can surface them instead of silently continuing.
private async Task Ps(string script, string operation, CancellationToken ct)
{
var r = await runner.RunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -Command \"$ErrorActionPreference='Stop'; {script}\"", ct);
r.EnsureSuccess(operation);
}
private static string Esc(string s) => s.Replace("'", "''");
}

View File

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

View File

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

View File

@@ -31,13 +31,28 @@ public sealed class BitLockerService(IProcessRunner runner) : IBitLockerService
"$p=ConvertTo-SecureString '", p, "' -AsPlainText -Force; ",
"$v=Get-BitLockerVolume -MountPoint $mp; ",
"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' })) { ",
"Add-BitLockerKeyProtector -MountPoint $mp -TpmAndPinProtector -Pin $p }; ",
"$kp=(Get-BitLockerVolume -MountPoint $mp).KeyProtector; ",
"if ($kp | Where-Object { $_.KeyProtectorType -eq 'TpmPin' }) { ",
"$kp | Where-Object { $_.KeyProtectorType -eq 'Tpm' } | ForEach-Object { ",
"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
// the end — this is what actually matters (a benign non-terminating warning alone
// 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,83 @@
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;
// 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)
{
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;
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));
}
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)?
if ((await TryRunAsync("winget", "--version", ct)).ExitCode == 0) return "winget";
// 2) Provision App Installer via the bundled bootstrap (or registered package), then re-probe.
progress.Report(new("Preparing app installer", 68));
var bootstrap = Path.Combine(appsDir, "bootstrap-winget.ps1");
await TryRunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -Command \"if (Test-Path '{bootstrap}') {{ & '{bootstrap}' }} else {{ " +
"Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe -EA SilentlyContinue }}\"",
ct);
if ((await TryRunAsync("winget", "--version", ct)).ExitCode == 0) return "winget";
// 3) Fall back to the WindowsApps execution-alias path (bare-name launch can fail under
// UseShellExecute=false even when winget is installed).
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var aliased = Path.Combine(local, "Microsoft", "WindowsApps", "winget.exe");
if (File.Exists(aliased) && (await TryRunAsync(aliased, "--version", ct)).ExitCode == 0) return aliased;
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,22 @@
@using SilverOS.Welcome.App.Components.Steps
@using SilverOS.Welcome.Core.Flavours
@using SilverOS.Welcome.Core.Apps
@using SilverOS.Welcome.Core.Preconfig
@inject IFlavourLoader FlavourLoader
@inject IAppCatalog AppCatalog
@inject IPreconfigStore PreconfigStore
@inject WizardState State
@if (_toolboxHome)
{
<div class="toolbox-home">
<h1>SilverMetal</h1>
<p class="toolbox-home-subtitle">Your device is set up and ready to go.</p>
<button class="btn-secondary" @onclick="ReRunSetup">Re-run setup</button>
</div>
}
else
{
<div class="wizard">
<div class="wizard-header">
<div class="wizard-steps-indicator">
@@ -37,16 +51,16 @@
<WelcomeStep />
break;
case 1:
<FlavourStep Flavours="_flavours" />
<FlavourStep Flavours="_flavours" OnSelected="StateHasChanged" />
break;
case 2:
<AccountStep OnValidityChanged="@(v => { _accountValid = v; StateHasChanged(); })" />
<AppsStep Apps="_catalog.AppsForRole(State.Flavour?.Id ?? string.Empty)" />
break;
case 3:
<PrefsStep />
break;
case 4:
<ApplyStep OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" />
<ApplyStep AutoStart="_autoApply" OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" />
break;
case 5:
<DoneStep />
@@ -71,29 +85,42 @@
}
</div>
</div>
}
@code {
private static readonly string[] _stepTitles = { "Welcome", "Flavour", "Account", "Prefs", "Apply", "Done" };
private static readonly string[] _stepTitles = { "Welcome", "Flavour", "Apps", "Prefs", "Apply", "Done" };
// Flavours dir: baked alongside the exe at publish time.
private static readonly string FlavoursDir = Path.Combine(
AppContext.BaseDirectory, "flavours");
// Apps catalog dir: baked alongside the exe at publish time.
private static readonly string AppsDir = Path.Combine(
AppContext.BaseDirectory, "apps");
private LoadedCatalog _catalog = new(Array.Empty<AppCatalogEntry>());
private int _currentStep = 0;
private bool _loading = true;
private bool _applyRunning = false;
private bool _accountValid = false;
private bool _toolboxHome = false;
private bool _autoApply = false;
private string? _error;
private IReadOnlyList<FlavourManifest> _flavours = Array.Empty<FlavourManifest>();
private bool CanGoNext => _currentStep switch
{
1 => State.Flavour is not null,
2 => _accountValid,
// 2 = Apps step is always valid (never blocks Next).
_ => true
};
protected override Task OnInitializedAsync() => LoadFlavours();
protected override Task OnInitializedAsync()
{
LoadFlavours();
SeedFromPreconfig();
return Task.CompletedTask;
}
private Task LoadFlavours()
{
@@ -102,6 +129,7 @@
try
{
_flavours = FlavourLoader.Load(FlavoursDir);
_catalog = AppCatalog.Load(AppsDir);
}
catch (Exception ex)
{
@@ -114,10 +142,55 @@
return Task.CompletedTask;
}
// Runs AFTER flavours + catalog are loaded (order matters): decides run-mode and,
// on a first run, pre-seeds wizard state from the WinPE collector's choices.
private void SeedFromPreconfig()
{
var pre = PreconfigStore.Load();
if (PreconfigStore.IsConfigured())
{
// Already ran once -> open the minimal toolbox-home landing, never auto-apply.
_toolboxHome = true;
return;
}
if (pre is null)
return; // fail-open: no preconfig -> normal wizard with flavour defaults.
// Match the collector's flavour by id; fall back to the loaded default if absent.
State.Flavour = _flavours.FirstOrDefault(f => f.Id == pre.Flavour)
?? _flavours.FirstOrDefault(f => f.IsDefault)
?? _flavours.FirstOrDefault();
foreach (var id in _catalog.DefaultSelectionForRole(pre.Flavour))
State.SelectedApps.Add(id);
if (pre.Bitlocker.Enable && !string.IsNullOrEmpty(pre.Bitlocker.Pin))
State.BitLockerPin = pre.Bitlocker.Pin;
// First run with a preconfig: the collector already captured account + flavour, so
// skip Welcome/Flavour and land on the Apps step (pre-checked with the flavour's
// defaults) so the user can review/adjust the app selection before applying. From
// there it's Apps -> Prefs -> Apply -> Done. (No auto-apply: the picker is the point.)
_currentStep = 2;
}
private void ReRunSetup()
{
_toolboxHome = false;
_currentStep = 0;
}
void Next()
{
if (_currentStep < _stepTitles.Length - 1)
_currentStep++;
// On entering the Apps step, seed the per-role default selection once.
if (_currentStep == 2 && State.SelectedApps.Count == 0 && State.Flavour is not null)
foreach (var id in _catalog.DefaultSelectionForRole(State.Flavour.Id))
State.SelectedApps.Add(id);
}
void Back()

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

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,12 +1,62 @@
@using QRCoder
@inject SilverOS.Welcome.Core.Apply.IProcessRunner ProcessRunner
<div class="step done-step">
<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>
@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>
}
<button class="btn-primary btn-restart" @onclick="RestartNow">Restart Now</button>
</div>
@code {
private string? _recoveryKey;
private string? _qrDataUri;
protected override void OnInitialized()
{
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 */ }
}
}
private async Task RestartNow()
{
await ProcessRunner.RunAsync("cmd.exe", "/c shutdown /r /t 5", CancellationToken.None);

View File

@@ -19,13 +19,19 @@
@code {
[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);
await OnSelected.InvokeAsync();
}
void Select(FlavourManifest f)
async Task Select(FlavourManifest f)
{
State.Flavour = f;
await OnSelected.InvokeAsync();
}
}

View File

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

View File

@@ -9,6 +9,8 @@
<ItemGroup>
<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>

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

View File

@@ -19,13 +19,6 @@ public class ApplyServicesTests
return m;
}
[Fact]
public async Task AccountService_throws_on_nonzero_exit()
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
new AccountService(Fail().Object).CreateAccountsAsync("alice", "pw", "apw"));
}
[Fact]
public async Task BitLockerService_throws_on_nonzero_exit()
{
@@ -33,26 +26,6 @@ public class ApplyServicesTests
new BitLockerService(Fail().Object).EnableAsync("123456"));
}
// Note: BootstrapService is intentionally best-effort (teardown cleanups must not fail the
// apply — auto-logon is already neutralised by the answer file's LogonCount=1), so it does
// NOT throw on a non-zero exit.
[Fact]
public async Task AccountService_creates_standard_daily_and_admin()
{
var run = Ok();
await new AccountService(run.Object).CreateAccountsAsync("alice", "pw1", "adminpw");
// daily user is a Standard user (added to Users, NOT Administrators)
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("New-LocalUser") && s.Contains("alice")), It.IsAny<CancellationToken>()));
// negative: the daily-user New-LocalUser call must never mention Administrators
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("New-LocalUser") && s.Contains("alice") && !s.Contains("Administrators")),
It.IsAny<CancellationToken>()), Times.Once);
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("'SilverOS Admin'") && s.Contains("Administrators")), It.IsAny<CancellationToken>()));
}
[Fact]
public async Task BitLockerService_enables_tpm_and_pin()
{
@@ -82,15 +55,4 @@ public class ApplyServicesTests
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("Shell.Application") && s.Contains("Eject")), It.IsAny<CancellationToken>()));
}
[Fact]
public async Task BootstrapService_removes_autologon_and_account()
{
var run = Ok();
await new BootstrapService(run.Object).TearDownAsync("sm-bootstrap");
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("AutoAdminLogon") && s.Contains("0")), It.IsAny<CancellationToken>()));
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("Remove-LocalUser") && s.Contains("sm-bootstrap")), It.IsAny<CancellationToken>()));
}
}

View File

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

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