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.
This commit is contained in:
sysadmin
2026-06-10 19:12:11 +01:00
parent ddd8784b56
commit 709744d533

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