diff --git a/windows/branding/Apply-Branding.ps1 b/windows/branding/Apply-Branding.ps1 index c196a2b..cc1b564 100644 --- a/windows/branding/Apply-Branding.ps1 +++ b/windows/branding/Apply-Branding.ps1 @@ -1,80 +1,160 @@ -#Requires -Version 5.1 +#Requires -Version 5.1 + <# + .SYNOPSIS Apply SilverMetal Windows branding (4 layers), offline (WIM) or online. + .DESCRIPTION + Offline: reg-load the mounted image's SOFTWARE + default NTUSER hives, write + values, stage assets, reg-unload. Online: write live HKLM + default-user hive. + Design: ../docs/superpowers/specs/2026-06-09-first-boot-branding-design.md + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateSet('Offline','Online')][string]$Mode, + [string]$MountPath, # required for Offline + [string]$Manifest = "$PSScriptRoot\branding.manifest.json", + [string]$AssetsDir = "$PSScriptRoot\assets", + [switch]$PassThru + ) + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + . "$PSScriptRoot\lib\BrandingLayers.ps1" + function Write-Stage { param($m) Write-Host "==> $m" -ForegroundColor Cyan } + + if ($Mode -eq 'Offline' -and -not $MountPath) { throw 'Offline mode requires -MountPath.' } + $m = Get-Content $Manifest -Raw | ConvertFrom-Json + + # Destination paths (image-relative for offline, live for online). + $winRoot = if ($Mode -eq 'Offline') { $MountPath } else { 'C:' } + $logoDest = Join-Path $winRoot 'Windows\System32\oemlogo.bmp' + $lockDir = Join-Path $winRoot 'Windows\Web\Screen\SilverMetal' + $wallDir = Join-Path $winRoot 'Windows\Web\Wallpaper\SilverMetal' + $themeDest = Join-Path $winRoot 'Windows\Resources\Themes' + $lockLive = 'C:\Windows\Web\Screen\SilverMetal\' + $m.lockScreen.image + $wallLive = 'C:\Windows\Web\Wallpaper\SilverMetal\' + $m.desktop.wallpaper + $logoLive = 'C:\Windows\System32\oemlogo.bmp' + + # --- stage assets --- + Write-Stage "Stage branding assets ($Mode)" + New-Item -ItemType Directory -Force $lockDir,$wallDir,$themeDest,(Split-Path $logoDest) | Out-Null + Copy-Item (Join-Path $AssetsDir $m.oem.logo) $logoDest -Force + Copy-Item (Join-Path $AssetsDir $m.lockScreen.image) (Join-Path $lockDir $m.lockScreen.image) -Force + Copy-Item (Join-Path $AssetsDir $m.desktop.wallpaper)(Join-Path $wallDir $m.desktop.wallpaper) -Force + Copy-Item (Join-Path $AssetsDir $m.desktop.theme) (Join-Path $themeDest $m.desktop.theme) -Force + + $result = [ordered]@{ OemApplied=$false; LockScreenApplied=$false; DesktopApplied=$false; BitLockerApplied=$false } + + function Invoke-WithHive { + param([string]$HivePath,[string]$Name,[scriptblock]$Body) + & reg load "HKLM\$Name" $HivePath | Out-Null + if ($LASTEXITCODE -ne 0) { throw "reg load $Name ($HivePath) failed" } + try { & $Body "Registry::HKEY_LOCAL_MACHINE\$Name" } + finally { + [gc]::Collect(); Start-Sleep -Milliseconds 500 + & reg unload "HKLM\$Name" | Out-Null - if ($LASTEXITCODE -ne 0) { Write-Warning "reg unload $Name failed ($LASTEXITCODE) — hive may be leaked" } + + if ($LASTEXITCODE -ne 0) { Write-Warning "reg unload $Name failed ($LASTEXITCODE) -- hive may be leaked" } + } + } + + if ($Mode -eq 'Offline') { + $swHive = Join-Path $MountPath 'Windows\System32\config\SOFTWARE' + $duHive = Join-Path $MountPath 'Users\Default\NTUSER.DAT' + Invoke-WithHive $swHive 'SM_BRAND_SW' { + param($sw) + Write-Stage 'OEM About'; Set-OemInformation -SoftwareRoot $sw -Manifest $m -LogoPath $logoLive; $result.OemApplied=$true + Write-Stage 'Lock screen'; Set-LockScreen -SoftwareRoot $sw -ImagePath $lockLive -Lock:$m.lockScreen.lock; $result.LockScreenApplied=$true + Write-Stage 'BitLocker preboot';Set-BitLockerPreboot -SoftwareRoot $sw -Manifest $m; $result.BitLockerApplied=$true + } + Invoke-WithHive $duHive 'SM_BRAND_DU' { + param($du) + Write-Stage 'Desktop'; Set-DesktopBranding -DefaultUserRoot $du -Manifest $m -WallpaperPath $wallLive; $result.DesktopApplied=$true + } + } else { + Set-OemInformation -SoftwareRoot 'HKLM:\SOFTWARE' -Manifest $m -LogoPath $logoLive; $result.OemApplied=$true + Set-LockScreen -SoftwareRoot 'HKLM:\SOFTWARE' -ImagePath $lockLive -Lock:$m.lockScreen.lock; $result.LockScreenApplied=$true + Set-BitLockerPreboot -SoftwareRoot 'HKLM:\SOFTWARE' -Manifest $m; $result.BitLockerApplied=$true + Invoke-WithHive 'C:\Users\Default\NTUSER.DAT' 'SM_BRAND_DU' { + param($du) Set-DesktopBranding -DefaultUserRoot $du -Manifest $m -WallpaperPath $wallLive; $result.DesktopApplied=$true + } + } + + Write-Host 'Branding applied.' -ForegroundColor Green + if ($PassThru) { [pscustomobject]$result } + diff --git a/windows/branding/lib/BrandingLayers.ps1 b/windows/branding/lib/BrandingLayers.ps1 index 7425954..2a45d64 100644 --- a/windows/branding/lib/BrandingLayers.ps1 +++ b/windows/branding/lib/BrandingLayers.ps1 @@ -1,65 +1,130 @@ -Set-StrictMode -Version Latest +Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + . "$PSScriptRoot\RegistryHelpers.ps1" + + function Set-OemInformation { + param([Parameter(Mandatory)][string]$SoftwareRoot,[Parameter(Mandatory)]$Manifest,[Parameter(Mandatory)][string]$LogoPath) + $sub = 'Microsoft\Windows\CurrentVersion\OEMInformation' + Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'Manufacturer' -Type String -Value $Manifest.oem.manufacturer + Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'Model' -Type String -Value $Manifest.oem.model + Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'SupportURL' -Type String -Value $Manifest.oem.supportUrl + Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'SupportHours' -Type String -Value $Manifest.oem.supportHours + Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'Logo' -Type String -Value $LogoPath + } + + function Set-LockScreen { + param([Parameter(Mandatory)][string]$SoftwareRoot,[Parameter(Mandatory)][string]$ImagePath,[bool]$Lock=$true) + # Per-device modern lock-screen image (reliable on Enterprise/IoT). + $csp = 'Microsoft\Windows\CurrentVersion\PersonalizationCSP' + Set-SmRegValue -Root $SoftwareRoot -SubKey $csp -Name 'LockScreenImagePath' -Type String -Value $ImagePath + Set-SmRegValue -Root $SoftwareRoot -SubKey $csp -Name 'LockScreenImageUrl' -Type String -Value $ImagePath + Set-SmRegValue -Root $SoftwareRoot -SubKey $csp -Name 'LockScreenImageStatus' -Type DWord -Value 1 + if ($Lock) { + $pol = 'Policies\Microsoft\Windows\Personalization' + Set-SmRegValue -Root $SoftwareRoot -SubKey $pol -Name 'LockScreenImage' -Type String -Value $ImagePath + Set-SmRegValue -Root $SoftwareRoot -SubKey $pol -Name 'NoChangingLockScreen' -Type DWord -Value 1 + } + } + + function Set-DesktopBranding { + param([Parameter(Mandatory)][string]$DefaultUserRoot,[Parameter(Mandatory)]$Manifest,[Parameter(Mandatory)][string]$WallpaperPath) + Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Control Panel\Desktop' -Name 'WallPaper' -Type String -Value $WallpaperPath + Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Control Panel\Desktop' -Name 'WallpaperStyle' -Type String -Value '10' # fill + if ($Manifest.desktop.darkMode) { + $p = 'Software\Microsoft\Windows\CurrentVersion\Themes\Personalize' + Set-SmRegValue -Root $DefaultUserRoot -SubKey $p -Name 'AppsUseLightTheme' -Type DWord -Value 0 + Set-SmRegValue -Root $DefaultUserRoot -SubKey $p -Name 'SystemUsesLightTheme' -Type DWord -Value 0 + } + # Accent (cyan). DWM uses fully-opaque DWORDs with DIFFERENT byte orders: + # ColorizationColor = 0xAARRGGBB (ARGB); AccentColor = 0xAABBGGRR (ABGR). + # Manifest holds the plain RGB hex (source of truth); derive both, alpha=FF. + # NOTE: exact accent rendering is VM-verified (plan §9 soft spot). + $rgb = $Manifest.desktop.accentColor.TrimStart('#') + $r = [Convert]::ToInt32($rgb.Substring(0,2),16) + $g = [Convert]::ToInt32($rgb.Substring(2,2),16) + $b = [Convert]::ToInt32($rgb.Substring(4,2),16) + $argb = [int](0xFF000000 -bor ($r -shl 16) -bor ($g -shl 8) -bor $b) # ColorizationColor + $abgr = [int](0xFF000000 -bor ($b -shl 16) -bor ($g -shl 8) -bor $r) # AccentColor + Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\DWM' -Name 'AccentColor' -Type DWord -Value $abgr + Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\DWM' -Name 'ColorizationColor' -Type DWord -Value $argb + if (-not $Manifest.desktop.lockWallpaper) { return } + Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\CurrentVersion\Policies\ActiveDesktop' -Name 'NoChangingWallPaper' -Type DWord -Value 1 + } + + function Set-BitLockerPreboot { + param([Parameter(Mandatory)][string]$SoftwareRoot,[Parameter(Mandatory)]$Manifest) + # GPO "Configure pre-boot recovery message and URL" (ADMX VolumeEncryption). + # NOTE: only the BitLocker RECOVERY screen is customisable; the normal PIN-entry + # screen text is fixed Windows UI. Exact value names are asserted by the read-back + # test; if a name is wrong the offline-apply verify (Task A4) catches it. + $fve = 'Policies\Microsoft\FVE' + Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'UseCustomRecoveryMessage' -Type DWord -Value 1 + Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'RecoveryMessage' -Type String -Value $Manifest.bitlocker.recoveryMessage + Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'UseCustomRecoveryUrl' -Type DWord -Value 1 + Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'RecoveryUrl' -Type String -Value $Manifest.bitlocker.recoveryUrl + } + diff --git a/windows/branding/lib/RegistryHelpers.ps1 b/windows/branding/lib/RegistryHelpers.ps1 index 18122e0..bf03380 100644 --- a/windows/branding/lib/RegistryHelpers.ps1 +++ b/windows/branding/lib/RegistryHelpers.ps1 @@ -1,17 +1,34 @@ -Set-StrictMode -Version Latest +Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + # Write a registry value under an arbitrary hive root (a live HKLM:/HKCU: path + # OR a loaded offline hive exposed as a PSDrive). Creates intermediate keys. + function Set-SmRegValue { + param( + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string]$SubKey, + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][ValidateSet('String','ExpandString','DWord','Binary')][string]$Type, + [Parameter(Mandatory)]$Value + ) + $key = Join-Path $Root $SubKey + if (-not (Test-Path $key)) { New-Item -Path $key -Force | Out-Null } + New-ItemProperty -Path $key -Name $Name -PropertyType $Type -Value $Value -Force | Out-Null + } + 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 13309f1..946b769 100644 --- a/windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/app.css +++ b/windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/app.css @@ -879,25 +879,36 @@ h1:focus { outline: none; } } /* ── BitLocker recovery key (Done step) ─────────────────────────────── */ +.done-step { display: flex; flex-direction: column; align-items: flex-start; } .recovery-panel { - margin: 1.25rem 0; - padding: 1rem 1.25rem; + margin: 0.75rem 0; + padding: 0.85rem 1rem; border: 1px solid var(--clr-accent); border-radius: var(--radius-sm, 8px); background: var(--clr-accent-glow, rgba(0,212,255,0.10)); + width: 100%; +} +.recovery-panel h3 { margin: 0 0 0.35rem; color: var(--clr-accent); font-family: var(--font-mono); font-size: 1rem; } +.recovery-lead { margin: 0 0 0.5rem; color: var(--clr-text-lo); font-size: 0.85rem; } +.recovery-row { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; } +.recovery-qr { + width: 132px; height: 132px; + background: #fff; padding: 6px; border-radius: var(--radius-sm, 8px); + flex: 0 0 auto; } -.recovery-panel h3 { margin: 0 0 0.5rem; color: var(--clr-accent); font-family: var(--font-mono); } .recovery-key { + flex: 1 1 14rem; font-family: var(--font-mono); - font-size: 1.05rem; + font-size: 1.0rem; letter-spacing: 0.04em; color: var(--clr-text-hi); background: rgba(0,0,0,0.30); - padding: 0.75rem 1rem; + padding: 0.6rem 0.85rem; border-radius: var(--radius-sm, 8px); white-space: pre-wrap; word-break: break-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; } diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs index a31410c..8f1dff5 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs @@ -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 // you're logged in as), THEN defer the real removal to a SYSTEM startup task that // runs on next boot, when sm-bootstrap is no longer logged on. It removes the - // account + profile, then deletes itself. Encoded command avoids schtasks quoting. + // account + profile, then unregisters itself. await Ps($"Remove-LocalUser -Name '{u}' -EA SilentlyContinue", ct); var cleanup = $"Remove-LocalUser -Name '{u}' -ErrorAction SilentlyContinue; " + $"Get-CimInstance Win32_UserProfile | Where-Object {{ $_.LocalPath -like '*\\{u}' }} | Remove-CimInstance -ErrorAction SilentlyContinue; " + - "schtasks /delete /tn 'SilverMetalBootstrapCleanup' /f"; + "Unregister-ScheduledTask -TaskName 'SilverMetalBootstrapCleanup' -Confirm:$false -ErrorAction SilentlyContinue"; var b64 = Convert.ToBase64String(System.Text.Encoding.Unicode.GetBytes(cleanup)); - await Ps("schtasks /create /tn 'SilverMetalBootstrapCleanup' " + - $"/tr 'powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand {b64}' " + - "/sc onstart /ru SYSTEM /rl HIGHEST /f", ct); + // Register-ScheduledTask (not schtasks.exe) — schtasks /tr caps at 261 chars and + // silently failed with the encoded payload, so the task was never created. + await Ps("$a=New-ScheduledTaskAction -Execute 'powershell.exe' -Argument " + + $"'-NoProfile -ExecutionPolicy Bypass -EncodedCommand {b64}'; " + + "$t=New-ScheduledTaskTrigger -AtStartup; " + + "$p=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest; " + + "Register-ScheduledTask -TaskName 'SilverMetalBootstrapCleanup' -Action $a -Trigger $t -Principal $p -Force | Out-Null", ct); } private static string Esc(string s) => s.Replace("'", "''"); private Task Ps(string s, CancellationToken ct) => diff --git a/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/DoneStep.razor b/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/DoneStep.razor index 35ed602..2b4385b 100644 --- a/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/DoneStep.razor +++ b/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/DoneStep.razor @@ -1,39 +1,60 @@ +@using QRCoder @inject SilverOS.Welcome.Core.Apply.IProcessRunner ProcessRunner
Your SilverMetal device is configured and ready.
+Your SilverMetal device is configured and ready.
@if (!string.IsNullOrWhiteSpace(_recoveryKey)) {- This is the only way back into your drive if you ever forget your PIN. - Write it down or photograph it now and keep it somewhere safe and separate from this device. +
+ This is the only way back into your drive if you forget your PIN. + Scan the code with your phone, or copy the key — keep it somewhere safe and + separate from this device.
-@_recoveryKey-
Also saved to C:\ProgramData\SilverMetal\bitlocker-recovery.txt on this device.
@_recoveryKey+
+ A copy is saved on this device at C:\ProgramData\SilverMetal\bitlocker-recovery.txt
+ — you can delete it once you've backed the key up elsewhere.
+
Click below to restart and start using it.