From ddd8784b5637c44209b1af693619ef27ee06a651 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Wed, 10 Jun 2026 19:06:53 +0100 Subject: [PATCH 1/2] fix(toolbox): move Done 'Restart now' to footer-right (was clipped in content) 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 --- .../src/SilverOS.Welcome.UI/Components/Routes.razor | 10 ++++++++++ .../Components/Steps/DoneStep.razor | 8 -------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor b/windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor index 26691fd..6f5527a 100644 --- a/windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor +++ b/windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor @@ -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") } + else if (_currentStep == _stepTitles.Length - 1) + { + + } } @@ -108,6 +113,11 @@ else private string? _error; private IReadOnlyList _flavours = Array.Empty(); + 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, 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 2b4385b..f9400e4 100644 --- a/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/DoneStep.razor +++ b/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/DoneStep.razor @@ -1,5 +1,4 @@ @using QRCoder -@inject SilverOS.Welcome.Core.Apply.IProcessRunner ProcessRunner

All Done!

@@ -27,8 +26,6 @@

} - - @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); - } } From 709744d533fb83637f2e7d886def43784fcc6930 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Wed, 10 Jun 2026 19:12:11 +0100 Subject: [PATCH 2/2] feat(apps): AppInstaller writes a diagnostic log (winget resolve + bootstrap + per-app) 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. --- .../Apps/AppInstaller.cs | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs index 0a11a60..f35b8e0 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs @@ -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> InstallAsync( IReadOnlyList apps, IProgress progress, CancellationToken ct = default) { var results = new List(); 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; @@ -33,6 +54,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 +65,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 +75,30 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn private async Task ResolveWingetAsync(IProgress 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. progress.Report(new("Preparing app installer", 68)); var bootstrap = Path.Combine(appsDir, "bootstrap-winget.ps1"); - await TryRunAsync("powershell.exe", + var b = 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"; + 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; }