SilverMetal Windows: first-boot experience & branding #6
@@ -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
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
# Brainstorming / design scratch (mockups, companion state) — durable specs live in docs/
|
||||
.superpowers/
|
||||
|
||||
# Build outputs
|
||||
build/output/
|
||||
build/cache/
|
||||
|
||||
80
windows/branding/Apply-Branding.ps1
Normal file
80
windows/branding/Apply-Branding.ps1
Normal 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 }
|
||||
11
windows/branding/README.md
Normal file
11
windows/branding/README.md
Normal 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.
|
||||
11
windows/branding/assets/README.md
Normal file
11
windows/branding/assets/README.md
Normal 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 |
|
||||
8
windows/branding/assets/SilverMetal.theme
Normal file
8
windows/branding/assets/SilverMetal.theme
Normal 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
|
||||
BIN
windows/branding/assets/lockscreen.jpg
Normal file
BIN
windows/branding/assets/lockscreen.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
windows/branding/assets/oemlogo.bmp
Normal file
BIN
windows/branding/assets/oemlogo.bmp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
windows/branding/assets/wallpaper.jpg
Normal file
BIN
windows/branding/assets/wallpaper.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
26
windows/branding/branding.manifest.json
Normal file
26
windows/branding/branding.manifest.json
Normal 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
|
||||
}
|
||||
}
|
||||
65
windows/branding/lib/BrandingLayers.ps1
Normal file
65
windows/branding/lib/BrandingLayers.ps1
Normal 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
|
||||
}
|
||||
17
windows/branding/lib/RegistryHelpers.ps1
Normal file
17
windows/branding/lib/RegistryHelpers.ps1
Normal 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
|
||||
}
|
||||
1034
windows/docs/superpowers/plans/2026-06-09-first-boot-branding.md
Normal file
1034
windows/docs/superpowers/plans/2026-06-09-first-boot-branding.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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 "Start-Process -FilePath 'C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe' -Verb RunAs"</CommandLine>
|
||||
<Description>Launch SilverOS Welcome elevated</Description>
|
||||
</SynchronousCommand>
|
||||
</FirstLogonCommands>
|
||||
<RegisteredOwner>SilverMetal</RegisteredOwner>
|
||||
<RegisteredOrganization>SilverLABS</RegisteredOrganization>
|
||||
<!--
|
||||
|
||||
@@ -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 {
|
||||
|
||||
65
windows/installer/oem/Configure-Kiosk.ps1
Normal file
65
windows/installer/oem/Configure-Kiosk.ps1
Normal 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'
|
||||
@@ -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 (
|
||||
|
||||
88
windows/tests/Branding.Tests.ps1
Normal file
88
windows/tests/Branding.Tests.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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"`.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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 ─────────────────────────────────────────────────── */
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user