39 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
35 changed files with 2224 additions and 26 deletions

View File

@@ -108,11 +108,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,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
}

File diff suppressed because it is too large Load Diff

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

@@ -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,16 @@
<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.
-->
<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>cmd /c powershell -NoProfile -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 -------------------------------------------------------
@@ -192,6 +209,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 +232,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 +283,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,46 @@
#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'
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'
# --- 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,6 +14,19 @@ 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 (

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

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

@@ -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,11 +1,13 @@
<?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 x:Name="blazorWebView" HostPage="wwwroot/index.html"
BlazorWebViewInitialized="OnBlazorInitialized"
UrlLoading="OnUrlLoading">
<BlazorWebView.RootComponents>
<RootComponent Selector="#app" ComponentType="{x:Type components:Routes}" />
</BlazorWebView.RootComponents>

View File

@@ -1,9 +1,34 @@
namespace SilverOS.Welcome.App;
using Microsoft.AspNetCore.Components.WebView;
namespace SilverOS.Welcome.App;
public partial class MainPage : ContentPage
{
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}");
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
}
void OnUrlLoading(object? sender, UrlLoadingEventArgs e)
=> Diag.Log("UrlLoading: " + e.Url);
}

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

@@ -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 ─────────────────────────────────────────────────── */
@@ -850,3 +877,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

@@ -33,6 +33,7 @@ public sealed class ApplyService(IProcessRunner runner, IAccountService accounts
await bitlocker.EnableAsync(req.BitLockerPin, ct);
progress.Report(new("Finishing up", 95));
await bootstrap.RevertKioskAsync(ct); // revert kiosk before account deletion (SID must still resolve)
await bootstrap.TearDownAsync(req.BootstrapUser, ct); // last — only after success
progress.Report(new("Done", 100));
}

View File

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

View File

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

View File

@@ -37,7 +37,7 @@
<WelcomeStep />
break;
case 1:
<FlavourStep Flavours="_flavours" />
<FlavourStep Flavours="_flavours" OnSelected="StateHasChanged" />
break;
case 2:
<AccountStep OnValidityChanged="@(v => { _accountValid = v; StateHasChanged(); })" />

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

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

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