diff --git a/windows/apps/bootstrap-winget.ps1 b/windows/apps/bootstrap-winget.ps1 new file mode 100644 index 0000000..2e64179 --- /dev/null +++ b/windows/apps/bootstrap-winget.ps1 @@ -0,0 +1,5 @@ +#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" } diff --git a/windows/apps/catalog.json b/windows/apps/catalog.json new file mode 100644 index 0000000..1bf4057 --- /dev/null +++ b/windows/apps/catalog.json @@ -0,0 +1,285 @@ +{ + "schemaVersion": 1, + "apps": [ + { + "id": "thunderbird", + "name": "Thunderbird", + "description": "Open-source email, calendar, and chat client.", + "source": { "winget": "Mozilla.Thunderbird" }, + "group": "essentials", + "roles": ["essentials"], + "defaultFor": ["essentials"], + "configure": null + }, + { + "id": "vlc", + "name": "VLC", + "description": "Plays virtually any audio or video file.", + "source": { "winget": "VideoLAN.VLC" }, + "group": "essentials", + "roles": ["essentials"], + "defaultFor": ["essentials"], + "configure": null + }, + { + "id": "7zip", + "name": "7-Zip", + "description": "High-ratio archive extractor and compressor.", + "source": { "winget": "7zip.7zip" }, + "group": "essentials", + "roles": ["essentials"], + "defaultFor": ["essentials"], + "configure": null + }, + { + "id": "libreoffice", + "name": "LibreOffice", + "description": "Full office suite for documents, sheets, and slides.", + "source": { "winget": "TheDocumentFoundation.LibreOffice" }, + "group": "essentials", + "roles": ["essentials"], + "defaultFor": ["essentials"], + "configure": null + }, + { + "id": "ungoogled-chromium", + "name": "ungoogled-chromium", + "description": "Chromium with Google integration stripped.", + "source": { "winget": "eloston.ungoogled-chromium" }, + "group": "essentials", + "roles": ["essentials"], + "defaultFor": [], + "configure": "ungoogled-chromium.ps1" + }, + { + "id": "keepassxc", + "name": "KeePassXC", + "description": "Offline, encrypted password manager.", + "source": { "winget": "KeePassXCTeam.KeePassXC" }, + "group": "essentials", + "roles": ["essentials", "journalist"], + "defaultFor": ["journalist"], + "configure": null + }, + { + "id": "vscodium", + "name": "VSCodium", + "description": "Telemetry-free VS Code build.", + "source": { "winget": "VSCodium.VSCodium" }, + "group": "developer", + "roles": ["developer"], + "defaultFor": ["developer"], + "configure": null + }, + { + "id": "git", + "name": "Git", + "description": "Distributed version control system.", + "source": { "winget": "Git.Git" }, + "group": "developer", + "roles": ["developer"], + "defaultFor": ["developer"], + "configure": null + }, + { + "id": "dotnet-sdk-9", + "name": ".NET 9 SDK", + "description": "SDK for building .NET 9 applications.", + "source": { "winget": "Microsoft.DotNet.SDK.9" }, + "group": "developer", + "roles": ["developer"], + "defaultFor": ["developer"], + "configure": null + }, + { + "id": "nodejs-lts", + "name": "Node.js LTS", + "description": "JavaScript runtime, long-term-support release.", + "source": { "winget": "OpenJS.NodeJS.LTS" }, + "group": "developer", + "roles": ["developer"], + "defaultFor": ["developer"], + "configure": null + }, + { + "id": "windows-terminal", + "name": "Windows Terminal", + "description": "Modern tabbed terminal for the shell.", + "source": { "winget": "Microsoft.WindowsTerminal" }, + "group": "developer", + "roles": ["developer"], + "defaultFor": ["developer"], + "configure": null + }, + { + "id": "powershell-7", + "name": "PowerShell 7", + "description": "Cross-platform PowerShell shell.", + "source": { "winget": "Microsoft.PowerShell" }, + "group": "developer", + "roles": ["developer"], + "defaultFor": ["developer"], + "configure": null + }, + { + "id": "claude-desktop", + "name": "Claude Desktop", + "description": "Anthropic Claude desktop client.", + "source": { "winget": "Anthropic.Claude" }, + "group": "developer", + "roles": ["developer"], + "defaultFor": ["developer"], + "configure": null + }, + { + "id": "visual-studio-2022", + "name": "Visual Studio 2022", + "description": "Full-featured IDE, Community edition.", + "source": { "winget": "Microsoft.VisualStudio.2022.Community" }, + "group": "developer", + "roles": ["developer"], + "defaultFor": [], + "configure": null + }, + { + "id": "jetbrains-rider", + "name": "JetBrains Rider", + "description": "Cross-platform .NET IDE.", + "source": { "winget": "JetBrains.Rider" }, + "group": "developer", + "roles": ["developer"], + "defaultFor": [], + "configure": null + }, + { + "id": "docker-desktop", + "name": "Docker Desktop", + "description": "Container build and run environment.", + "source": { "winget": "Docker.DockerDesktop" }, + "group": "developer", + "roles": ["developer"], + "defaultFor": [], + "configure": null + }, + { + "id": "google-chrome", + "name": "Google Chrome", + "description": "Google's web browser.", + "source": { "winget": "Google.Chrome" }, + "group": "developer", + "roles": ["developer"], + "defaultFor": [], + "configure": null + }, + { + "id": "postgresql", + "name": "PostgreSQL", + "description": "Relational database server.", + "source": { "winget": "PostgreSQL.PostgreSQL" }, + "group": "developer", + "roles": ["developer"], + "defaultFor": [], + "configure": null + }, + { + "id": "bruno", + "name": "Bruno", + "description": "Offline-first API client.", + "source": { "winget": "Bruno.Bruno" }, + "group": "developer", + "roles": ["developer"], + "defaultFor": [], + "configure": null + }, + { + "id": "veracrypt", + "name": "VeraCrypt", + "description": "On-the-fly disk and volume encryption.", + "source": { "winget": "IDRIX.VeraCrypt" }, + "group": "journalist", + "roles": ["journalist", "privacy-max"], + "defaultFor": ["journalist"], + "configure": null + }, + { + "id": "joplin", + "name": "Joplin", + "description": "Encrypted, open-source note-taking app.", + "source": { "winget": "Joplin.Joplin" }, + "group": "journalist", + "roles": ["journalist"], + "defaultFor": ["journalist"], + "configure": null + }, + { + "id": "obs-studio", + "name": "OBS Studio", + "description": "Screen recording and live streaming.", + "source": { "winget": "OBSProject.OBSStudio" }, + "group": "journalist", + "roles": ["journalist"], + "defaultFor": [], + "configure": null + }, + { + "id": "standard-notes", + "name": "Standard Notes", + "description": "End-to-end encrypted notes app.", + "source": { "winget": "StandardNotes.StandardNotes" }, + "group": "journalist", + "roles": ["journalist"], + "defaultFor": [], + "configure": null + }, + { + "id": "signal", + "name": "Signal", + "description": "Encrypted private messaging.", + "source": { "winget": "OpenWhisperSystems.Signal" }, + "group": "journalist", + "roles": ["journalist"], + "defaultFor": [], + "configure": null + }, + { + "id": "tor-browser", + "name": "Tor Browser", + "description": "Anonymous browsing over the Tor network.", + "source": { "winget": "TorProject.TorBrowser" }, + "group": "journalist", + "roles": ["journalist"], + "defaultFor": [], + "configure": null + }, + { + "id": "spotify", + "name": "Spotify", + "description": "Music and podcast streaming.", + "source": { "winget": "Spotify.Spotify" }, + "group": "daily-driver", + "roles": ["daily-driver"], + "defaultFor": [], + "configure": null + }, + { + "id": "zoom", + "name": "Zoom", + "description": "Video conferencing client.", + "source": { "winget": "Zoom.Zoom" }, + "group": "daily-driver", + "roles": ["daily-driver"], + "defaultFor": [], + "configure": null + }, + { + "id": "discord", + "name": "Discord", + "description": "Voice, video, and text chat.", + "source": { "winget": "Discord.Discord" }, + "group": "daily-driver", + "roles": ["daily-driver"], + "defaultFor": [], + "configure": null + } + ] +} diff --git a/windows/apps/configure/ungoogled-chromium.ps1 b/windows/apps/configure/ungoogled-chromium.ps1 new file mode 100644 index 0000000..a3a6d07 --- /dev/null +++ b/windows/apps/configure/ungoogled-chromium.ps1 @@ -0,0 +1,7 @@ +#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 diff --git a/windows/docs/superpowers/plans/2026-06-10-wizard-app-recipes.md b/windows/docs/superpowers/plans/2026-06-10-wizard-app-recipes.md new file mode 100644 index 0000000..d1f9218 --- /dev/null +++ b/windows/docs/superpowers/plans/2026-06-10-wizard-app-recipes.md @@ -0,0 +1,730 @@ +# 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(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 Roles { get; init; } = Array.Empty(); + public IReadOnlyList DefaultFor { get; init; } = Array.Empty(); + 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 All) +{ + // Essentials (offered to all roles) first, then the role's own apps. Stable, de-duped by id. + public IReadOnlyList 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 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? Apps); + + public LoadedCatalog Load(string directory) + { + var path = Path.Combine(directory, "catalog.json"); + if (!File.Exists(path)) return new LoadedCatalog(Array.Empty()); + try + { + var f = JsonSerializer.Deserialize(File.ReadAllText(path), AppCatalogEntry.JsonOptions); + return new LoadedCatalog(f?.Apps ?? Array.Empty()); + } + catch (JsonException) + { + // A bad catalog must never block onboarding — degrade to "no extra apps". + return new LoadedCatalog(Array.Empty()); + } + } +} +``` + +> 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 Runner(int exit = 0) { + var m = new Mock(); + m.Setup(r => r.RunAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .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(s => s.Contains("--version")), It.IsAny())) + .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(_ => { })); + run.Verify(r => r.RunAsync("winget", It.Is(s => + s.Contains("install") && s.Contains("VSCodium.VSCodium") && s.Contains("--silent")), + It.IsAny()), 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(s => s.Contains("--version")), It.IsAny())) + .ReturnsAsync(new ProcessResult(1, "", "not found")); + var sut = new AppInstaller(run.Object, "C:\\apps"); + await sut.InstallAsync(new[] { App("tb", "Mozilla.Thunderbird") }, new Progress(_ => { })); + run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => + s.Contains("DesktopAppInstaller")), It.IsAny()), Times.AtLeastOnce); + } + + [Fact] + public async Task One_app_failure_does_not_stop_the_rest() + { + var run = new Mock(); + run.Setup(r => r.RunAsync("winget", It.Is(s => s.Contains("--version")), It.IsAny())) + .ReturnsAsync(new ProcessResult(0, "v1.8", "")); + run.Setup(r => r.RunAsync("winget", It.Is(s => s.Contains("Bad.App")), It.IsAny())) + .ReturnsAsync(new ProcessResult(1, "", "fail")); + run.Setup(r => r.RunAsync("winget", It.Is(s => s.Contains("Good.App")), It.IsAny())) + .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(_ => { })); + 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> InstallAsync( + IReadOnlyList apps, IProgress 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> InstallAsync( + IReadOnlyList apps, IProgress progress, CancellationToken ct = default) + { + var results = new List(); + 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 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 + +
+

Choose your apps

+

We'll install these during setup. The SilverLABS Stack (browser, VPN, keys) is already included.

+ + @foreach (var grp in _groups) + { +

@GroupTitle(grp.Key)

+
+ @foreach (var app in grp) + { + + } +
+ } +
+ +@code { + [Parameter] public IReadOnlyList Apps { get; set; } = Array.Empty(); + + private IEnumerable> _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()); + 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: + + break; + case 3: + + break; + case 4: + + break; + case 5: + + break; + case 6: + + 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 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(); + var appsDir = Path.Combine(AppContext.BaseDirectory, "apps"); + builder.Services.AddSingleton(sp => new AppInstaller(sp.GetRequiredService(), appsDir)); +``` +And add `installer` to the `ApplyService` factory: +```csharp + builder.Services.AddSingleton(sp => new ApplyService( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + 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()` (or a small list). Add an installer mock: +```csharp +var installer = new Mock(); +installer.Setup(i => i.InstallAsync(It.IsAny>(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(Array.Empty()); +``` +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). diff --git a/windows/docs/superpowers/specs/2026-06-09-wizard-app-recipes-design.md b/windows/docs/superpowers/specs/2026-06-09-wizard-app-recipes-design.md new file mode 100644 index 0000000..11f5dc3 --- /dev/null +++ b/windows/docs/superpowers/specs/2026-06-09-wizard-app-recipes-design.md @@ -0,0 +1,166 @@ +# SilverOS Welcome — Role App Recipes + +> **Status**: design — 2026-06-09. Approved in brainstorming. Adds a per-role app-install +> picker to the first-logon Welcome wizard. Builds on the wizard in +> `windows/welcome/` and the flavour model in `windows/flavours/`. + +## 1. Goal + +Make the wizard's role/flavour selection *do something*: after choosing a role, the user +sees a grouped, pre-checked list of apps to install during onboarding — role-relevant tools +plus privacy-trimmed essentials — and the chosen apps are installed as part of Apply. + +## 2. Decisions (locked in brainstorming) + +- **Install engine: winget, phased.** v1 uses winget (`winget install --id --silent`). + IoT Enterprise LTSC ships *without* winget, so the engine bootstraps the App Installer at + apply time. Every catalog app has a winget id. The catalog's per-app `source` object is + **swappable** to a curated SilverMetal mirror later (the air-gap/privacy end-state) without + changing the UI or engine contract. +- **Stack stays auto-installed.** The SilverLABS Stack (`SilverBrowser`=ungoogled-chromium + rebrand, `SilverVPN`, `SilverKeys`, …) continues to install automatically via each flavour's + `appSet` and is **not** in the picker (shown as "included"). The picker adds **role apps** + plus **opt-in privacy-trimmed third-party** apps (incl. vanilla ungoogled-chromium and a + Thunderbird email option). +- **Per-role lists + Essentials defaults** as in §4 (operator-approved; editable in the JSON). +- **WDAC caveat**: Developer/Daily-Driver run app-control in *audit* (apps run); Privacy-Max/ + Journalist run *enforce* (third-party apps blocked until allow-listed). v1 installs anyway and + surfaces a clear note for enforce-mode roles; full WDAC allow-listing is a follow-up. + +## 3. Architecture (four small units) + +``` +windows/apps/catalog.json # the app catalog (staged into the image like flavours/) +SilverOS.Welcome.Core/Apps/ + AppCatalog.cs # record + loader (mirrors FlavourManifest) + AppCatalogEntry.cs + IAppInstaller.cs / AppInstaller.cs # winget bootstrap + per-app install + configure +SilverOS.Welcome.UI/Components/Steps/ + AppsStep.razor # new wizard step: grouped checkboxes +WizardState.cs # + SelectedApps (ids) +ApplyService.cs # calls IAppInstaller after the Stack/hardening +``` + +### 3a. Catalog schema (`catalog.json`) +```json +{ + "schemaVersion": 1, + "apps": [ + { + "id": "vscodium", + "name": "VSCodium", + "description": "Telemetry-free VS Code build.", + "source": { "winget": "VSCodium.VSCodium" }, + "group": "developer", + "roles": ["developer"], + "defaultFor": ["developer"], + "configure": null + }, + { + "id": "ungoogled-chromium", + "name": "ungoogled-chromium", + "description": "Chromium with Google integration stripped.", + "source": { "winget": "eloston.ungoogled-chromium" }, + "group": "essentials", + "roles": ["essentials"], + "defaultFor": [], + "configure": "ungoogled-chromium.ps1" + } + ] +} +``` +- `roles`: which roles are *offered* this app (`essentials` = all roles). +- `defaultFor`: roles where the checkbox is **pre-checked**. +- `source.winget`: the winget id (only `source` key in v1; a future `mirror` key is additive). +- `configure`: optional post-install script (relative to `windows/apps/configure/`), e.g. the + ungoogled-chromium policy that enables the Chrome Web Store + sets safe search/suggestions. + +### 3b. AppCatalog loader +Mirrors `FlavourManifest`/`IFlavourLoader`: `AppCatalogEntry` record + `AppCatalog.Load(dir)` +reading `catalog.json` with the same `JsonSerializerOptions` (case-insensitive, comments, +trailing commas). A pure function `AppsForRole(role)` returns the entries to display grouped, +and `DefaultSelectionForRole(role)` returns the pre-checked ids. + +### 3c. AppsStep (new wizard step, after Flavour) +- Renders **Essentials** group first, then the chosen role's group; each app a checkbox with + name + description; pre-checked from `DefaultSelectionForRole`. +- Writes the set of selected ids into `WizardState.SelectedApps`. +- Always valid (zero apps is allowed) — Next is never blocked. Notifies the host like the other + steps (so Next state is correct immediately — same `OnSelected`/`StateHasChanged` pattern as + the FlavourStep fix). +- `Routes.razor` gains a step between Flavour (1) and Account; step indices/titles shift by one. + +### 3d. AppInstaller (Apply-step engine) +`IAppInstaller.InstallAsync(IReadOnlyList apps, IProgress, ct)`: +1. **Bootstrap winget** if absent: install `Microsoft.DesktopAppInstaller` + deps + (`Microsoft.VCLibs…`, `Microsoft.UI.Xaml…`) via `Add-AppxProvisionedPackage`/staged msix. +2. **Per app**: `winget install --id --silent --accept-package-agreements + --accept-source-agreements`; capture exit code; **continue on failure** (a bad app must not + fail onboarding); run `configure` script if present and the install succeeded. +3. Return a per-app result list (id → installed/failed); ApplyService stows a summary for the + Done step ("Installed N of M apps; failed: …"). + +Runs **after** hardening + Stack + accounts, **before** BitLocker (so encryption is last), via a +new `progress.Report(new("Installing apps", …))` stage. + +## 4. Catalog contents (v1 — editable) + +| Group | App | winget id | default-checked for | +|---|---|---|---| +| essentials | Thunderbird | `Mozilla.Thunderbird` | all | +| essentials | VLC | `VideoLAN.VLC` | all | +| essentials | 7-Zip | `7zip.7zip` | all | +| essentials | LibreOffice | `TheDocumentFoundation.LibreOffice` | all | +| essentials | ungoogled-chromium | `eloston.ungoogled-chromium` | — (opt-in; configured) | +| essentials | KeePassXC | `KeePassXCTeam.KeePassXC` | — | +| developer | VSCodium | `VSCodium.VSCodium` | developer | +| developer | Git | `Git.Git` | developer | +| developer | .NET 9 SDK | `Microsoft.DotNet.SDK.9` | developer | +| developer | Node.js LTS | `OpenJS.NodeJS.LTS` | developer | +| developer | Windows Terminal | `Microsoft.WindowsTerminal` | developer | +| developer | PowerShell 7 | `Microsoft.PowerShell` | developer | +| developer | Claude Desktop | `Anthropic.Claude` | developer | +| developer | Visual Studio 2022 | `Microsoft.VisualStudio.2022.Community` | — | +| developer | JetBrains Rider | `JetBrains.Rider` | — | +| developer | Docker Desktop | `Docker.DockerDesktop` | — | +| developer | Claude Code (CLI) | *(npm `@anthropic-ai/claude-code`, needs Node)* | — | +| developer | Google Chrome | `Google.Chrome` | — | +| developer | PostgreSQL | `PostgreSQL.PostgreSQL` | — | +| developer | Bruno (API client) | `Bruno.Bruno` | — | +| journalist | VeraCrypt | `IDRIX.VeraCrypt` | journalist | +| journalist | KeePassXC | `KeePassXCTeam.KeePassXC` | journalist | +| journalist | Joplin | `Joplin.Joplin` | journalist | +| journalist | OBS Studio | `OBSProject.OBSStudio` | — | +| journalist | Standard Notes | `StandardNotes.StandardNotes` | — | +| journalist | Signal | `OpenWhisperSystems.Signal` | — | +| journalist | Tor Browser | `TorProject.TorBrowser` | — | +| daily-driver | Spotify | `Spotify.Spotify` | — | +| daily-driver | Zoom | `Zoom.Zoom` | — | +| daily-driver | Discord | `Discord.Discord` | — | +| privacy-max | VeraCrypt | `IDRIX.VeraCrypt` | — | + +> Claude Code (CLI) installs via npm, not winget — modelled with a `source.npm` variant the +> engine handles separately (and only if Node is selected/present). Listed but lower priority. + +## 5. Build wiring +- `build.ps1`: stage `windows/apps/` (catalog.json + configure/) into the image + (`C:\Program Files\SilverOS\Welcome\apps\` next to the flavours), same as flavours. +- The Welcome app reads the catalog from `AppContext.BaseDirectory\apps\catalog.json`. + +## 6. Error handling +- Catalog missing/!parse → the Apps step shows an empty/"no extra apps" state and onboarding + continues (never blocks). +- winget bootstrap fails (offline) → log it, skip the install stage with a Done-step note; + onboarding still completes. +- Per-app install failure → recorded, surfaced in the Done summary, never throws. + +## 7. Testing +- `AppCatalog` deserialization + `AppsForRole`/`DefaultSelectionForRole` unit tests (xUnit). +- `AppInstaller` against a fake `IProcessRunner`: asserts winget-bootstrap when absent, the exact + `winget install` invocation per selected app, continue-on-failure, and configure-script run. +- `AppsStep` selection/validity (bUnit, matching the existing step tests' style). + +## 8. Out of scope (follow-ups) +- Curated SilverMetal mirror (the `source.mirror` end-state) + signing for WDAC-enforce. +- WDAC allow-listing of installed apps for Privacy-Max/Journalist enforce mode. +- Per-app version pinning / update policy. diff --git a/windows/hardening/03-kernel-credential.ps1 b/windows/hardening/03-kernel-credential.ps1 index d72e833..4faff17 100644 --- a/windows/hardening/03-kernel-credential.ps1 +++ b/windows/hardening/03-kernel-credential.ps1 @@ -33,6 +33,6 @@ New-Item $ki -Force | Out-Null Set-ItemProperty $ki -Name DeviceEnumerationPolicy -Type DWord -Value 0 # block until authorized # TODO-M1: confirm msinfo32 reports VBS=Running + Credential Guard + HVCI after reboot; -# confirm whether Kernel DMA Protection shows On (IVRS bit) — open question §8. +# confirm whether Kernel DMA Protection shows On (IVRS bit) -- open question §8. Write-Host ' [D] policy set (VBS/HVCI/CredGuard/LSA-PPL/DMA). Effective after reboot.' diff --git a/windows/installer/build.ps1 b/windows/installer/build.ps1 index 31e3f4c..4656af2 100644 --- a/windows/installer/build.ps1 +++ b/windows/installer/build.ps1 @@ -172,6 +172,17 @@ function Copy-WelcomePayload { } else { Write-Warning " No *.json flavour files found in $flavoursDir -- image will ship with no flavours." } + # Stage the app catalog + configure/bootstrap scripts next to the Welcome app + # (mirrors the flavours copy above): catalog.json, configure\*.ps1, bootstrap-winget.ps1. + $appsDest = Join-Path $dest 'apps' + $null = New-Item -ItemType Directory -Force $appsDest + $appsDir = Join-Path $WindowsDir 'apps' + if (Test-Path $appsDir) { + Copy-Item (Join-Path $appsDir '*') $appsDest -Recurse -Force + Write-Host " Copied app catalog + scripts to $appsDest" + } else { + Write-Warning " No apps dir found at $appsDir -- image will ship with no app catalog." + } # --- Guard: verify the payload actually landed in the mounted image ------- $stagedExe = Join-Path $dest 'SilverOS.Welcome.App.exe' if (-not (Test-Path $stagedExe)) { @@ -181,6 +192,10 @@ function Copy-WelcomePayload { if (-not $stagedFlavours) { throw "Welcome bake failed: no flavour manifests staged in '$destFlavours'. Add *.json files under windows/flavours/ or the installed wizard will have no flavour choices." } + $stagedCatalog = Join-Path $appsDest 'catalog.json' + if (-not (Test-Path $stagedCatalog)) { + throw "Welcome bake failed: app catalog.json missing from image (expected at '$stagedCatalog'). Add windows/apps/catalog.json or the wizard's Apps step will be empty." + } Write-Host " Welcome payload staged at $dest" } diff --git a/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs b/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs index 07a01ba..322c36f 100644 --- a/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs +++ b/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using SilverOS.Welcome.Core.Apply; +using SilverOS.Welcome.Core.Apps; using SilverOS.Welcome.Core.Flavours; using SilverOS.Welcome.App.Components; @@ -34,14 +35,18 @@ public static class MauiProgram var hardeningDir = @"C:\Windows\Setup\Scripts\hardening"; builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + var appsDir = Path.Combine(AppContext.BaseDirectory, "apps"); + builder.Services.AddSingleton(sp => new AppInstaller(sp.GetRequiredService(), appsDir)); builder.Services.AddSingleton(sp => new ApplyService( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), hardeningDir)); builder.Services.AddScoped(); diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs index a6b0cd9..a1c8179 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs @@ -1,4 +1,6 @@ +using SilverOS.Welcome.Core.Apps; using SilverOS.Welcome.Core.Flavours; namespace SilverOS.Welcome.Core.Apply; public sealed record ApplyRequest(FlavourManifest Flavour, string Username, string Password, - string AdminPassword, string BitLockerPin, string BootstrapUser); + string AdminPassword, string BitLockerPin, string BootstrapUser, + IReadOnlyList Apps); diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs index 8e1fa3f..17727d2 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs @@ -1,9 +1,11 @@ using System.Text.Json; +using SilverOS.Welcome.Core.Apps; using SilverOS.Welcome.Core.Flavours; namespace SilverOS.Welcome.Core.Apply; public sealed class ApplyService(IProcessRunner runner, IAccountService accounts, - IBitLockerService bitlocker, IBootstrapService bootstrap, string hardeningDir) : IApplyService + IBitLockerService bitlocker, IBootstrapService bootstrap, IAppInstaller installer, + string hardeningDir) : IApplyService { public async Task RunAsync(ApplyRequest req, IProgress progress, CancellationToken ct = default) { @@ -29,6 +31,9 @@ public sealed class ApplyService(IProcessRunner runner, IAccountService accounts progress.Report(new("Creating your account", 55)); await accounts.CreateAccountsAsync(req.Username, req.Password, req.AdminPassword, ct); + progress.Report(new("Installing apps", 70)); + await installer.InstallAsync(req.Apps, progress, ct); // continue-on-failure; never throws + progress.Report(new("Encrypting the disk", 75)); await bitlocker.EnableAsync(req.BitLockerPin, ct); diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalog.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalog.cs new file mode 100644 index 0000000..b095cad --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalog.cs @@ -0,0 +1,37 @@ +using System.Text.Json; + +namespace SilverOS.Welcome.Core.Apps; + +public sealed record LoadedCatalog(IReadOnlyList All) +{ + // Essentials (offered to all roles) first, then the role's own apps. Stable, de-duped by id. + public IReadOnlyList 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 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? Apps); + + public LoadedCatalog Load(string directory) + { + var path = Path.Combine(directory, "catalog.json"); + if (!File.Exists(path)) return new LoadedCatalog(Array.Empty()); + try + { + var f = JsonSerializer.Deserialize(File.ReadAllText(path), AppCatalogEntry.JsonOptions); + return new LoadedCatalog(f?.Apps ?? Array.Empty()); + } + catch (JsonException) + { + // A bad catalog must never block onboarding — degrade to "no extra apps". + return new LoadedCatalog(Array.Empty()); + } + } +} diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalogEntry.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalogEntry.cs new file mode 100644 index 0000000..5a886d8 --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalogEntry.cs @@ -0,0 +1,28 @@ +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 Roles { get; init; } = Array.Empty(); + public IReadOnlyList DefaultFor { get; init; } = Array.Empty(); + public string? Configure { get; init; } + + public static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; +} diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs new file mode 100644 index 0000000..9e1d69b --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apps/AppInstaller.cs @@ -0,0 +1,54 @@ +using SilverOS.Welcome.Core.Apply; + +namespace SilverOS.Welcome.Core.Apps; + +public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppInstaller +{ + public async Task> InstallAsync( + IReadOnlyList apps, IProgress progress, CancellationToken ct = default) + { + var results = new List(); + 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); + // best-effort: configuration failure does not mark the install as failed + await runner.RunAsync("powershell.exe", + $"-NoProfile -ExecutionPolicy Bypass -File \"{script}\"", ct); + } + } + results.Add(new AppInstallResult(app.Id, ok)); + } + return results; + } + + // winget (App Installer) is absent from IoT Enterprise LTSC. + // Detect it; if missing, provision via the bundled bootstrap script or the registered package family name. + 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); + } +} diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppCatalog.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppCatalog.cs new file mode 100644 index 0000000..c092c8f --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppCatalog.cs @@ -0,0 +1,6 @@ +namespace SilverOS.Welcome.Core.Apps; + +public interface IAppCatalog +{ + LoadedCatalog Load(string directory); +} diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppInstaller.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppInstaller.cs new file mode 100644 index 0000000..b3f64b1 --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apps/IAppInstaller.cs @@ -0,0 +1,11 @@ +using SilverOS.Welcome.Core.Apply; + +namespace SilverOS.Welcome.Core.Apps; + +public sealed record AppInstallResult(string Id, bool Installed); + +public interface IAppInstaller +{ + Task> InstallAsync( + IReadOnlyList apps, IProgress progress, CancellationToken ct = default); +} diff --git a/windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor b/windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor index f4bb24c..26d4326 100644 --- a/windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor +++ b/windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor @@ -1,6 +1,8 @@ @using SilverOS.Welcome.App.Components.Steps @using SilverOS.Welcome.Core.Flavours +@using SilverOS.Welcome.Core.Apps @inject IFlavourLoader FlavourLoader +@inject IAppCatalog AppCatalog @inject WizardState State
@@ -40,15 +42,18 @@ break; case 2: - + break; case 3: - + break; case 4: - + break; case 5: + + break; + case 6: break; } @@ -61,7 +66,7 @@ @onclick="Back"> Back - @if (_currentStep < _stepTitles.Length - 1 && _currentStep != 4) + @if (_currentStep < _stepTitles.Length - 1 && _currentStep != 5) {