feat: WinPE pre-config collector + simplified first-boot toolbox (SP1) #21
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using SilverOS.Welcome.Core.Apply;
|
using SilverOS.Welcome.Core.Apply;
|
||||||
using SilverOS.Welcome.Core.Apps;
|
using SilverOS.Welcome.Core.Apps;
|
||||||
using SilverOS.Welcome.Core.Flavours;
|
using SilverOS.Welcome.Core.Flavours;
|
||||||
|
using SilverOS.Welcome.Core.Preconfig;
|
||||||
using SilverOS.Welcome.App.Components;
|
using SilverOS.Welcome.App.Components;
|
||||||
|
|
||||||
namespace SilverOS.Welcome.App;
|
namespace SilverOS.Welcome.App;
|
||||||
@@ -41,6 +42,7 @@ public static class MauiProgram
|
|||||||
builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
|
builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
|
||||||
sp.GetRequiredService<IBitLockerService>(),
|
sp.GetRequiredService<IBitLockerService>(),
|
||||||
sp.GetRequiredService<IAppInstaller>()));
|
sp.GetRequiredService<IAppInstaller>()));
|
||||||
|
builder.Services.AddSingleton<IPreconfigStore>(_ => new PreconfigStore(@"C:\ProgramData\SilverMetal"));
|
||||||
builder.Services.AddScoped<WizardState>();
|
builder.Services.AddScoped<WizardState>();
|
||||||
|
|
||||||
return builder.Build();
|
return builder.Build();
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
@using SilverOS.Welcome.App.Components.Steps
|
@using SilverOS.Welcome.App.Components.Steps
|
||||||
@using SilverOS.Welcome.Core.Flavours
|
@using SilverOS.Welcome.Core.Flavours
|
||||||
@using SilverOS.Welcome.Core.Apps
|
@using SilverOS.Welcome.Core.Apps
|
||||||
|
@using SilverOS.Welcome.Core.Preconfig
|
||||||
@inject IFlavourLoader FlavourLoader
|
@inject IFlavourLoader FlavourLoader
|
||||||
@inject IAppCatalog AppCatalog
|
@inject IAppCatalog AppCatalog
|
||||||
|
@inject IPreconfigStore PreconfigStore
|
||||||
@inject WizardState State
|
@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">
|
||||||
<div class="wizard-header">
|
<div class="wizard-header">
|
||||||
<div class="wizard-steps-indicator">
|
<div class="wizard-steps-indicator">
|
||||||
@@ -45,15 +57,12 @@
|
|||||||
<AppsStep Apps="_catalog.AppsForRole(State.Flavour?.Id ?? string.Empty)" />
|
<AppsStep Apps="_catalog.AppsForRole(State.Flavour?.Id ?? string.Empty)" />
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
<AccountStep OnValidityChanged="@(v => { _accountValid = v; StateHasChanged(); })" />
|
|
||||||
break;
|
|
||||||
case 4:
|
|
||||||
<PrefsStep />
|
<PrefsStep />
|
||||||
break;
|
break;
|
||||||
case 5:
|
case 4:
|
||||||
<ApplyStep OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" />
|
<ApplyStep OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" />
|
||||||
break;
|
break;
|
||||||
case 6:
|
case 5:
|
||||||
<DoneStep />
|
<DoneStep />
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -66,7 +75,7 @@
|
|||||||
@onclick="Back">
|
@onclick="Back">
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
@if (_currentStep < _stepTitles.Length - 1 && _currentStep != 5)
|
@if (_currentStep < _stepTitles.Length - 1 && _currentStep != 4)
|
||||||
{
|
{
|
||||||
<button class="btn-primary"
|
<button class="btn-primary"
|
||||||
disabled="@(!CanGoNext)"
|
disabled="@(!CanGoNext)"
|
||||||
@@ -76,9 +85,10 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@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.
|
// Flavours dir: baked alongside the exe at publish time.
|
||||||
private static readonly string FlavoursDir = Path.Combine(
|
private static readonly string FlavoursDir = Path.Combine(
|
||||||
@@ -93,7 +103,7 @@
|
|||||||
private int _currentStep = 0;
|
private int _currentStep = 0;
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
private bool _applyRunning = false;
|
private bool _applyRunning = false;
|
||||||
private bool _accountValid = false;
|
private bool _toolboxHome = false;
|
||||||
private string? _error;
|
private string? _error;
|
||||||
private IReadOnlyList<FlavourManifest> _flavours = Array.Empty<FlavourManifest>();
|
private IReadOnlyList<FlavourManifest> _flavours = Array.Empty<FlavourManifest>();
|
||||||
|
|
||||||
@@ -101,11 +111,15 @@
|
|||||||
{
|
{
|
||||||
1 => State.Flavour is not null,
|
1 => State.Flavour is not null,
|
||||||
// 2 = Apps step is always valid (never blocks Next).
|
// 2 = Apps step is always valid (never blocks Next).
|
||||||
3 => _accountValid,
|
|
||||||
_ => true
|
_ => true
|
||||||
};
|
};
|
||||||
|
|
||||||
protected override Task OnInitializedAsync() => LoadFlavours();
|
protected override Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
LoadFlavours();
|
||||||
|
SeedFromPreconfig();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
private Task LoadFlavours()
|
private Task LoadFlavours()
|
||||||
{
|
{
|
||||||
@@ -127,6 +141,40 @@
|
|||||||
return Task.CompletedTask;
|
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()
|
void Next()
|
||||||
{
|
{
|
||||||
if (_currentStep < _stepTitles.Length - 1)
|
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.Apps
|
||||||
|
@using SilverOS.Welcome.Core.Preconfig
|
||||||
@inject IApplyService ApplyService
|
@inject IApplyService ApplyService
|
||||||
@inject IAppCatalog AppCatalog
|
@inject IAppCatalog AppCatalog
|
||||||
|
@inject IPreconfigStore PreconfigStore
|
||||||
@inject WizardState State
|
@inject WizardState State
|
||||||
|
|
||||||
<div class="step apply-step">
|
<div class="step apply-step">
|
||||||
@@ -110,6 +112,12 @@
|
|||||||
_complete = true;
|
_complete = true;
|
||||||
_running = false;
|
_running = false;
|
||||||
_percent = 100;
|
_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();
|
StateHasChanged();
|
||||||
await OnRunningChanged.InvokeAsync(false);
|
await OnRunningChanged.InvokeAsync(false);
|
||||||
await OnComplete.InvokeAsync();
|
await OnComplete.InvokeAsync();
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ public sealed class WizardState
|
|||||||
// Apps step: ids of catalog apps the user chose to install.
|
// Apps step: ids of catalog apps the user chose to install.
|
||||||
public HashSet<string> SelectedApps { get; set; } = new();
|
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; } = "";
|
public string BitLockerPin { get; set; } = "";
|
||||||
|
|
||||||
// Prefs step
|
// Prefs step
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using SilverOS.Welcome.App.Components.Steps;
|
|||||||
using SilverOS.Welcome.Core.Apply;
|
using SilverOS.Welcome.Core.Apply;
|
||||||
using SilverOS.Welcome.Core.Apps;
|
using SilverOS.Welcome.Core.Apps;
|
||||||
using SilverOS.Welcome.Core.Flavours;
|
using SilverOS.Welcome.Core.Flavours;
|
||||||
|
using SilverOS.Welcome.Core.Preconfig;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
public class ApplyStepTests : TestContext
|
public class ApplyStepTests : TestContext
|
||||||
@@ -17,6 +18,19 @@ public class ApplyStepTests : TestContext
|
|||||||
private static void AddCatalog(IServiceCollection services) =>
|
private static void AddCatalog(IServiceCollection services) =>
|
||||||
services.AddSingleton<IAppCatalog>(new AppCatalog());
|
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]
|
[Fact]
|
||||||
public async Task Calls_apply_with_the_wizard_selections()
|
public async Task Calls_apply_with_the_wizard_selections()
|
||||||
{
|
{
|
||||||
@@ -24,14 +38,12 @@ public class ApplyStepTests : TestContext
|
|||||||
var state = new WizardState
|
var state = new WizardState
|
||||||
{
|
{
|
||||||
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } },
|
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(state);
|
||||||
Services.AddSingleton(apply.Object);
|
Services.AddSingleton(apply.Object);
|
||||||
AddCatalog(Services);
|
AddCatalog(Services);
|
||||||
|
AddPreconfig(Services);
|
||||||
var cut = RenderComponent<ApplyStep>();
|
var cut = RenderComponent<ApplyStep>();
|
||||||
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
||||||
apply.Verify(a => a.RunAsync(
|
apply.Verify(a => a.RunAsync(
|
||||||
@@ -49,11 +61,12 @@ public class ApplyStepTests : TestContext
|
|||||||
var state = new WizardState
|
var state = new WizardState
|
||||||
{
|
{
|
||||||
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } },
|
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(state);
|
||||||
Services.AddSingleton(apply.Object);
|
Services.AddSingleton(apply.Object);
|
||||||
AddCatalog(Services);
|
AddCatalog(Services);
|
||||||
|
AddPreconfig(Services);
|
||||||
var completed = false;
|
var completed = false;
|
||||||
var cut = RenderComponent<ApplyStep>(p => p.Add(s => s.OnComplete, EventCallback.Factory.Create(this, () => { completed = true; })));
|
var cut = RenderComponent<ApplyStep>(p => p.Add(s => s.OnComplete, EventCallback.Factory.Create(this, () => { completed = true; })));
|
||||||
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
||||||
@@ -69,11 +82,12 @@ public class ApplyStepTests : TestContext
|
|||||||
var state = new WizardState
|
var state = new WizardState
|
||||||
{
|
{
|
||||||
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } },
|
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(state);
|
||||||
Services.AddSingleton(apply.Object);
|
Services.AddSingleton(apply.Object);
|
||||||
AddCatalog(Services);
|
AddCatalog(Services);
|
||||||
|
AddPreconfig(Services);
|
||||||
var cut = RenderComponent<ApplyStep>();
|
var cut = RenderComponent<ApplyStep>();
|
||||||
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
||||||
Assert.Contains("Module 03 failed", cut.Markup);
|
Assert.Contains("Module 03 failed", cut.Markup);
|
||||||
|
|||||||
Reference in New Issue
Block a user