From e88e476cd6ea82a61f7e3b9a803510036a6fd2d7 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Wed, 10 Jun 2026 09:12:39 +0100 Subject: [PATCH] feat(toolbox): drop Account step, pre-seed from preconfig, run-once vs toolbox-home Co-Authored-By: Claude Opus 4.8 --- .../src/SilverOS.Welcome.App/MauiProgram.cs | 2 + .../Components/Routes.razor | 68 +++++++++++--- .../Components/Steps/AccountStep.razor | 90 ------------------- .../Components/Steps/ApplyStep.razor | 8 ++ .../Components/WizardState.cs | 3 - .../SilverOS.Welcome.Tests/ApplyStepTests.cs | 24 +++-- 6 files changed, 87 insertions(+), 108 deletions(-) delete mode 100644 windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/AccountStep.razor diff --git a/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs b/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs index bbbbd37..b2153cc 100644 --- a/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs +++ b/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using SilverOS.Welcome.Core.Apply; using SilverOS.Welcome.Core.Apps; using SilverOS.Welcome.Core.Flavours; +using SilverOS.Welcome.Core.Preconfig; using SilverOS.Welcome.App.Components; namespace SilverOS.Welcome.App; @@ -41,6 +42,7 @@ public static class MauiProgram builder.Services.AddSingleton(sp => new ApplyService( sp.GetRequiredService(), sp.GetRequiredService())); + builder.Services.AddSingleton(_ => new PreconfigStore(@"C:\ProgramData\SilverMetal")); builder.Services.AddScoped(); return builder.Build(); diff --git a/windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor b/windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor index 26d4326..a5ebfe6 100644 --- a/windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor +++ b/windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor @@ -1,10 +1,22 @@ @using SilverOS.Welcome.App.Components.Steps @using SilverOS.Welcome.Core.Flavours @using SilverOS.Welcome.Core.Apps +@using SilverOS.Welcome.Core.Preconfig @inject IFlavourLoader FlavourLoader @inject IAppCatalog AppCatalog +@inject IPreconfigStore PreconfigStore @inject WizardState State +@if (_toolboxHome) +{ +
+

SilverMetal

+

Your device is set up and ready to go.

+ +
+} +else +{
@@ -45,15 +57,12 @@ break; case 3: - - break; - case 4: break; - case 5: + case 4: break; - case 6: + case 5: break; } @@ -66,7 +75,7 @@ @onclick="Back"> Back - @if (_currentStep < _stepTitles.Length - 1 && _currentStep != 5) + @if (_currentStep < _stepTitles.Length - 1 && _currentStep != 4) {
+} @code { - private static readonly string[] _stepTitles = { "Welcome", "Flavour", "Apps", "Account", "Prefs", "Apply", "Done" }; + private static readonly string[] _stepTitles = { "Welcome", "Flavour", "Apps", "Prefs", "Apply", "Done" }; // Flavours dir: baked alongside the exe at publish time. private static readonly string FlavoursDir = Path.Combine( @@ -93,7 +103,7 @@ private int _currentStep = 0; private bool _loading = true; private bool _applyRunning = false; - private bool _accountValid = false; + private bool _toolboxHome = false; private string? _error; private IReadOnlyList _flavours = Array.Empty(); @@ -101,11 +111,15 @@ { 1 => State.Flavour is not null, // 2 = Apps step is always valid (never blocks Next). - 3 => _accountValid, _ => true }; - protected override Task OnInitializedAsync() => LoadFlavours(); + protected override Task OnInitializedAsync() + { + LoadFlavours(); + SeedFromPreconfig(); + return Task.CompletedTask; + } private Task LoadFlavours() { @@ -127,6 +141,40 @@ return Task.CompletedTask; } + // Runs AFTER flavours + catalog are loaded (order matters): decides run-mode and, + // on a first run, pre-seeds wizard state from the WinPE collector's choices. + private void SeedFromPreconfig() + { + var pre = PreconfigStore.Load(); + + if (PreconfigStore.IsConfigured()) + { + // Already ran once -> open the minimal toolbox-home landing, never auto-apply. + _toolboxHome = true; + return; + } + + if (pre is null) + return; // fail-open: no preconfig -> normal wizard with flavour defaults. + + // Match the collector's flavour by id; fall back to the loaded default if absent. + State.Flavour = _flavours.FirstOrDefault(f => f.Id == pre.Flavour) + ?? _flavours.FirstOrDefault(f => f.IsDefault) + ?? _flavours.FirstOrDefault(); + + foreach (var id in _catalog.DefaultSelectionForRole(pre.Flavour)) + State.SelectedApps.Add(id); + + if (pre.Bitlocker.Enable && !string.IsNullOrEmpty(pre.Bitlocker.Pin)) + State.BitLockerPin = pre.Bitlocker.Pin; + } + + private void ReRunSetup() + { + _toolboxHome = false; + _currentStep = 0; + } + void Next() { if (_currentStep < _stepTitles.Length - 1) diff --git a/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/AccountStep.razor b/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/AccountStep.razor deleted file mode 100644 index 7e1b5b6..0000000 --- a/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/AccountStep.razor +++ /dev/null @@ -1,90 +0,0 @@ -@inject WizardState State - - - -@code { - private readonly Dictionary _errors = new(); - private readonly HashSet _touched = new(); - - /// Notifies the wizard host whenever validity changes (and on initial mount). - [Parameter] public EventCallback OnValidityChanged { get; set; } - - /// True when all fields are valid. - public bool IsValid { get; private set; } - - protected override void OnInitialized() => Validate(); - - private void OnUsernameInput(ChangeEventArgs e) { State.Username = e.Value?.ToString() ?? ""; _touched.Add("username"); Validate(); } - private void OnPasswordInput(ChangeEventArgs e) { State.Password = e.Value?.ToString() ?? ""; _touched.Add("password"); Validate(); } - private void OnAdminPasswordInput(ChangeEventArgs e) { State.AdminPassword = e.Value?.ToString() ?? ""; _touched.Add("adminpassword"); Validate(); } - private void OnPinInput(ChangeEventArgs e) { State.BitLockerPin = e.Value?.ToString() ?? ""; _touched.Add("bitlockerpin"); Validate(); } - - void Validate() - { - _errors.Clear(); - - if (string.IsNullOrWhiteSpace(State.Username)) - _errors["username"] = "Daily username is required."; - - if (string.IsNullOrWhiteSpace(State.Password)) - _errors["password"] = "Password is required."; - - if (string.IsNullOrWhiteSpace(State.AdminPassword)) - _errors["adminpassword"] = "Administrator password is required."; - - var pin = State.BitLockerPin ?? ""; - if (!System.Text.RegularExpressions.Regex.IsMatch(pin, @"^\d{6,}$")) - _errors["bitlockerpin"] = "BitLocker PIN must be all digits and at least 6 digits long."; - - IsValid = _errors.Count == 0; - _ = OnValidityChanged.InvokeAsync(IsValid); - } -} diff --git a/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor b/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor index d1d173e..323ec3b 100644 --- a/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor +++ b/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor @@ -1,6 +1,8 @@ @using SilverOS.Welcome.Core.Apps +@using SilverOS.Welcome.Core.Preconfig @inject IApplyService ApplyService @inject IAppCatalog AppCatalog +@inject IPreconfigStore PreconfigStore @inject WizardState State
@@ -110,6 +112,12 @@ _complete = true; _running = false; _percent = 100; + + // Apply succeeded: wipe the BitLocker pin from the preconfig and stamp the + // configured marker so the next launch opens toolbox-home instead of re-applying. + PreconfigStore.ClearPin(); + PreconfigStore.MarkConfigured(); + StateHasChanged(); await OnRunningChanged.InvokeAsync(false); await OnComplete.InvokeAsync(); diff --git a/windows/welcome/src/SilverOS.Welcome.UI/Components/WizardState.cs b/windows/welcome/src/SilverOS.Welcome.UI/Components/WizardState.cs index 7dca9ee..128c29e 100644 --- a/windows/welcome/src/SilverOS.Welcome.UI/Components/WizardState.cs +++ b/windows/welcome/src/SilverOS.Welcome.UI/Components/WizardState.cs @@ -9,9 +9,6 @@ public sealed class WizardState // Apps step: ids of catalog apps the user chose to install. public HashSet SelectedApps { get; set; } = new(); - public string Username { get; set; } = ""; - public string Password { get; set; } = ""; - public string AdminPassword { get; set; } = ""; public string BitLockerPin { get; set; } = ""; // Prefs step diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyStepTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyStepTests.cs index 620fa9d..755f1ea 100644 --- a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyStepTests.cs +++ b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyStepTests.cs @@ -7,6 +7,7 @@ using SilverOS.Welcome.App.Components.Steps; using SilverOS.Welcome.Core.Apply; using SilverOS.Welcome.Core.Apps; using SilverOS.Welcome.Core.Flavours; +using SilverOS.Welcome.Core.Preconfig; using Xunit; public class ApplyStepTests : TestContext @@ -17,6 +18,19 @@ public class ApplyStepTests : TestContext private static void AddCatalog(IServiceCollection services) => services.AddSingleton(new AppCatalog()); + // ApplyStep injects IPreconfigStore to clear the pin + mark configured after a + // successful apply; a no-op fake keeps these UI tests off the real filesystem. + private static void AddPreconfig(IServiceCollection services) => + services.AddSingleton(new FakePreconfigStore()); + + private sealed class FakePreconfigStore : IPreconfigStore + { + public Preconfig? Load() => null; + public void ClearPin() { } + public bool IsConfigured() => false; + public void MarkConfigured() { } + } + [Fact] public async Task Calls_apply_with_the_wizard_selections() { @@ -24,14 +38,12 @@ public class ApplyStepTests : TestContext var state = new WizardState { Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } }, - Username = "alice", - Password = "pw", - AdminPassword = "apw", BitLockerPin = "123456" }; Services.AddSingleton(state); Services.AddSingleton(apply.Object); AddCatalog(Services); + AddPreconfig(Services); var cut = RenderComponent(); await cut.InvokeAsync(() => cut.Instance.StartAsync()); apply.Verify(a => a.RunAsync( @@ -49,11 +61,12 @@ public class ApplyStepTests : TestContext var state = new WizardState { Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } }, - Username = "alice", Password = "pw", AdminPassword = "apw", BitLockerPin = "123456" + BitLockerPin = "123456" }; Services.AddSingleton(state); Services.AddSingleton(apply.Object); AddCatalog(Services); + AddPreconfig(Services); var completed = false; var cut = RenderComponent(p => p.Add(s => s.OnComplete, EventCallback.Factory.Create(this, () => { completed = true; }))); await cut.InvokeAsync(() => cut.Instance.StartAsync()); @@ -69,11 +82,12 @@ public class ApplyStepTests : TestContext var state = new WizardState { Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } }, - Username = "alice", Password = "pw", AdminPassword = "apw", BitLockerPin = "123456" + BitLockerPin = "123456" }; Services.AddSingleton(state); Services.AddSingleton(apply.Object); AddCatalog(Services); + AddPreconfig(Services); var cut = RenderComponent(); await cut.InvokeAsync(() => cut.Instance.StartAsync()); Assert.Contains("Module 03 failed", cut.Markup);