Merge pull request 'feat(welcome): per-role app recipes in the first-boot wizard' (#17) from feat/app-recipes into main
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (push) Failing after 5m27s
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (push) Failing after 5m27s
This commit was merged in pull request #17.
This commit is contained in:
5
windows/apps/bootstrap-winget.ps1
Normal file
5
windows/apps/bootstrap-winget.ps1
Normal file
@@ -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" }
|
||||
285
windows/apps/catalog.json
Normal file
285
windows/apps/catalog.json
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
7
windows/apps/configure/ungoogled-chromium.ps1
Normal file
7
windows/apps/configure/ungoogled-chromium.ps1
Normal file
@@ -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
|
||||
730
windows/docs/superpowers/plans/2026-06-10-wizard-app-recipes.md
Normal file
730
windows/docs/superpowers/plans/2026-06-10-wizard-app-recipes.md
Normal file
@@ -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<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).
|
||||
@@ -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 <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<AppCatalogEntry> apps, IProgress<ApplyProgress>, 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 <source.winget> --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.
|
||||
@@ -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.'
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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<IProcessRunner, ProcessRunner>();
|
||||
builder.Services.AddSingleton<IFlavourLoader, FlavourLoader>();
|
||||
builder.Services.AddSingleton<IAppCatalog, AppCatalog>();
|
||||
builder.Services.AddSingleton<IAccountService, AccountService>();
|
||||
builder.Services.AddSingleton<IBitLockerService, BitLockerService>();
|
||||
builder.Services.AddSingleton<IBootstrapService, BootstrapService>();
|
||||
var appsDir = Path.Combine(AppContext.BaseDirectory, "apps");
|
||||
builder.Services.AddSingleton<IAppInstaller>(sp => new AppInstaller(sp.GetRequiredService<IProcessRunner>(), appsDir));
|
||||
builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
|
||||
sp.GetRequiredService<IProcessRunner>(),
|
||||
sp.GetRequiredService<IAccountService>(),
|
||||
sp.GetRequiredService<IBitLockerService>(),
|
||||
sp.GetRequiredService<IBootstrapService>(),
|
||||
sp.GetRequiredService<IAppInstaller>(),
|
||||
hardeningDir));
|
||||
builder.Services.AddScoped<WizardState>();
|
||||
|
||||
|
||||
@@ -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<AppCatalogEntry> Apps);
|
||||
|
||||
@@ -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<ApplyProgress> 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);
|
||||
|
||||
|
||||
37
windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalog.cs
Normal file
37
windows/welcome/src/SilverOS.Welcome.Core/Apps/AppCatalog.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
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>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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
|
||||
};
|
||||
}
|
||||
@@ -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<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);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace SilverOS.Welcome.Core.Apps;
|
||||
|
||||
public interface IAppCatalog
|
||||
{
|
||||
LoadedCatalog Load(string directory);
|
||||
}
|
||||
@@ -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<IReadOnlyList<AppInstallResult>> InstallAsync(
|
||||
IReadOnlyList<AppCatalogEntry> apps, IProgress<ApplyProgress> progress, CancellationToken ct = default);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<div class="wizard">
|
||||
@@ -40,15 +42,18 @@
|
||||
<FlavourStep Flavours="_flavours" OnSelected="StateHasChanged" />
|
||||
break;
|
||||
case 2:
|
||||
<AccountStep OnValidityChanged="@(v => { _accountValid = v; StateHasChanged(); })" />
|
||||
<AppsStep Apps="_catalog.AppsForRole(State.Flavour?.Id ?? string.Empty)" />
|
||||
break;
|
||||
case 3:
|
||||
<PrefsStep />
|
||||
<AccountStep OnValidityChanged="@(v => { _accountValid = v; StateHasChanged(); })" />
|
||||
break;
|
||||
case 4:
|
||||
<ApplyStep OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" />
|
||||
<PrefsStep />
|
||||
break;
|
||||
case 5:
|
||||
<ApplyStep OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" />
|
||||
break;
|
||||
case 6:
|
||||
<DoneStep />
|
||||
break;
|
||||
}
|
||||
@@ -61,7 +66,7 @@
|
||||
@onclick="Back">
|
||||
Back
|
||||
</button>
|
||||
@if (_currentStep < _stepTitles.Length - 1 && _currentStep != 4)
|
||||
@if (_currentStep < _stepTitles.Length - 1 && _currentStep != 5)
|
||||
{
|
||||
<button class="btn-primary"
|
||||
disabled="@(!CanGoNext)"
|
||||
@@ -73,12 +78,18 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private static readonly string[] _stepTitles = { "Welcome", "Flavour", "Account", "Prefs", "Apply", "Done" };
|
||||
private static readonly string[] _stepTitles = { "Welcome", "Flavour", "Apps", "Account", "Prefs", "Apply", "Done" };
|
||||
|
||||
// Flavours dir: baked alongside the exe at publish time.
|
||||
private static readonly string FlavoursDir = Path.Combine(
|
||||
AppContext.BaseDirectory, "flavours");
|
||||
|
||||
// Apps catalog dir: baked alongside the exe at publish time.
|
||||
private static readonly string AppsDir = Path.Combine(
|
||||
AppContext.BaseDirectory, "apps");
|
||||
|
||||
private LoadedCatalog _catalog = new(Array.Empty<AppCatalogEntry>());
|
||||
|
||||
private int _currentStep = 0;
|
||||
private bool _loading = true;
|
||||
private bool _applyRunning = false;
|
||||
@@ -89,7 +100,8 @@
|
||||
private bool CanGoNext => _currentStep switch
|
||||
{
|
||||
1 => State.Flavour is not null,
|
||||
2 => _accountValid,
|
||||
// 2 = Apps step is always valid (never blocks Next).
|
||||
3 => _accountValid,
|
||||
_ => true
|
||||
};
|
||||
|
||||
@@ -102,6 +114,7 @@
|
||||
try
|
||||
{
|
||||
_flavours = FlavourLoader.Load(FlavoursDir);
|
||||
_catalog = AppCatalog.Load(AppsDir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -118,6 +131,11 @@
|
||||
{
|
||||
if (_currentStep < _stepTitles.Length - 1)
|
||||
_currentStep++;
|
||||
|
||||
// On entering the Apps step, seed the per-role default selection once.
|
||||
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);
|
||||
}
|
||||
|
||||
void Back()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
@using SilverOS.Welcome.Core.Apps
|
||||
@inject IApplyService ApplyService
|
||||
@inject IAppCatalog AppCatalog
|
||||
@inject WizardState State
|
||||
|
||||
<div class="step apply-step">
|
||||
@@ -81,13 +83,17 @@
|
||||
StateHasChanged();
|
||||
await OnRunningChanged.InvokeAsync(true);
|
||||
|
||||
var apps = AppCatalog.Load(Path.Combine(AppContext.BaseDirectory, "apps"))
|
||||
.All.Where(a => State.SelectedApps.Contains(a.Id)).ToList();
|
||||
|
||||
var req = new ApplyRequest(
|
||||
Flavour: State.Flavour!,
|
||||
Username: State.Username,
|
||||
Password: State.Password,
|
||||
AdminPassword: State.AdminPassword,
|
||||
BitLockerPin: State.BitLockerPin,
|
||||
BootstrapUser: "sm-bootstrap");
|
||||
BootstrapUser: "sm-bootstrap",
|
||||
Apps: apps);
|
||||
|
||||
var progress = new Progress<ApplyProgress>(p =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@ namespace SilverOS.Welcome.App.Components;
|
||||
public sealed class WizardState
|
||||
{
|
||||
public FlavourManifest? Flavour { get; set; }
|
||||
|
||||
// Apps step: ids of catalog apps the user chose to install.
|
||||
public HashSet<string> SelectedApps { get; set; } = new();
|
||||
|
||||
public string Username { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
public string AdminPassword { get; set; } = "";
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
Assert.Contains("vscodium", ids);
|
||||
Assert.Contains("rider", ids);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
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();
|
||||
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();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Moq;
|
||||
using SilverOS.Welcome.Core.Apply;
|
||||
using SilverOS.Welcome.Core.Apps;
|
||||
using SilverOS.Welcome.Core.Flavours;
|
||||
using Xunit;
|
||||
|
||||
@@ -69,11 +70,18 @@ public class ApplyServiceHardeningIntegrationTests
|
||||
boot.Setup(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
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>());
|
||||
|
||||
var sut = new ApplyService(
|
||||
runner: new ProcessRunner(),
|
||||
accounts: acct.Object,
|
||||
bitlocker: bl.Object,
|
||||
bootstrap: boot.Object,
|
||||
installer: installer.Object,
|
||||
hardeningDir: tmp);
|
||||
|
||||
// Flavour requests modules 00 and 05 only — 03 and 07 must be skipped.
|
||||
@@ -82,7 +90,7 @@ public class ApplyServiceHardeningIntegrationTests
|
||||
Id = "test",
|
||||
Hardening = new HardeningSpec { Modules = new[] { "00", "05" } }
|
||||
};
|
||||
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap");
|
||||
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap", Array.Empty<AppCatalogEntry>());
|
||||
|
||||
// ---- Act ----
|
||||
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
using Moq;
|
||||
using SilverOS.Welcome.Core.Apply;
|
||||
using SilverOS.Welcome.Core.Apps;
|
||||
using SilverOS.Welcome.Core.Flavours;
|
||||
using Xunit;
|
||||
|
||||
public class ApplyServiceTests
|
||||
{
|
||||
private static Mock<IAppInstaller> NoApps()
|
||||
{
|
||||
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>());
|
||||
return installer;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Runs_modules_then_accounts_then_bitlocker_then_bootstrap_last()
|
||||
{
|
||||
@@ -16,15 +26,16 @@ public class ApplyServiceTests
|
||||
var acct = new Mock<IAccountService>(); acct.Setup(a => a.CreateAccountsAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("accounts")).Returns(Task.CompletedTask);
|
||||
var bl = new Mock<IBitLockerService>(); bl.Setup(b => b.EnableAsync(It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
|
||||
var boot = new Mock<IBootstrapService>(); boot.Setup(b => b.TearDownAsync(It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("bootstrap")).Returns(Task.CompletedTask);
|
||||
var installer = NoApps(); installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),It.IsAny<IProgress<ApplyProgress>>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty<AppInstallResult>());
|
||||
|
||||
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, "C:\\hard");
|
||||
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, installer.Object, "C:\\hard");
|
||||
var flavour = new FlavourManifest { Id="daily-driver", Hardening = new HardeningSpec { Modules = new[]{"00"} } };
|
||||
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap");
|
||||
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap", System.Array.Empty<AppCatalogEntry>());
|
||||
var progress = new List<string>();
|
||||
|
||||
await sut.RunAsync(req, new Progress<ApplyProgress>(p => progress.Add(p.Stage)));
|
||||
|
||||
Assert.Equal(new[]{"modules","accounts","bitlocker","bootstrap"}, order);
|
||||
Assert.Equal(new[]{"modules","accounts","apps","bitlocker","bootstrap"}, order);
|
||||
Assert.Contains("Applying hardening", progress);
|
||||
}
|
||||
|
||||
@@ -34,8 +45,8 @@ public class ApplyServiceTests
|
||||
var run = new Mock<IProcessRunner>(); run.Setup(r => r.RunAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).ReturnsAsync(new ProcessResult(0,"",""));
|
||||
var acct = new Mock<IAccountService>(); acct.Setup(a => a.CreateAccountsAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("boom"));
|
||||
var bl = new Mock<IBitLockerService>(); var boot = new Mock<IBootstrapService>();
|
||||
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, "C:\\hard");
|
||||
var req = new ApplyRequest(new FlavourManifest{ Hardening=new HardeningSpec{Modules=new[]{"00"}}}, "a","b","c","123456","sm-bootstrap");
|
||||
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, NoApps().Object, "C:\\hard");
|
||||
var req = new ApplyRequest(new FlavourManifest{ Hardening=new HardeningSpec{Modules=new[]{"00"}}}, "a","b","c","123456","sm-bootstrap", System.Array.Empty<AppCatalogEntry>());
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.RunAsync(req, new Progress<ApplyProgress>(_ => {})));
|
||||
boot.Verify(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,18 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using SilverOS.Welcome.App.Components;
|
||||
using SilverOS.Welcome.App.Components.Steps;
|
||||
using SilverOS.Welcome.Core.Apply;
|
||||
using SilverOS.Welcome.Core.Apps;
|
||||
using SilverOS.Welcome.Core.Flavours;
|
||||
using Xunit;
|
||||
|
||||
public class ApplyStepTests : TestContext
|
||||
{
|
||||
// The component loads the catalog from AppContext.BaseDirectory/apps; no catalog.json
|
||||
// is staged in the test bin, so the real AppCatalog degrades to an empty list — which is
|
||||
// exactly what these tests want (no apps selected → empty Apps on the request).
|
||||
private static void AddCatalog(IServiceCollection services) =>
|
||||
services.AddSingleton<IAppCatalog>(new AppCatalog());
|
||||
|
||||
[Fact]
|
||||
public async Task Calls_apply_with_the_wizard_selections()
|
||||
{
|
||||
@@ -24,6 +31,7 @@ public class ApplyStepTests : TestContext
|
||||
};
|
||||
Services.AddSingleton(state);
|
||||
Services.AddSingleton(apply.Object);
|
||||
AddCatalog(Services);
|
||||
var cut = RenderComponent<ApplyStep>();
|
||||
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
||||
apply.Verify(a => a.RunAsync(
|
||||
@@ -45,6 +53,7 @@ public class ApplyStepTests : TestContext
|
||||
};
|
||||
Services.AddSingleton(state);
|
||||
Services.AddSingleton(apply.Object);
|
||||
AddCatalog(Services);
|
||||
var completed = false;
|
||||
var cut = RenderComponent<ApplyStep>(p => p.Add(s => s.OnComplete, EventCallback.Factory.Create(this, () => { completed = true; })));
|
||||
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
||||
@@ -64,6 +73,7 @@ public class ApplyStepTests : TestContext
|
||||
};
|
||||
Services.AddSingleton(state);
|
||||
Services.AddSingleton(apply.Object);
|
||||
AddCatalog(Services);
|
||||
var cut = RenderComponent<ApplyStep>();
|
||||
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
||||
Assert.Contains("Module 03 failed", cut.Markup);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Moq;
|
||||
using SilverOS.Welcome.Core.Apply;
|
||||
using SilverOS.Welcome.Core.Apps;
|
||||
|
||||
public class BootstrapServiceRevertKioskTests
|
||||
{
|
||||
@@ -85,13 +86,18 @@ public class BootstrapServiceRevertKioskTests
|
||||
.Callback(() => order.Add("teardown"))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, "C:\\hard");
|
||||
var installer = new Mock<IAppInstaller>();
|
||||
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
|
||||
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(System.Array.Empty<AppInstallResult>());
|
||||
|
||||
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, installer.Object, "C:\\hard");
|
||||
var flavour = new SilverOS.Welcome.Core.Flavours.FlavourManifest
|
||||
{
|
||||
Id = "daily-driver",
|
||||
Hardening = new SilverOS.Welcome.Core.Flavours.HardeningSpec { Modules = new[] { "00" } }
|
||||
};
|
||||
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap");
|
||||
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap", System.Array.Empty<AppCatalogEntry>());
|
||||
|
||||
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user