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:
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user