perf(welcome): cut first-boot cold-start + add loading affordance
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 4m46s

The Welcome wizard showed nothing until WebView2 cold-started and Blazor
booted, so the whole startup cost presented as a blank window long enough
that operators thought first boot had failed.

- Native MAUI splash overlay (renders in the first frame, no WebView2/JIT
  dependency) + a visually identical in-page splash inside #app, so the
  native -> webview -> Blazor handoff reads as one continuous loading
  screen. Fades out on first successful WV2 NavigationCompleted.
- PublishReadyToRun=true (publish-only) to remove first-run JIT on the
  one-shot cold-disk path. R2R header verified present after publish.
- Fixed-version WebView2 runtime baked offline next to the exe (build.ps1
  stages it, app points WEBVIEW2_BROWSER_EXECUTABLE_FOLDER at it). Removes
  the Evergreen registry probe and the LTSC "no WebView2 at all" risk flagged
  in welcome-app-spec.md; air-gap friendly. Absent => falls back to Evergreen.
- De-flash launch: drop the `cmd /c` wrapper and add -WindowStyle Hidden in
  autounattend FirstLogonCommands (kills the console flash + one process).

Verified: Release build clean, win-x64 self-contained publish succeeds with
R2R confirmed, 38/38 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sysadmin
2026-06-10 09:06:02 +01:00
parent 72fa329ddd
commit 30a168e853
9 changed files with 240 additions and 9 deletions

View File

@@ -118,11 +118,19 @@
render when launched as a bare Shell Launcher shell). Configure-Kiosk.ps1
bakes the silent-elevation UAC policy + the lockdown (Keyboard Filter,
DisableTaskMgr, hidden taskbar); the wizard runs fullscreen-topmost on top.
Launch is via a single hidden-window PowerShell (no `cmd /c` wrapper): the
old `cmd /c powershell ...` spawned an extra process AND flashed a visible
console window on the bare first-boot desktop — which itself read as "the
machine is doing something broken" before the wizard appeared. `-WindowStyle
Hidden` + dropping the cmd shim removes that flash and one process off the
critical path. Elevation (-Verb RunAs) is still required for ApplyService
(account/BitLocker/hardening) and is silent thanks to the baked UAC policy.
-->
<FirstLogonCommands>
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<Order>1</Order>
<CommandLine>cmd /c powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath 'C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe' -Verb RunAs"</CommandLine>
<CommandLine>powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -Command "Start-Process -FilePath 'C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe' -Verb RunAs"</CommandLine>
<Description>Launch SilverOS Welcome elevated</Description>
</SynchronousCommand>
</FirstLogonCommands>

View File

@@ -183,6 +183,22 @@ function Copy-WelcomePayload {
} else {
Write-Warning " No apps dir found at $appsDir -- image will ship with no app catalog."
}
# Stage the fixed-version WebView2 runtime, if vendored, next to the app.
# Cold-start + air-gap: a fixed-version runtime is just files (no installer
# step at first boot) and removes the dependency on whether IoT Enterprise LTSC
# ships WebView2 at all. Operator populates windows\welcome\runtime\webview2\
# with an EXTRACTED "Microsoft Edge WebView2 Fixed Version" distribution (the
# folder that contains msedgewebview2.exe) -- handled like the drivers dir:
# absent is allowed (VM/dev test), in which case the app falls back to Evergreen.
$wv2Src = Join-Path $WindowsDir 'welcome\runtime\webview2'
if (Test-Path (Join-Path $wv2Src 'msedgewebview2.exe')) {
$wv2Dest = Join-Path $dest 'webview2'
$null = New-Item -ItemType Directory -Force $wv2Dest
Copy-Item (Join-Path $wv2Src '*') $wv2Dest -Recurse -Force
Write-Host " Staged fixed-version WebView2 runtime to $wv2Dest"
} else {
Write-Warning " No fixed-version WebView2 runtime at $wv2Src (expected msedgewebview2.exe) -- image will rely on the Evergreen runtime being present at first boot. See windows\welcome\runtime\webview2\README.md."
}
# --- Guard: verify the payload actually landed in the mounted image -------
$stagedExe = Join-Path $dest 'SilverOS.Welcome.App.exe'
if (-not (Test-Path $stagedExe)) {

View File

@@ -0,0 +1,44 @@
# Fixed-version WebView2 runtime (vendored)
The SilverOS Welcome wizard is a MAUI Blazor Hybrid app — it needs the **Microsoft
Edge WebView2 Runtime**. IoT Enterprise LTSC images frequently ship **without** it,
and even when present, the Evergreen runtime adds first-boot cold-start cost (registry
probe; on-demand install if absent). To make first boot fast *and* air-gapped, we bake
a **fixed-version** runtime here and point the app at it via
`WEBVIEW2_BROWSER_EXECUTABLE_FOLDER` (see `MauiProgram.cs`).
## What goes in this folder
The **extracted** contents of a "Microsoft Edge WebView2 Fixed Version" distribution —
i.e. this directory must directly contain `msedgewebview2.exe` (plus its sibling DLLs,
`*.pak`, locales, etc.). The build (`windows/installer/build.ps1`,
`Copy-WelcomePayload`) detects `msedgewebview2.exe` and copies the whole folder to
`C:\Program Files\SilverOS\Welcome\webview2\` inside the image.
```
windows/welcome/runtime/webview2/
├── README.md <- this file (the only thing committed)
├── msedgewebview2.exe <- you add these
├── *.dll
├── *.pak
└── ...
```
## How to obtain it
1. Download the **Fixed Version** (x64) CAB from the official WebView2 distribution
page: <https://developer.microsoft.com/microsoft-edge/webview2/> → "Fixed Version".
Match the channel/arch to the target (x64, since the app publishes `win-x64`).
2. Expand the CAB and copy the inner runtime folder's contents here so that
`msedgewebview2.exe` sits directly in this directory.
3. Pin the version in `windows/installer/inputs.manifest.json` alongside the other
baked inputs (SBOM hygiene).
## If you skip this
The build does **not** fail — it logs a warning and the image relies on whatever
Evergreen runtime is present at first boot. Fine for a quick VM smoke test; **not**
recommended for shipped LTSC media (risk of a blank/hung wizard and slower cold start).
> The runtime binaries are **not** committed (large, Microsoft-redistributable, version-
> pinned per build). Only this README is tracked.

View File

@@ -5,12 +5,54 @@
xmlns:components="clr-namespace:SilverOS.Welcome.App.Components;assembly=SilverOS.Welcome.UI"
x:Class="SilverOS.Welcome.App.MainPage">
<BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html"
BlazorWebViewInitialized="OnBlazorInitialized"
UrlLoading="OnUrlLoading">
<BlazorWebView.RootComponents>
<RootComponent Selector="#app" ComponentType="{x:Type components:Routes}" />
</BlazorWebView.RootComponents>
</BlazorWebView>
<!--
A native MAUI splash sits ON TOP of the BlazorWebView. MAUI controls render
immediately when the window is shown — they do NOT wait on WebView2/.NET JIT —
so the user sees branded "loading" within the first frame instead of a blank
window for the seconds it takes WebView2 to cold-start and Blazor to boot.
The overlay is dismissed in MainPage.xaml.cs once WV2 finishes its first
navigation (the index.html splash then carries the eye through Blazor's boot).
-->
<Grid BackgroundColor="#0b0f14">
<BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html"
BlazorWebViewInitialized="OnBlazorInitialized"
UrlLoading="OnUrlLoading">
<BlazorWebView.RootComponents>
<RootComponent Selector="#app" ComponentType="{x:Type components:Routes}" />
</BlazorWebView.RootComponents>
</BlazorWebView>
<Grid x:Name="SplashOverlay"
BackgroundColor="#0b0f14"
InputTransparent="False">
<VerticalStackLayout HorizontalOptions="Center"
VerticalOptions="Center"
Spacing="22">
<Label Text="SilverOS"
HorizontalOptions="Center"
FontFamily="OpenSansRegular"
FontSize="42"
FontAutoScalingEnabled="False"
TextColor="#e8edf5" />
<Label Text="WELCOME"
HorizontalOptions="Center"
FontSize="13"
CharacterSpacing="8"
TextColor="#00d4ff" />
<ActivityIndicator IsRunning="True"
Color="#00d4ff"
HeightRequest="34"
WidthRequest="34"
HorizontalOptions="Center"
Margin="0,10,0,0" />
<Label Text="Preparing your setup…"
HorizontalOptions="Center"
FontSize="14"
TextColor="#8fa4bc" />
</VerticalStackLayout>
</Grid>
</Grid>
</ContentPage>

View File

@@ -4,6 +4,8 @@ namespace SilverOS.Welcome.App;
public partial class MainPage : ContentPage
{
bool _splashDismissed;
public MainPage()
{
InitializeComponent();
@@ -20,7 +22,13 @@ public partial class MainPage : ContentPage
{
var wv = e.WebView; // Microsoft.UI.Xaml.Controls.WebView2
wv.NavigationCompleted += (a, b) =>
{
Diag.Log($"WV2 NavigationCompleted ok={b.IsSuccess} status={b.WebErrorStatus}");
// First completed navigation = the WebView has content on screen.
// Drop the native splash so the (visually identical) in-page splash
// carries through Blazor's final boot without a flash of blank.
if (b.IsSuccess) DismissSplash();
};
if (wv.CoreWebView2 is not null)
wv.CoreWebView2.ProcessFailed += (a, b) =>
Diag.Log("WV2 ProcessFailed: " + b.ProcessFailedKind);
@@ -29,6 +37,26 @@ public partial class MainPage : ContentPage
#endif
}
// Fade the native splash out once, then collapse it so it never intercepts input.
void DismissSplash()
{
if (_splashDismissed) return;
_splashDismissed = true;
MainThread.BeginInvokeOnMainThread(async () =>
{
try
{
await SplashOverlay.FadeTo(0, 250, Easing.CubicOut);
}
catch { /* fade is cosmetic — never block on it */ }
finally
{
SplashOverlay.IsVisible = false;
SplashOverlay.InputTransparent = true;
}
});
}
void OnUrlLoading(object? sender, UrlLoadingEventArgs e)
=> Diag.Log("UrlLoading: " + e.Url);
}

View File

@@ -17,6 +17,23 @@ public static class MauiProgram
Directory.CreateDirectory(wv2);
Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", wv2);
// Cold-start + air-gap: prefer a fixed-version WebView2 runtime baked next to
// the app (build.ps1 stages it under .\webview2). This removes two first-boot
// costs at once: (1) the Evergreen-runtime registry probe, and (2) the risk
// that IoT Enterprise LTSC ships WITHOUT WebView2 entirely — in which case the
// wizard would block/blank waiting on an on-demand install. If the baked folder
// is absent (VM/dev test), fall through to whatever Evergreen runtime exists.
var fixedRuntime = Path.Combine(AppContext.BaseDirectory, "webview2");
if (File.Exists(Path.Combine(fixedRuntime, "msedgewebview2.exe")))
{
Environment.SetEnvironmentVariable("WEBVIEW2_BROWSER_EXECUTABLE_FOLDER", fixedRuntime);
Diag.Log("WebView2: using baked fixed-version runtime at " + fixedRuntime);
}
else
{
Diag.Log("WebView2: no baked runtime found; relying on installed Evergreen");
}
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()

View File

@@ -25,6 +25,16 @@
<WindowsPackageType>None</WindowsPackageType>
<SupportedOSPlatformVersion>10.0.19041.0</SupportedOSPlatformVersion>
<!--
Cold-start: precompile to ReadyToRun so the self-contained image does not
JIT the whole app on first launch (this wizard runs exactly once, on the
slowest possible "fresh OS, cold disk" path, so first-run JIT is pure cost).
R2R only — trimming/NativeAOT are NOT safe for MAUI Blazor Hybrid (heavy
reflection in Blazor + DI). Larger binaries are fine: the payload is baked
into the image, never downloaded. Only takes effect on `dotnet publish -r win-x64`.
-->
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
</PropertyGroup>
<ItemGroup>

View File

@@ -303,6 +303,58 @@ h1:focus { outline: none; }
gap: 1rem;
}
/* ── Boot splash (pre-Blazor; mirrors the native MAUI splash) ───────── */
.sm-boot {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.4rem;
background: var(--clr-void);
z-index: 2000;
}
.sm-boot-title {
font-family: var(--font-mono);
font-size: 2.6rem;
font-weight: 300;
letter-spacing: -0.02em;
color: var(--clr-text-hi);
}
.sm-boot-kicker {
font-family: var(--font-mono);
font-size: 0.8rem;
letter-spacing: 0.55em;
text-indent: 0.55em; /* balance the trailing letter-spacing */
color: var(--clr-accent);
}
.sm-boot-spinner {
width: 34px;
height: 34px;
border: 3px solid var(--clr-border-hi);
border-top-color: var(--clr-accent);
border-radius: 50%;
animation: sm-spin 0.8s linear infinite;
}
@keyframes sm-spin {
to { transform: rotate(360deg); }
}
.sm-boot-text {
font-family: var(--font-ui);
font-size: 0.9rem;
color: var(--clr-text-mid);
}
@media (prefers-reduced-motion: reduce) {
.sm-boot-spinner { animation: none; }
}
/* ── Loading / error states ─────────────────────────────────────────── */
.loading {
font-family: var(--font-mono);

View File

@@ -15,7 +15,21 @@
<div class="status-bar-safe-area"></div>
<div id="app">Loading...</div>
<!--
In-page boot splash. Lives INSIDE #app so Blazor wipes it automatically the
moment the root component first renders. Styled to match the native MAUI
splash (same void bg + electric-ice accent), so handoff native -> webview ->
Blazor reads as one continuous loading screen rather than three flashes.
Inline-styled on the wrapper so it shows even before app.css paints.
-->
<div id="app" style="background:#0b0f14">
<div class="sm-boot">
<div class="sm-boot-title">SilverOS</div>
<div class="sm-boot-kicker">WELCOME</div>
<div class="sm-boot-spinner" aria-hidden="true"></div>
<div class="sm-boot-text">Preparing your setup…</div>
</div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.