10 Commits

Author SHA1 Message Date
sysadmin
f44fa150e2 fix(first-boot): run hardening from toolbox, repair branding online re-apply, bake winget into image, Apply UX
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 <noreply@anthropic.com>
2026-06-11 01:34:07 +01:00
dfae1f136b Merge pull request 'fix(build): drop invalid --no-incremental from dotnet publish' (#33) from fix/clean-publish-flag into main
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (push) Failing after 9s
2026-06-10 22:26:28 +00:00
sysadmin
74e48aa1e5 fix(build): drop invalid --no-incremental from dotnet publish (MSB1001)
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 8s
dotnet publish rejects --no-incremental (it's a dotnet build switch) -> MSB1001 Unknown
switch -> build failed. The bin/obj wipe alone forces the clean recompile we need.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:26:08 +01:00
a6ac6ce355 Merge pull request 'fix(build): clean compile before publish (CI shipped a stale toolbox DLL)' (#32) from fix/ci-clean-publish into main
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (push) Failing after 31s
2026-06-10 22:21:30 +00:00
sysadmin
9832121dbb fix(build): clean compile before publish (CI shipped stale Core.dll)
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 1m31s
The deployed toolbox Core.dll was timestamped BEFORE its own build ran -- the CI runner's
incremental build reused a cached SilverOS.Welcome.Core.dll, so source fixes (e.g. the winget
bootstrap brace fix) never reached the published exe. Wipe all bin/obj under welcome/ and pass
--no-incremental so every build is a clean compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:21:04 +01:00
d0a5925652 Merge pull request 'fix(apps): winget bootstrap never ran (unbalanced-brace parse error) — the real apps-skip cause' (#31) from fix/winget-bootstrap-brace into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 5m32s
2026-06-10 21:44:32 +00:00
sysadmin
e91c4de7ed fix(apps): winget bootstrap never ran (unbalanced-brace parse error in inline cmd)
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 36s
appinstall.log on the VM showed: bootstrap-winget exit=1 'Unexpected token }'. The
inline -Command was built from an interpolated string ($"...{{...}}" -> {/}) concatenated
with a NON-interpolated string whose '}}' stayed literal, so the emitted PowerShell ended
in '}}' and failed to parse -> the bootstrap (and thus winget install) never executed ->
all apps skipped on every run, regardless of network. Invoke the bootstrap .ps1 file
directly instead (it self-checks + installs winget online); fall back to the inbox
re-register only when the script is absent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:44:05 +01:00
51ab88b1f8 Merge pull request 'fix(toolbox): move Done 'Restart now' button to the footer-right (was clipped)' (#30) from fix/done-restart-footer into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 5m41s
2026-06-10 18:12:19 +00:00
sysadmin
709744d533 feat(apps): AppInstaller writes a diagnostic log (winget resolve + bootstrap + per-app)
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 2s
Writes C:\ProgramData\SilverMetal\appinstall.log (best-effort) so a post-install mount
shows exactly where app installs fail: winget probe results, bootstrap-winget output,
and per-app winget exit codes. Makes the no-apps-installed failure diagnosable instead
of inferred.
2026-06-10 19:12:11 +01:00
sysadmin
ddd8784b56 fix(toolbox): move Done 'Restart now' to footer-right (was clipped in content)
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 33s
The in-content Restart button overflowed its fixed width. Move it into the wizard
footer's right slot (where Next/Apply sits) as a btn-primary; Routes owns the restart
shutdown now, DoneStep just shows the recovery key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 19:06:53 +01:00
12 changed files with 262 additions and 41 deletions

View File

@@ -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

View File

@@ -160,6 +160,12 @@ function Invoke-PublishWelcome {
Write-Stage 'Stage 3b: publish SilverOS Welcome app (win-x64 self-contained)'
$proj = Join-Path $WindowsDir 'welcome\src\SilverOS.Welcome.App'
$out = Join-Path $WorkDir 'welcome-publish'
# Force a CLEAN compile. The CI runner reuses build artifacts across runs, and dotnet's
# incremental build has shipped a STALE SilverOS.Welcome.Core.dll (old code despite fixed
# source) -- so wipe every bin/obj under welcome/ before publishing (a clean tree forces a
# full recompile; note `dotnet publish` does NOT accept --no-incremental).
Get-ChildItem (Join-Path $WindowsDir 'welcome') -Recurse -Directory -EA SilentlyContinue |
Where-Object { $_.Name -in 'bin', 'obj' } | Remove-Item -Recurse -Force -EA SilentlyContinue
& dotnet publish $proj -c Release -f net9.0-windows10.0.19041.0 -r win-x64 --self-contained true -o $out
if ($LASTEXITCODE -ne 0) { throw 'Welcome app dotnet publish failed' }
Write-Host " Published to: $out"
@@ -232,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'
@@ -297,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

View File

@@ -54,11 +54,13 @@ public static class MauiProgram
builder.Services.AddSingleton<IFlavourLoader, FlavourLoader>();
builder.Services.AddSingleton<IAppCatalog, AppCatalog>();
builder.Services.AddSingleton<IBitLockerService, BitLockerService>();
builder.Services.AddSingleton<IHardeningService, HardeningService>();
var appsDir = Path.Combine(AppContext.BaseDirectory, "apps");
builder.Services.AddSingleton<IAppInstaller>(sp => new AppInstaller(sp.GetRequiredService<IProcessRunner>(), appsDir));
builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
sp.GetRequiredService<IBitLockerService>(),
sp.GetRequiredService<IAppInstaller>()));
sp.GetRequiredService<IAppInstaller>(),
sp.GetRequiredService<IHardeningService>()));
builder.Services.AddSingleton<IPreconfigStore>(_ => new PreconfigStore(@"C:\ProgramData\SilverMetal"));
builder.Services.AddScoped<WizardState>();

View File

@@ -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;

View File

@@ -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<ApplyProgress> 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<string>(), ct);
progress.Report(new("Done", 100));
}
}

View File

@@ -0,0 +1,28 @@
namespace SilverOS.Welcome.Core.Apply;
// Runs the §AH 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<string> 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);
}
}

View File

@@ -0,0 +1,10 @@
namespace SilverOS.Welcome.Core.Apply;
public interface IHardeningService
{
/// <summary>
/// Runs the §AH hardening modules from the image-staged payload. <paramref name="modules"/>
/// is the flavour's numeric module selection (e.g. ["00","03","05"]); empty = all modules.
/// </summary>
Task RunAsync(IReadOnlyList<string> modules, CancellationToken ct = default);
}

View File

@@ -4,17 +4,38 @@ namespace SilverOS.Welcome.Core.Apps;
public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppInstaller
{
// Best-effort diagnostic log (winget resolution, bootstrap output, per-app results).
// Lives under ProgramData so it survives + is readable post-install. Never throws.
private static readonly string LogPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "SilverMetal", "appinstall.log");
private static void Log(string msg)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
File.AppendAllText(LogPath, $"{DateTime.Now:HH:mm:ss.fff} {msg}{Environment.NewLine}");
}
catch { /* logging is best-effort */ }
}
private static string Snip(string? s) =>
string.IsNullOrWhiteSpace(s) ? "" : s.Trim().Replace("\r", " ").Replace("\n", " ") is var t && t.Length > 300 ? t[..300] : t;
public async Task<IReadOnlyList<AppInstallResult>> InstallAsync(
IReadOnlyList<AppCatalogEntry> apps, IProgress<ApplyProgress> progress, CancellationToken ct = default)
{
var results = new List<AppInstallResult>();
if (apps.Count == 0) return results;
Log($"InstallAsync: {apps.Count} app(s) requested: {string.Join(", ", apps.Select(a => a.Id))}");
// App installs are non-critical: a missing/broken winget (e.g. offline IoT LTSC) must
// NEVER fail onboarding. Resolve winget defensively; if it can't be found, skip installs.
var winget = await ResolveWingetAsync(progress, ct);
if (winget is null)
{
Log($"winget UNAVAILABLE -> skipping all {apps.Count} app(s)");
progress.Report(new($"App installer unavailable - skipping {apps.Count} app(s)", 80));
foreach (var app in apps) results.Add(new AppInstallResult(app.Id, false));
return results;
@@ -24,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))
@@ -33,6 +56,7 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn
$"install --id {id} --silent --accept-package-agreements --accept-source-agreements --disable-interactivity",
ct);
ok = r.ExitCode == 0;
Log($"install {id}: exit={r.ExitCode} ok={ok} err={Snip(r.StdErr)}");
if (ok && !string.IsNullOrWhiteSpace(app.Configure))
{
var script = Path.Combine(appsDir, "configure", app.Configure);
@@ -43,6 +67,7 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn
}
results.Add(new AppInstallResult(app.Id, ok));
}
Log($"InstallAsync done: {results.Count(r => r.Installed)}/{results.Count} installed");
return results;
}
@@ -52,22 +77,34 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn
private async Task<string?> ResolveWingetAsync(IProgress<ApplyProgress> progress, CancellationToken ct)
{
// 1) Already launchable by name (on PATH for this process)?
if ((await TryRunAsync("winget", "--version", ct)).ExitCode == 0) return "winget";
var p1 = await TryRunAsync("winget", "--version", ct);
Log($"winget probe (PATH): exit={p1.ExitCode} out={Snip(p1.StdOut)}");
if (p1.ExitCode == 0) return "winget";
// 2) Provision App Installer via the bundled bootstrap (or registered package), then re-probe.
// 2) Provision App Installer, then re-probe. Run the bootstrap SCRIPT FILE directly
// (it checks for winget and installs it online if absent). Invoking the .ps1 file
// avoids an inline -Command (a prior inline if/else had an unbalanced-brace parse bug
// from a non-interpolated string, so the bootstrap never actually ran).
progress.Report(new("Preparing app installer", 68));
var bootstrap = Path.Combine(appsDir, "bootstrap-winget.ps1");
await TryRunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -Command \"if (Test-Path '{bootstrap}') {{ & '{bootstrap}' }} else {{ " +
"Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe -EA SilentlyContinue }}\"",
ct);
if ((await TryRunAsync("winget", "--version", ct)).ExitCode == 0) return "winget";
var b = File.Exists(bootstrap)
? await TryRunAsync("powershell.exe", $"-NoProfile -ExecutionPolicy Bypass -File \"{bootstrap}\"", ct)
: await TryRunAsync("powershell.exe",
"-NoProfile -ExecutionPolicy Bypass -Command \"Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe -EA SilentlyContinue\"",
ct);
Log($"bootstrap-winget: exit={b.ExitCode} out={Snip(b.StdOut)} err={Snip(b.StdErr)}");
var p2 = await TryRunAsync("winget", "--version", ct);
Log($"winget probe (post-bootstrap): exit={p2.ExitCode} out={Snip(p2.StdOut)}");
if (p2.ExitCode == 0) return "winget";
// 3) Fall back to the WindowsApps execution-alias path (bare-name launch can fail under
// UseShellExecute=false even when winget is installed).
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var aliased = Path.Combine(local, "Microsoft", "WindowsApps", "winget.exe");
if (File.Exists(aliased) && (await TryRunAsync(aliased, "--version", ct)).ExitCode == 0) return aliased;
var aliasExists = File.Exists(aliased);
var p3Exit = aliasExists ? (await TryRunAsync(aliased, "--version", ct)).ExitCode : -1;
Log($"winget alias path '{aliased}': exists={aliasExists} probe={p3Exit}");
if (aliasExists && p3Exit == 0) return aliased;
return null;
}

View File

@@ -6,6 +6,7 @@
@inject IAppCatalog AppCatalog
@inject IPreconfigStore PreconfigStore
@inject WizardState State
@inject SilverOS.Welcome.Core.Apply.IProcessRunner ProcessRunner
@if (_toolboxHome)
{
@@ -83,6 +84,10 @@ else
@(_currentStep == _stepTitles.Length - 2 ? "Apply" : "Next")
</button>
}
else if (_currentStep == _stepTitles.Length - 1)
{
<button class="btn-primary" @onclick="RestartNow">Restart now</button>
}
</div>
</div>
}
@@ -108,6 +113,11 @@ else
private string? _error;
private IReadOnlyList<FlavourManifest> _flavours = Array.Empty<FlavourManifest>();
private async Task RestartNow()
{
await ProcessRunner.RunAsync("cmd.exe", "/c shutdown /r /t 5", default);
}
private bool CanGoNext => _currentStep switch
{
1 => State.Flavour is not null,

View File

@@ -30,11 +30,24 @@
else
{
<div class="apply-progress-container">
<div class="apply-stage-label">@_stageLabel</div>
<div class="apply-stage-row">
@if (!_complete)
{
<span class="apply-spinner" aria-hidden="true"></span>
}
<div class="apply-stage-label">@_stageLabel</div>
</div>
<div class="apply-progress-track">
<div class="apply-progress-bar" style="width: @(_percent)%"></div>
<div class="apply-progress-bar @(_complete ? "" : "working")" style="width: @(_percent)%"></div>
</div>
<div class="apply-percent-label">@(_percent)%</div>
@if (!_complete)
{
<p class="apply-hint">
Installing your apps and applying security hardening — this can take several
minutes. Please leave the device powered on.
</p>
}
</div>
@if (_complete)

View File

@@ -1,5 +1,4 @@
@using QRCoder
@inject SilverOS.Welcome.Core.Apply.IProcessRunner ProcessRunner
<div class="step done-step">
<h1>All Done!</h1>
@@ -27,8 +26,6 @@
</small></p>
</div>
}
<button class="btn-primary btn-restart" @onclick="RestartNow">Restart Now</button>
</div>
@code {
@@ -56,9 +53,4 @@
catch { /* QR is best-effort; the key text still shows */ }
}
}
private async Task RestartNow()
{
await ProcessRunner.RunAsync("cmd.exe", "/c shutdown /r /t 5", CancellationToken.None);
}
}

View File

@@ -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<string>();
var run = new Mock<IProcessRunner>();
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "", ""));
var bl = new Mock<IBitLockerService>();
bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
@@ -32,25 +29,26 @@ public class ApplyServiceTests
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty<AppInstallResult>());
var hard = new Mock<IHardeningService>();
hard.Setup(h => h.RunAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
.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<AppCatalogEntry>());
var progress = new List<string>();
await sut.RunAsync(req, new Progress<ApplyProgress>(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<string>();
var run = new Mock<IProcessRunner>();
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "", ""));
var bl = new Mock<IBitLockerService>();
bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
@@ -58,27 +56,46 @@ public class ApplyServiceTests
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty<AppInstallResult>());
var hard = new Mock<IHardeningService>();
hard.Setup(h => h.RunAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
.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<AppCatalogEntry>());
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
Assert.Equal(new[] { "apps" }, order);
Assert.Equal(new[] { "apps", "hardening" }, order);
bl.Verify(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Hardening_runs_with_the_flavour_modules()
{
var bl = new Mock<IBitLockerService>();
var installer = NoApps();
var hard = new Mock<IHardeningService>();
var sut = new ApplyService(bl.Object, installer.Object, hard.Object);
var req = new ApplyRequest(Flavour(), "123456", System.Array.Empty<AppCatalogEntry>());
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
// Flavour() declares Modules = ["00"]; hardening must be invoked with exactly that.
hard.Verify(h => h.RunAsync(
It.Is<IReadOnlyList<string>>(m => m.Count == 1 && m[0] == "00"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Installs_the_requested_apps()
{
var run = new Mock<IProcessRunner>();
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "", ""));
var bl = new Mock<IBitLockerService>();
var installer = NoApps();
var hard = new Mock<IHardeningService>();
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<ApplyProgress>(_ => { }));