From 709744d533fb83637f2e7d886def43784fcc6930 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Wed, 10 Jun 2026 19:12:11 +0100 Subject: [PATCH] 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; }