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