From 66e7fd4ae82007966e69eb8a73c9a09fc2f5fd78 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Tue, 9 Jun 2026 13:53:58 +0100 Subject: [PATCH 01/24] docs(windows): first-boot experience & branding design spec Design for SilverMetal Windows first-boot: declarative branding build (4 layers baked offline into the WIM, shared dual-mode module), hardened onboarding kiosk (Shell Launcher v2 + Keyboard Filter for the one-time sm-bootstrap session), and the Hybrid fullscreen glass-card presentation for the Welcome app. Fills the empty Invoke-Brand stub (M4 branding). Approved in brainstorming. Next: writing-plans. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + .../2026-06-09-first-boot-branding-design.md | 180 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 windows/docs/superpowers/specs/2026-06-09-first-boot-branding-design.md diff --git a/.gitignore b/.gitignore index 3a6602c..4cc4203 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Brainstorming / design scratch (mockups, companion state) — durable specs live in docs/ +.superpowers/ + # Build outputs build/output/ build/cache/ diff --git a/windows/docs/superpowers/specs/2026-06-09-first-boot-branding-design.md b/windows/docs/superpowers/specs/2026-06-09-first-boot-branding-design.md new file mode 100644 index 0000000..7dca339 --- /dev/null +++ b/windows/docs/superpowers/specs/2026-06-09-first-boot-branding-design.md @@ -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 | -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=`. | 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. From e4241f7f598221c85f71addb88f0207a5c3abe5b Mon Sep 17 00:00:00 2001 From: sysadmin Date: Tue, 9 Jun 2026 13:59:29 +0100 Subject: [PATCH 02/24] docs(windows): first-boot branding implementation plan Phased TDD plan: A (branding module, hardware-free), B (kiosk: Shell Launcher v2 + Keyboard Filter), C (MAUI fullscreen glass presentation), D (build integration + VM e2e). Bite-sized tasks with complete code. Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-09-first-boot-branding.md | 1034 +++++++++++++++++ 1 file changed, 1034 insertions(+) create mode 100644 windows/docs/superpowers/plans/2026-06-09-first-boot-branding.md diff --git a/windows/docs/superpowers/plans/2026-06-09-first-boot-branding.md b/windows/docs/superpowers/plans/2026-06-09-first-boot-branding.md new file mode 100644 index 0000000..0b1eb23 --- /dev/null +++ b/windows/docs/superpowers/plans/2026-06-09-first-boot-branding.md @@ -0,0 +1,1034 @@ +# SilverMetal Windows — First-Boot Experience & Branding Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bake SilverMetal Windows' four branding layers declaratively into the WIM, lock the one-time onboarding session into a hardened kiosk, and present the Welcome app as a fullscreen branded glass card. + +**Architecture:** A shared dual-mode PowerShell branding module (`windows/branding/`) writes registry + stages assets, run offline by `build.ps1`'s `Invoke-Brand` and online by self-apply. A build-only kiosk uses Shell Launcher v2 + Keyboard Filter configured at end-of-setup for the `sm-bootstrap` session. The MAUI Welcome app becomes a borderless fullscreen window with a CSS-only frosted-glass presentation. + +**Tech Stack:** PowerShell 5.1 + DISM + offline registry (`reg load`), Pester v5 tests, Windows IoT Enterprise LTSC features (Shell Launcher v2 / Keyboard Filter via WMI), .NET MAUI Blazor (WebView2) + WinUI AppWindow, CSS. + +**Spec:** [`../specs/2026-06-09-first-boot-branding-design.md`](../specs/2026-06-09-first-boot-branding-design.md) + +**Branch:** `feat/first-boot-branding` (already created off `origin/main`; spec committed at `66e7fd4`). + +**Conventions to follow (from the existing repo):** +- `build.ps1` already does offline `reg load`/`reg unload` with a `[gc]::Collect(); Start-Sleep` guard before unload — reuse that idiom. +- Hardening modules are `windows/hardening/NN-*.ps1`, `Set-StrictMode -Version Latest`, `$ErrorActionPreference='Stop'`, `Write-Stage`/`Write-Host` logging. Match that style. +- Tests live in `windows/tests/`. The repo already has `Assert-IsoStructure.ps1`. + +--- + +## Phase A — Branding module (`windows/branding/`) + +Self-contained and testable on any Windows box with Pester — **no ISO, no hardware**. Do this phase first. + +### Task A1: Branding manifest + asset folder skeleton + +**Files:** +- Create: `windows/branding/branding.manifest.json` +- Create: `windows/branding/assets/README.md` +- Create: `windows/branding/README.md` + +- [ ] **Step 1: Write the manifest (single source of truth for all branding strings)** + +`windows/branding/branding.manifest.json`: +```json +{ + "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", + "accentBgr": "00d4ff", + "darkMode": true, + "lockWallpaper": false + } +} +``` + +- [ ] **Step 2: Write the assets README (documents required placeholder assets)** + +`windows/branding/assets/README.md`: +```markdown +# 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 | +``` + +- [ ] **Step 3: Write the module README** + +`windows/branding/README.md`: +```markdown +# SilverMetal Windows branding (shared, dual-mode) + +`Apply-Branding.ps1` writes the four branding layers either OFFLINE into a +mounted WIM (`-Mode Offline -MountPath `, 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. +``` + +- [ ] **Step 4: Commit** + +```bash +git add windows/branding +git commit -m "feat(branding): manifest + module skeleton for SilverMetal Windows branding" +``` + +--- + +### Task A2: Registry hive abstraction + Pester harness + +The module must write to a hive root that is either a loaded offline hive (`HKLM\SM_*`) or the live hive (`HKLM:`/default-user). Build a tiny helper that takes a **PSDrive-style root** so tests can point it at a throwaway key. + +**Files:** +- Create: `windows/branding/lib/RegistryHelpers.ps1` +- Test: `windows/tests/Branding.Tests.ps1` + +- [ ] **Step 1: Write the failing test** + +`windows/tests/Branding.Tests.ps1`: +```powershell +#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } +. "$PSScriptRoot\..\branding\lib\RegistryHelpers.ps1" + +Describe 'Set-SmRegValue' { + BeforeAll { + $script:root = 'HKCU:\Software\SilverMetalTest' + if (Test-Path $root) { Remove-Item $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 + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1"` +Expected: FAIL — `RegistryHelpers.ps1` not found / `Set-SmRegValue` not defined. + +- [ ] **Step 3: Implement the helper** + +`windows/branding/lib/RegistryHelpers.ps1`: +```powershell +Set-StrictMode -Version Latest + +# 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 +} +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1"` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add windows/branding/lib windows/tests/Branding.Tests.ps1 +git commit -m "feat(branding): registry helper + Pester harness" +``` + +--- + +### Task A3: Layer writers (pure functions over a hive root) + +Each layer is a function that writes its values under a given `SOFTWARE` root and/or default-user root. Pure registry writes — testable against throwaway keys. Asset staging is separate (Task A5). + +**Files:** +- Create: `windows/branding/lib/BrandingLayers.ps1` +- Modify: `windows/tests/Branding.Tests.ps1` (append) + +- [ ] **Step 1: Write the failing tests (append to `Branding.Tests.ps1`)** + +```powershell +. "$PSScriptRoot\..\branding\lib\BrandingLayers.ps1" + +Describe 'Branding layer writers' { + BeforeAll { + $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.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' + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1"` +Expected: FAIL — layer functions not defined. + +- [ ] **Step 3: Implement the layer writers** + +`windows/branding/lib/BrandingLayers.ps1`: +```powershell +Set-StrictMode -Version Latest +. "$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). BGR DWORD from manifest hex (stored little-endian as 0x00BBGGRR). + $bgr = [Convert]::ToInt32($Manifest.desktop.accentBgr,16) + Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\DWM' -Name 'AccentColor' -Type DWord -Value $bgr + Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\DWM' -Name 'ColorizationColor' -Type DWord -Value $bgr + 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 +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1"` +Expected: PASS (all layer tests green). + +- [ ] **Step 5: Commit** + +```bash +git add windows/branding/lib/BrandingLayers.ps1 windows/tests/Branding.Tests.ps1 +git commit -m "feat(branding): OEM/lockscreen/desktop/bitlocker layer writers + tests" +``` + +--- + +### Task A4: `Apply-Branding.ps1` orchestrator (offline + online hive mounting) + +Ties the layers together and handles `reg load`/`reg unload` for offline mode and the default-user hive for online mode. + +**Files:** +- Create: `windows/branding/Apply-Branding.ps1` +- Modify: `windows/tests/Branding.Tests.ps1` (append an offline integration test against a throwaway hive file) + +- [ ] **Step 1: Write the failing offline-mode integration test (append)** + +```powershell +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 + } + # 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 + } +} +``` + +> Note: this test requires an **elevated** shell (`reg load` needs admin). The CI Windows runner runs elevated; document that in `windows/tests/README.md` (Task A6). + +- [ ] **Step 2: Run to verify failure** + +Run (elevated): `pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1 -Tag ''"` +Expected: FAIL — `Apply-Branding.ps1` missing. + +- [ ] **Step 3: Implement the orchestrator** + +`windows/branding/Apply-Branding.ps1`: +```powershell +#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 ($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 } +``` + +- [ ] **Step 4: Provide placeholder assets so staging succeeds** + +Create minimal placeholder files so `Copy-Item` works in tests/builds (real art lands later): +```powershell +# Generate tiny valid placeholders (run once, commit the outputs) +Add-Type -AssemblyName System.Drawing +$dir = 'windows/branding/assets' +$bmp = New-Object System.Drawing.Bitmap 120,120 +$g = [System.Drawing.Graphics]::FromImage($bmp); $g.Clear([System.Drawing.Color]::FromArgb(11,15,20)) +$bmp.Save("$dir/oemlogo.bmp", [System.Drawing.Imaging.ImageFormat]::Bmp) +$big = New-Object System.Drawing.Bitmap 1920,1200 +$g2 = [System.Drawing.Graphics]::FromImage($big); $g2.Clear([System.Drawing.Color]::FromArgb(8,15,23)) +$big.Save("$dir/lockscreen.jpg", [System.Drawing.Imaging.ImageFormat]::Jpeg) +$big.Save("$dir/wallpaper.jpg", [System.Drawing.Imaging.ImageFormat]::Jpeg) +@" +[Theme] +DisplayName=SilverMetal +[Control Panel\Desktop] +Wallpaper=%SystemRoot%\Web\Wallpaper\SilverMetal\wallpaper.jpg +WallpaperStyle=10 +[VisualStyles] +SystemMode=Dark +AppMode=Dark +"@ | Set-Content "$dir/SilverMetal.theme" -Encoding ASCII +``` + +- [ ] **Step 5: Run the full branding test suite (elevated)** + +Run: `pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1"` +Expected: PASS (helper + layers + offline integration). + +- [ ] **Step 6: Commit** + +```bash +git add windows/branding/Apply-Branding.ps1 windows/branding/assets windows/tests/Branding.Tests.ps1 +git commit -m "feat(branding): Apply-Branding orchestrator (offline/online) + placeholder assets" +``` + +--- + +### Task A5: Wire `Invoke-Brand` in `build.ps1` + +**Files:** +- Modify: `windows/installer/build.ps1:249-250` (replace the `Invoke-Brand` stub) + +- [ ] **Step 1: Replace the stub** + +Replace lines 249-250 (`function Invoke-Brand { ... deferred to M4. }`) with: +```powershell +# --- 5. Brand -------------------------------------------------------------- +# 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' +} +``` + +- [ ] **Step 2: Call branding inside the mounted-WIM block** + +In `Invoke-ServiceWim`, after `Copy-WelcomePayload` (line 214) and before the UAC block, add: +```powershell + # Bake the four branding layers into the offline hives (must be inside the mount). + Write-Stage 'Stage 3e: 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' } +``` + +- [ ] **Step 3: Lint-parse the script** + +Run: `pwsh -NoProfile -Command "[void][System.Management.Automation.Language.Parser]::ParseFile('windows/installer/build.ps1',[ref]$null,[ref]$null); 'OK'"` +Expected: `OK` + +- [ ] **Step 4: Commit** + +```bash +git add windows/installer/build.ps1 +git commit -m "feat(build): wire branding into Invoke-ServiceWim (offline hive bake)" +``` + +--- + +### Task A6: Test docs + +**Files:** +- Modify: `windows/tests/README.md` + +- [ ] **Step 1: Document the branding test + elevation requirement** + +Append to `windows/tests/README.md`: +```markdown +## 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"`. +``` + +- [ ] **Step 2: Commit** + +```bash +git add windows/tests/README.md +git commit -m "docs(tests): document branding test suite + elevation requirement" +``` + +**Phase A done** → branding bakes offline and is unit-tested. + +--- + +## Phase B — Hardened kiosk (build-only) + +### Task B1: `Configure-Kiosk.ps1` — Shell Launcher v2 + Keyboard Filter + +**Files:** +- Create: `windows/installer/oem/Configure-Kiosk.ps1` + +- [ ] **Step 1: Implement the kiosk configurator (runs as SYSTEM at end-of-setup)** + +`windows/installer/oem/Configure-Kiosk.ps1`: +```powershell +#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' +@" +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '$WelcomeExe' -Verb RunAs" +: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' +$wesl=Get-CimInstance -Namespace $cls -ClassName WESL_UserSetting -ErrorAction Stop +Invoke-CimMethod -Namespace $cls -ClassName WESL_UserSetting -MethodName SetEnabled -Arguments @{Enabled=$true} | Out-Null +# 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' +``` + +- [ ] **Step 2: Parse-lint** + +Run: `pwsh -NoProfile -Command "[void][System.Management.Automation.Language.Parser]::ParseFile('windows/installer/oem/Configure-Kiosk.ps1',[ref]$null,[ref]$null); 'OK'"` +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add windows/installer/oem/Configure-Kiosk.ps1 +git commit -m "feat(kiosk): Configure-Kiosk.ps1 (Shell Launcher v2 + Keyboard Filter + escapes)" +``` + +--- + +### Task B2: Enable kiosk features offline in `build.ps1` + +**Files:** +- Modify: `windows/installer/build.ps1` (`Invoke-ServiceWim`, after drivers block ~line 193) + +- [ ] **Step 1: Enable the optional features in the mounted WIM** + +After the drivers block in `Invoke-ServiceWim`, add: +```powershell + # 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 + } +``` + +- [ ] **Step 2: Stage `Configure-Kiosk.ps1` alongside the hardening payload** + +In the hardening-staging block (~line 210), after copying `SetupComplete.cmd`, add: +```powershell + Copy-Item (Join-Path $PSScriptRoot 'oem\Configure-Kiosk.ps1') $scripts -Force +``` + +- [ ] **Step 3: Parse-lint** (same command as Task A5 Step 3) → `OK` + +- [ ] **Step 4: Commit** + +```bash +git add windows/installer/build.ps1 +git commit -m "feat(build): enable kiosk features offline + stage Configure-Kiosk.ps1" +``` + +--- + +### Task B3: Invoke kiosk config from `SetupComplete.cmd` + +**Files:** +- Modify: `windows/installer/oem/SetupComplete.cmd` + +- [ ] **Step 1: Add the kiosk-config call (before the Welcome-present branch)** + +Insert after line 15 (`echo [...] first-boot start`): +```bat +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 +) +``` + +- [ ] **Step 2: Verify the file still has the deferral branch intact** (read it; the existing if/else for hardening deferral must remain). + +Run: `pwsh -NoProfile -Command "Get-Content windows/installer/oem/SetupComplete.cmd"` +Expected: kiosk-config block present AND the original hardening-deferral if/else present. + +- [ ] **Step 3: Commit** + +```bash +git add windows/installer/oem/SetupComplete.cmd +git commit -m "feat(kiosk): configure kiosk from SetupComplete before first logon" +``` + +--- + +### Task B4: Remove the redundant `FirstLogonCommands` launch + +Shell Launcher now launches the Welcome app as the session shell, so the answer-file launch would double-launch. + +**Files:** +- Modify: `windows/installer/autounattend/autounattend.xml:122-128` + +- [ ] **Step 1: Delete the `` block** + +Remove lines 122-128 (the whole `` element) and replace the preceding comment (lines 115-121) with: +```xml + +``` + +- [ ] **Step 2: Validate the XML still parses** + +Run: `pwsh -NoProfile -Command "[xml](Get-Content windows/installer/autounattend/autounattend.xml -Raw); 'OK'"` +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add windows/installer/autounattend/autounattend.xml +git commit -m "feat(kiosk): drop FirstLogonCommands launch (Shell Launcher owns launch)" +``` + +--- + +### Task B5: Kiosk teardown in the Welcome app `ApplyService` + +The app already deletes `sm-bootstrap` + removes AutoLogon on success. Add reversal of the kiosk config so the real user gets a normal desktop. + +**Files:** +- Modify: the bootstrap/teardown service in `windows/welcome/src/SilverOS.Welcome.Core` (find the class that removes AutoLogon — likely `BootstrapService`). +- Test: existing Core test project (mirror its pattern). + +- [ ] **Step 1: Find the teardown code** + +Run: `pwsh -NoProfile -Command "Select-String -Path windows/welcome/src/**/*.cs -Pattern 'AutoAdminLogon|sm-bootstrap|DeleteUser|Bootstrap' -List | Select-Object Path"` +Expected: locates `BootstrapService` (or equivalent). Read it to match its `IProcessRunner`/registry idiom. + +- [ ] **Step 2: Write a failing test for `RevertKioskAsync` (match the project's existing test style)** + +Add to the Core test project a test asserting `BootstrapService.RevertKioskAsync()` invokes: a process that clears the `sm-bootstrap` WESL custom shell, and registry deletes for `DisableTaskMgr`/`DisableLockWorkstation`/`HideFastUserSwitching`. Use the project's existing fake `IProcessRunner` to capture invocations. (Mirror an existing `BootstrapService` test exactly — repeat its arrange/fake setup.) + +- [ ] **Step 3: Run to verify failure** → method not defined. + +- [ ] **Step 4: Implement `RevertKioskAsync`** + +Add to `BootstrapService`: +```csharp +public async Task RevertKioskAsync() +{ + // Remove sm-bootstrap custom shell + disable Shell Launcher's per-user entry. + await _runner.RunPowerShellAsync( + "$c='root\\standardcimv2\\embedded';" + + "$w=Get-CimInstance -Namespace $c -ClassName WESL_UserSetting;" + + "$sid=(New-Object System.Security.Principal.NTAccount('sm-bootstrap')).Translate([System.Security.Principal.SecurityIdentifier]).Value;" + + "Invoke-CimMethod -InputObject $w -MethodName RemoveCustomShell -Arguments @{Sid=$sid} -ErrorAction SilentlyContinue;" + + "Invoke-CimMethod -InputObject $w -MethodName SetEnabled -Arguments @{Enabled=$false} -ErrorAction SilentlyContinue"); + // Revert escape policies. + await _runner.RunPowerShellAsync( + "$s='HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System';" + + "Remove-ItemProperty $s -Name DisableTaskMgr,DisableLockWorkstation,HideFastUserSwitching -ErrorAction SilentlyContinue"); +} +``` +> If `IProcessRunner` has no `RunPowerShellAsync`, use its existing method (Step 1 told you the real signature) — adapt the two calls to it. + +- [ ] **Step 5: Call `RevertKioskAsync` from the success path** + +In `ApplyService`, in the teardown sequence (where `sm-bootstrap` is deleted / AutoLogon removed), call `await _bootstrap.RevertKioskAsync();` **before** the reboot and **before** deleting `sm-bootstrap` (the SID must still resolve). + +- [ ] **Step 6: Run tests** → PASS. Build the whole solution (`dotnet build` on the welcome `.sln`) → 0 errors. + +- [ ] **Step 7: Commit** + +```bash +git add windows/welcome +git commit -m "feat(kiosk): revert kiosk (shell launcher + escapes) on wizard success" +``` + +**Phase B done** → onboarding session is an escape-proof kiosk, reverted for the real user. + +--- + +## Phase C — First-boot presentation (MAUI Welcome app) + +### Task C1: Borderless fullscreen window + +**Files:** +- Modify: `windows/welcome/src/SilverOS.Welcome.App/App.xaml.cs` +- Create: `windows/welcome/src/SilverOS.Welcome.App/Platforms/Windows/WindowExtensions.cs` + +- [ ] **Step 1: Add a Windows-only window customizer** + +`Platforms/Windows/WindowExtensions.cs`: +```csharp +#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 +``` + +- [ ] **Step 2: Call it when the native window is created** + +In `App.xaml.cs`, replace `CreateWindow` with one that hooks the handler: +```csharp +protected override Window CreateWindow(IActivationState? activationState) +{ + 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; +} +``` + +- [ ] **Step 3: Build for Windows** + +Run: `dotnet build windows/welcome/src/SilverOS.Welcome.App -f net9.0-windows10.0.19041.0` +Expected: 0 errors. + +- [ ] **Step 4: Commit** + +```bash +git add windows/welcome/src/SilverOS.Welcome.App +git commit -m "feat(welcome): borderless fullscreen non-closable kiosk window" +``` + +--- + +### Task C2: Brand stylesheet (the void/cyan glass system) + +**Files:** +- Create: `windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/silvermetal.css` +- Modify: `windows/welcome/src/SilverOS.Welcome.App/wwwroot/index.html` (link the stylesheet) + +- [ ] **Step 1: Add the brand stylesheet** (ported from the approved mockups — backdrop wall + frosted glass card + step rail) + +`wwwroot/css/silvermetal.css`: +```css +:root{ + --void:#0b0f14; --ink:#e8edf5; --mid:#8fa4bc; + --accent:#00d4ff; --accent2:#00e5a0; --line:rgba(255,255,255,.08); +} +html,body{height:100%;margin:0;background:#05080c;color:var(--ink); + font-family:"Segoe UI Variable","Segoe UI",system-ui,sans-serif;overflow:hidden} +.sm-wall{position:fixed;inset:0;background: + radial-gradient(85% 75% at 28% 18%, #173247, transparent 60%), + radial-gradient(75% 75% at 82% 92%, #0f3528, transparent 55%), + linear-gradient(135deg,#080f17,#0a1612)} +.sm-wall::after{content:"SILVERMETAL";position:fixed;right:26px;bottom:18px; + font:700 12px/1 system-ui;letter-spacing:4px;color:rgba(255,255,255,.18)} +.sm-glass{position:fixed;inset:12% 16%;border-radius:18px; + background:rgba(16,22,31,.55);backdrop-filter:blur(18px); + border:1px solid rgba(255,255,255,.14); + box-shadow:0 24px 70px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.12); + display:flex;flex-direction:column;overflow:hidden;animation:sm-rise .5s ease both} +@keyframes sm-rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}} +.sm-rail{display:flex;gap:14px;padding:14px 26px;font:600 10px/1 "Cascadia Mono",Consolas,monospace; + letter-spacing:1px;color:var(--mid);border-bottom:1px solid var(--line)} +.sm-rail .on{color:var(--accent)} .sm-rail .done{color:var(--accent2)} +.sm-body{flex:1;padding:18px 30px;overflow:auto;min-height:0} +.sm-next{align-self:flex-end;margin:14px 26px;background:linear-gradient(180deg,#13b6e6,#0a93c8); + color:#001018;font-weight:700;border:0;border-radius:9px;padding:10px 22px;cursor:pointer} +``` + +- [ ] **Step 2: Link it in `index.html`** (after the existing stylesheet links) + +```html + +``` + +- [ ] **Step 3: Build** → 0 errors. Commit. + +```bash +git add windows/welcome/src/SilverOS.Welcome.App/wwwroot +git commit -m "feat(welcome): SilverMetal void/cyan glass stylesheet" +``` + +--- + +### Task C3: Apply the Hybrid shell to `MainLayout` + +**Files:** +- Modify: `windows/welcome/src/SilverOS.Welcome.App/Components/Layout/MainLayout.razor` + +- [ ] **Step 1: Replace the stock template layout with the glass shell** + +`MainLayout.razor`: +```razor +@inherits LayoutComponentBase + +
+
+
+ @Body +
+
+``` +> The step rail (`sm-rail`) and the Next button (`sm-next`) live inside the wizard step components, which render the per-step content into `@Body`. If a shared step chrome already exists, move the rail here instead — match whatever the existing step components expect (read `Components/Steps` first). + +- [ ] **Step 2: Build + run the app on the dev box (windowed sanity check is fine without the kiosk)** + +Run: `dotnet build windows/welcome/src/SilverOS.Welcome.App -f net9.0-windows10.0.19041.0` +Expected: 0 errors. (Visual verification happens in the VM e2e, Task D2.) + +- [ ] **Step 3: Commit** + +```bash +git add windows/welcome/src/SilverOS.Welcome.App/Components +git commit -m "feat(welcome): Hybrid glass-card shell in MainLayout" +``` + +--- + +### Task C4: Remove the dead default-template nav + +The stock `NavMenu` + the "About → learn.microsoft.com" top row are template cruft that the kiosk must not show. + +**Files:** +- Modify: `windows/welcome/src/SilverOS.Welcome.App/Components/Layout/MainLayout.razor` (already replaced — confirms no `` / top-row remain) +- Delete: `windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor` (if nothing else references it) + +- [ ] **Step 1: Check for other references to NavMenu** + +Run: `pwsh -NoProfile -Command "Select-String -Path windows/welcome/src/**/*.razor -Pattern 'NavMenu'"` +Expected: only the (now-removed) MainLayout — if so, delete `NavMenu.razor`. If referenced elsewhere, leave it and just ensure it's not rendered in the kiosk path. + +- [ ] **Step 2: Build** → 0 errors. **Commit.** + +```bash +git add -A windows/welcome/src/SilverOS.Welcome.App/Components +git commit -m "chore(welcome): remove stock template nav from kiosk shell" +``` + +**Phase C done** → Welcome app presents as the fullscreen branded glass card. + +--- + +## Phase D — Integration + VM end-to-end + +### Task D1: Build a test ISO with branding + kiosk enabled + +**Files:** none (operational). Build host = Windows + ADK (per `iso-builder.md`). + +- [ ] **Step 1: Run the pipeline against a licensed base ISO** + +Run (elevated, on the Windows runner / ADK box): +```powershell +windows\installer\build.ps1 -SourceIso -SkipInputVerify +``` +Expected: completes through Stage 7; `out\SilverMetal-Enhanced-Windows.iso` + `.sha256` + `.sbom.json` produced. Branding stage 3e and kiosk feature-enable log lines present. + +- [ ] **Step 2: Assert ISO structure** + +Run: `pwsh -NoProfile windows/tests/Assert-IsoStructure.ps1 -IsoPath windows/installer/out/SilverMetal-Enhanced-Windows.iso` (or the script's actual parameter). +Expected: PASS. + +--- + +### Task D2: VM boot e2e (uses the existing harness) + +**Files:** none (operational). Harness = SLAB01 VM 102 + `_stageiso.py` / `_pverun.py` / `_shot.py` (see memory `silveros-welcome-app-implementation.md`). + +- [ ] **Step 1: Stage + boot the ISO in VM 102** (per the established harness flow; eject CD after file-copy to avoid the reinstall loop). + +- [ ] **Step 2: Verify the kiosk session** — screenshot the sm-bootstrap session. Assert: + - The Welcome app fills the screen as the **glass card on the branded wall**. + - **No taskbar, no Start** is present (Shell Launcher replaced Explorer). + - Win key / Win+L / Ctrl+Alt+Del → Task Manager are blocked (try via `_pverun`/host key-send; the wizard stays foreground). + +- [ ] **Step 3: Complete the wizard** (pick a flavour, create the real user, set BitLocker PIN per existing flow). Let it reboot. + +- [ ] **Step 4: Verify the real-user desktop**: + - Logs into a **normal Explorer desktop** (taskbar back) — kiosk reverted. + - **Branded wallpaper** + dark theme + cyan accent. + - **Lock screen** shows the branded image and cannot be changed (Settings ▸ Personalization ▸ Lock screen is greyed / policy-managed). + - **Settings ▸ System ▸ About** shows Manufacturer `SilverLABS`, Model `SilverMetal Windows`, Support `https://silverlabs.uk`, logo. + +- [ ] **Step 5: Verify BitLocker recovery message** (best-effort): trigger recovery (or inspect `manage-bde -status` + the FVE policy) to confirm the custom recovery message/URL applied. If the `FVE` value names from Task A3 proved wrong, fix them now and re-run Phase A tests + rebuild. + +- [ ] **Step 6: Record evidence** — capture screenshots into `.superpowers/` (gitignored) and note results in the PR description. + +--- + +### Task D3: Finish the branch + +- [ ] **Step 1: Run the full local test suite** + +Run: `pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1"` → PASS, and `dotnet build` the welcome `.sln` → 0 errors. + +- [ ] **Step 2: Use `superpowers:finishing-a-development-branch`** to open the PR (`feat/first-boot-branding` → `main`), summarizing the three components + VM evidence. Note follow-ups: SilverOS→SilverMetal rename; final brand assets; any `FVE`/`PersonalizationCSP` value-name corrections discovered on the VM. + +--- + +## Self-review notes (author) + +- **Spec coverage**: A=branding module (§4) ✓; A5=Invoke-Brand wiring (§7.1) ✓; B=kiosk (§5, §7.2–7.4) ✓; C=presentation (§6, §7.5) ✓; D=testing (§8) ✓. Honest BitLocker limitation (§4) is carried into A3's comment + D2 Step 5. Open items (§9) are attached to the tasks that resolve them (FVE names → A3/D2; PersonalizationCSP → A3/D2; elevation route → B1; assets → A4; rename → D3). +- **Type consistency**: `Set-SmRegValue`, `Set-OemInformation`, `Set-LockScreen`, `Set-DesktopBranding`, `Set-BitLockerPreboot`, `Apply-Branding.ps1 -Mode/-MountPath/-PassThru`, `Configure-Kiosk.ps1`, `RevertKioskAsync`, `ApplyKioskChrome` are used consistently across tasks. +- **Known soft spots flagged in-place (not placeholders)**: exact `FVE` value names and `PersonalizationCSP` reliability are written concretely but guarded by read-back tests + the VM verify; `IProcessRunner` method name is resolved by reading the real class in B5 Step 1. From 73d6611ab568a6a1a3e3186af5b0346465cdfbb1 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Tue, 9 Jun 2026 14:06:46 +0100 Subject: [PATCH 03/24] feat(branding): manifest + module skeleton for SilverMetal Windows branding --- windows/branding/README.md | 11 +++++++++++ windows/branding/assets/README.md | 11 +++++++++++ windows/branding/branding.manifest.json | 26 +++++++++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 windows/branding/README.md create mode 100644 windows/branding/assets/README.md create mode 100644 windows/branding/branding.manifest.json diff --git a/windows/branding/README.md b/windows/branding/README.md new file mode 100644 index 0000000..c2c48e4 --- /dev/null +++ b/windows/branding/README.md @@ -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 `, 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. diff --git a/windows/branding/assets/README.md b/windows/branding/assets/README.md new file mode 100644 index 0000000..46302bc --- /dev/null +++ b/windows/branding/assets/README.md @@ -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 | diff --git a/windows/branding/branding.manifest.json b/windows/branding/branding.manifest.json new file mode 100644 index 0000000..39d5e1e --- /dev/null +++ b/windows/branding/branding.manifest.json @@ -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", + "accentBgr": "00d4ff", + "darkMode": true, + "lockWallpaper": false + } +} From 7de5262c43b19d33cd2c278546f56fc1fb230c55 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Tue, 9 Jun 2026 14:08:34 +0100 Subject: [PATCH 04/24] feat(branding): registry helper + Pester harness --- windows/branding/lib/RegistryHelpers.ps1 | 16 ++++++++++++++++ windows/tests/Branding.Tests.ps1 | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 windows/branding/lib/RegistryHelpers.ps1 create mode 100644 windows/tests/Branding.Tests.ps1 diff --git a/windows/branding/lib/RegistryHelpers.ps1 b/windows/branding/lib/RegistryHelpers.ps1 new file mode 100644 index 0000000..943aed8 --- /dev/null +++ b/windows/branding/lib/RegistryHelpers.ps1 @@ -0,0 +1,16 @@ +Set-StrictMode -Version Latest + +# 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 +} diff --git a/windows/tests/Branding.Tests.ps1 b/windows/tests/Branding.Tests.ps1 new file mode 100644 index 0000000..f7cb680 --- /dev/null +++ b/windows/tests/Branding.Tests.ps1 @@ -0,0 +1,22 @@ +#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 + } +} From 320b4c675a11169a8220fdfbe2205066c5203610 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Tue, 9 Jun 2026 14:10:17 +0100 Subject: [PATCH 05/24] feat(branding): OEM/lockscreen/desktop/bitlocker layer writers + tests --- windows/branding/lib/BrandingLayers.ps1 | 56 +++++++++++++++++++++++++ windows/tests/Branding.Tests.ps1 | 40 ++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 windows/branding/lib/BrandingLayers.ps1 diff --git a/windows/branding/lib/BrandingLayers.ps1 b/windows/branding/lib/BrandingLayers.ps1 new file mode 100644 index 0000000..d1b02fa --- /dev/null +++ b/windows/branding/lib/BrandingLayers.ps1 @@ -0,0 +1,56 @@ +Set-StrictMode -Version Latest +. "$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). BGR DWORD from manifest hex (stored little-endian as 0x00BBGGRR). + $bgr = [Convert]::ToInt32($Manifest.desktop.accentBgr,16) + Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\DWM' -Name 'AccentColor' -Type DWord -Value $bgr + Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\DWM' -Name 'ColorizationColor' -Type DWord -Value $bgr + 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 +} diff --git a/windows/tests/Branding.Tests.ps1 b/windows/tests/Branding.Tests.ps1 index f7cb680..db019b6 100644 --- a/windows/tests/Branding.Tests.ps1 +++ b/windows/tests/Branding.Tests.ps1 @@ -20,3 +20,43 @@ Describe 'Set-SmRegValue' { (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.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' + } +} From 50856b8f2874deafb3427250eb86d35ae6bdb3d2 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Tue, 9 Jun 2026 14:12:45 +0100 Subject: [PATCH 06/24] feat(branding): Apply-Branding orchestrator (offline/online) + placeholder assets --- windows/branding/Apply-Branding.ps1 | 76 ++++++++++++++++++++++ windows/branding/assets/SilverMetal.theme | 8 +++ windows/branding/assets/lockscreen.jpg | Bin 0 -> 36629 bytes windows/branding/assets/oemlogo.bmp | Bin 0 -> 57654 bytes windows/branding/assets/wallpaper.jpg | Bin 0 -> 36629 bytes windows/tests/Branding.Tests.ps1 | 23 +++++++ 6 files changed, 107 insertions(+) create mode 100644 windows/branding/Apply-Branding.ps1 create mode 100644 windows/branding/assets/SilverMetal.theme create mode 100644 windows/branding/assets/lockscreen.jpg create mode 100644 windows/branding/assets/oemlogo.bmp create mode 100644 windows/branding/assets/wallpaper.jpg diff --git a/windows/branding/Apply-Branding.ps1 b/windows/branding/Apply-Branding.ps1 new file mode 100644 index 0000000..ca91545 --- /dev/null +++ b/windows/branding/Apply-Branding.ps1 @@ -0,0 +1,76 @@ +#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 ($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 } diff --git a/windows/branding/assets/SilverMetal.theme b/windows/branding/assets/SilverMetal.theme new file mode 100644 index 0000000..f09c699 --- /dev/null +++ b/windows/branding/assets/SilverMetal.theme @@ -0,0 +1,8 @@ +[Theme] +DisplayName=SilverMetal +[Control Panel\Desktop] +Wallpaper=%SystemRoot%\Web\Wallpaper\SilverMetal\wallpaper.jpg +WallpaperStyle=10 +[VisualStyles] +SystemMode=Dark +AppMode=Dark diff --git a/windows/branding/assets/lockscreen.jpg b/windows/branding/assets/lockscreen.jpg new file mode 100644 index 0000000000000000000000000000000000000000..43d619334d88d366874a0ba58156b1d9d62a3363 GIT binary patch literal 36629 zcmeIwH&7H&0D$55Hr(Fr-r?=Bcbqm3Nl`0;v0(s3p#!s(V73DT>WnjhnGUmdoY_hg zOqD3c#=!{Kh+?e3tmDkNs}9assg2G5_PuJ~?!15ZOLiw8i*R{qS*egxiiPeal8;1* zP!+|S?$F$+`*mH@bki_={-7BQTBc=%QX=6{ik)Iv;aJ#Cb)wN|a9CPA=ENgT)bU;- zRreW959oTp30WcMuSfEdu>IO8qh6I6Lb0W4%Veh*DTLJB(|QN~Ii%vA(P#JrrsZyE z4+}-As-mghsomWh-TOkb^+9XZ3R<2sTW^LWN^&2*A z-m-Pu_8m<-ckSM@ci;X42NQ=5A31vL_=%IJ&$P9lJ$L@X#miSZuU@-;~I}^!ba|C58GUYpCqEF5B%=G)>hEuS+U*?ow?{ zPtWy5iYkn%n$(QE2ES9>+|t?+$jq-Ch*sBjnX#;bzU)CS?R(jug*EWnjhnGUmdoY_hg zOqD3c#=!{Kh+?e3tmDkNs}9assg2G5_PuJ~?!15ZOLiw8i*R{qS*egxiiPeal8;1* zP!+|S?$F$+`*mH@bki_={-7BQTBc=%QX=6{ik)Iv;aJ#Cb)wN|a9CPA=ENgT)bU;- zRreW959oTp30WcMuSfEdu>IO8qh6I6Lb0W4%Veh*DTLJB(|QN~Ii%vA(P#JrrsZyE z4+}-As-mghsomWh-TOkb^+9XZ3R<2sTW^LWN^&2*A z-m-Pu_8m<-ckSM@ci;X42NQ=5A31vL_=%IJ&$P9lJ$L@X#miSZuU@-;~I}^!ba|C58GUYpCqEF5B%=G)>hEuS+U*?ow?{ zPtWy5iYkn%n$(QE2ES9>+|t?+$jq-Ch*sBjnX#;bzU)CS?R(jug*E$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 + } +} From bd5d82f6b420cd731cbb7dff0148abe4a4200033 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Tue, 9 Jun 2026 14:13:30 +0100 Subject: [PATCH 07/24] feat(build): wire branding into Invoke-ServiceWim (offline hive bake) --- windows/installer/build.ps1 | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/windows/installer/build.ps1 b/windows/installer/build.ps1 index 36ee376..45f83c7 100644 --- a/windows/installer/build.ps1 +++ b/windows/installer/build.ps1 @@ -213,6 +213,11 @@ function Invoke-ServiceWim { # 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 3e: 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. @@ -247,7 +252,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 { From 6aa963f02443fe9f1684d91c48b63338f6282f50 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Tue, 9 Jun 2026 14:14:13 +0100 Subject: [PATCH 08/24] docs(tests): document branding test suite + elevation requirement ci(branding): run branding Pester suite before Build packed ISO step --- .gitea/workflows/build-iso-windows.yaml | 10 ++++++++++ windows/tests/README.md | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/.gitea/workflows/build-iso-windows.yaml b/.gitea/workflows/build-iso-windows.yaml index b2cdbbc..ad9b428 100644 --- a/.gitea/workflows/build-iso-windows.yaml +++ b/.gitea/workflows/build-iso-windows.yaml @@ -108,6 +108,16 @@ jobs: } "path=$dst" >> $env:GITHUB_OUTPUT + - name: Test branding module (Pester) + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue + if (-not (Get-Module -ListAvailable Pester | Where-Object Version -ge '5.0.0')) { + Install-Module Pester -MinimumVersion 5.0 -Scope CurrentUser -Force -SkipPublisherCheck + } + $r = Invoke-Pester windows/tests/Branding.Tests.ps1 -PassThru -Output Detailed + if ($r.FailedCount -gt 0) { throw "$($r.FailedCount) branding test(s) failed" } + - name: Build packed ISO shell: pwsh run: | diff --git a/windows/tests/README.md b/windows/tests/README.md index 9859c34..0d37854 100644 --- a/windows/tests/README.md +++ b/windows/tests/README.md @@ -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"`. From 4ff12ab54330978b7386033c5d03440a437465df Mon Sep 17 00:00:00 2001 From: sysadmin Date: Tue, 9 Jun 2026 14:23:50 +0100 Subject: [PATCH 09/24] fix(branding): guard reg unload, set ErrorAction in libs, accent field rename, test hive unload + assertions --- windows/branding/Apply-Branding.ps1 | 6 +++++- windows/branding/branding.manifest.json | 2 +- windows/branding/lib/BrandingLayers.ps1 | 5 +++-- windows/branding/lib/RegistryHelpers.ps1 | 1 + windows/tests/Branding.Tests.ps1 | 3 +++ 5 files changed, 13 insertions(+), 4 deletions(-) diff --git a/windows/branding/Apply-Branding.ps1 b/windows/branding/Apply-Branding.ps1 index ca91545..c196a2b 100644 --- a/windows/branding/Apply-Branding.ps1 +++ b/windows/branding/Apply-Branding.ps1 @@ -47,7 +47,11 @@ function Invoke-WithHive { & 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 } + 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') { diff --git a/windows/branding/branding.manifest.json b/windows/branding/branding.manifest.json index 39d5e1e..dd0b2e6 100644 --- a/windows/branding/branding.manifest.json +++ b/windows/branding/branding.manifest.json @@ -19,7 +19,7 @@ "desktop": { "wallpaper": "wallpaper.jpg", "theme": "SilverMetal.theme", - "accentBgr": "00d4ff", + "accentColor": "00d4ff", "darkMode": true, "lockWallpaper": false } diff --git a/windows/branding/lib/BrandingLayers.ps1 b/windows/branding/lib/BrandingLayers.ps1 index d1b02fa..aa07ba4 100644 --- a/windows/branding/lib/BrandingLayers.ps1 +++ b/windows/branding/lib/BrandingLayers.ps1 @@ -1,4 +1,5 @@ Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' . "$PSScriptRoot\RegistryHelpers.ps1" function Set-OemInformation { @@ -34,8 +35,8 @@ function Set-DesktopBranding { 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). BGR DWORD from manifest hex (stored little-endian as 0x00BBGGRR). - $bgr = [Convert]::ToInt32($Manifest.desktop.accentBgr,16) + # Accent color as COLORREF (0x00RRGGBB). #00d4ff = cyan. + $bgr = [Convert]::ToInt32($Manifest.desktop.accentColor,16) Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\DWM' -Name 'AccentColor' -Type DWord -Value $bgr Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\DWM' -Name 'ColorizationColor' -Type DWord -Value $bgr if (-not $Manifest.desktop.lockWallpaper) { return } diff --git a/windows/branding/lib/RegistryHelpers.ps1 b/windows/branding/lib/RegistryHelpers.ps1 index 943aed8..18122e0 100644 --- a/windows/branding/lib/RegistryHelpers.ps1 +++ b/windows/branding/lib/RegistryHelpers.ps1 @@ -1,4 +1,5 @@ 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. diff --git a/windows/tests/Branding.Tests.ps1 b/windows/tests/Branding.Tests.ps1 index 2fdced4..c50bf88 100644 --- a/windows/tests/Branding.Tests.ps1 +++ b/windows/tests/Branding.Tests.ps1 @@ -38,6 +38,7 @@ Describe 'Branding layer writers' { $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' } @@ -58,6 +59,7 @@ Describe 'Branding layer writers' { $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' } } @@ -70,6 +72,7 @@ Describe 'Apply-Branding -Mode Offline' { 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. } From f00ef19578f331b44a6dc215991fd1d7031201fc Mon Sep 17 00:00:00 2001 From: sysadmin Date: Tue, 9 Jun 2026 14:26:33 +0100 Subject: [PATCH 10/24] feat(kiosk): Configure-Kiosk.ps1 (Shell Launcher v2 + Keyboard Filter + escapes) --- windows/installer/oem/Configure-Kiosk.ps1 | 60 +++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 windows/installer/oem/Configure-Kiosk.ps1 diff --git a/windows/installer/oem/Configure-Kiosk.ps1 b/windows/installer/oem/Configure-Kiosk.ps1 new file mode 100644 index 0000000..2cd00bb --- /dev/null +++ b/windows/installer/oem/Configure-Kiosk.ps1 @@ -0,0 +1,60 @@ +#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' +@" +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '$WelcomeExe' -Verb RunAs" +: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' +$wesl=Get-CimInstance -Namespace $cls -ClassName WESL_UserSetting -ErrorAction Stop +Invoke-CimMethod -Namespace $cls -ClassName WESL_UserSetting -MethodName SetEnabled -Arguments @{Enabled=$true} | Out-Null +# 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' From f199981cf1236cbf50679429a41fbc479958358c Mon Sep 17 00:00:00 2001 From: sysadmin Date: Tue, 9 Jun 2026 14:26:57 +0100 Subject: [PATCH 11/24] feat(build): enable kiosk features offline + stage Configure-Kiosk.ps1 --- windows/installer/build.ps1 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/windows/installer/build.ps1 b/windows/installer/build.ps1 index 45f83c7..73e7982 100644 --- a/windows/installer/build.ps1 +++ b/windows/installer/build.ps1 @@ -192,6 +192,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,6 +215,7 @@ 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. From a8d7522a700e5ca446ff5506fc49fa9d67c0c2cd Mon Sep 17 00:00:00 2001 From: sysadmin Date: Tue, 9 Jun 2026 14:27:13 +0100 Subject: [PATCH 12/24] feat(kiosk): configure kiosk from SetupComplete before first logon --- windows/installer/oem/SetupComplete.cmd | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/windows/installer/oem/SetupComplete.cmd b/windows/installer/oem/SetupComplete.cmd index 36bce02..53dfa39 100644 --- a/windows/installer/oem/SetupComplete.cmd +++ b/windows/installer/oem/SetupComplete.cmd @@ -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 ( From c14fcf67b165c663ddfbf4f424ce516f94a66357 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Tue, 9 Jun 2026 14:27:37 +0100 Subject: [PATCH 13/24] feat(kiosk): drop FirstLogonCommands launch (Shell Launcher owns launch) --- windows/installer/autounattend/autounattend.xml | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/windows/installer/autounattend/autounattend.xml b/windows/installer/autounattend/autounattend.xml index a68c48d..773914e 100644 --- a/windows/installer/autounattend/autounattend.xml +++ b/windows/installer/autounattend/autounattend.xml @@ -113,19 +113,10 @@ bootstrap-OneTime!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> <!-- From ee2d6fd8f2c4fc998cf5900d7dc5afa7ed1c0cea Mon Sep 17 00:00:00 2001 From: sysadmin <sysadmin@silverlined.dev> Date: Tue, 9 Jun 2026 14:29:07 +0100 Subject: [PATCH 14/24] feat(kiosk): revert kiosk (shell launcher + escapes) on wizard success --- .../Apply/ApplyService.cs | 1 + .../Apply/BootstrapService.cs | 19 ++++ .../Apply/IBootstrapService.cs | 6 +- .../BootstrapServiceRevertKioskTests.cs | 99 +++++++++++++++++++ 4 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 windows/welcome/tests/SilverOS.Welcome.Tests/BootstrapServiceRevertKioskTests.cs diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs index 8571878..8e1fa3f 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs @@ -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)); } diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs index e367dcf..0e2c1a5 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs @@ -1,6 +1,25 @@ namespace SilverOS.Welcome.Core.Apply; public sealed class BootstrapService(IProcessRunner runner) : IBootstrapService { + 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" + + "}", + "Revert Shell Launcher", 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", + "Revert escape policies", ct); + } + public async Task TearDownAsync(string bootstrapUser, CancellationToken ct = default) { const string key = "'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon'"; diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/IBootstrapService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/IBootstrapService.cs index 7fef74f..5e48d5b 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/IBootstrapService.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/IBootstrapService.cs @@ -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); +} diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/BootstrapServiceRevertKioskTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/BootstrapServiceRevertKioskTests.cs new file mode 100644 index 0000000..198b51e --- /dev/null +++ b/windows/welcome/tests/SilverOS.Welcome.Tests/BootstrapServiceRevertKioskTests.cs @@ -0,0 +1,99 @@ +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_throws_on_nonzero_exit() + { + await Assert.ThrowsAsync<InvalidOperationException>(() => + new BootstrapService(Fail().Object).RevertKioskAsync()); + } + + [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); + } +} From 2d8b651e34fa0bbf05b77acf625656df4188feec Mon Sep 17 00:00:00 2001 From: sysadmin <sysadmin@silverlined.dev> Date: Tue, 9 Jun 2026 14:36:14 +0100 Subject: [PATCH 15/24] fix(kiosk): re-fetch WESL after enable, robust launcher quoting, intent comments Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- windows/installer/oem/Configure-Kiosk.ps1 | 9 +++++++-- .../src/SilverOS.Welcome.Core/Apply/BootstrapService.cs | 3 +++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/windows/installer/oem/Configure-Kiosk.ps1 b/windows/installer/oem/Configure-Kiosk.ps1 index 2cd00bb..552930a 100644 --- a/windows/installer/oem/Configure-Kiosk.ps1 +++ b/windows/installer/oem/Configure-Kiosk.ps1 @@ -19,9 +19,12 @@ 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 -FilePath '$WelcomeExe' -Verb RunAs" +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 @@ -30,8 +33,10 @@ Log "wrote launcher $launcher" # --- Shell Launcher v2 (WMI bridge) --- $cls='root\standardcimv2\embedded' -$wesl=Get-CimInstance -Namespace $cls -ClassName WESL_UserSetting -ErrorAction Stop +# 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). diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs index 0e2c1a5..b557631 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs @@ -3,6 +3,9 @@ public sealed class BootstrapService(IProcessRunner runner) : IBootstrapService { public async Task RevertKioskAsync(CancellationToken ct = default) { + // -EA SilentlyContinue throughout: Shell Launcher revert is best-effort. + // If WESL is unavailable the real user still gets Explorer (no custom shell + // for their SID). Intentional: don't fail teardown over a missing WMI class. // Remove sm-bootstrap custom shell entry + disable Shell Launcher's per-user entry. await Ps( "$c='root\\\\standardcimv2\\\\embedded';" + From 64ae04d56ce8f79f8cf8657729476565316fe4c9 Mon Sep 17 00:00:00 2001 From: sysadmin <sysadmin@silverlined.dev> Date: Tue, 9 Jun 2026 14:39:30 +0100 Subject: [PATCH 16/24] feat(welcome): borderless fullscreen non-closable kiosk window --- .../src/SilverOS.Welcome.App/App.xaml.cs | 10 ++++++- .../Platforms/Windows/WindowExtensions.cs | 26 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 windows/welcome/src/SilverOS.Welcome.App/Platforms/Windows/WindowExtensions.cs diff --git a/windows/welcome/src/SilverOS.Welcome.App/App.xaml.cs b/windows/welcome/src/SilverOS.Welcome.App/App.xaml.cs index b2863e7..1991936 100644 --- a/windows/welcome/src/SilverOS.Welcome.App/App.xaml.cs +++ b/windows/welcome/src/SilverOS.Welcome.App/App.xaml.cs @@ -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; } } diff --git a/windows/welcome/src/SilverOS.Welcome.App/Platforms/Windows/WindowExtensions.cs b/windows/welcome/src/SilverOS.Welcome.App/Platforms/Windows/WindowExtensions.cs new file mode 100644 index 0000000..6241ba7 --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.App/Platforms/Windows/WindowExtensions.cs @@ -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 From bb7b4b0fed1473b81184868228e77160482a973c Mon Sep 17 00:00:00 2001 From: sysadmin <sysadmin@silverlined.dev> Date: Tue, 9 Jun 2026 14:40:15 +0100 Subject: [PATCH 17/24] feat(welcome): SilverMetal void/cyan glass stylesheet --- .../wwwroot/css/silvermetal.css | 24 +++++++++++++++++++ .../SilverOS.Welcome.App/wwwroot/index.html | 1 + 2 files changed, 25 insertions(+) create mode 100644 windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/silvermetal.css diff --git a/windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/silvermetal.css b/windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/silvermetal.css new file mode 100644 index 0000000..7fcfd93 --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/silvermetal.css @@ -0,0 +1,24 @@ +:root{ + --void:#0b0f14; --ink:#e8edf5; --mid:#8fa4bc; + --accent:#00d4ff; --accent2:#00e5a0; --line:rgba(255,255,255,.08); +} +html,body{height:100%;margin:0;background:#05080c;color:var(--ink); + font-family:"Segoe UI Variable","Segoe UI",system-ui,sans-serif;overflow:hidden} +.sm-wall{position:fixed;inset:0;background: + radial-gradient(85% 75% at 28% 18%, #173247, transparent 60%), + radial-gradient(75% 75% at 82% 92%, #0f3528, transparent 55%), + linear-gradient(135deg,#080f17,#0a1612)} +.sm-wall::after{content:"SILVERMETAL";position:fixed;right:26px;bottom:18px; + font:700 12px/1 system-ui;letter-spacing:4px;color:rgba(255,255,255,.18)} +.sm-glass{position:fixed;inset:12% 16%;border-radius:18px; + background:rgba(16,22,31,.55);backdrop-filter:blur(18px); + border:1px solid rgba(255,255,255,.14); + box-shadow:0 24px 70px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.12); + display:flex;flex-direction:column;overflow:hidden;animation:sm-rise .5s ease both} +@keyframes sm-rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}} +.sm-rail{display:flex;gap:14px;padding:14px 26px;font:600 10px/1 "Cascadia Mono",Consolas,monospace; + letter-spacing:1px;color:var(--mid);border-bottom:1px solid var(--line)} +.sm-rail .on{color:var(--accent)} .sm-rail .done{color:var(--accent2)} +.sm-body{flex:1;padding:18px 30px;overflow:auto;min-height:0} +.sm-next{align-self:flex-end;margin:14px 26px;background:linear-gradient(180deg,#13b6e6,#0a93c8); + color:#001018;font-weight:700;border:0;border-radius:9px;padding:10px 22px;cursor:pointer} diff --git a/windows/welcome/src/SilverOS.Welcome.App/wwwroot/index.html b/windows/welcome/src/SilverOS.Welcome.App/wwwroot/index.html index 03fe4fa..4511da2 100644 --- a/windows/welcome/src/SilverOS.Welcome.App/wwwroot/index.html +++ b/windows/welcome/src/SilverOS.Welcome.App/wwwroot/index.html @@ -8,6 +8,7 @@ <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" /> <link rel="stylesheet" href="css/app.css" /> <link rel="stylesheet" href="SilverOS.Welcome.App.styles.css" /> + <link rel="stylesheet" href="css/silvermetal.css" /> <link rel="icon" href="data:,"> </head> From 395e86137bfd4288b428ed60bff85ff30d68ed66 Mon Sep 17 00:00:00 2001 From: sysadmin <sysadmin@silverlined.dev> Date: Tue, 9 Jun 2026 14:40:49 +0100 Subject: [PATCH 18/24] feat(welcome): Hybrid glass-card shell in MainLayout --- .../Components/Layout/MainLayout.razor | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/MainLayout.razor b/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/MainLayout.razor index 7cd63fe..a941dde 100644 --- a/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/MainLayout.razor +++ b/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/MainLayout.razor @@ -1,17 +1,8 @@ @inherits LayoutComponentBase -<div class="page"> - <div class="sidebar"> - <NavMenu /> +<div class="sm-wall"></div> +<div class="sm-glass"> + <div class="sm-body"> + @Body </div> - - <main> - <div class="top-row px-4"> - <a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a> - </div> - - <article class="content px-4"> - @Body - </article> - </main> </div> From 65de29c58b08f120870fb805f14a46652c280180 Mon Sep 17 00:00:00 2001 From: sysadmin <sysadmin@silverlined.dev> Date: Tue, 9 Jun 2026 14:41:29 +0100 Subject: [PATCH 19/24] chore(welcome): remove stock template nav from kiosk shell --- .../Components/Layout/NavMenu.razor | 27 ----- .../Components/Layout/NavMenu.razor.css | 101 ------------------ 2 files changed, 128 deletions(-) delete mode 100644 windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor delete mode 100644 windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor.css diff --git a/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor b/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor deleted file mode 100644 index ef077a3..0000000 --- a/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor +++ /dev/null @@ -1,27 +0,0 @@ -<div class="top-row ps-3 navbar navbar-dark"> - <div class="container-fluid"> - <a class="navbar-brand" href="">SilverOS.Welcome.App</a> - </div> -</div> - -<input type="checkbox" title="Navigation menu" class="navbar-toggler" /> - -<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()"> - <nav class="flex-column"> - <div class="nav-item px-3"> - <NavLink class="nav-link" href="" Match="NavLinkMatch.All"> - <span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home - </NavLink> - </div> - <div class="nav-item px-3"> - <NavLink class="nav-link" href="counter"> - <span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Counter - </NavLink> - </div> - <div class="nav-item px-3"> - <NavLink class="nav-link" href="weather"> - <span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather - </NavLink> - </div> - </nav> -</div> diff --git a/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor.css b/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor.css deleted file mode 100644 index 06fe5ad..0000000 --- a/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor.css +++ /dev/null @@ -1,101 +0,0 @@ -.navbar-toggler { - appearance: none; - cursor: pointer; - width: 3.5rem; - height: 2.5rem; - color: white; - position: absolute; - top: 0.5rem; - right: 1rem; - border: 1px solid rgba(255, 255, 255, 0.1); - background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1); -} - - .navbar-toggler:checked { - background-color: rgba(255, 255, 255, 0.5); - } - -.top-row { - height: 3.5rem; - background-color: rgba(0,0,0,0.4); -} - -.navbar-brand { - font-size: 1.1rem; -} - -.bi { - display: inline-block; - position: relative; - width: 1.25rem; - height: 1.25rem; - margin-right: 0.75rem; - top: -1px; - background-size: cover; -} - -.bi-house-door-fill-nav-menu { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); -} - -.bi-plus-square-fill-nav-menu { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); -} - -.bi-list-nested-nav-menu { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); -} - -.nav-item { - font-size: 0.9rem; - padding-bottom: 0.5rem; -} - - .nav-item:first-of-type { - padding-top: 1rem; - } - - .nav-item:last-of-type { - padding-bottom: 1rem; - } - - .nav-item ::deep a { - color: #d7d7d7; - border-radius: 4px; - height: 3rem; - display: flex; - align-items: center; - line-height: 3rem; - } - - .nav-item ::deep a.active { - background-color: rgba(255,255,255,0.37); - color: white; - } - - .nav-item ::deep a:hover { - background-color: rgba(255,255,255,0.1); - color: white; - } - -.nav-scrollable { - display: none; -} - -.navbar-toggler:checked ~ .nav-scrollable { - display: block; -} - -@media (min-width: 641px) { - .navbar-toggler { - display: none; - } - - .nav-scrollable { - /* Never collapse the sidebar for wide screens */ - display: block; - /* Allow sidebar to scroll for tall menus */ - height: calc(100vh - 3.5rem); - overflow-y: auto; - } -} From f314dccf53fb8902f0efbc67d723c43cc9aba843 Mon Sep 17 00:00:00 2001 From: sysadmin <sysadmin@silverlined.dev> Date: Tue, 9 Jun 2026 14:51:40 +0100 Subject: [PATCH 20/24] fix(welcome): remove dead Phase C artifacts (silvermetal.css, App-project layout edits) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete wwwroot/css/silvermetal.css (sm-* selectors targeting no markup), remove its <link> from index.html, and restore App-project stock Layout/Pages components (MainLayout, NavMenu, Home) to their pre-Phase-C state at 2d8b651. These files are unused dead-code — the wizard shell lives in SilverOS.Welcome.UI. --- .../Components/Layout/MainLayout.razor | 17 ++- .../Components/Layout/NavMenu.razor | 27 +++++ .../Components/Layout/NavMenu.razor.css | 101 ++++++++++++++++++ .../wwwroot/css/silvermetal.css | 24 ----- .../SilverOS.Welcome.App/wwwroot/index.html | 1 - 5 files changed, 141 insertions(+), 29 deletions(-) create mode 100644 windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor create mode 100644 windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor.css delete mode 100644 windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/silvermetal.css diff --git a/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/MainLayout.razor b/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/MainLayout.razor index a941dde..7cd63fe 100644 --- a/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/MainLayout.razor +++ b/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/MainLayout.razor @@ -1,8 +1,17 @@ @inherits LayoutComponentBase -<div class="sm-wall"></div> -<div class="sm-glass"> - <div class="sm-body"> - @Body +<div class="page"> + <div class="sidebar"> + <NavMenu /> </div> + + <main> + <div class="top-row px-4"> + <a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a> + </div> + + <article class="content px-4"> + @Body + </article> + </main> </div> diff --git a/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor b/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor new file mode 100644 index 0000000..ef077a3 --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor @@ -0,0 +1,27 @@ +<div class="top-row ps-3 navbar navbar-dark"> + <div class="container-fluid"> + <a class="navbar-brand" href="">SilverOS.Welcome.App</a> + </div> +</div> + +<input type="checkbox" title="Navigation menu" class="navbar-toggler" /> + +<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()"> + <nav class="flex-column"> + <div class="nav-item px-3"> + <NavLink class="nav-link" href="" Match="NavLinkMatch.All"> + <span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home + </NavLink> + </div> + <div class="nav-item px-3"> + <NavLink class="nav-link" href="counter"> + <span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Counter + </NavLink> + </div> + <div class="nav-item px-3"> + <NavLink class="nav-link" href="weather"> + <span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather + </NavLink> + </div> + </nav> +</div> diff --git a/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor.css b/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor.css new file mode 100644 index 0000000..06fe5ad --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor.css @@ -0,0 +1,101 @@ +.navbar-toggler { + appearance: none; + cursor: pointer; + width: 3.5rem; + height: 2.5rem; + color: white; + position: absolute; + top: 0.5rem; + right: 1rem; + border: 1px solid rgba(255, 255, 255, 0.1); + background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1); +} + + .navbar-toggler:checked { + background-color: rgba(255, 255, 255, 0.5); + } + +.top-row { + height: 3.5rem; + background-color: rgba(0,0,0,0.4); +} + +.navbar-brand { + font-size: 1.1rem; +} + +.bi { + display: inline-block; + position: relative; + width: 1.25rem; + height: 1.25rem; + margin-right: 0.75rem; + top: -1px; + background-size: cover; +} + +.bi-house-door-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); +} + +.bi-plus-square-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); +} + +.bi-list-nested-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); +} + +.nav-item { + font-size: 0.9rem; + padding-bottom: 0.5rem; +} + + .nav-item:first-of-type { + padding-top: 1rem; + } + + .nav-item:last-of-type { + padding-bottom: 1rem; + } + + .nav-item ::deep a { + color: #d7d7d7; + border-radius: 4px; + height: 3rem; + display: flex; + align-items: center; + line-height: 3rem; + } + + .nav-item ::deep a.active { + background-color: rgba(255,255,255,0.37); + color: white; + } + + .nav-item ::deep a:hover { + background-color: rgba(255,255,255,0.1); + color: white; + } + +.nav-scrollable { + display: none; +} + +.navbar-toggler:checked ~ .nav-scrollable { + display: block; +} + +@media (min-width: 641px) { + .navbar-toggler { + display: none; + } + + .nav-scrollable { + /* Never collapse the sidebar for wide screens */ + display: block; + /* Allow sidebar to scroll for tall menus */ + height: calc(100vh - 3.5rem); + overflow-y: auto; + } +} diff --git a/windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/silvermetal.css b/windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/silvermetal.css deleted file mode 100644 index 7fcfd93..0000000 --- a/windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/silvermetal.css +++ /dev/null @@ -1,24 +0,0 @@ -:root{ - --void:#0b0f14; --ink:#e8edf5; --mid:#8fa4bc; - --accent:#00d4ff; --accent2:#00e5a0; --line:rgba(255,255,255,.08); -} -html,body{height:100%;margin:0;background:#05080c;color:var(--ink); - font-family:"Segoe UI Variable","Segoe UI",system-ui,sans-serif;overflow:hidden} -.sm-wall{position:fixed;inset:0;background: - radial-gradient(85% 75% at 28% 18%, #173247, transparent 60%), - radial-gradient(75% 75% at 82% 92%, #0f3528, transparent 55%), - linear-gradient(135deg,#080f17,#0a1612)} -.sm-wall::after{content:"SILVERMETAL";position:fixed;right:26px;bottom:18px; - font:700 12px/1 system-ui;letter-spacing:4px;color:rgba(255,255,255,.18)} -.sm-glass{position:fixed;inset:12% 16%;border-radius:18px; - background:rgba(16,22,31,.55);backdrop-filter:blur(18px); - border:1px solid rgba(255,255,255,.14); - box-shadow:0 24px 70px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.12); - display:flex;flex-direction:column;overflow:hidden;animation:sm-rise .5s ease both} -@keyframes sm-rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}} -.sm-rail{display:flex;gap:14px;padding:14px 26px;font:600 10px/1 "Cascadia Mono",Consolas,monospace; - letter-spacing:1px;color:var(--mid);border-bottom:1px solid var(--line)} -.sm-rail .on{color:var(--accent)} .sm-rail .done{color:var(--accent2)} -.sm-body{flex:1;padding:18px 30px;overflow:auto;min-height:0} -.sm-next{align-self:flex-end;margin:14px 26px;background:linear-gradient(180deg,#13b6e6,#0a93c8); - color:#001018;font-weight:700;border:0;border-radius:9px;padding:10px 22px;cursor:pointer} diff --git a/windows/welcome/src/SilverOS.Welcome.App/wwwroot/index.html b/windows/welcome/src/SilverOS.Welcome.App/wwwroot/index.html index 4511da2..03fe4fa 100644 --- a/windows/welcome/src/SilverOS.Welcome.App/wwwroot/index.html +++ b/windows/welcome/src/SilverOS.Welcome.App/wwwroot/index.html @@ -8,7 +8,6 @@ <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" /> <link rel="stylesheet" href="css/app.css" /> <link rel="stylesheet" href="SilverOS.Welcome.App.styles.css" /> - <link rel="stylesheet" href="css/silvermetal.css" /> <link rel="icon" href="data:,"> </head> From 83ee1522772e1653a740caf693ba3ecbf7e251c0 Mon Sep 17 00:00:00 2001 From: sysadmin <sysadmin@silverlined.dev> Date: Tue, 9 Jun 2026 14:51:48 +0100 Subject: [PATCH 21/24] feat(welcome): frosted glass-card framing for wizard on void wall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the full-viewport .wizard rule with a fixed-inset frosted-glass card (backdrop-filter blur, semi-transparent surface, rounded corners, inset shadow) so the Mercury void/gradient wall shows behind and around the card. Adds @keyframes sm-rise entrance animation and a body::after SILVERMETAL wordmark watermark on the wall. Targets the real .wizard class in SilverOS.Welcome.UI/ Components/Routes.razor — no App-project markup touched. --- .../SilverOS.Welcome.App/wwwroot/css/app.css | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/app.css b/windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/app.css index 1b8751c..555a055 100644 --- a/windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/app.css +++ b/windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/app.css @@ -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 ─────────────────────────────────────────────────── */ From 9e9af94dfde29f499c03b9c195bf79a479b9b76d Mon Sep 17 00:00:00 2001 From: sysadmin <sysadmin@silverlined.dev> Date: Tue, 9 Jun 2026 15:05:50 +0100 Subject: [PATCH 22/24] fix(branding): opaque ARGB/ABGR accent DWORDs; fix stage labels + stale launch comments Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- windows/branding/lib/BrandingLayers.ps1 | 16 ++++++++++++---- windows/installer/autounattend/autounattend.xml | 9 +++++---- windows/installer/build.ps1 | 9 +++++---- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/windows/branding/lib/BrandingLayers.ps1 b/windows/branding/lib/BrandingLayers.ps1 index aa07ba4..7425954 100644 --- a/windows/branding/lib/BrandingLayers.ps1 +++ b/windows/branding/lib/BrandingLayers.ps1 @@ -35,10 +35,18 @@ function Set-DesktopBranding { 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 color as COLORREF (0x00RRGGBB). #00d4ff = cyan. - $bgr = [Convert]::ToInt32($Manifest.desktop.accentColor,16) - Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\DWM' -Name 'AccentColor' -Type DWord -Value $bgr - Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\DWM' -Name 'ColorizationColor' -Type DWord -Value $bgr + # 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 } diff --git a/windows/installer/autounattend/autounattend.xml b/windows/installer/autounattend/autounattend.xml index 773914e..522cedb 100644 --- a/windows/installer/autounattend/autounattend.xml +++ b/windows/installer/autounattend/autounattend.xml @@ -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> diff --git a/windows/installer/build.ps1 b/windows/installer/build.ps1 index 73e7982..ddf78a1 100644 --- a/windows/installer/build.ps1 +++ b/windows/installer/build.ps1 @@ -222,17 +222,18 @@ function Invoke-ServiceWim { Copy-WelcomePayload # Bake the four branding layers into the offline hives (must be inside the mount). - Write-Stage 'Stage 3e: bake SilverMetal branding (OEM/lockscreen/desktop/bitlocker)' + 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' } From 500e21f186c6a9e3b3cdc8e5609b08f9ccb25386 Mon Sep 17 00:00:00 2001 From: sysadmin <sysadmin@silverlined.dev> Date: Tue, 9 Jun 2026 15:10:52 +0100 Subject: [PATCH 23/24] ci(branding): force Pester 5 + use v5 config object (fix -Output ambiguity vs Pester 3) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .gitea/workflows/build-iso-windows.yaml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/build-iso-windows.yaml b/.gitea/workflows/build-iso-windows.yaml index ad9b428..9ca239c 100644 --- a/.gitea/workflows/build-iso-windows.yaml +++ b/.gitea/workflows/build-iso-windows.yaml @@ -111,11 +111,20 @@ jobs: - 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 '5.0.0')) { + if (-not (Get-Module -ListAvailable Pester | Where-Object { $_.Version -ge [version]'5.0.0' })) { Install-Module Pester -MinimumVersion 5.0 -Scope CurrentUser -Force -SkipPublisherCheck } - $r = Invoke-Pester windows/tests/Branding.Tests.ps1 -PassThru -Output Detailed + 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 From bc847ea6d98f6ffa40a68c160defebec4a1d0b2b Mon Sep 17 00:00:00 2001 From: sysadmin <sysadmin@silverlined.dev> Date: Tue, 9 Jun 2026 15:14:48 +0100 Subject: [PATCH 24/24] fix(build): discard stale image mounts at startup + ephemeral CI WorkDir A prior aborted build left a DISM image mounted in the fixed WorkDir, locking install.wim and breaking the Stage 2 extract clean-up. Add a Stage 0 that discards any orphaned SilverMetal mounts + loaded hives before recreating the work dirs, and run CI in an ephemeral per-job RUNNER_TEMP WorkDir so concurrent/aborted runs can't collide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .gitea/workflows/build-iso-windows.yaml | 1 + windows/installer/build.ps1 | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/.gitea/workflows/build-iso-windows.yaml b/.gitea/workflows/build-iso-windows.yaml index 9ca239c..3f3d1c9 100644 --- a/.gitea/workflows/build-iso-windows.yaml +++ b/.gitea/workflows/build-iso-windows.yaml @@ -132,6 +132,7 @@ jobs: 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) diff --git a/windows/installer/build.ps1 b/windows/installer/build.ps1 index ddf78a1..b35922c 100644 --- a/windows/installer/build.ps1 +++ b/windows/installer/build.ps1 @@ -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 -------------------------------------------------------