feat(welcome): wizard steps + flavour selection UI

Six wizard step components (Welcome/Flavour/Account/Prefs/Apply/Done),
Routes.razor wizard host with Next/Back navigation and IFlavourLoader
wiring, bUnit FlavourStepTests (TDD red→green), AccountStep field
validation (username/password/admin-password required; BitLocker PIN
numeric ≥6 digits). Test project upgraded to Razor SDK /
net9.0-windows10.0.19041.0 + UseMaui=true to reference the MAUI app
assembly. Non-Windows platform folders removed; demo pages removed.
All 14 tests pass (13 existing + 1 new bUnit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysadmin
2026-06-09 03:03:13 +01:00
parent 1f8ada3a45
commit 1630bde1ee
27 changed files with 345 additions and 337 deletions

View File

@@ -1,16 +0,0 @@
@page "/counter"
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}

View File

@@ -1,61 +0,0 @@
@page "/weather"
<h1>Weather</h1>
<p>This component demonstrates showing data.</p>
@if (forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private WeatherForecast[]? forecasts;
protected override async Task OnInitializedAsync()
{
// Simulate asynchronous loading to demonstrate a loading indicator
await Task.Delay(500);
var startDate = DateOnly.FromDateTime(DateTime.Now);
var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = startDate.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = summaries[Random.Shared.Next(summaries.Length)]
}).ToArray();
}
private class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public string? Summary { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
}

View File

@@ -1,6 +1,118 @@
<Router AppAssembly="@typeof(MauiProgram).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
</Router>
@using SilverOS.Welcome.App.Components.Steps
@using SilverOS.Welcome.Core.Flavours
@inject IFlavourLoader FlavourLoader
@inject WizardState State
<div class="wizard">
<div class="wizard-header">
<div class="wizard-steps-indicator">
@for (int i = 0; i < _stepTitles.Length; i++)
{
var idx = i;
<span class="wizard-step-dot @(idx == _currentStep ? "active" : idx < _currentStep ? "done" : "")">
@_stepTitles[idx]
</span>
}
</div>
</div>
<div class="wizard-body">
@if (_loading)
{
<p class="loading">Loading flavours…</p>
}
else if (_error is not null)
{
<p class="error">@_error</p>
}
else
{
@switch (_currentStep)
{
case 0:
<WelcomeStep />
break;
case 1:
<FlavourStep Flavours="_flavours" />
break;
case 2:
<AccountStep @ref="_accountStep" />
break;
case 3:
<PrefsStep />
break;
case 4:
<ApplyStep />
break;
case 5:
<DoneStep />
break;
}
}
</div>
<div class="wizard-footer">
<button class="btn-secondary"
disabled="@(_currentStep == 0)"
@onclick="Back">
Back
</button>
@if (_currentStep < _stepTitles.Length - 1 && _currentStep != 4)
{
<button class="btn-primary"
disabled="@(!CanGoNext)"
@onclick="Next">
@(_currentStep == _stepTitles.Length - 2 ? "Apply" : "Next")
</button>
}
</div>
</div>
@code {
private static readonly string[] _stepTitles = { "Welcome", "Flavour", "Account", "Prefs", "Apply", "Done" };
// Flavours dir: baked alongside the exe at publish time.
private static readonly string FlavoursDir = Path.Combine(
AppContext.BaseDirectory, "flavours");
private int _currentStep = 0;
private bool _loading = true;
private string? _error;
private IReadOnlyList<FlavourManifest> _flavours = Array.Empty<FlavourManifest>();
private AccountStep? _accountStep;
private bool CanGoNext => _currentStep switch
{
2 => _accountStep?.IsValid ?? false,
_ => true
};
protected override Task OnInitializedAsync()
{
try
{
_flavours = FlavourLoader.Load(FlavoursDir);
}
catch (Exception ex)
{
_error = $"Failed to load flavours: {ex.Message}";
}
finally
{
_loading = false;
}
return Task.CompletedTask;
}
void Next()
{
if (_currentStep < _stepTitles.Length - 1)
_currentStep++;
}
void Back()
{
if (_currentStep > 0)
_currentStep--;
}
}

View File

@@ -0,0 +1,84 @@
@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 (_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 (_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 (_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 (_errors.TryGetValue("bitlockerpin", out var be))
{
<span class="field-error">@be</span>
}
</div>
</div>
@code {
private readonly Dictionary<string, string> _errors = new();
/// <summary>True when all fields are valid. Used by the wizard host to gate Next.</summary>
public bool IsValid { get; private set; }
protected override void OnInitialized() => Validate();
private void OnUsernameInput(ChangeEventArgs e) { State.Username = e.Value?.ToString() ?? ""; Validate(); }
private void OnPasswordInput(ChangeEventArgs e) { State.Password = e.Value?.ToString() ?? ""; Validate(); }
private void OnAdminPasswordInput(ChangeEventArgs e) { State.AdminPassword = e.Value?.ToString() ?? ""; Validate(); }
private void OnPinInput(ChangeEventArgs e) { State.BitLockerPin = e.Value?.ToString() ?? ""; 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;
}
}

View File

@@ -0,0 +1,16 @@
@* Minimal placeholder — full wiring in Task 11 *@
<div class="step apply-step">
<h1>Applying Configuration</h1>
<p class="step-subtitle">Your settings will be applied now.</p>
<button class="btn-primary" @onclick="Start" disabled="@_started">Start</button>
@if (_started)
{
<p class="apply-status">Working… please wait.</p>
}
</div>
@code {
private bool _started;
void Start() => _started = true;
}

View File

@@ -0,0 +1,14 @@
@inject SilverOS.Welcome.Core.Apply.IProcessRunner ProcessRunner
<div class="step done-step">
<h1>All Done!</h1>
<p>Your SilverOS device is configured and ready. Click below to restart and start using it.</p>
<button class="btn-primary btn-restart" @onclick="RestartNow">Restart Now</button>
</div>
@code {
private async Task RestartNow()
{
await ProcessRunner.RunAsync("cmd.exe", "/c shutdown /r /t 5", CancellationToken.None);
}
}

View File

@@ -0,0 +1,31 @@
@inject WizardState State
<div class="step flavour-step">
<h1>What's this device for?</h1>
<p class="step-subtitle">Choose the flavour that best matches how this PC will be used.</p>
<div class="flavour-grid">
@foreach (var f in Flavours)
{
<div class="flavour-card @(State.Flavour?.Id == f.Id ? "selected" : "")"
data-id="@f.Id"
@onclick="() => Select(f)">
<h3>@f.Label</h3>
<p>@f.Description</p>
</div>
}
</div>
</div>
@code {
[Parameter] public IReadOnlyList<FlavourManifest> Flavours { get; set; } = Array.Empty<FlavourManifest>();
protected override void OnInitialized()
{
State.Flavour ??= Flavours.FirstOrDefault(f => f.IsDefault);
}
void Select(FlavourManifest f)
{
State.Flavour = f;
}
}

View File

@@ -0,0 +1,30 @@
@inject WizardState State
<div class="step prefs-step">
<h1>Preferences</h1>
<p class="step-subtitle">A few final settings before we apply your configuration.</p>
<div class="prefs-list">
<div class="pref-item">
<label>
<input type="checkbox" @bind="State.AutoUpdates" />
Enable automatic Windows Updates
</label>
</div>
<div class="pref-item">
<label>
<input type="checkbox" @bind="State.Telemetry" />
Send diagnostic data to Microsoft <small>(off = privacy-max)</small>
</label>
</div>
<div class="pref-item">
<label>
<input type="checkbox" @bind="State.InstallDefenderUpdates" />
Keep Microsoft Defender definitions updated
</label>
</div>
</div>
</div>
@code {
}

View File

@@ -0,0 +1,13 @@
@inject WizardState State
<div class="step welcome-step">
<div class="welcome-hero">
<h1>Welcome to SilverOS</h1>
<p class="tagline">Let's get your device set up the way you want it.</p>
<p>This wizard will guide you through a few quick steps to configure your system, create your account, and apply the right security settings for your needs.</p>
<p class="time-estimate">Takes about 5 minutes.</p>
</div>
</div>
@code {
}

View File

@@ -9,4 +9,9 @@ public sealed class WizardState
public string Password { get; set; } = "";
public string AdminPassword { get; set; } = "";
public string BitLockerPin { get; set; } = "";
// Prefs step
public bool AutoUpdates { get; set; } = true;
public bool Telemetry { get; set; } = false;
public bool InstallDefenderUpdates { get; set; } = true;
}

View File

@@ -7,3 +7,5 @@
@using Microsoft.JSInterop
@using SilverOS.Welcome.App
@using SilverOS.Welcome.App.Components
@using SilverOS.Welcome.App.Components.Steps
@using SilverOS.Welcome.Core.Flavours

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true"></application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -1,10 +0,0 @@
using Android.App;
using Android.Content.PM;
using Android.OS;
namespace SilverOS.Welcome.App;
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
}

View File

@@ -1,15 +0,0 @@
using Android.App;
using Android.Runtime;
namespace SilverOS.Welcome.App;
[Application]
public class MainApplication : MauiApplication
{
public MainApplication(IntPtr handle, JniHandleOwnership ownership)
: base(handle, ownership)
{
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#512BD4</color>
<color name="colorPrimaryDark">#2B0B98</color>
<color name="colorAccent">#2B0B98</color>
</resources>

View File

@@ -1,9 +0,0 @@
using Foundation;
namespace SilverOS.Welcome.App;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<!-- See https://aka.ms/maui-publish-app-store#add-entitlements for more information about adding entitlements.-->
<dict>
<!-- App Sandbox must be enabled to distribute a MacCatalyst app through the Mac App Store. -->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- When App Sandbox is enabled, this value is required to open outgoing network connections. -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@@ -1,38 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The Mac App Store requires you specify if the app uses encryption. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/itsappusesnonexemptencryption -->
<!-- <key>ITSAppUsesNonExemptEncryption</key> -->
<!-- Please indicate <true/> or <false/> here. -->
<!-- Specify the category for your app here. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/lsapplicationcategorytype -->
<!-- <key>LSApplicationCategoryType</key> -->
<!-- <string>public.app-category.YOUR-CATEGORY-HERE</string> -->
<key>UIDeviceFamily</key>
<array>
<integer>2</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
</dict>
</plist>

View File

@@ -1,15 +0,0 @@
using ObjCRuntime;
using UIKit;
namespace SilverOS.Welcome.App;
public class Program
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}

View File

@@ -1,16 +0,0 @@
using System;
using Microsoft.Maui;
using Microsoft.Maui.Hosting;
namespace SilverOS.Welcome.App;
class Program : MauiApplication
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
static void Main(string[] args)
{
var app = new Program();
app.Run(args);
}
}

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="maui-application-id-placeholder" version="0.0.0" api-version="9" xmlns="http://tizen.org/ns/packages">
<profile name="common" />
<ui-application appid="maui-application-id-placeholder" exec="SilverOS.Welcome.App.dll" multiple="false" nodisplay="false" taskmanage="true" type="dotnet" launch_mode="single">
<label>maui-application-title-placeholder</label>
<icon>maui-appicon-placeholder</icon>
<metadata key="http://tizen.org/metadata/prefer_dotnet_aot" value="true" />
</ui-application>
<shortcut-list />
<privileges>
<privilege>http://tizen.org/privilege/internet</privilege>
</privileges>
<dependencies />
<provides-appdefined-privileges />
</manifest>

View File

@@ -1,9 +0,0 @@
using Foundation;
namespace SilverOS.Welcome.App;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@@ -1,32 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
</dict>
</plist>

View File

@@ -1,15 +0,0 @@
using ObjCRuntime;
using UIKit;
namespace SilverOS.Welcome.App;
public class Program
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}

View File

@@ -1,51 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
This is the minimum required version of the Apple Privacy Manifest for .NET MAUI apps.
The contents below are needed because of APIs that are used in the .NET framework and .NET MAUI SDK.
You are responsible for adding extra entries as needed for your application.
More information: https://aka.ms/maui-privacy-manifest
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
</array>
</dict>
<!--
The entry below is only needed when you're using the Preferences API in your app.
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict> -->
</array>
</dict>
</plist>

View File

@@ -0,0 +1,23 @@
using Bunit;
using Microsoft.Extensions.DependencyInjection;
using SilverOS.Welcome.App.Components.Steps;
using SilverOS.Welcome.App.Components;
using SilverOS.Welcome.Core.Flavours;
using Xunit;
public class FlavourStepTests : TestContext
{
[Fact]
public void Renders_one_card_per_flavour_and_preselects_default()
{
var flavours = new[]
{
new FlavourManifest { Id="daily-driver", Label="Daily-Driver", IsDefault=true, Hardening=new(){Modules=new[]{"00"}} },
new FlavourManifest { Id="privacy-max", Label="Privacy-Max", Hardening=new(){Modules=new[]{"00"}} },
};
Services.AddSingleton(new WizardState());
var cut = RenderComponent<FlavourStep>(p => p.Add(s => s.Flavours, flavours));
Assert.Equal(2, cut.FindAll(".flavour-card").Count);
Assert.Contains("selected", cut.Find(".flavour-card[data-id=daily-driver]").ClassList);
}
}

View File

@@ -1,13 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net9.0-windows</TargetFramework>
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<UseMaui>true</UseMaui>
<!-- Suppress MAUI OutputType so the test runner can pick up the dll -->
<OutputType>Library</OutputType>
<!-- Suppress MAUI implicit-package warnings; we only need the SDK for TFM compatibility -->
<SkipValidateMauiImplicitPackageReferences>true</SkipValidateMauiImplicitPackageReferences>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="bunit" Version="1.37.7" />
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Moq" Version="4.20.72" />
@@ -20,7 +26,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SilverOS.Welcome.Core\SilverOS.Welcome.Core.csproj" />
<ProjectReference Include="..\..\src\SilverOS.Welcome.App\SilverOS.Welcome.App.csproj" />
</ItemGroup>
</Project>