feat(toolbox): drop Account step, pre-seed from preconfig, run-once vs toolbox-home

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sysadmin
2026-06-10 09:12:39 +01:00
parent 2730b29cb6
commit e88e476cd6
6 changed files with 87 additions and 108 deletions

View File

@@ -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<IApplyService>(sp => new ApplyService(
sp.GetRequiredService<IBitLockerService>(),
sp.GetRequiredService<IAppInstaller>()));
builder.Services.AddSingleton<IPreconfigStore>(_ => new PreconfigStore(@"C:\ProgramData\SilverMetal"));
builder.Services.AddScoped<WizardState>();
return builder.Build();

View File

@@ -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)
{
<div class="toolbox-home">
<h1>SilverMetal</h1>
<p class="toolbox-home-subtitle">Your device is set up and ready to go.</p>
<button class="btn-secondary" @onclick="ReRunSetup">Re-run setup</button>
</div>
}
else
{
<div class="wizard">
<div class="wizard-header">
<div class="wizard-steps-indicator">
@@ -45,15 +57,12 @@
<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:
case 4:
<ApplyStep OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" />
break;
case 6:
case 5:
<DoneStep />
break;
}
@@ -66,7 +75,7 @@
@onclick="Back">
Back
</button>
@if (_currentStep < _stepTitles.Length - 1 && _currentStep != 5)
@if (_currentStep < _stepTitles.Length - 1 && _currentStep != 4)
{
<button class="btn-primary"
disabled="@(!CanGoNext)"
@@ -76,9 +85,10 @@
}
</div>
</div>
}
@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<FlavourManifest> _flavours = Array.Empty<FlavourManifest>();
@@ -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)

View File

@@ -1,90 +0,0 @@
@inject WizardState State
<div class="step account-step">
<h1>Set Up Your Account</h1>
<p class="step-subtitle">Create your daily-use account and administrator credentials.</p>
<div class="field-group">
<label for="username">Daily Username</label>
<input id="username" type="text" placeholder="e.g. alice"
value="@State.Username"
@oninput="OnUsernameInput" />
@if (_touched.Contains("username") && _errors.TryGetValue("username", out var ue))
{
<span class="field-error">@ue</span>
}
</div>
<div class="field-group">
<label for="password">Daily Password</label>
<input id="password" type="password"
value="@State.Password"
@oninput="OnPasswordInput" />
@if (_touched.Contains("password") && _errors.TryGetValue("password", out var pe))
{
<span class="field-error">@pe</span>
}
</div>
<div class="field-group">
<label for="adminpassword">Administrator Password</label>
<input id="adminpassword" type="password"
value="@State.AdminPassword"
@oninput="OnAdminPasswordInput" />
@if (_touched.Contains("adminpassword") && _errors.TryGetValue("adminpassword", out var ae))
{
<span class="field-error">@ae</span>
}
</div>
<div class="field-group">
<label for="bitlockerpin">BitLocker PIN <small>(numeric, 6+ digits)</small></label>
<input id="bitlockerpin" type="password" inputmode="numeric" pattern="[0-9]*"
value="@State.BitLockerPin"
@oninput="OnPinInput" />
@if (_touched.Contains("bitlockerpin") && _errors.TryGetValue("bitlockerpin", out var be))
{
<span class="field-error">@be</span>
}
</div>
</div>
@code {
private readonly Dictionary<string, string> _errors = new();
private readonly HashSet<string> _touched = new();
/// <summary>Notifies the wizard host whenever validity changes (and on initial mount).</summary>
[Parameter] public EventCallback<bool> OnValidityChanged { get; set; }
/// <summary>True when all fields are valid.</summary>
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);
}
}

View File

@@ -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
<div class="step apply-step">
@@ -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();

View File

@@ -9,9 +9,6 @@ public sealed class WizardState
// 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; } = "";
public string BitLockerPin { get; set; } = "";
// Prefs step

View File

@@ -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<IAppCatalog>(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<IPreconfigStore>(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<ApplyStep>();
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<ApplyStep>(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<ApplyStep>();
await cut.InvokeAsync(() => cut.Instance.StartAsync());
Assert.Contains("Module 03 failed", cut.Markup);