SilverMetal Windows: first-boot experience & branding #6

Merged
SilverLABS merged 25 commits from feat/first-boot-branding into main 2026-06-09 14:30:04 +00:00
27 changed files with 1864 additions and 25 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,80 @@
#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,65 @@
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,17 @@
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,10 +101,11 @@
</LocalAccounts>
</UserAccounts>
<!--
AutoLogon: logs in as sm-bootstrap exactly once so that 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.
AutoLogon: logs in as sm-bootstrap exactly once so that Shell Launcher v2
(configured by Configure-Kiosk.ps1, run from SetupComplete.cmd) can launch
the Welcome wizard as the sm-bootstrap session shell. After the wizard
completes successfully, ApplyService removes the AutoAdminLogon registry
values and deletes sm-bootstrap, so the one-time session cannot be re-entered.
-->
<AutoLogon>
<Enabled>true</Enabled>
@@ -113,19 +114,10 @@
<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.
The Welcome wizard is launched by Shell Launcher v2 as the sm-bootstrap
session shell (Configure-Kiosk.ps1, run from SetupComplete.cmd). No
FirstLogonCommands launch is needed; adding one would double-launch.
-->
<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>
<Description>Launch SilverOS Welcome elevated</Description>
</SynchronousCommand>
</FirstLogonCommands>
<RegisteredOwner>SilverMetal</RegisteredOwner>
<RegisteredOrganization>SilverLABS</RegisteredOrganization>
<!--

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,25 @@ 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 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 +278,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,65 @@
#Requires -Version 5.1
<#
.SYNOPSIS Configure the one-time sm-bootstrap onboarding kiosk.
.DESCRIPTION
Runs from SetupComplete.cmd as SYSTEM, after accounts exist, before first
logon. Sets the sm-bootstrap shell to an elevating launcher for the Welcome
app (no Explorer => no taskbar/Start), turns on the Keyboard Filter for shell
hotkeys, and disables Task Manager / lock / fast-user-switch escapes.
Reverted by the Welcome app's ApplyService on wizard success.
#>
[CmdletBinding()]
param([string]$BootstrapUser='sm-bootstrap',
[string]$WelcomeExe='C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe')
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 }
# Elevating launcher: Shell Launcher runs this as the shell; it relaunches the
# Welcome app elevated (silent via the baked UAC auto-approve).
$launcher='C:\Windows\Setup\Scripts\Start-WelcomeShell.cmd'
$welcomeEscaped = $WelcomeExe.Replace("'","''")
@"
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -LiteralPath '$welcomeEscaped' -Verb RunAs"
REM Shell Launcher tracks this CMD process; the Welcome app runs detached above.
REM Loop keeps the process alive so Shell Launcher doesn't restart it on idle.
:loop
timeout /t 3600 >nul
goto loop
"@ | Set-Content $launcher -Encoding ASCII
Log "wrote launcher $launcher"
# --- Shell Launcher v2 (WMI bridge) ---
$cls='root\standardcimv2\embedded'
# Enable Shell Launcher FIRST, then fetch a fresh instance (the pre-enable
# snapshot's instance methods can silently no-op on some WESL builds).
Invoke-CimMethod -Namespace $cls -ClassName WESL_UserSetting -MethodName SetEnabled -Arguments @{Enabled=$true} | Out-Null
$wesl=Get-CimInstance -Namespace $cls -ClassName WESL_UserSetting -ErrorAction Stop
# Default shell stays Explorer for everyone else.
Invoke-CimMethod -InputObject $wesl -MethodName SetDefaultShell -Arguments @{Shell='explorer.exe';DefaultAction=[uint32]0} | Out-Null
# sm-bootstrap => the elevating launcher; on exit, restart the shell (action 0).
Invoke-CimMethod -InputObject $wesl -MethodName SetCustomShell -Arguments @{
Sid=(New-Object System.Security.Principal.NTAccount($BootstrapUser)).Translate([System.Security.Principal.SecurityIdentifier]).Value
Shell="cmd.exe /c `"$launcher`""
DefaultAction=[uint32]0
} | Out-Null
Log 'shell launcher configured for sm-bootstrap'
# --- Keyboard Filter (block shell hotkeys) ---
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'){
$p=Get-CimInstance -Namespace $kf -ClassName WEKF_PredefinedKey -Filter "Id='$combo'" -ErrorAction SilentlyContinue
if($p){ $p.Enabled=$true; Set-CimInstance -InputObject $p }
}
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
Log 'escape policies set; kiosk ready'

View File

@@ -14,6 +14,11 @@ set HARD=C:\Windows\Setup\Scripts\hardening
echo [%DATE% %TIME%] SilverMetal first-boot start >> "%LOG%"
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

@@ -9,6 +9,14 @@ public partial class App : Application
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new MainPage()) { Title = "SilverOS.Welcome.App" };
var window = new Window(new MainPage()) { Title = "SilverMetal Windows" };
#if WINDOWS
window.HandlerChanged += (s, e) =>
{
if (window.Handler?.PlatformView is Microsoft.UI.Xaml.Window native)
native.ApplyKioskChrome();
};
#endif
return window;
}
}

View File

@@ -0,0 +1,26 @@
#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.
public static void ApplyKioskChrome(this Microsoft.UI.Xaml.Window winuiWindow)
{
var hwnd = WindowNative.GetWindowHandle(winuiWindow);
var id = Win32Interop.GetWindowIdFromWindow(hwnd);
var appWindow = AppWindow.GetFromWindowId(id);
if (appWindow.Presenter is OverlappedPresenter p)
{
p.SetBorderAndTitleBar(false, false);
p.IsResizable = false; p.IsMaximizable = false; p.IsMinimizable = false;
}
appWindow.SetPresenter(AppWindowPresenterKind.FullScreen);
// Block the close box; the wizard exits by rebooting, not by closing.
appWindow.Closing += (s, e) => e.Cancel = 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 ─────────────────────────────────────────────────── */

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

@@ -1,6 +1,29 @@
namespace SilverOS.Welcome.Core.Apply;
public sealed class BootstrapService(IProcessRunner runner) : IBootstrapService
{
// Kiosk revert is BEST-EFFORT (like TearDownAsync): -EA SilentlyContinue throughout.
// If WESL is unavailable the real user still gets Explorer (no custom shell for their
// SID). Intentional: don't fail the apply over a missing WMI class. Must run BEFORE
// TearDownAsync so the sm-bootstrap SID still resolves.
public async Task RevertKioskAsync(CancellationToken ct = default)
{
// Remove sm-bootstrap custom shell entry + disable Shell Launcher's per-user entry.
await Ps(
"$c='root\\\\standardcimv2\\\\embedded';" +
"$w=Get-CimInstance -Namespace $c -ClassName WESL_UserSetting -EA SilentlyContinue;" +
"if($w){" +
"$sid=(New-Object System.Security.Principal.NTAccount('sm-bootstrap')).Translate([System.Security.Principal.SecurityIdentifier]).Value;" +
"Invoke-CimMethod -InputObject $w -MethodName RemoveCustomShell -Arguments @{Sid=$sid} -EA SilentlyContinue | Out-Null;" +
"Invoke-CimMethod -InputObject $w -MethodName SetEnabled -Arguments @{Enabled=$false} -EA SilentlyContinue | Out-Null" +
"}",
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",
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

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

@@ -0,0 +1,102 @@
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_removes_custom_shell_and_disables_shell_launcher()
{
var run = Ok();
await new BootstrapService(run.Object).RevertKioskAsync();
// First call: Shell Launcher revert — must reference WESL_UserSetting and RemoveCustomShell + SetEnabled.
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("WESL_UserSetting") &&
s.Contains("RemoveCustomShell") &&
s.Contains("SetEnabled")),
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);
}
}