# Wizard App Recipes Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a per-role app-install picker to the SilverOS Welcome wizard — choosing a role shows grouped, pre-checked app checkboxes (role tools + privacy-trimmed essentials) that get installed via winget during Apply.

**Architecture:** A JSON app catalog (staged into the image like flavours) loaded by a Core loader mirroring `FlavourLoader`; a new `AppsStep` Blazor step writing selected ids into `WizardState`; an `AppInstaller` Core service that bootstraps winget and runs `winget install` per selected app (continue-on-failure) plus optional `configure` scripts; wired into `ApplyService` after the Stack and before BitLocker.

**Tech Stack:** .NET 9 / C# (SilverOS.Welcome.Core + .UI), MAUI Blazor, winget, xUnit + Moq, System.Text.Json.

**Spec:** [`../specs/2026-06-09-wizard-app-recipes-design.md`](../specs/2026-06-09-wizard-app-recipes-design.md)

**Branch:** `feat/app-recipes` (spec committed at `583ed44`).

**Conventions (match existing code):**
- Loaders mirror `FlavourLoader` (`Core/Flavours/FlavourLoader.cs`) + `FlavourManifest.JsonOptions` (case-insensitive, comments, trailing commas).
- `IProcessRunner.RunAsync(string file, string args, CancellationToken ct)` → `ProcessResult(ExitCode, StdOut, StdErr)` with `.EnsureSuccess(op)`.
- Services are `AddSingleton` in `MauiProgram.cs`; `ApplyService` is built via a factory (lines 40-45) and takes a directory string (`hardeningDir`).
- Tests: xUnit + Moq in `windows/welcome/tests/SilverOS.Welcome.Tests`. Run with `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release`.
- The app namespace root is `SilverOS.Welcome.App` even in the `.UI`/`.Core` projects (see existing files).

---

## Phase A — App catalog (Core)

### Task A1: AppCatalogEntry + AppCatalog records

**Files:**
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalogEntry.cs`
- Test: `windows/welcome/tests/SilverOS.Welcome.Tests/AppCatalogTests.cs`

- [ ] **Step 1: Write the failing test**

`AppCatalogTests.cs`:
```csharp
using System.Text.Json;
using SilverOS.Welcome.Core.Apps;
using Xunit;

public class AppCatalogTests
{
    [Fact]
    public void Deserializes_a_catalog_entry()
    {
        var json = """
        { "id":"vscodium","name":"VSCodium","description":"Telemetry-free VS Code.",
          "source":{"winget":"VSCodium.VSCodium"},"group":"developer",
          "roles":["developer"],"defaultFor":["developer"],"configure":null }
        """;
        var e = JsonSerializer.Deserialize<AppCatalogEntry>(json, AppCatalogEntry.JsonOptions)!;
        Assert.Equal("vscodium", e.Id);
        Assert.Equal("VSCodium.VSCodium", e.Source.Winget);
        Assert.Contains("developer", e.Roles);
        Assert.Contains("developer", e.DefaultFor);
    }
}
```

- [ ] **Step 2: Run to verify it fails**

Run: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release --filter AppCatalogTests`
Expected: FAIL — `AppCatalogEntry` not defined.

- [ ] **Step 3: Implement the records**

`AppCatalogEntry.cs`:
```csharp
using System.Text.Json;

namespace SilverOS.Welcome.Core.Apps;

public sealed record AppSource
{
    public string? Winget { get; init; }
    // Future: public string? Mirror { get; init; }  // swappable to a curated mirror.
}

public sealed record AppCatalogEntry
{
    public string Id { get; init; } = "";
    public string Name { get; init; } = "";
    public string Description { get; init; } = "";
    public AppSource Source { get; init; } = new();
    public string Group { get; init; } = "";
    public IReadOnlyList<string> Roles { get; init; } = Array.Empty<string>();
    public IReadOnlyList<string> DefaultFor { get; init; } = Array.Empty<string>();
    public string? Configure { get; init; }

    public static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNameCaseInsensitive = true,
        ReadCommentHandling = JsonCommentHandling.Skip,
        AllowTrailingCommas = true
    };
}
```

- [ ] **Step 4: Run to verify it passes**

Run: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release --filter AppCatalogTests`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalogEntry.cs windows/welcome/tests/SilverOS.Welcome.Tests/AppCatalogTests.cs
git commit -m "feat(apps): AppCatalogEntry record + test"
```

---

### Task A2: AppCatalog loader + role filtering

**Files:**
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalog.cs`
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppCatalog.cs`
- Modify: `windows/welcome/tests/SilverOS.Welcome.Tests/AppCatalogTests.cs` (append)

- [ ] **Step 1: Write failing tests (append)**

```csharp
public class AppCatalogLoaderTests
{
    static string WriteCatalog(string body)
    {
        var dir = Path.Combine(Path.GetTempPath(), "smcat-" + Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(dir);
        File.WriteAllText(Path.Combine(dir, "catalog.json"), body);
        return dir;
    }

    const string Body = """
    { "schemaVersion":1, "apps":[
      {"id":"tb","name":"Thunderbird","source":{"winget":"Mozilla.Thunderbird"},"group":"essentials","roles":["essentials"],"defaultFor":["essentials"]},
      {"id":"vscodium","name":"VSCodium","source":{"winget":"VSCodium.VSCodium"},"group":"developer","roles":["developer"],"defaultFor":["developer"]},
      {"id":"rider","name":"Rider","source":{"winget":"JetBrains.Rider"},"group":"developer","roles":["developer"],"defaultFor":[]}
    ]}
    """;

    [Fact]
    public void AppsForRole_returns_essentials_plus_role()
    {
        var c = new AppCatalog().Load(WriteCatalog(Body));
        var ids = c.AppsForRole("developer").Select(a => a.Id).ToList();
        Assert.Contains("tb", ids);        // essentials (all roles)
        Assert.Contains("vscodium", ids);  // developer
        Assert.Contains("rider", ids);     // developer (offered, not default)
    }

    [Fact]
    public void DefaultSelection_only_pre_checks_defaultFor()
    {
        var c = new AppCatalog().Load(WriteCatalog(Body));
        var def = c.DefaultSelectionForRole("developer");
        Assert.Contains("tb", def);
        Assert.Contains("vscodium", def);
        Assert.DoesNotContain("rider", def);
    }

    [Fact]
    public void Missing_catalog_returns_empty_not_throw()
    {
        var c = new AppCatalog().Load(Path.Combine(Path.GetTempPath(), "nope-" + Guid.NewGuid().ToString("N")));
        Assert.Empty(c.All);
    }
}
```

- [ ] **Step 2: Run to verify failure** → `AppCatalog` not defined.

- [ ] **Step 3: Implement loader**

`IAppCatalog.cs`:
```csharp
namespace SilverOS.Welcome.Core.Apps;

public interface IAppCatalog
{
    LoadedCatalog Load(string directory);
}
```

`AppCatalog.cs`:
```csharp
using System.Text.Json;

namespace SilverOS.Welcome.Core.Apps;

public sealed record LoadedCatalog(IReadOnlyList<AppCatalogEntry> All)
{
    // Essentials (offered to all roles) first, then the role's own apps. Stable, de-duped by id.
    public IReadOnlyList<AppCatalogEntry> AppsForRole(string role) =>
        All.Where(a => a.Roles.Contains("essentials") || a.Roles.Contains(role))
           .GroupBy(a => a.Id).Select(g => g.First())
           .OrderByDescending(a => a.Group == "essentials").ThenBy(a => a.Name).ToList();

    public IReadOnlyList<string> DefaultSelectionForRole(string role) =>
        AppsForRole(role).Where(a => a.DefaultFor.Contains("essentials") || a.DefaultFor.Contains(role))
                         .Select(a => a.Id).ToList();
}

public sealed class AppCatalog : IAppCatalog
{
    private sealed record CatalogFile(int SchemaVersion, IReadOnlyList<AppCatalogEntry>? Apps);

    public LoadedCatalog Load(string directory)
    {
        var path = Path.Combine(directory, "catalog.json");
        if (!File.Exists(path)) return new LoadedCatalog(Array.Empty<AppCatalogEntry>());
        try
        {
            var f = JsonSerializer.Deserialize<CatalogFile>(File.ReadAllText(path), AppCatalogEntry.JsonOptions);
            return new LoadedCatalog(f?.Apps ?? Array.Empty<AppCatalogEntry>());
        }
        catch (JsonException)
        {
            // A bad catalog must never block onboarding — degrade to "no extra apps".
            return new LoadedCatalog(Array.Empty<AppCatalogEntry>());
        }
    }
}
```

> Note: `IAppCatalog.Load` returns `LoadedCatalog`; the test calls `new AppCatalog().Load(dir)` then `.AppsForRole`/`.DefaultSelectionForRole`/`.All`.

- [ ] **Step 4: Run to verify pass** → all AppCatalog tests green.

- [ ] **Step 5: Commit**

```bash
git add windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalog.cs windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppCatalog.cs windows/welcome/tests/SilverOS.Welcome.Tests/AppCatalogTests.cs
git commit -m "feat(apps): AppCatalog loader + role filtering"
```

---

## Phase B — Install engine (Core)

### Task B1: IAppInstaller + AppInstaller (winget)

**Files:**
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppInstaller.cs`
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs`
- Test: `windows/welcome/tests/SilverOS.Welcome.Tests/AppInstallerTests.cs`

- [ ] **Step 1: Write failing tests**

`AppInstallerTests.cs`:
```csharp
using Moq;
using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Apply;

public class AppInstallerTests
{
    static AppCatalogEntry App(string id, string winget, string? cfg = null) =>
        new() { Id = id, Name = id, Source = new AppSource { Winget = winget }, Configure = cfg };

    static Mock<IProcessRunner> Runner(int exit = 0) {
        var m = new Mock<IProcessRunner>();
        m.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
         .ReturnsAsync(new ProcessResult(exit, "", ""));
        return m;
    }

    [Fact]
    public async Task Installs_each_selected_app_via_winget()
    {
        var run = Runner();
        // winget present -> no bootstrap needed
        run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
           .ReturnsAsync(new ProcessResult(0, "v1.8", ""));
        var sut = new AppInstaller(run.Object, "C:\\apps");
        var res = await sut.InstallAsync(new[] { App("vscodium", "VSCodium.VSCodium") },
                                         new Progress<ApplyProgress>(_ => { }));
        run.Verify(r => r.RunAsync("winget", It.Is<string>(s =>
            s.Contains("install") && s.Contains("VSCodium.VSCodium") && s.Contains("--silent")),
            It.IsAny<CancellationToken>()), Times.Once);
        Assert.True(res.Single().Installed);
    }

    [Fact]
    public async Task Bootstraps_winget_when_absent()
    {
        var run = Runner();
        // winget --version fails -> absent -> bootstrap path runs powershell
        run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
           .ReturnsAsync(new ProcessResult(1, "", "not found"));
        var sut = new AppInstaller(run.Object, "C:\\apps");
        await sut.InstallAsync(new[] { App("tb", "Mozilla.Thunderbird") }, new Progress<ApplyProgress>(_ => { }));
        run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
            s.Contains("DesktopAppInstaller")), It.IsAny<CancellationToken>()), Times.AtLeastOnce);
    }

    [Fact]
    public async Task One_app_failure_does_not_stop_the_rest()
    {
        var run = new Mock<IProcessRunner>();
        run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
           .ReturnsAsync(new ProcessResult(0, "v1.8", ""));
        run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("Bad.App")), It.IsAny<CancellationToken>()))
           .ReturnsAsync(new ProcessResult(1, "", "fail"));
        run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("Good.App")), It.IsAny<CancellationToken>()))
           .ReturnsAsync(new ProcessResult(0, "", ""));
        var sut = new AppInstaller(run.Object, "C:\\apps");
        var res = await sut.InstallAsync(new[] { App("bad","Bad.App"), App("good","Good.App") },
                                         new Progress<ApplyProgress>(_ => { }));
        Assert.False(res.First(r => r.Id == "bad").Installed);
        Assert.True(res.First(r => r.Id == "good").Installed);
    }
}
```

- [ ] **Step 2: Run to verify failure** → `AppInstaller`/`IAppInstaller` not defined.

- [ ] **Step 3: Implement the installer**

`IAppInstaller.cs`:
```csharp
using SilverOS.Welcome.Core.Apply;

namespace SilverOS.Welcome.Core.Apps;

public sealed record AppInstallResult(string Id, bool Installed);

public interface IAppInstaller
{
    Task<IReadOnlyList<AppInstallResult>> InstallAsync(
        IReadOnlyList<AppCatalogEntry> apps, IProgress<ApplyProgress> progress, CancellationToken ct = default);
}
```

`AppInstaller.cs`:
```csharp
using SilverOS.Welcome.Core.Apply;

namespace SilverOS.Welcome.Core.Apps;

public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppInstaller
{
    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;

        await EnsureWingetAsync(ct);

        var i = 0;
        foreach (var app in apps)
        {
            i++;
            progress.Report(new($"Installing {app.Name} ({i}/{apps.Count})", 80));
            var ok = false;
            var id = app.Source.Winget;
            if (!string.IsNullOrWhiteSpace(id))
            {
                var r = await runner.RunAsync("winget",
                    $"install --id {id} --silent --accept-package-agreements --accept-source-agreements --disable-interactivity", ct);
                ok = r.ExitCode == 0;
                if (ok && !string.IsNullOrWhiteSpace(app.Configure))
                {
                    var script = Path.Combine(appsDir, "configure", app.Configure);
                    await runner.RunAsync("powershell.exe",
                        $"-NoProfile -ExecutionPolicy Bypass -File \"{script}\"", ct);  // best-effort config
                }
            }
            results.Add(new AppInstallResult(app.Id, ok));
        }
        return results;
    }

    // winget (App Installer) is NOT in IoT Enterprise LTSC. Detect, and if absent, provision it.
    private async Task EnsureWingetAsync(CancellationToken ct)
    {
        var probe = await runner.RunAsync("winget", "--version", ct);
        if (probe.ExitCode == 0) return;
        var bootstrap = Path.Combine(appsDir, "bootstrap-winget.ps1");
        await runner.RunAsync("powershell.exe",
            $"-NoProfile -ExecutionPolicy Bypass -Command \"if (Test-Path '{bootstrap}') {{ & '{bootstrap}' }} else {{ " +
            "Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe -EA SilentlyContinue }}\"", ct);
    }
}
```

> The test asserts a `powershell.exe` call containing `DesktopAppInstaller` during bootstrap — the
> inline command above contains it. The real `bootstrap-winget.ps1` (staged) does the robust
> install; this keeps the engine testable without the script present.

- [ ] **Step 4: Run to verify pass** → AppInstaller tests green.

- [ ] **Step 5: Commit**

```bash
git add windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppInstaller.cs windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs windows/welcome/tests/SilverOS.Welcome.Tests/AppInstallerTests.cs
git commit -m "feat(apps): winget install engine (bootstrap + per-app + configure, continue-on-failure)"
```

---

## Phase C — Wizard step (UI)

### Task C1: WizardState.SelectedApps

**Files:**
- Modify: `windows/welcome/src/SilverOS.Welcome.UI/Components/WizardState.cs`

- [ ] **Step 1: Add the property**

In `WizardState`, after `BitLockerPin`:
```csharp
    // Apps step: ids of catalog apps the user chose to install.
    public HashSet<string> SelectedApps { get; set; } = new();
```

- [ ] **Step 2: Build** `dotnet build windows/welcome/src/SilverOS.Welcome.UI -c Release` → 0 errors.

- [ ] **Step 3: Commit**

```bash
git add windows/welcome/src/SilverOS.Welcome.UI/Components/WizardState.cs
git commit -m "feat(apps): WizardState.SelectedApps"
```

---

### Task C2: AppsStep.razor

**Files:**
- Create: `windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/AppsStep.razor`

- [ ] **Step 1: Create the step**

`AppsStep.razor`:
```razor
@using SilverOS.Welcome.Core.Apps
@inject WizardState State

<div class="step apps-step">
    <h1>Choose your apps</h1>
    <p class="step-subtitle">We'll install these during setup. The SilverLABS Stack (browser, VPN, keys) is already included.</p>

    @foreach (var grp in _groups)
    {
        <h3 class="apps-group">@GroupTitle(grp.Key)</h3>
        <div class="apps-grid">
            @foreach (var app in grp)
            {
                <label class="app-card @(State.SelectedApps.Contains(app.Id) ? "selected" : "")">
                    <input type="checkbox" checked="@State.SelectedApps.Contains(app.Id)"
                           @onchange="e => Toggle(app.Id, (bool)e.Value!)" />
                    <span class="app-name">@app.Name</span>
                    <span class="app-desc">@app.Description</span>
                </label>
            }
        </div>
    }
</div>

@code {
    [Parameter] public IReadOnlyList<AppCatalogEntry> Apps { get; set; } = Array.Empty<AppCatalogEntry>();

    private IEnumerable<IGrouping<string, AppCatalogEntry>> _groups =>
        Apps.GroupBy(a => a.Group).OrderByDescending(g => g.Key == "essentials");

    private static string GroupTitle(string g) => g switch
    {
        "essentials" => "Essentials",
        "developer" => "Developer tools",
        "journalist" => "Journalist tools",
        "daily-driver" => "Everyday apps",
        "privacy-max" => "Privacy tools",
        _ => g
    };

    void Toggle(string id, bool on)
    {
        if (on) State.SelectedApps.Add(id); else State.SelectedApps.Remove(id);
    }
}
```

- [ ] **Step 2: Build** the UI project → 0 errors.

- [ ] **Step 3: Commit**

```bash
git add windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/AppsStep.razor
git commit -m "feat(apps): AppsStep grouped checkboxes"
```

---

### Task C3: Wire AppsStep into Routes (after Flavour)

**Files:**
- Modify: `windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor`

Read `Routes.razor` first. The wizard is a fixed-index switch with `_stepTitles`. Insert "Apps"
as step index 2 (between Flavour=1 and Account, which becomes 3), and load the catalog like flavours.

- [ ] **Step 1: Add the catalog field + load**

In `Routes.razor` `@code`, alongside the flavour fields, inject + load the catalog:
```razor
@inject IAppCatalog AppCatalog
```
```csharp
    private LoadedCatalog _catalog = new(Array.Empty<AppCatalogEntry>());
    private static readonly string AppsDir = Path.Combine(AppContext.BaseDirectory, "apps");
```
In `LoadFlavours()` (or `OnInitializedAsync`), after flavours load:
```csharp
    _catalog = AppCatalog.Load(AppsDir);
```

- [ ] **Step 2: Update `_stepTitles` and the switch**

Change titles to: `{ "Welcome", "Flavour", "Apps", "Account", "Prefs", "Apply", "Done" }`.
Insert the Apps case and renumber the rest:
```razor
                case 2:
                    <AppsStep Apps="_catalog.AppsForRole(State.Flavour?.Id ?? string.Empty)" />
                    break;
                case 3:
                    <AccountStep OnValidityChanged="@(v => { _accountValid = v; StateHasChanged(); })" />
                    break;
                case 4:
                    <PrefsStep />
                    break;
                case 5:
                    <ApplyStep OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" />
                    break;
                case 6:
                    <DoneStep />
                    break;
```
Update `CanGoNext` indices (Flavour stays 1; Account becomes 3): the `2 => _accountValid` case becomes `3 => _accountValid`, and the Apply step index in `Next()`/`_applyRunning` guard (`_currentStep != 4` → `_currentStep != 5`).

- [ ] **Step 3: Pre-select defaults when entering the Apps step**

In `FlavourStep`'s selection (or when advancing to Apps), seed `State.SelectedApps` from the catalog defaults. Simplest: in `Routes` `Next()`, when moving **into** step 2 and `SelectedApps` is empty, seed it:
```csharp
    void Next()
    {
        if (_currentStep < _stepTitles.Length - 1) _currentStep++;
        if (_currentStep == 2 && State.SelectedApps.Count == 0 && State.Flavour is not null)
            foreach (var id in _catalog.DefaultSelectionForRole(State.Flavour.Id)) State.SelectedApps.Add(id);
    }
```

- [ ] **Step 4: Build** the UI project → 0 errors.

- [ ] **Step 5: Commit**

```bash
git add windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor
git commit -m "feat(apps): insert Apps step after Flavour + seed per-role defaults"
```

---

## Phase D — Apply integration

### Task D1: ApplyRequest.SelectedApps + ApplyService + DI

**Files:**
- Modify: `windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs`
- Modify: `windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs`
- Modify: `windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor`
- Modify: `windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs`
- Modify: `windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs` (and any other ApplyService test mocks)

- [ ] **Step 1: Extend ApplyRequest**

`ApplyRequest.cs`:
```csharp
using SilverOS.Welcome.Core.Flavours;
using SilverOS.Welcome.Core.Apps;
namespace SilverOS.Welcome.Core.Apply;
public sealed record ApplyRequest(FlavourManifest Flavour, string Username, string Password,
    string AdminPassword, string BitLockerPin, string BootstrapUser,
    IReadOnlyList<AppCatalogEntry> Apps);
```

- [ ] **Step 2: Inject IAppInstaller into ApplyService + run after Stack, before BitLocker**

`ApplyService.cs` ctor — add `IAppInstaller installer`:
```csharp
public sealed class ApplyService(IProcessRunner runner, IAccountService accounts,
    IBitLockerService bitlocker, IBootstrapService bootstrap, IAppInstaller installer, string hardeningDir) : IApplyService
```
After `CreateAccountsAsync` and before `bitlocker.EnableAsync`:
```csharp
        progress.Report(new("Installing apps", 70));
        await installer.InstallAsync(req.Apps, progress, ct);   // continue-on-failure; never throws
```

- [ ] **Step 3: Update MauiProgram DI**

`MauiProgram.cs`: add (near the other singletons)
```csharp
        builder.Services.AddSingleton<IAppCatalog, AppCatalog>();
        var appsDir = Path.Combine(AppContext.BaseDirectory, "apps");
        builder.Services.AddSingleton<IAppInstaller>(sp => new AppInstaller(sp.GetRequiredService<IProcessRunner>(), appsDir));
```
And add `installer` to the `ApplyService` factory:
```csharp
        builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
            sp.GetRequiredService<IProcessRunner>(),
            sp.GetRequiredService<IAccountService>(),
            sp.GetRequiredService<IBitLockerService>(),
            sp.GetRequiredService<IBootstrapService>(),
            sp.GetRequiredService<IAppInstaller>(),
            hardeningDir));
```

- [ ] **Step 4: ApplyStep passes the selected entries**

`ApplyStep.razor` — where it builds `ApplyRequest`, resolve the selected ids to entries and pass them. Inject `IAppCatalog`, load once, then:
```csharp
    [Inject] IAppCatalog AppCatalog { get; set; } = default!;
    // ...
    var apps = AppCatalog.Load(Path.Combine(AppContext.BaseDirectory, "apps"))
                         .All.Where(a => State.SelectedApps.Contains(a.Id)).ToList();
    var req = new ApplyRequest(State.Flavour!, State.Username, State.Password,
        State.AdminPassword, State.BitLockerPin, "sm-bootstrap", apps);
```

- [ ] **Step 5: Fix the existing ApplyService test mocks**

In `ApplyServiceTests.cs` / `ApplyServicesTests.cs` / `ApplyServiceHardeningIntegrationTests.cs`: every `new ApplyService(...)` gains a mock `IAppInstaller` arg, and every `new ApplyRequest(...)` gains a final `Array.Empty<AppCatalogEntry>()` (or a small list). Add an installer mock:
```csharp
var installer = new Mock<IAppInstaller>();
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(), It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
         .ReturnsAsync(Array.Empty<AppInstallResult>());
```
and a callback to record ordering if those tests assert order (insert `"apps"` between `"accounts"` and `"bitlocker"` in the expected sequence).

- [ ] **Step 6: Run the whole suite**

Run: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release`
Expected: PASS (all green, including updated ordering assertions).

- [ ] **Step 7: Commit**

```bash
git add windows/welcome
git commit -m "feat(apps): install selected apps during Apply (after Stack, before BitLocker)"
```

---

## Phase E — Catalog data + build staging

### Task E1: catalog.json + configure + bootstrap + build.ps1 staging

**Files:**
- Create: `windows/apps/catalog.json`
- Create: `windows/apps/configure/ungoogled-chromium.ps1`
- Create: `windows/apps/bootstrap-winget.ps1`
- Modify: `windows/installer/build.ps1`

- [ ] **Step 1: Write `catalog.json`** (the §4 table from the spec, all entries, UTF-8 no-BOM is fine — JSON read by .NET)

Create `windows/apps/catalog.json` with `schemaVersion:1` and the full `apps` array exactly per the spec §4 table (id/name/description/source.winget/group/roles/defaultFor), with `eloston.ungoogled-chromium` carrying `"configure":"ungoogled-chromium.ps1"`. (Claude Code CLI entry: omit from v1 or give `"source":{}` and skip — winget-only engine ignores empty source.)

- [ ] **Step 2: `configure/ungoogled-chromium.ps1`** — enable the Web Store + safe search via Chromium policy (HKLM\SOFTWARE\Policies\Chromium):

```powershell
#Requires -Version 5.1
$ErrorActionPreference='SilentlyContinue'
$pol='HKLM:\SOFTWARE\Policies\Chromium'
New-Item $pol -Force | Out-Null
New-ItemProperty $pol -Name 'ForceGoogleSafeSearch' -Value 1 -PropertyType DWord -Force | Out-Null
$ext="$pol\ExtensionInstallSources"; New-Item $ext -Force | Out-Null
New-ItemProperty $ext -Name '1' -Value 'https://chrome.google.com/webstore/*' -PropertyType String -Force | Out-Null
```

- [ ] **Step 3: `bootstrap-winget.ps1`** — robust App Installer provisioning:

```powershell
#Requires -Version 5.1
$ErrorActionPreference='SilentlyContinue'
# Register the inbox App Installer if present, else nothing to do (offline image w/o it).
Get-AppxPackage -AllUsers Microsoft.DesktopAppInstaller |
  ForEach-Object { Add-AppxPackage -DisableDevelopmentMode -Register "$($_.InstallLocation)\AppXManifest.xml" }
```

- [ ] **Step 4: Stage `windows/apps/` in build.ps1**

In `Invoke-ServiceWim`, where the Welcome payload is staged (the `Copy-WelcomePayload` area), add the apps dir next to flavours:
```powershell
        # Stage the app catalog + configure/bootstrap scripts next to the Welcome app.
        $appsDest = Join-Path $dest 'apps'
        $null = New-Item -ItemType Directory -Force $appsDest
        Copy-Item (Join-Path $WindowsDir 'apps\*') $appsDest -Recurse -Force
```
(where `$dest` is `C:\Program Files\SilverOS\Welcome`, same var the flavours copy uses).

- [ ] **Step 5: Parse-lint + verify JSON**

Run: `pwsh -NoProfile -Command "Get-Content windows/apps/catalog.json -Raw | ConvertFrom-Json | Out-Null; 'json ok'"`
Run: `pwsh -NoProfile -Command "[void][System.Management.Automation.Language.Parser]::ParseFile('C:\Staging\Source\SilverMetal\windows\installer\build.ps1',[ref]$null,[ref]$null); 'ps ok'"`
Expected: `json ok`, `ps ok`.

- [ ] **Step 6: Commit**

```bash
git add windows/apps windows/installer/build.ps1
git commit -m "feat(apps): catalog.json + chromium configure + winget bootstrap + build staging"
```

---

## Phase F — Verify

### Task F1: Full build + test

- [ ] **Step 1**: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release` → all pass.
- [ ] **Step 2**: `dotnet build windows/welcome/src/SilverOS.Welcome.App -f net9.0-windows10.0.19041.0 -c Release` → 0 errors (the full MAUI app compiles with the new step + DI).
- [ ] **Step 3**: Use `superpowers:finishing-a-development-branch` to open the PR (`feat/app-recipes` → main). The catalog/install actually exercised end-to-end is verified on the **next VM run** (or hardware): pick a role → Apps step shows grouped pre-checked apps → Apply installs them via winget. Note in the PR: winget needs network (the VM's HVCI-blocked NIC means in-VM install verification needs the virtio rig or hardware).

---

## Self-review notes (author)

- **Spec coverage**: catalog schema (§3a) → A1/A2 + E1; loader/filter (§3b) → A2; AppsStep (§3c) → C2/C3; AppInstaller winget+bootstrap+configure+continue-on-failure (§3d) → B1; per-role lists (§4) → E1 catalog.json; build staging (§5) → E1; error handling (§6) → A2 (missing catalog), B1 (per-app failure), E1 (bootstrap); testing (§7) → A1/A2/B1 + D1 ordering. WDAC caveat (§2) is a Done-step note (carried as a follow-up — surfaced in PR/Done summary, no code gate).
- **Type consistency**: `AppCatalogEntry`, `AppSource.Winget`, `LoadedCatalog.AppsForRole/DefaultSelectionForRole/All`, `IAppCatalog.Load`, `IAppInstaller.InstallAsync` → `AppInstallResult(Id,Installed)`, `ApplyRequest(...Apps)`, `WizardState.SelectedApps` — used consistently across A→F.
- **Known integration risk flagged in-place**: Routes step-index renumbering (C3) is the fiddly part — the plan renumbers the switch, `CanGoNext`, and the Apply-step guard explicitly; the executor must read Routes.razor first (it's stated).
