fix(first-boot): branding-online encoding crash + bootstrap cleanup + recovery QR #15

Manually merged
SilverLABS merged 1 commits from fix/branding-encoding-cleanup-qr into main 2026-06-09 22:36:43 +00:00
7 changed files with 226 additions and 26 deletions

View File

@@ -1,80 +1,160 @@
#Requires -Version 5.1 #Requires -Version 5.1
<# <#
.SYNOPSIS Apply SilverMetal Windows branding (4 layers), offline (WIM) or online. .SYNOPSIS Apply SilverMetal Windows branding (4 layers), offline (WIM) or online.
.DESCRIPTION .DESCRIPTION
Offline: reg-load the mounted image's SOFTWARE + default NTUSER hives, write Offline: reg-load the mounted image's SOFTWARE + default NTUSER hives, write
values, stage assets, reg-unload. Online: write live HKLM + default-user hive. values, stage assets, reg-unload. Online: write live HKLM + default-user hive.
Design: ../docs/superpowers/specs/2026-06-09-first-boot-branding-design.md Design: ../docs/superpowers/specs/2026-06-09-first-boot-branding-design.md
#> #>
[CmdletBinding()] [CmdletBinding()]
param( param(
[Parameter(Mandatory)][ValidateSet('Offline','Online')][string]$Mode, [Parameter(Mandatory)][ValidateSet('Offline','Online')][string]$Mode,
[string]$MountPath, # required for Offline [string]$MountPath, # required for Offline
[string]$Manifest = "$PSScriptRoot\branding.manifest.json", [string]$Manifest = "$PSScriptRoot\branding.manifest.json",
[string]$AssetsDir = "$PSScriptRoot\assets", [string]$AssetsDir = "$PSScriptRoot\assets",
[switch]$PassThru [switch]$PassThru
) )
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
. "$PSScriptRoot\lib\BrandingLayers.ps1" . "$PSScriptRoot\lib\BrandingLayers.ps1"
function Write-Stage { param($m) Write-Host "==> $m" -ForegroundColor Cyan } function Write-Stage { param($m) Write-Host "==> $m" -ForegroundColor Cyan }
if ($Mode -eq 'Offline' -and -not $MountPath) { throw 'Offline mode requires -MountPath.' } if ($Mode -eq 'Offline' -and -not $MountPath) { throw 'Offline mode requires -MountPath.' }
$m = Get-Content $Manifest -Raw | ConvertFrom-Json $m = Get-Content $Manifest -Raw | ConvertFrom-Json
# Destination paths (image-relative for offline, live for online). # Destination paths (image-relative for offline, live for online).
$winRoot = if ($Mode -eq 'Offline') { $MountPath } else { 'C:' } $winRoot = if ($Mode -eq 'Offline') { $MountPath } else { 'C:' }
$logoDest = Join-Path $winRoot 'Windows\System32\oemlogo.bmp' $logoDest = Join-Path $winRoot 'Windows\System32\oemlogo.bmp'
$lockDir = Join-Path $winRoot 'Windows\Web\Screen\SilverMetal' $lockDir = Join-Path $winRoot 'Windows\Web\Screen\SilverMetal'
$wallDir = Join-Path $winRoot 'Windows\Web\Wallpaper\SilverMetal' $wallDir = Join-Path $winRoot 'Windows\Web\Wallpaper\SilverMetal'
$themeDest = Join-Path $winRoot 'Windows\Resources\Themes' $themeDest = Join-Path $winRoot 'Windows\Resources\Themes'
$lockLive = 'C:\Windows\Web\Screen\SilverMetal\' + $m.lockScreen.image $lockLive = 'C:\Windows\Web\Screen\SilverMetal\' + $m.lockScreen.image
$wallLive = 'C:\Windows\Web\Wallpaper\SilverMetal\' + $m.desktop.wallpaper $wallLive = 'C:\Windows\Web\Wallpaper\SilverMetal\' + $m.desktop.wallpaper
$logoLive = 'C:\Windows\System32\oemlogo.bmp' $logoLive = 'C:\Windows\System32\oemlogo.bmp'
# --- stage assets --- # --- stage assets ---
Write-Stage "Stage branding assets ($Mode)" Write-Stage "Stage branding assets ($Mode)"
New-Item -ItemType Directory -Force $lockDir,$wallDir,$themeDest,(Split-Path $logoDest) | Out-Null 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.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.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.wallpaper)(Join-Path $wallDir $m.desktop.wallpaper) -Force
Copy-Item (Join-Path $AssetsDir $m.desktop.theme) (Join-Path $themeDest $m.desktop.theme) -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 } $result = [ordered]@{ OemApplied=$false; LockScreenApplied=$false; DesktopApplied=$false; BitLockerApplied=$false }
function Invoke-WithHive { function Invoke-WithHive {
param([string]$HivePath,[string]$Name,[scriptblock]$Body) param([string]$HivePath,[string]$Name,[scriptblock]$Body)
& reg load "HKLM\$Name" $HivePath | Out-Null & reg load "HKLM\$Name" $HivePath | Out-Null
if ($LASTEXITCODE -ne 0) { throw "reg load $Name ($HivePath) failed" } if ($LASTEXITCODE -ne 0) { throw "reg load $Name ($HivePath) failed" }
try { & $Body "Registry::HKEY_LOCAL_MACHINE\$Name" } try { & $Body "Registry::HKEY_LOCAL_MACHINE\$Name" }
finally { finally {
[gc]::Collect(); Start-Sleep -Milliseconds 500 [gc]::Collect(); Start-Sleep -Milliseconds 500
& reg unload "HKLM\$Name" | Out-Null & reg unload "HKLM\$Name" | Out-Null
if ($LASTEXITCODE -ne 0) { Write-Warning "reg unload $Name failed ($LASTEXITCODE) — hive may be leaked" }
if ($LASTEXITCODE -ne 0) { Write-Warning "reg unload $Name failed ($LASTEXITCODE) -- hive may be leaked" }
} }
} }
if ($Mode -eq 'Offline') { if ($Mode -eq 'Offline') {
$swHive = Join-Path $MountPath 'Windows\System32\config\SOFTWARE' $swHive = Join-Path $MountPath 'Windows\System32\config\SOFTWARE'
$duHive = Join-Path $MountPath 'Users\Default\NTUSER.DAT' $duHive = Join-Path $MountPath 'Users\Default\NTUSER.DAT'
Invoke-WithHive $swHive 'SM_BRAND_SW' { Invoke-WithHive $swHive 'SM_BRAND_SW' {
param($sw) param($sw)
Write-Stage 'OEM About'; Set-OemInformation -SoftwareRoot $sw -Manifest $m -LogoPath $logoLive; $result.OemApplied=$true 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 '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 Write-Stage 'BitLocker preboot';Set-BitLockerPreboot -SoftwareRoot $sw -Manifest $m; $result.BitLockerApplied=$true
} }
Invoke-WithHive $duHive 'SM_BRAND_DU' { Invoke-WithHive $duHive 'SM_BRAND_DU' {
param($du) param($du)
Write-Stage 'Desktop'; Set-DesktopBranding -DefaultUserRoot $du -Manifest $m -WallpaperPath $wallLive; $result.DesktopApplied=$true Write-Stage 'Desktop'; Set-DesktopBranding -DefaultUserRoot $du -Manifest $m -WallpaperPath $wallLive; $result.DesktopApplied=$true
} }
} else { } else {
Set-OemInformation -SoftwareRoot 'HKLM:\SOFTWARE' -Manifest $m -LogoPath $logoLive; $result.OemApplied=$true 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-LockScreen -SoftwareRoot 'HKLM:\SOFTWARE' -ImagePath $lockLive -Lock:$m.lockScreen.lock; $result.LockScreenApplied=$true
Set-BitLockerPreboot -SoftwareRoot 'HKLM:\SOFTWARE' -Manifest $m; $result.BitLockerApplied=$true Set-BitLockerPreboot -SoftwareRoot 'HKLM:\SOFTWARE' -Manifest $m; $result.BitLockerApplied=$true
Invoke-WithHive 'C:\Users\Default\NTUSER.DAT' 'SM_BRAND_DU' { Invoke-WithHive 'C:\Users\Default\NTUSER.DAT' 'SM_BRAND_DU' {
param($du) Set-DesktopBranding -DefaultUserRoot $du -Manifest $m -WallpaperPath $wallLive; $result.DesktopApplied=$true param($du) Set-DesktopBranding -DefaultUserRoot $du -Manifest $m -WallpaperPath $wallLive; $result.DesktopApplied=$true
} }
} }
Write-Host 'Branding applied.' -ForegroundColor Green Write-Host 'Branding applied.' -ForegroundColor Green
if ($PassThru) { [pscustomobject]$result } if ($PassThru) { [pscustomobject]$result }

View File

@@ -1,65 +1,130 @@
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
. "$PSScriptRoot\RegistryHelpers.ps1" . "$PSScriptRoot\RegistryHelpers.ps1"
function Set-OemInformation { function Set-OemInformation {
param([Parameter(Mandatory)][string]$SoftwareRoot,[Parameter(Mandatory)]$Manifest,[Parameter(Mandatory)][string]$LogoPath) param([Parameter(Mandatory)][string]$SoftwareRoot,[Parameter(Mandatory)]$Manifest,[Parameter(Mandatory)][string]$LogoPath)
$sub = 'Microsoft\Windows\CurrentVersion\OEMInformation' $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 '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 '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 '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 'SupportHours' -Type String -Value $Manifest.oem.supportHours
Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'Logo' -Type String -Value $LogoPath Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'Logo' -Type String -Value $LogoPath
} }
function Set-LockScreen { function Set-LockScreen {
param([Parameter(Mandatory)][string]$SoftwareRoot,[Parameter(Mandatory)][string]$ImagePath,[bool]$Lock=$true) param([Parameter(Mandatory)][string]$SoftwareRoot,[Parameter(Mandatory)][string]$ImagePath,[bool]$Lock=$true)
# Per-device modern lock-screen image (reliable on Enterprise/IoT). # Per-device modern lock-screen image (reliable on Enterprise/IoT).
$csp = 'Microsoft\Windows\CurrentVersion\PersonalizationCSP' $csp = 'Microsoft\Windows\CurrentVersion\PersonalizationCSP'
Set-SmRegValue -Root $SoftwareRoot -SubKey $csp -Name 'LockScreenImagePath' -Type String -Value $ImagePath 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 'LockScreenImageUrl' -Type String -Value $ImagePath
Set-SmRegValue -Root $SoftwareRoot -SubKey $csp -Name 'LockScreenImageStatus' -Type DWord -Value 1 Set-SmRegValue -Root $SoftwareRoot -SubKey $csp -Name 'LockScreenImageStatus' -Type DWord -Value 1
if ($Lock) { if ($Lock) {
$pol = 'Policies\Microsoft\Windows\Personalization' $pol = 'Policies\Microsoft\Windows\Personalization'
Set-SmRegValue -Root $SoftwareRoot -SubKey $pol -Name 'LockScreenImage' -Type String -Value $ImagePath Set-SmRegValue -Root $SoftwareRoot -SubKey $pol -Name 'LockScreenImage' -Type String -Value $ImagePath
Set-SmRegValue -Root $SoftwareRoot -SubKey $pol -Name 'NoChangingLockScreen' -Type DWord -Value 1 Set-SmRegValue -Root $SoftwareRoot -SubKey $pol -Name 'NoChangingLockScreen' -Type DWord -Value 1
} }
} }
function Set-DesktopBranding { function Set-DesktopBranding {
param([Parameter(Mandatory)][string]$DefaultUserRoot,[Parameter(Mandatory)]$Manifest,[Parameter(Mandatory)][string]$WallpaperPath) 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 'WallPaper' -Type String -Value $WallpaperPath
Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Control Panel\Desktop' -Name 'WallpaperStyle' -Type String -Value '10' # fill Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Control Panel\Desktop' -Name 'WallpaperStyle' -Type String -Value '10' # fill
if ($Manifest.desktop.darkMode) { if ($Manifest.desktop.darkMode) {
$p = 'Software\Microsoft\Windows\CurrentVersion\Themes\Personalize' $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 'AppsUseLightTheme' -Type DWord -Value 0
Set-SmRegValue -Root $DefaultUserRoot -SubKey $p -Name 'SystemUsesLightTheme' -Type DWord -Value 0 Set-SmRegValue -Root $DefaultUserRoot -SubKey $p -Name 'SystemUsesLightTheme' -Type DWord -Value 0
} }
# Accent (cyan). DWM uses fully-opaque DWORDs with DIFFERENT byte orders: # Accent (cyan). DWM uses fully-opaque DWORDs with DIFFERENT byte orders:
# ColorizationColor = 0xAARRGGBB (ARGB); AccentColor = 0xAABBGGRR (ABGR). # ColorizationColor = 0xAARRGGBB (ARGB); AccentColor = 0xAABBGGRR (ABGR).
# Manifest holds the plain RGB hex (source of truth); derive both, alpha=FF. # Manifest holds the plain RGB hex (source of truth); derive both, alpha=FF.
# NOTE: exact accent rendering is VM-verified (plan §9 soft spot). # NOTE: exact accent rendering is VM-verified (plan §9 soft spot).
$rgb = $Manifest.desktop.accentColor.TrimStart('#') $rgb = $Manifest.desktop.accentColor.TrimStart('#')
$r = [Convert]::ToInt32($rgb.Substring(0,2),16) $r = [Convert]::ToInt32($rgb.Substring(0,2),16)
$g = [Convert]::ToInt32($rgb.Substring(2,2),16) $g = [Convert]::ToInt32($rgb.Substring(2,2),16)
$b = [Convert]::ToInt32($rgb.Substring(4,2),16) $b = [Convert]::ToInt32($rgb.Substring(4,2),16)
$argb = [int](0xFF000000 -bor ($r -shl 16) -bor ($g -shl 8) -bor $b) # ColorizationColor $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 $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 'AccentColor' -Type DWord -Value $abgr
Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\DWM' -Name 'ColorizationColor' -Type DWord -Value $argb Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\DWM' -Name 'ColorizationColor' -Type DWord -Value $argb
if (-not $Manifest.desktop.lockWallpaper) { return } if (-not $Manifest.desktop.lockWallpaper) { return }
Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\CurrentVersion\Policies\ActiveDesktop' -Name 'NoChangingWallPaper' -Type DWord -Value 1 Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\CurrentVersion\Policies\ActiveDesktop' -Name 'NoChangingWallPaper' -Type DWord -Value 1
} }
function Set-BitLockerPreboot { function Set-BitLockerPreboot {
param([Parameter(Mandatory)][string]$SoftwareRoot,[Parameter(Mandatory)]$Manifest) param([Parameter(Mandatory)][string]$SoftwareRoot,[Parameter(Mandatory)]$Manifest)
# GPO "Configure pre-boot recovery message and URL" (ADMX VolumeEncryption). # GPO "Configure pre-boot recovery message and URL" (ADMX VolumeEncryption).
# NOTE: only the BitLocker RECOVERY screen is customisable; the normal PIN-entry # 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 # 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. # test; if a name is wrong the offline-apply verify (Task A4) catches it.
$fve = 'Policies\Microsoft\FVE' $fve = 'Policies\Microsoft\FVE'
Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'UseCustomRecoveryMessage' -Type DWord -Value 1 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 '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 'UseCustomRecoveryUrl' -Type DWord -Value 1
Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'RecoveryUrl' -Type String -Value $Manifest.bitlocker.recoveryUrl Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'RecoveryUrl' -Type String -Value $Manifest.bitlocker.recoveryUrl
} }

View File

@@ -1,17 +1,34 @@
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
# Write a registry value under an arbitrary hive root (a live HKLM:/HKCU: path # 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. # OR a loaded offline hive exposed as a PSDrive). Creates intermediate keys.
function Set-SmRegValue { function Set-SmRegValue {
param( param(
[Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)][string]$Root,
[Parameter(Mandatory)][string]$SubKey, [Parameter(Mandatory)][string]$SubKey,
[Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][ValidateSet('String','ExpandString','DWord','Binary')][string]$Type, [Parameter(Mandatory)][ValidateSet('String','ExpandString','DWord','Binary')][string]$Type,
[Parameter(Mandatory)]$Value [Parameter(Mandatory)]$Value
) )
$key = Join-Path $Root $SubKey $key = Join-Path $Root $SubKey
if (-not (Test-Path $key)) { New-Item -Path $key -Force | Out-Null } 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 New-ItemProperty -Path $key -Name $Name -PropertyType $Type -Value $Value -Force | Out-Null
} }

View File

@@ -879,25 +879,36 @@ h1:focus { outline: none; }
} }
/* ── BitLocker recovery key (Done step) ─────────────────────────────── */ /* ── BitLocker recovery key (Done step) ─────────────────────────────── */
.done-step { display: flex; flex-direction: column; align-items: flex-start; }
.recovery-panel { .recovery-panel {
margin: 1.25rem 0; margin: 0.75rem 0;
padding: 1rem 1.25rem; padding: 0.85rem 1rem;
border: 1px solid var(--clr-accent); border: 1px solid var(--clr-accent);
border-radius: var(--radius-sm, 8px); border-radius: var(--radius-sm, 8px);
background: var(--clr-accent-glow, rgba(0,212,255,0.10)); background: var(--clr-accent-glow, rgba(0,212,255,0.10));
width: 100%;
}
.recovery-panel h3 { margin: 0 0 0.35rem; color: var(--clr-accent); font-family: var(--font-mono); font-size: 1rem; }
.recovery-lead { margin: 0 0 0.5rem; color: var(--clr-text-lo); font-size: 0.85rem; }
.recovery-row { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
.recovery-qr {
width: 132px; height: 132px;
background: #fff; padding: 6px; border-radius: var(--radius-sm, 8px);
flex: 0 0 auto;
} }
.recovery-panel h3 { margin: 0 0 0.5rem; color: var(--clr-accent); font-family: var(--font-mono); }
.recovery-key { .recovery-key {
flex: 1 1 14rem;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 1.05rem; font-size: 1.0rem;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: var(--clr-text-hi); color: var(--clr-text-hi);
background: rgba(0,0,0,0.30); background: rgba(0,0,0,0.30);
padding: 0.75rem 1rem; padding: 0.6rem 0.85rem;
border-radius: var(--radius-sm, 8px); border-radius: var(--radius-sm, 8px);
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
user-select: all; user-select: all;
margin: 0.5rem 0; margin: 0;
} }
.recovery-note { color: var(--clr-text-lo); } .recovery-note { color: var(--clr-text-lo); margin: 0.5rem 0 0; }
.done-step .btn-restart { margin-top: 1rem; align-self: flex-start; }

View File

@@ -39,16 +39,20 @@ public sealed class BootstrapService(IProcessRunner runner) : IBootstrapService
// Best-effort in-session removal (usually no-ops — you can't delete the account // Best-effort in-session removal (usually no-ops — you can't delete the account
// you're logged in as), THEN defer the real removal to a SYSTEM startup task that // you're logged in as), THEN defer the real removal to a SYSTEM startup task that
// runs on next boot, when sm-bootstrap is no longer logged on. It removes the // runs on next boot, when sm-bootstrap is no longer logged on. It removes the
// account + profile, then deletes itself. Encoded command avoids schtasks quoting. // account + profile, then unregisters itself.
await Ps($"Remove-LocalUser -Name '{u}' -EA SilentlyContinue", ct); await Ps($"Remove-LocalUser -Name '{u}' -EA SilentlyContinue", ct);
var cleanup = var cleanup =
$"Remove-LocalUser -Name '{u}' -ErrorAction SilentlyContinue; " + $"Remove-LocalUser -Name '{u}' -ErrorAction SilentlyContinue; " +
$"Get-CimInstance Win32_UserProfile | Where-Object {{ $_.LocalPath -like '*\\{u}' }} | Remove-CimInstance -ErrorAction SilentlyContinue; " + $"Get-CimInstance Win32_UserProfile | Where-Object {{ $_.LocalPath -like '*\\{u}' }} | Remove-CimInstance -ErrorAction SilentlyContinue; " +
"schtasks /delete /tn 'SilverMetalBootstrapCleanup' /f"; "Unregister-ScheduledTask -TaskName 'SilverMetalBootstrapCleanup' -Confirm:$false -ErrorAction SilentlyContinue";
var b64 = Convert.ToBase64String(System.Text.Encoding.Unicode.GetBytes(cleanup)); var b64 = Convert.ToBase64String(System.Text.Encoding.Unicode.GetBytes(cleanup));
await Ps("schtasks /create /tn 'SilverMetalBootstrapCleanup' " + // Register-ScheduledTask (not schtasks.exe) — schtasks /tr caps at 261 chars and
$"/tr 'powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand {b64}' " + // silently failed with the encoded payload, so the task was never created.
"/sc onstart /ru SYSTEM /rl HIGHEST /f", ct); await Ps("$a=New-ScheduledTaskAction -Execute 'powershell.exe' -Argument " +
$"'-NoProfile -ExecutionPolicy Bypass -EncodedCommand {b64}'; " +
"$t=New-ScheduledTaskTrigger -AtStartup; " +
"$p=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest; " +
"Register-ScheduledTask -TaskName 'SilverMetalBootstrapCleanup' -Action $a -Trigger $t -Principal $p -Force | Out-Null", ct);
} }
private static string Esc(string s) => s.Replace("'", "''"); private static string Esc(string s) => s.Replace("'", "''");
private Task Ps(string s, CancellationToken ct) => private Task Ps(string s, CancellationToken ct) =>

View File

@@ -1,39 +1,60 @@
@using QRCoder
@inject SilverOS.Welcome.Core.Apply.IProcessRunner ProcessRunner @inject SilverOS.Welcome.Core.Apply.IProcessRunner ProcessRunner
<div class="step done-step"> <div class="step done-step">
<h1>All Done!</h1> <h1>All Done!</h1>
<p>Your SilverMetal device is configured and ready.</p> <p class="step-subtitle">Your SilverMetal device is configured and ready.</p>
@if (!string.IsNullOrWhiteSpace(_recoveryKey)) @if (!string.IsNullOrWhiteSpace(_recoveryKey))
{ {
<div class="recovery-panel"> <div class="recovery-panel">
<h3>⚠ Save your BitLocker recovery key</h3> <h3>⚠ Back up your BitLocker recovery key</h3>
<p class="step-subtitle"> <p class="recovery-lead">
This is the <strong>only</strong> way back into your drive if you ever forget your PIN. This is the <strong>only</strong> way back into your drive if you forget your PIN.
Write it down or photograph it now and keep it somewhere safe and separate from this device. Scan the code with your phone, or copy the key — keep it somewhere safe and
separate from this device.
</p> </p>
<pre class="recovery-key">@_recoveryKey</pre> <div class="recovery-row">
<p class="recovery-note"><small>Also saved to <code>C:\ProgramData\SilverMetal\bitlocker-recovery.txt</code> on this device.</small></p> @if (_qrDataUri is not null)
{
<img class="recovery-qr" src="@_qrDataUri" alt="BitLocker recovery key QR code" />
}
<pre class="recovery-key">@_recoveryKey</pre>
</div>
<p class="recovery-note"><small>
A copy is saved on this device at <code>C:\ProgramData\SilverMetal\bitlocker-recovery.txt</code>
— you can delete it once you've backed the key up elsewhere.
</small></p>
</div> </div>
} }
<p>Click below to restart and start using it.</p>
<button class="btn-primary btn-restart" @onclick="RestartNow">Restart Now</button> <button class="btn-primary btn-restart" @onclick="RestartNow">Restart Now</button>
</div> </div>
@code { @code {
private string? _recoveryKey; private string? _recoveryKey;
private string? _qrDataUri;
protected override void OnInitialized() protected override void OnInitialized()
{ {
// The BitLocker step saved the 48-digit recovery key to ProgramData; surface it
// here so the user records it before finishing (TPM+PIN alone is unrecoverable).
try try
{ {
const string path = @"C:\ProgramData\SilverMetal\bitlocker-recovery.txt"; const string path = @"C:\ProgramData\SilverMetal\bitlocker-recovery.txt";
if (File.Exists(path)) _recoveryKey = File.ReadAllText(path).Trim(); if (File.Exists(path)) _recoveryKey = File.ReadAllText(path).Trim();
} }
catch { /* best-effort display */ } catch { /* best-effort display */ }
if (!string.IsNullOrWhiteSpace(_recoveryKey))
{
try
{
using var gen = new QRCodeGenerator();
using var data = gen.CreateQrCode(_recoveryKey, QRCodeGenerator.ECCLevel.M);
var png = new PngByteQRCode(data).GetGraphic(6);
_qrDataUri = "data:image/png;base64," + Convert.ToBase64String(png);
}
catch { /* QR is best-effort; the key text still shows */ }
}
} }
private async Task RestartNow() private async Task RestartNow()

View File

@@ -9,6 +9,8 @@
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
<!-- Pure-managed QR generator (no System.Drawing/native deps) for the recovery key. -->
<PackageReference Include="QRCoder" Version="1.6.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>