From f44fa150e2c85bc8e8444bdc31333c787cec8195 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Thu, 11 Jun 2026 01:34:07 +0100 Subject: [PATCH] fix(first-boot): run hardening from toolbox, repair branding online re-apply, bake winget into image, Apply UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three regressions surfaced by VM 102 validation, plus the winget reliability fix: - Hardening never ran. SetupComplete.cmd DEFERS hardening to the toolbox when the Welcome app is present ("hardening deferred to SilverOS Welcome"), but ApplyService only did apps->bitlocker->done — the call was dropped in the collector slim-down, so all 8 modules were staged-but-never-executed. Add IHardeningService/HardeningService and run it (with the flavour's module selection) as the last Apply step. - Branding disappeared. Apply-Branding.ps1 -Mode Online crashed looking for C:\branding.manifest.json (param default's $PSScriptRoot came back unrooted under -File), so the post-OOBE re-apply never ran and personalization reverted. Resolve the manifest/assets robustly in the body, falling back to the script's own directory. - Apps didn't install. The runtime winget bootstrap failed silently on IoT LTSC (exit 1, no diag). Provision App Installer + VCLibs + UI.Xaml into the offline image at build time (Add-AppxProvisionedPackage) so winget is present at first boot. The runtime bootstrap remains as a non-fatal fallback. - Apply UX looked hung. Add a continuous progress-bar sheen + spinner + "this can take several minutes" hint, and make the percentages monotonic (apps 30->70, bitlocker 75, hardening 90, done 100). Tests: 32 passing (ApplyService now verifies apps->bitlocker->hardening order + that hardening receives the flavour modules). Co-Authored-By: Claude Opus 4.8 --- windows/branding/Apply-Branding.ps1 | 8 +++ windows/installer/build.ps1 | 50 +++++++++++++++++++ .../src/SilverOS.Welcome.App/MauiProgram.cs | 4 +- .../SilverOS.Welcome.App/wwwroot/css/app.css | 43 +++++++++++++++- .../Apply/ApplyService.cs | 15 ++++-- .../Apply/HardeningService.cs | 28 +++++++++++ .../Apply/IHardeningService.cs | 10 ++++ .../Apps/AppInstaller.cs | 4 +- .../Components/Steps/ApplyStep.razor | 17 ++++++- .../ApplyServiceTests.cs | 49 ++++++++++++------ 10 files changed, 203 insertions(+), 25 deletions(-) create mode 100644 windows/welcome/src/SilverOS.Welcome.Core/Apply/HardeningService.cs create mode 100644 windows/welcome/src/SilverOS.Welcome.Core/Apply/IHardeningService.cs diff --git a/windows/branding/Apply-Branding.ps1 b/windows/branding/Apply-Branding.ps1 index cc1b564..f012f86 100644 --- a/windows/branding/Apply-Branding.ps1 +++ b/windows/branding/Apply-Branding.ps1 @@ -42,6 +42,14 @@ function Write-Stage { param($m) Write-Host "==> $m" -ForegroundColor Cyan } if ($Mode -eq 'Offline' -and -not $MountPath) { throw 'Offline mode requires -MountPath.' } +# Resolve the manifest/assets robustly. The param defaults reference $PSScriptRoot, +# which can come back unrooted when this script is launched via -File from +# SetupComplete.cmd (the online re-apply crashed looking for C:\branding.manifest.json, +# wiping all branding after OOBE). Fall back to the script's own directory. +$scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path } +if (-not (Test-Path -LiteralPath $Manifest)) { $Manifest = Join-Path $scriptDir 'branding.manifest.json' } +if (-not (Test-Path -LiteralPath $AssetsDir)) { $AssetsDir = Join-Path $scriptDir 'assets' } + $m = Get-Content $Manifest -Raw | ConvertFrom-Json diff --git a/windows/installer/build.ps1 b/windows/installer/build.ps1 index ec07486..0cfa62a 100644 --- a/windows/installer/build.ps1 +++ b/windows/installer/build.ps1 @@ -238,6 +238,48 @@ function Copy-WelcomePayload { Write-Host " Welcome payload staged at $dest" } +# Provision winget (App Installer) + its runtime deps into the OFFLINE image. +# IoT Enterprise LTSC ships no winget and the runtime online bootstrap proved +# unreliable (silent Add-AppxPackage failures), so we bake it in: download the +# msixbundle + VCLibs + UI.Xaml on the (internet-connected) build host, then +# Add-AppxProvisionedPackage into the mount so every first-boot user gets winget. +# Non-fatal: a transient download failure must not brick the whole ISO build — the +# staged runtime bootstrap-winget.ps1 remains as a last-resort fallback. +function Add-WingetToImage { + param([Parameter(Mandatory)][string]$MountPath, [Parameter(Mandatory)][string]$CacheDir) + Write-Stage 'Stage 3c2: provision winget (App Installer + VCLibs + UI.Xaml) into image' + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $null = New-Item -ItemType Directory -Force $CacheDir + + $bundle = Join-Path $CacheDir 'Microsoft.DesktopAppInstaller.msixbundle' + $vclibs = Join-Path $CacheDir 'Microsoft.VCLibs.x64.14.00.Desktop.appx' + $xamlNupkg = Join-Path $CacheDir 'microsoft.ui.xaml.2.8.6.nupkg' + $xamlDir = Join-Path $CacheDir 'uixaml' + + Write-Host ' downloading App Installer (aka.ms/getwinget)' + Invoke-WebRequest -UseBasicParsing 'https://aka.ms/getwinget' -OutFile $bundle + Write-Host ' downloading VCLibs desktop framework' + Invoke-WebRequest -UseBasicParsing 'https://aka.ms/Microsoft.VCLibs.x64.14.00.Desktop.appx' -OutFile $vclibs + Write-Host ' downloading Microsoft.UI.Xaml 2.8.6 (nupkg)' + Invoke-WebRequest -UseBasicParsing 'https://globalcdn.nuget.org/packages/microsoft.ui.xaml.2.8.6.nupkg' -OutFile $xamlNupkg + + # The UI.Xaml appx lives inside the nupkg (a zip): tools\AppX\x64\Release\Microsoft.UI.Xaml.2.8.appx + if (Test-Path $xamlDir) { Remove-Item $xamlDir -Recurse -Force } + Expand-Archive -LiteralPath $xamlNupkg -DestinationPath $xamlDir -Force + $xaml = Get-ChildItem (Join-Path $xamlDir 'tools\AppX\x64\Release') -Filter 'Microsoft.UI.Xaml.*.appx' -EA SilentlyContinue | + Select-Object -First 1 + if (-not $xaml) { throw "UI.Xaml appx not found inside nupkg at tools\AppX\x64\Release" } + + Write-Host ' provisioning App Installer into image (Add-AppxProvisionedPackage)' + Add-AppxProvisionedPackage -Path $MountPath -PackagePath $bundle ` + -DependencyPackagePath $vclibs, $xaml.FullName -SkipLicense | Out-Null + Write-Host ' winget provisioned into image OK' -ForegroundColor Green + } catch { + Write-Warning " winget provisioning FAILED (continuing; runtime bootstrap remains as fallback): $($_.Exception.Message)" + } +} + # --- 3. Service the WIM offline (DISM) ------------------------------------- function Invoke-ServiceWim { Write-Stage 'Stage 3: offline-service install.wim' @@ -303,6 +345,14 @@ function Invoke-ServiceWim { # Stage Welcome app + flavours while the WIM is still mounted. Copy-WelcomePayload + # Provision winget (App Installer) + its deps INTO the image. IoT Enterprise LTSC + # ships no winget, and the runtime online bootstrap proved unreliable (silent + # Add-AppxPackage failures). Baking it in means winget is present for every user + # at first boot, so the toolbox's app installs work without a runtime download. + if ($env:SILVERMETAL_WELCOME_ENABLED -ne '0') { + Add-WingetToImage -MountPath $mount -CacheDir (Join-Path $WorkDir 'winget') + } + # Bake the four branding layers into the offline hives (must be inside the mount). Write-Stage 'Stage 3d: bake SilverMetal branding (OEM/lockscreen/desktop/bitlocker)' & (Join-Path $WindowsDir 'branding\Apply-Branding.ps1') -Mode Offline -MountPath $mount diff --git a/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs b/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs index f713e5b..306ff53 100644 --- a/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs +++ b/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs @@ -54,11 +54,13 @@ public static class MauiProgram builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); var appsDir = Path.Combine(AppContext.BaseDirectory, "apps"); builder.Services.AddSingleton(sp => new AppInstaller(sp.GetRequiredService(), appsDir)); builder.Services.AddSingleton(sp => new ApplyService( sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); builder.Services.AddSingleton(_ => new PreconfigStore(@"C:\ProgramData\SilverMetal")); builder.Services.AddScoped(); 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 313c8b9..1fa0051 100644 --- a/windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/app.css +++ b/windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/app.css @@ -754,12 +754,28 @@ h1:focus { outline: none; } animation: step-enter 0.35s var(--ease-out) both; } +.apply-stage-row { + display: flex; + align-items: center; + gap: 0.55rem; + margin-bottom: 0.6rem; +} + +.apply-spinner { + width: 13px; + height: 13px; + border-radius: 50%; + border: 2px solid var(--clr-accent-glow); + border-top-color: var(--clr-accent); + animation: sm-spin 0.8s linear infinite; + flex: 0 0 auto; +} + .apply-stage-label { font-family: var(--font-mono); font-size: 0.8rem; color: var(--clr-accent); letter-spacing: 0.04em; - margin-bottom: 0.6rem; } .apply-progress-track { @@ -772,11 +788,29 @@ h1:focus { outline: none; } } .apply-progress-bar { + position: relative; height: 100%; background: linear-gradient(90deg, var(--clr-accent) 0%, var(--clr-success) 100%); border-radius: 99px; transition: width 0.4s var(--ease-out); box-shadow: 0 0 10px var(--clr-accent-glow); + overflow: hidden; + min-width: 2px; +} + +/* Continuous sheen so the bar visibly animates even when a single step (e.g. a + long winget install) holds the percentage static — it never looks hung. */ +.apply-progress-bar.working::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.5) 50%, transparent 100%); + transform: translateX(-100%); + animation: sm-shimmer 1.3s ease-in-out infinite; +} + +@keyframes sm-shimmer { + to { transform: translateX(100%); } } .apply-percent-label { @@ -787,6 +821,13 @@ h1:focus { outline: none; } margin-top: 0.3rem; } +.apply-hint { + font-size: 0.74rem; + color: var(--clr-text-lo); + margin-top: 0.9rem; + line-height: 1.5; +} + /* Complete state */ .apply-complete { display: flex; diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs index a77dad2..4e439be 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs @@ -1,10 +1,11 @@ using SilverOS.Welcome.Core.Apps; namespace SilverOS.Welcome.Core.Apply; -// Toolbox Apply pipeline: apps -> bitlocker -> done. -// Account creation moved to Windows Setup (WinPE collector); OS hardening runs from -// SetupComplete; sm-bootstrap teardown is owned by Setup, not the toolbox. -public sealed class ApplyService(IBitLockerService bitlocker, IAppInstaller installer) : IApplyService +// Toolbox Apply pipeline: apps -> bitlocker -> hardening -> done. +// Account creation moved to Windows Setup (WinPE collector); sm-bootstrap teardown is +// owned by Setup. Hardening is DEFERRED to the toolbox by SetupComplete.cmd, so it runs +// HERE (last, after the network-dependent app installs and the disk enrol). +public sealed class ApplyService(IBitLockerService bitlocker, IAppInstaller installer, IHardeningService hardening) : IApplyService { public async Task RunAsync(ApplyRequest req, IProgress progress, CancellationToken ct = default) { @@ -16,6 +17,12 @@ public sealed class ApplyService(IBitLockerService bitlocker, IAppInstaller inst progress.Report(new("Encrypting the disk", 75)); await bitlocker.EnableAsync(req.BitLockerPin, ct); } + + // Run hardening last: it can lock down the network/execution surface, so it must + // come after the (network-dependent) app installs and the BitLocker PowerShell call. + progress.Report(new("Applying security hardening", 90)); + await hardening.RunAsync(req.Flavour?.Hardening.Modules ?? Array.Empty(), ct); + progress.Report(new("Done", 100)); } } diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/HardeningService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/HardeningService.cs new file mode 100644 index 0000000..87c42b4 --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/HardeningService.cs @@ -0,0 +1,28 @@ +namespace SilverOS.Welcome.Core.Apply; + +// Runs the §A–H hardening modules. SetupComplete.cmd DEFERS hardening to the toolbox +// when the Welcome app is present ("hardening deferred to SilverOS Welcome"), so the +// toolbox MUST actually run it here — a collector slim-down dropped this call, which +// left hardening staged-but-never-executed on every install. +public sealed class HardeningService(IProcessRunner runner) : IHardeningService +{ + // build.ps1 stages the payload here (Windows\Setup\Scripts\hardening). + private const string ScriptPath = @"C:\Windows\Setup\Scripts\hardening\Invoke-Hardening.ps1"; + + public async Task RunAsync(IReadOnlyList modules, CancellationToken ct = default) + { + // Dev/VM image without the staged payload: no-op rather than crash the apply. + if (!File.Exists(ScriptPath)) return; + + var args = $"-NoProfile -ExecutionPolicy Bypass -File \"{ScriptPath}\""; + if (modules is { Count: > 0 }) + // Invoke-Hardening.ps1 takes a single CSV token ("00,03,05") so -File binding + // delivers it reliably regardless of quoting. + args += $" -Modules \"{string.Join(",", modules)}\""; + + // Invoke-Hardening.ps1 runs with ErrorActionPreference=Continue and try/catches each + // module, so it returns 0 and logs its own per-module warnings. We deliberately do NOT + // EnsureSuccess: a single hardening warning must never block onboarding completion. + await runner.RunAsync("powershell.exe", args, ct); + } +} diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/IHardeningService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/IHardeningService.cs new file mode 100644 index 0000000..91f5a6c --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/IHardeningService.cs @@ -0,0 +1,10 @@ +namespace SilverOS.Welcome.Core.Apply; + +public interface IHardeningService +{ + /// + /// Runs the §A–H hardening modules from the image-staged payload. + /// is the flavour's numeric module selection (e.g. ["00","03","05"]); empty = all modules. + /// + Task RunAsync(IReadOnlyList modules, CancellationToken ct = default); +} diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs index 2a11f2a..0ed38fa 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs @@ -45,7 +45,9 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn foreach (var app in apps) { i++; - progress.Report(new($"Installing {app.Name} ({i}/{apps.Count})", 80)); + // Spread app installs across 30→70% (monotonic: BitLocker=75, hardening=90 follow). + var pct = 30 + (int)(40.0 * (i - 1) / Math.Max(1, apps.Count)); + progress.Report(new($"Installing {app.Name} ({i}/{apps.Count})", pct)); var ok = false; var id = app.Source.Winget; if (!string.IsNullOrWhiteSpace(id)) diff --git a/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor b/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor index aae2437..ef309ba 100644 --- a/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor +++ b/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor @@ -30,11 +30,24 @@ else {
-
@_stageLabel
+
+ @if (!_complete) + { + + } +
@_stageLabel
+
-
+
@(_percent)%
+ @if (!_complete) + { +

+ Installing your apps and applying security hardening — this can take several + minutes. Please leave the device powered on. +

+ }
@if (_complete) diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs index dc484b6..d7c1c99 100644 --- a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs +++ b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs @@ -19,12 +19,9 @@ public class ApplyServiceTests new() { Id = "daily-driver", Hardening = new HardeningSpec { Modules = new[] { "00" } } }; [Fact] - public async Task Runs_apps_then_bitlocker_when_pin_supplied() + public async Task Runs_apps_then_bitlocker_then_hardening_when_pin_supplied() { var order = new List(); - var run = new Mock(); - run.Setup(r => r.RunAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProcessResult(0, "", "")); var bl = new Mock(); bl.Setup(b => b.EnableAsync(It.IsAny(), It.IsAny())) .Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask); @@ -32,25 +29,26 @@ public class ApplyServiceTests installer.Setup(i => i.InstallAsync(It.IsAny>(), It.IsAny>(), It.IsAny())) .Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty()); + var hard = new Mock(); + hard.Setup(h => h.RunAsync(It.IsAny>(), It.IsAny())) + .Callback(() => order.Add("hardening")).Returns(Task.CompletedTask); - var sut = new ApplyService(bl.Object, installer.Object); + var sut = new ApplyService(bl.Object, installer.Object, hard.Object); var req = new ApplyRequest(Flavour(), "123456", System.Array.Empty()); var progress = new List(); await sut.RunAsync(req, new Progress(p => progress.Add(p.Stage))); - Assert.Equal(new[] { "apps", "bitlocker" }, order); + Assert.Equal(new[] { "apps", "bitlocker", "hardening" }, order); Assert.Contains("Installing apps", progress); + Assert.Contains("Applying security hardening", progress); Assert.Contains("Done", progress); } [Fact] - public async Task Empty_pin_skips_bitlocker() + public async Task Empty_pin_skips_bitlocker_but_still_hardens() { var order = new List(); - var run = new Mock(); - run.Setup(r => r.RunAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProcessResult(0, "", "")); var bl = new Mock(); bl.Setup(b => b.EnableAsync(It.IsAny(), It.IsAny())) .Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask); @@ -58,27 +56,46 @@ public class ApplyServiceTests installer.Setup(i => i.InstallAsync(It.IsAny>(), It.IsAny>(), It.IsAny())) .Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty()); + var hard = new Mock(); + hard.Setup(h => h.RunAsync(It.IsAny>(), It.IsAny())) + .Callback(() => order.Add("hardening")).Returns(Task.CompletedTask); - var sut = new ApplyService(bl.Object, installer.Object); + var sut = new ApplyService(bl.Object, installer.Object, hard.Object); var req = new ApplyRequest(Flavour(), "", System.Array.Empty()); await sut.RunAsync(req, new Progress(_ => { })); - Assert.Equal(new[] { "apps" }, order); + Assert.Equal(new[] { "apps", "hardening" }, order); bl.Verify(b => b.EnableAsync(It.IsAny(), It.IsAny()), Times.Never); } + [Fact] + public async Task Hardening_runs_with_the_flavour_modules() + { + var bl = new Mock(); + var installer = NoApps(); + var hard = new Mock(); + + var sut = new ApplyService(bl.Object, installer.Object, hard.Object); + var req = new ApplyRequest(Flavour(), "123456", System.Array.Empty()); + + await sut.RunAsync(req, new Progress(_ => { })); + + // Flavour() declares Modules = ["00"]; hardening must be invoked with exactly that. + hard.Verify(h => h.RunAsync( + It.Is>(m => m.Count == 1 && m[0] == "00"), + It.IsAny()), Times.Once); + } + [Fact] public async Task Installs_the_requested_apps() { - var run = new Mock(); - run.Setup(r => r.RunAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProcessResult(0, "", "")); var bl = new Mock(); var installer = NoApps(); + var hard = new Mock(); var apps = new[] { new AppCatalogEntry { Id = "firefox", Name = "Firefox" } }; - var sut = new ApplyService(bl.Object, installer.Object); + var sut = new ApplyService(bl.Object, installer.Object, hard.Object); var req = new ApplyRequest(Flavour(), "123456", apps); await sut.RunAsync(req, new Progress(_ => { }));