Merge pull request 'feat(welcome): SilverOS Welcome first-logon wizard (flavour engine + apply orchestrator + MAUI UI + image bake)' (#4) from feat/welcome-app into main
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 4m40s

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-06-09 10:31:34 +00:00
85 changed files with 3138 additions and 16 deletions

View File

@@ -58,6 +58,24 @@ jobs:
}
if (-not (Test-Path $deploy)) { throw 'ADK Deployment Tools install failed.' }
- name: Setup .NET 9 SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Install MAUI workload
shell: pwsh
run: |
Write-Host "dotnet $(dotnet --version)"
Write-Host 'Installing/repairing MAUI workload (idempotent)...'
dotnet workload install maui
Write-Host 'MAUI workload ready.'
- name: Build + test SilverOS Welcome
shell: pwsh
run: |
dotnet test windows/welcome/SilverOS.Welcome.sln -c Release
- name: Acquire base ISO
id: iso
shell: pwsh

View File

@@ -0,0 +1,9 @@
{
"id": "daily-driver",
"label": "Daily-Driver",
"description": "Balanced privacy and usability. Full hardened baseline; app control in audit.",
"isDefault": true,
"hardening": { "modules": ["00","01","02","03","04","05","06","07"], "params": { "wdac": "audit" } },
"appSet": ["SilverBrowser","SilverVPN","SilverKeys"],
"settings": { "autoLock": 120 }
}

View File

@@ -0,0 +1,9 @@
{
"id": "developer",
"label": "Developer",
"description": "Hardened baseline with developer tooling allowances.",
"isDefault": false,
"hardening": { "modules": ["00","01","02","03","04","05","06","07"], "params": { "wdac": "audit" } },
"appSet": ["SilverBrowser","SilverVPN","SilverKeys"],
"settings": { "autoLock": 300 }
}

View File

@@ -0,0 +1,9 @@
{
"id": "journalist",
"label": "Journalist",
"description": "Privacy-first with duress + secure comms emphasis.",
"isDefault": false,
"hardening": { "modules": ["00","01","02","03","04","05","06","07"], "params": { "wdac": "enforce" } },
"appSet": ["SilverBrowser","SilverVPN","SilverChat","SilverDuress","SilverKeys","SilverSync"],
"settings": { "autoLock": 60 }
}

View File

@@ -0,0 +1,9 @@
{
"id": "privacy-max",
"label": "Privacy-Max",
"description": "Maximum lockdown. App control enforced, tightest toggles.",
"isDefault": false,
"hardening": { "modules": ["00","01","02","03","04","05","06","07"], "params": { "wdac": "enforce" } },
"appSet": ["SilverBrowser","SilverVPN","SilverKeys","SilverDuress","SilverChat"],
"settings": { "autoLock": 60 }
}

View File

@@ -1,17 +1,21 @@
#Requires -Version 5.1
<# SilverMetal Enhanced - Windows | First-boot hardening runner.
Runs the §A-H modules (00*.ps1 .. 08*.ps1) in order, then the Verify gate.
Called by SetupComplete.cmd via -File (no cmd-quoting fragility). Logs to the
pipeline that SetupComplete redirects.
#>
[CmdletBinding()] param()
<# Runs the §A-H modules (optionally a subset) then Verify.
-Modules "00","03","05" -> run only those numeric-prefixed modules (default: all 0*).
-ParamsJson '{"wdac":"audit"}' -> exported as $env:SM_PARAMS for modules to read. #>
[CmdletBinding()] param([string]$Modules, [string]$ParamsJson)
$ErrorActionPreference = 'Continue'
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
if ($ParamsJson) { $env:SM_PARAMS = $ParamsJson }
# Accept Modules as a single CSV token (e.g. "00,03,05") so that ProcessStartInfo
# -File binding delivers it reliably as one string regardless of quoting.
$ModuleList = if ($Modules) { $Modules -split ',' } else { @() }
Write-Host "=== SilverMetal hardening modules ==="
Get-ChildItem (Join-Path $here '0*.ps1') | Sort-Object Name | ForEach-Object {
Write-Host "--> $($_.Name)"
try { & $_.FullName } catch { Write-Warning "$($_.Name) FAILED: $_" }
$all = Get-ChildItem (Join-Path $here '0*.ps1') | Sort-Object Name
if ($ModuleList.Count -gt 0) { $all = $all | Where-Object { $ModuleList -contains $_.Name.Substring(0,2) } }
foreach ($f in $all) {
Write-Host "--> $($f.Name)"
try { & $f.FullName } catch { Write-Warning "$($f.Name) FAILED: $_" }
}
Write-Host "=== Verify (effects needing reboot/PIN will show pending) ==="
Write-Host "=== Verify ==="
try { & (Join-Path $here 'Verify-SilverMetalWindows.ps1') } catch { Write-Warning "Verify error: $_" }
Write-Host "=== SilverMetal hardening runner done ==="
Write-Host "=== runner done ==="

View File

@@ -84,14 +84,48 @@
</OOBE>
<UserAccounts>
<LocalAccounts>
<!--
sm-bootstrap: ephemeral one-time admin account used ONLY for the
SilverOS Welcome onboarding wizard. The Welcome app's ApplyService
tears this account down on success (removes AutoAdminLogon registry
keys, deletes the account, and creates the real end-user account
instead). Never ship this password as-is for end-users; the
production pipeline MUST inject a per-device credential.
-->
<LocalAccount wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<Name>silvermetal</Name>
<Name>sm-bootstrap</Name>
<Group>Administrators</Group>
<DisplayName>SilverMetal</DisplayName>
<Password><Value>open sesame</Value><PlainText>true</PlainText></Password>
<DisplayName>SilverOS Bootstrap</DisplayName>
<Password><Value>bootstrap-OneTime!</Value><PlainText>true</PlainText></Password>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<!--
AutoLogon: logs in as sm-bootstrap exactly once so that FirstLogonCommands
can launch the Welcome wizard. After the wizard completes successfully,
ApplyService removes the AutoAdminLogon registry values and deletes
sm-bootstrap, so the one-time session cannot be re-entered.
-->
<AutoLogon>
<Enabled>true</Enabled>
<LogonCount>1</LogonCount>
<Username>sm-bootstrap</Username>
<Password><Value>bootstrap-OneTime!</Value><PlainText>true</PlainText></Password>
</AutoLogon>
<!--
FirstLogonCommands: launch the Welcome wizard ELEVATED (full admin token).
The offline UAC auto-approve policy baked into the image (ConsentPromptBehaviorAdmin=0,
PromptOnSecureDesktop=0) means Start-Process -Verb RunAs silently elevates without
a UAC prompt during this ephemeral sm-bootstrap session. The sm-bootstrap account
is torn down by ApplyService on wizard completion.
-->
<FirstLogonCommands>
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<Order>1</Order>
<CommandLine>cmd /c powershell -NoProfile -ExecutionPolicy Bypass -Command &quot;Start-Process -FilePath 'C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe' -Verb RunAs&quot;</CommandLine>
<Description>Launch SilverOS Welcome elevated</Description>
</SynchronousCommand>
</FirstLogonCommands>
<RegisteredOwner>SilverMetal</RegisteredOwner>
<RegisteredOrganization>SilverLABS</RegisteredOrganization>
<!--

View File

@@ -117,6 +117,56 @@ function Invoke-ForceLegacySetup {
}
}
# --- 3b. Publish Welcome app (runs before WIM mount; no mount needed) --------
function Invoke-PublishWelcome {
if ($env:SILVERMETAL_WELCOME_ENABLED -eq '0') {
Write-Host ' SILVERMETAL_WELCOME_ENABLED=0 -- skipping Welcome app publish.' -ForegroundColor Yellow
return
}
Write-Stage 'Stage 3b: publish SilverOS Welcome app (win-x64 self-contained)'
$proj = Join-Path $WindowsDir 'welcome\src\SilverOS.Welcome.App'
$out = Join-Path $WorkDir 'welcome-publish'
& dotnet publish $proj -c Release -f net9.0-windows10.0.19041.0 -r win-x64 --self-contained true -o $out
if ($LASTEXITCODE -ne 0) { throw 'Welcome app dotnet publish failed' }
Write-Host " Published to: $out"
}
# --- 3c. Copy Welcome payload into mounted WIM (called inside Invoke-ServiceWim) ---
function Copy-WelcomePayload {
if ($env:SILVERMETAL_WELCOME_ENABLED -eq '0') {
Write-Host ' SILVERMETAL_WELCOME_ENABLED=0 -- skipping Welcome payload copy.' -ForegroundColor Yellow
return
}
Write-Stage 'Stage 3c: stage SilverOS Welcome app + flavours into mounted image'
$out = Join-Path $WorkDir 'welcome-publish'
if (-not (Test-Path $out)) { throw "Welcome publish output not found at '$out' -- did Invoke-PublishWelcome succeed?" }
# Destination: C:\Program Files\SilverOS\Welcome (+ flavours subdir)
$dest = Join-Path $mount 'Program Files\SilverOS\Welcome'
$destFlavours = Join-Path $dest 'flavours'
$null = New-Item -ItemType Directory -Force $dest, $destFlavours
# Copy published app (SilverOS.Welcome.App.exe + all runtime deps)
Copy-Item "$out\*" $dest -Recurse -Force
# Copy flavour definitions (*.json)
$flavoursDir = Join-Path $WindowsDir 'flavours'
$flavourFiles = Get-ChildItem $flavoursDir -Filter '*.json' -EA SilentlyContinue
if ($flavourFiles) {
Copy-Item $flavourFiles.FullName $destFlavours -Force
Write-Host " Copied $($flavourFiles.Count) flavour(s) to $destFlavours"
} else {
Write-Warning " No *.json flavour files found in $flavoursDir -- image will ship with no flavours."
}
# --- Guard: verify the payload actually landed in the mounted image -------
$stagedExe = Join-Path $dest 'SilverOS.Welcome.App.exe'
if (-not (Test-Path $stagedExe)) {
throw "Welcome bake failed: SilverOS.Welcome.App.exe missing from image (expected at '$stagedExe'). Check that dotnet publish produced the exe and Copy-Item succeeded."
}
$stagedFlavours = Get-ChildItem $destFlavours -Filter '*.json' -EA SilentlyContinue
if (-not $stagedFlavours) {
throw "Welcome bake failed: no flavour manifests staged in '$destFlavours'. Add *.json files under windows/flavours/ or the installed wizard will have no flavour choices."
}
Write-Host " Welcome payload staged at $dest"
}
# --- 3. Service the WIM offline (DISM) -------------------------------------
function Invoke-ServiceWim {
Write-Stage 'Stage 3: offline-service install.wim'
@@ -159,6 +209,29 @@ function Invoke-ServiceWim {
$null = New-Item -ItemType Directory -Force $scripts, (Join-Path $scripts 'hardening')
Copy-Item (Join-Path $PSScriptRoot 'oem\SetupComplete.cmd') $scripts -Force
Copy-Item (Join-Path $WindowsDir 'hardening\*') (Join-Path $scripts 'hardening') -Recurse -Force
# Stage Welcome app + flavours while the WIM is still mounted.
Copy-WelcomePayload
# Bake offline UAC auto-approve policy so the Welcome wizard (launched via
# Start-Process -Verb RunAs in FirstLogonCommands) silently elevates during
# the ephemeral sm-bootstrap session without a UAC prompt.
# UAC stays enabled (EnableLUA=1); the wizard's hardening re-tightens the
# policy for the daily user. Only applies when Welcome is enabled.
if ($env:SILVERMETAL_WELCOME_ENABLED -ne '0') {
Write-Stage 'Stage 3d: bake offline UAC auto-approve policy (silent elevation for sm-bootstrap)'
$hive = Join-Path $mount 'Windows\System32\config\SOFTWARE'
& reg load HKLM\SM_OFFLINE "$hive" | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'reg load SOFTWARE hive failed' }
try {
& reg add 'HKLM\SM_OFFLINE\Microsoft\Windows\CurrentVersion\Policies\System' /v ConsentPromptBehaviorAdmin /t REG_DWORD /d 0 /f | Out-Null
& reg add 'HKLM\SM_OFFLINE\Microsoft\Windows\CurrentVersion\Policies\System' /v PromptOnSecureDesktop /t REG_DWORD /d 0 /f | Out-Null
Write-Host ' ConsentPromptBehaviorAdmin=0, PromptOnSecureDesktop=0 written to offline SOFTWARE hive.'
} finally {
[gc]::Collect(); Start-Sleep -Milliseconds 500
& reg unload HKLM\SM_OFFLINE | Out-Null
}
}
} finally {
Dismount-WindowsImage -Path $mount -Save | Out-Null
}
@@ -217,7 +290,8 @@ function Invoke-Attest {
Invoke-VerifyInput
Invoke-Extract
Invoke-ForceLegacySetup
Invoke-ServiceWim
Invoke-PublishWelcome # publish Welcome app before mount (no WIM needed)
Invoke-ServiceWim # mounts WIM, stages hardening + Welcome payload, dismounts
Invoke-InjectUnattend
Invoke-Brand
Invoke-Repack

View File

@@ -14,7 +14,11 @@ set HARD=C:\Windows\Setup\Scripts\hardening
echo [%DATE% %TIME%] SilverMetal first-boot start >> "%LOG%"
powershell -NoProfile -ExecutionPolicy Bypass -File "%HARD%\Invoke-Hardening.ps1" >> "%LOG%" 2>&1
if exist "C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe" (
echo [%DATE% %TIME%] hardening deferred to SilverOS Welcome >> "%LOG%"
) else (
powershell -NoProfile -ExecutionPolicy Bypass -File "%HARD%\Invoke-Hardening.ps1" >> "%LOG%" 2>&1
)
echo [%DATE% %TIME%] SilverMetal first-boot done >> "%LOG%"
exit /b 0

View File

@@ -36,6 +36,14 @@ try {
$mods = Get-ChildItem (Join-Path $mount 'Windows\Setup\Scripts\hardening') -Filter *.ps1 -EA SilentlyContinue
Assert 'hardening modules baked (>=9 .ps1)' ($mods.Count -ge 9)
Assert 'Verify script baked' (Test-Path (Join-Path $mount 'Windows\Setup\Scripts\hardening\Verify-SilverMetalWindows.ps1'))
# Welcome app payload assertions (skipped when Welcome is intentionally disabled).
if ($env:SILVERMETAL_WELCOME_ENABLED -ne '0') {
$welcomeExe = Join-Path $mount 'Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe'
Assert 'Welcome exe baked into WIM' (Test-Path $welcomeExe)
$welcomeFlavours = Get-ChildItem (Join-Path $mount 'Program Files\SilverOS\Welcome\flavours') -Filter '*.json' -EA SilentlyContinue
Assert 'Welcome flavours baked (>=1 .json)' ($welcomeFlavours.Count -ge 1)
}
} finally { Dismount-WindowsImage -Path $mount -Discard | Out-Null }
}
} finally { Dismount-DiskImage -ImagePath $IsoPath | Out-Null }

View File

@@ -109,3 +109,19 @@ A flavour is a JSON file in `windows/flavours/` baked into the image:
- **Kiosk lock-down of the bootstrap session** — ensure the user can't escape the wizard to a privileged shell before the real account exists.
- **Apply failure handling** — if a module fails mid-apply, the wizard must surface it and leave the device in a recoverable state (don't delete bootstrap until apply + account creation succeed).
- **Admin-credential UX** — confirm the daily/admin two-password step is clear (vs. generating + showing the admin secret once); ensure password strength rules on both.
## 10. VM end-to-end validation — VALIDATED 2026-06-09
Built the packed ISO via CI (`build-iso-windows.yaml`, all green: 22 unit/bUnit tests + ISO bake + offline payload assertions) and ran it on SLAB01 VM 102 (UEFI + SecureBoot pre-enrolled keys + vTPM 2.0). **Verified end-to-end:**
- Boots UEFI/SecureBoot; forced-legacy hands-off install onto a blank disk; OOBE auto-completes.
- `sm-bootstrap` auto-logs in; the Welcome app **auto-launches elevated** and renders the wizard (Mercury theme, bundled fonts).
- All four flavours load from the baked `flavours/*.json`; **Daily-Driver** pre-selected.
- Drove the full wizard: Welcome → Flavour → Account (created `alice` + admin password + BitLocker PIN) → Prefs → Apply → "All Done!".
- Post-apply, offline + on-login assertions: **`alice` created** (Standard daily user) and **`SilverOS Admin` created** (separate elevation account) both appear on the login screen; **`sm-bootstrap` removed**; **no auto-login** (AutoAdminLogon disabled → teardown ran); **BitLocker enabled** (volume encrypted, `-FVE-FS-`). Account creation + bootstrap teardown require elevation, confirming the app runs elevated.
**Three deployment bugs found + fixed during validation** (only a real run surfaced them): (1) WebView2 user-data folder defaulted to non-writable `Program Files` → redirected to LocalAppData; (2) FirstLogonCommands run un-elevated → bake `ConsentPromptBehaviorAdmin=0` offline + launch via `Start-Process -Verb RunAs`; (3) AccountStep `Next` never enabled because the wizard host didn't re-render on child validity change → added an `OnValidityChanged` callback. Also a separate CI fix: bUnit tests must reference a Razor Class Library, not the MAUI app assembly (else `WindowsAppRuntime` `DllNotFound` on the clean runner).
**Follow-ups (not v1-pipeline blockers):**
- **BitLocker PIN not enforced** — the device booted with no pre-boot PIN prompt (TPM-only auto-unlock). The intended TPM+PIN protector did not take effect, most likely because the FVE "require startup PIN" policy (`UseAdvancedStartup`/`UseTPMPIN`) wasn't set before `Enable-BitLocker`. The user's PIN currently provides no boot protection — fix before shipping.
- **Apply services don't check PowerShell exit codes** — `AccountService`/`BitLockerService`/`BootstrapService` run `powershell.exe` but ignore `ProcessResult.ExitCode`, so silent failures (like the BitLocker PIN degradation) go unsurfaced. They should check exit codes and fail/log loudly.

View File

@@ -0,0 +1,84 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SilverOS.Welcome.Core", "src\SilverOS.Welcome.Core\SilverOS.Welcome.Core.csproj", "{939DF856-EB90-473B-9C46-D8504B94A81B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SilverOS.Welcome.Tests", "tests\SilverOS.Welcome.Tests\SilverOS.Welcome.Tests.csproj", "{73B13415-D01D-4409-B85F-62C8A4A8C95D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SilverOS.Welcome.App", "src\SilverOS.Welcome.App\SilverOS.Welcome.App.csproj", "{E62F6F39-C734-436E-9193-78D313205A02}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SilverOS.Welcome.UI", "src\SilverOS.Welcome.UI\SilverOS.Welcome.UI.csproj", "{BCC7EDC1-3170-4273-820F-6A1204D0BCAB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{939DF856-EB90-473B-9C46-D8504B94A81B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{939DF856-EB90-473B-9C46-D8504B94A81B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{939DF856-EB90-473B-9C46-D8504B94A81B}.Debug|x64.ActiveCfg = Debug|Any CPU
{939DF856-EB90-473B-9C46-D8504B94A81B}.Debug|x64.Build.0 = Debug|Any CPU
{939DF856-EB90-473B-9C46-D8504B94A81B}.Debug|x86.ActiveCfg = Debug|Any CPU
{939DF856-EB90-473B-9C46-D8504B94A81B}.Debug|x86.Build.0 = Debug|Any CPU
{939DF856-EB90-473B-9C46-D8504B94A81B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{939DF856-EB90-473B-9C46-D8504B94A81B}.Release|Any CPU.Build.0 = Release|Any CPU
{939DF856-EB90-473B-9C46-D8504B94A81B}.Release|x64.ActiveCfg = Release|Any CPU
{939DF856-EB90-473B-9C46-D8504B94A81B}.Release|x64.Build.0 = Release|Any CPU
{939DF856-EB90-473B-9C46-D8504B94A81B}.Release|x86.ActiveCfg = Release|Any CPU
{939DF856-EB90-473B-9C46-D8504B94A81B}.Release|x86.Build.0 = Release|Any CPU
{73B13415-D01D-4409-B85F-62C8A4A8C95D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{73B13415-D01D-4409-B85F-62C8A4A8C95D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{73B13415-D01D-4409-B85F-62C8A4A8C95D}.Debug|x64.ActiveCfg = Debug|Any CPU
{73B13415-D01D-4409-B85F-62C8A4A8C95D}.Debug|x64.Build.0 = Debug|Any CPU
{73B13415-D01D-4409-B85F-62C8A4A8C95D}.Debug|x86.ActiveCfg = Debug|Any CPU
{73B13415-D01D-4409-B85F-62C8A4A8C95D}.Debug|x86.Build.0 = Debug|Any CPU
{73B13415-D01D-4409-B85F-62C8A4A8C95D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{73B13415-D01D-4409-B85F-62C8A4A8C95D}.Release|Any CPU.Build.0 = Release|Any CPU
{73B13415-D01D-4409-B85F-62C8A4A8C95D}.Release|x64.ActiveCfg = Release|Any CPU
{73B13415-D01D-4409-B85F-62C8A4A8C95D}.Release|x64.Build.0 = Release|Any CPU
{73B13415-D01D-4409-B85F-62C8A4A8C95D}.Release|x86.ActiveCfg = Release|Any CPU
{73B13415-D01D-4409-B85F-62C8A4A8C95D}.Release|x86.Build.0 = Release|Any CPU
{E62F6F39-C734-436E-9193-78D313205A02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E62F6F39-C734-436E-9193-78D313205A02}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E62F6F39-C734-436E-9193-78D313205A02}.Debug|x64.ActiveCfg = Debug|Any CPU
{E62F6F39-C734-436E-9193-78D313205A02}.Debug|x64.Build.0 = Debug|Any CPU
{E62F6F39-C734-436E-9193-78D313205A02}.Debug|x86.ActiveCfg = Debug|Any CPU
{E62F6F39-C734-436E-9193-78D313205A02}.Debug|x86.Build.0 = Debug|Any CPU
{E62F6F39-C734-436E-9193-78D313205A02}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E62F6F39-C734-436E-9193-78D313205A02}.Release|Any CPU.Build.0 = Release|Any CPU
{E62F6F39-C734-436E-9193-78D313205A02}.Release|x64.ActiveCfg = Release|Any CPU
{E62F6F39-C734-436E-9193-78D313205A02}.Release|x64.Build.0 = Release|Any CPU
{E62F6F39-C734-436E-9193-78D313205A02}.Release|x86.ActiveCfg = Release|Any CPU
{E62F6F39-C734-436E-9193-78D313205A02}.Release|x86.Build.0 = Release|Any CPU
{BCC7EDC1-3170-4273-820F-6A1204D0BCAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BCC7EDC1-3170-4273-820F-6A1204D0BCAB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BCC7EDC1-3170-4273-820F-6A1204D0BCAB}.Debug|x64.ActiveCfg = Debug|Any CPU
{BCC7EDC1-3170-4273-820F-6A1204D0BCAB}.Debug|x64.Build.0 = Debug|Any CPU
{BCC7EDC1-3170-4273-820F-6A1204D0BCAB}.Debug|x86.ActiveCfg = Debug|Any CPU
{BCC7EDC1-3170-4273-820F-6A1204D0BCAB}.Debug|x86.Build.0 = Debug|Any CPU
{BCC7EDC1-3170-4273-820F-6A1204D0BCAB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BCC7EDC1-3170-4273-820F-6A1204D0BCAB}.Release|Any CPU.Build.0 = Release|Any CPU
{BCC7EDC1-3170-4273-820F-6A1204D0BCAB}.Release|x64.ActiveCfg = Release|Any CPU
{BCC7EDC1-3170-4273-820F-6A1204D0BCAB}.Release|x64.Build.0 = Release|Any CPU
{BCC7EDC1-3170-4273-820F-6A1204D0BCAB}.Release|x86.ActiveCfg = Release|Any CPU
{BCC7EDC1-3170-4273-820F-6A1204D0BCAB}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{939DF856-EB90-473B-9C46-D8504B94A81B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{73B13415-D01D-4409-B85F-62C8A4A8C95D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{E62F6F39-C734-436E-9193-78D313205A02} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{BCC7EDC1-3170-4273-820F-6A1204D0BCAB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:SilverOS.Welcome.App"
x:Class="SilverOS.Welcome.App.App">
<Application.Resources>
<ResourceDictionary>
<!--
For information about styling .NET MAUI pages
please refer to the documentation:
https://go.microsoft.com/fwlink/?linkid=2282329
-->
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,14 @@
namespace SilverOS.Welcome.App;
public partial class App : Application
{
public App()
{
InitializeComponent();
}
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new MainPage()) { Title = "SilverOS.Welcome.App" };
}
}

View File

@@ -0,0 +1,17 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>

View File

@@ -0,0 +1,77 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}

View File

@@ -0,0 +1,27 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">SilverOS.Welcome.App</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="counter">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Counter
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="weather">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather
</NavLink>
</div>
</nav>
</div>

View File

@@ -0,0 +1,101 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep a {
color: #d7d7d7;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep a:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

View File

@@ -0,0 +1,5 @@
@page "/"
<h1>Hello, world!</h1>
Welcome to your new app.

View File

@@ -0,0 +1,12 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using SilverOS.Welcome.App
@using SilverOS.Welcome.App.Components
@using SilverOS.Welcome.App.Components.Steps
@using SilverOS.Welcome.Core.Flavours
@using SilverOS.Welcome.Core.Apply

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:SilverOS.Welcome.App"
xmlns:components="clr-namespace:SilverOS.Welcome.App.Components;assembly=SilverOS.Welcome.UI"
x:Class="SilverOS.Welcome.App.MainPage">
<BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html">
<BlazorWebView.RootComponents>
<RootComponent Selector="#app" ComponentType="{x:Type components:Routes}" />
</BlazorWebView.RootComponents>
</BlazorWebView>
</ContentPage>

View File

@@ -0,0 +1,9 @@
namespace SilverOS.Welcome.App;
public partial class MainPage : ContentPage
{
public MainPage()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,50 @@
using Microsoft.Extensions.Logging;
using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Flavours;
using SilverOS.Welcome.App.Components;
namespace SilverOS.Welcome.App;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
// Redirect WebView2 user-data folder off Program Files (not writable at runtime)
// to a per-user writable path so the embedded browser can always create its data dir.
var wv2 = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"SilverOS", "Welcome", "WebView2");
Directory.CreateDirectory(wv2);
Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", wv2);
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
builder.Services.AddMauiBlazorWebView();
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
builder.Logging.AddDebug();
#endif
var hardeningDir = @"C:\Windows\Setup\Scripts\hardening";
builder.Services.AddSingleton<IProcessRunner, ProcessRunner>();
builder.Services.AddSingleton<IFlavourLoader, FlavourLoader>();
builder.Services.AddSingleton<IAccountService, AccountService>();
builder.Services.AddSingleton<IBitLockerService, BitLockerService>();
builder.Services.AddSingleton<IBootstrapService, BootstrapService>();
builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
sp.GetRequiredService<IProcessRunner>(),
sp.GetRequiredService<IAccountService>(),
sp.GetRequiredService<IBitLockerService>(),
sp.GetRequiredService<IBootstrapService>(),
hardeningDir));
builder.Services.AddScoped<WizardState>();
return builder.Build();
}
}

View File

@@ -0,0 +1,8 @@
<maui:MauiWinUIApplication
x:Class="SilverOS.Welcome.App.WinUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:maui="using:Microsoft.Maui"
xmlns:local="using:SilverOS.Welcome.App.WinUI">
</maui:MauiWinUIApplication>

View File

@@ -0,0 +1,24 @@
using Microsoft.UI.Xaml;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace SilverOS.Welcome.App.WinUI;
/// <summary>
/// Provides application-specific behavior to supplement the default Application class.
/// </summary>
public partial class App : MauiWinUIApplication
{
/// <summary>
/// Initializes the singleton application object. This is the first line of authored code
/// executed, and as such is the logical equivalent of main() or WinMain().
/// </summary>
public App()
{
this.InitializeComponent();
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity Name="maui-package-name-placeholder" Publisher="CN=User Name" Version="0.0.0.0" />
<mp:PhoneIdentity PhoneProductId="AEFDABFE-79AB-4ADF-96C2-47C4599150E3" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
<Properties>
<DisplayName>$placeholder$</DisplayName>
<PublisherDisplayName>User Name</PublisherDisplayName>
<Logo>$placeholder$.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate" />
</Resources>
<Applications>
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="$placeholder$"
Description="$placeholder$"
Square150x150Logo="$placeholder$.png"
Square44x44Logo="$placeholder$.png"
BackgroundColor="transparent">
<uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
<uap:SplashScreen Image="$placeholder$.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="SilverOS.Welcome.App.WinUI.app"/>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- The combination of below two tags have the following effect:
1) Per-Monitor for >= Windows 10 Anniversary Update
2) System < Windows 10 Anniversary Update
-->
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
</windowsSettings>
</application>
</assembly>

View File

@@ -0,0 +1,8 @@
{
"profiles": {
"Windows Machine": {
"commandName": "Project",
"nativeDebugging": false
}
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="456" height="456" fill="#512BD4" />
</svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,93 @@
<svg width="419" height="519" viewBox="0 0 419 519" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M284.432 247.568L284.004 221.881C316.359 221.335 340.356 211.735 355.308 193.336C382.408 159.996 372.893 108.183 372.786 107.659L398.013 102.831C398.505 105.432 409.797 167.017 375.237 209.53C355.276 234.093 324.719 246.894 284.432 247.568Z" fill="#8A6FE8"/>
<path d="M331.954 109.36L361.826 134.245C367.145 138.676 375.055 137.959 379.497 132.639C383.928 127.32 383.211 119.41 377.891 114.969L348.019 90.0842C342.7 85.6531 334.79 86.3702 330.348 91.6896C325.917 97.0197 326.634 104.929 331.954 109.36Z" fill="#8A6FE8"/>
<path d="M407.175 118.062L417.92 94.2263C420.735 87.858 417.856 80.4087 411.488 77.5831C405.12 74.7682 397.67 77.6473 394.845 84.0156L383.831 108.461L407.175 118.062Z" fill="#8A6FE8"/>
<path d="M401.363 105.175L401.234 69.117C401.181 62.1493 395.498 56.541 388.53 56.5945C381.562 56.648 375.954 62.3313 376.007 69.2989L376.018 96.11L401.363 105.175Z" fill="#8A6FE8"/>
<path d="M386.453 109.071L378.137 73.9548C376.543 67.169 369.757 62.9628 362.971 64.5575C356.185 66.1523 351.979 72.938 353.574 79.7237L362.04 115.482L386.453 109.071Z" fill="#8A6FE8"/>
<path d="M381.776 142.261C396.359 142.261 408.181 130.44 408.181 115.857C408.181 101.274 396.359 89.4527 381.776 89.4527C367.194 89.4527 355.372 101.274 355.372 115.857C355.372 130.44 367.194 142.261 381.776 142.261Z" fill="url(#paint0_radial)"/>
<path d="M248.267 406.979C248.513 384.727 245.345 339.561 222.376 301.736L199.922 315.372C220.76 349.675 222.323 389.715 221.841 407.182C221.798 408.627 235.263 409.933 248.267 406.979Z" fill="url(#paint1_linear)"/>
<path d="M221.841 406.936L242.637 406.84L262.052 518.065L220.311 518.258C217.132 518.269 214.724 515.711 214.938 512.532L221.841 406.936Z" fill="#522CD5"/>
<path d="M306.566 488.814C310.173 491.661 310.109 495.782 309.831 500.127L308.964 513.452C308.803 515.839 306.727 517.798 304.34 517.809L260.832 518.012C258.125 518.023 256.08 515.839 256.262 513.142L256.551 499.335C256.883 494.315 255.192 492.474 251.307 487.744C244.649 479.663 224.967 435.62 226.84 406.925L248.256 406.829C249.691 423.858 272.167 461.682 306.566 488.814Z" fill="url(#paint2_linear)"/>
<path d="M309.82 500.127C310.023 497.088 310.077 494.176 308.889 491.715L254.635 491.961C256.134 494.166 256.765 496.092 256.562 499.314L256.273 513.121C256.091 515.828 258.146 518.012 260.843 517.99L304.34 517.798C306.727 517.787 308.803 515.828 308.964 513.442L309.82 500.127Z" fill="url(#paint3_radial)"/>
<path d="M133.552 407.471C133.103 385.22 135.864 340.021 158.49 301.993L181.073 315.425C160.545 349.921 159.346 389.972 159.989 407.428C160.042 408.884 146.578 410.318 133.552 407.471Z" fill="url(#paint4_linear)"/>
<path d="M110.798 497.152C110.765 494.187 111.204 491.575 112.457 487.23C131.882 434.132 133.52 407.364 133.52 407.364L159.999 407.246C159.999 407.246 161.819 433.512 181.716 486.427C183.289 490.195 183.471 493.641 183.674 496.831L183.792 513.816C183.803 516.374 181.716 518.483 179.158 518.494L177.873 518.504L116.781 518.782L115.496 518.793C112.927 518.804 110.83 516.728 110.819 514.159L110.798 497.152Z" fill="url(#paint5_linear)"/>
<path d="M110.798 497.152C110.798 496.67 110.808 496.199 110.83 495.739C110.969 494.262 111.643 492.603 114.875 492.582L180.207 492.282C182.561 492.367 183.343 494.176 183.589 495.311C183.621 495.814 183.664 496.328 183.696 496.82L183.813 513.806C183.824 515.411 183.011 516.824 181.769 517.669C181.031 518.172 180.132 518.472 179.179 518.483L177.895 518.494L116.802 518.772L115.528 518.782C114.244 518.793 113.077 518.269 112.232 517.434C111.386 516.599 110.862 515.432 110.851 514.148L110.798 497.152Z" fill="url(#paint6_radial)"/>
<path d="M314.979 246.348C324.162 210.407 318.008 181.777 318.008 181.777L326.452 181.734L326.656 181.574C314.262 115.75 256.326 66.0987 186.949 66.4198C108.796 66.773 45.7233 130.424 46.0765 208.577C46.4297 286.731 110.08 349.803 188.234 349.45C249.905 349.172 302.178 309.474 321.304 254.343C321.872 251.999 321.797 247.804 314.979 246.348Z" fill="url(#paint7_radial)"/>
<path d="M310.237 279.035L65.877 280.148C71.3998 289.428 77.95 298.012 85.3672 305.761L290.972 304.829C298.336 297.005 304.8 288.368 310.237 279.035Z" fill="#D8CFF7"/>
<path d="M235.062 312.794L280.924 312.585L280.74 272.021L234.877 272.23L235.062 312.794Z" fill="#512BD4"/>
<path d="M243.001 297.626C242.691 297.626 242.434 297.53 242.22 297.327C242.006 297.123 241.899 296.866 241.899 296.588C241.899 296.299 242.006 296.042 242.22 295.839C242.434 295.625 242.691 295.528 243.001 295.528C243.312 295.528 243.568 295.635 243.782 295.839C243.996 296.042 244.114 296.299 244.114 296.588C244.114 296.877 244.007 297.123 243.793 297.327C243.568 297.519 243.312 297.626 243.001 297.626Z" fill="white"/>
<path d="M255.192 297.434H253.212L247.967 289.203C247.839 289 247.721 288.775 247.636 288.55H247.593C247.636 288.786 247.657 289.299 247.657 290.091L247.668 297.444H245.912L245.891 286.228H247.999L253.062 294.265C253.276 294.597 253.415 294.833 253.479 294.95H253.511C253.458 294.651 253.437 294.148 253.437 293.441L253.426 286.217H255.17L255.192 297.434Z" fill="white"/>
<path d="M263.733 297.412L257.589 297.423L257.568 286.206L263.465 286.195V287.779L259.387 287.79L259.398 290.969L263.155 290.958V292.532L259.398 292.542L259.409 295.86L263.733 295.85V297.412Z" fill="white"/>
<path d="M272.445 287.758L269.298 287.769L269.32 297.401H267.5L267.479 287.769L264.343 287.779V286.195L272.434 286.174L272.445 287.758Z" fill="white"/>
<path d="M315.279 246.337C324.355 210.836 318.457 182.483 318.308 181.798L171.484 182.462C171.484 182.462 162.226 181.563 162.268 190.018C162.311 198.463 162.761 222.341 162.878 248.746C162.9 254.172 167.363 256.773 170.863 256.751C170.874 256.751 311.618 252.213 315.279 246.337Z" fill="url(#paint8_radial)"/>
<path d="M227.685 246.798C227.685 246.798 250.183 228.827 254.571 225.499C258.959 222.17 262.812 221.977 266.869 225.445C270.925 228.913 293.616 246.498 293.616 246.498L227.685 246.798Z" fill="#A08BE8"/>
<path d="M320.748 256.141C320.748 256.141 324.943 248.414 315.279 246.348C315.289 246.305 170.927 246.894 170.927 246.894C167.566 246.905 163.232 244.925 162.846 241.671C162.857 244.004 162.878 246.369 162.889 248.756C162.91 253.68 166.582 256.27 169.878 256.698C170.21 256.73 170.542 256.773 170.874 256.773L180.742 256.73L320.748 256.141Z" fill="#512BD4"/>
<path d="M206.4 233.214C212.511 233.095 217.302 224.667 217.102 214.39C216.901 204.112 211.785 195.878 205.674 195.997C199.563 196.116 194.772 204.544 194.973 214.821C195.173 225.099 200.289 233.333 206.4 233.214Z" fill="#512BD4"/>
<path d="M306.249 214.267C306.356 203.989 301.488 195.605 295.377 195.541C289.266 195.478 284.225 203.758 284.118 214.037C284.011 224.315 288.878 232.699 294.99 232.763C301.101 232.826 306.142 224.545 306.249 214.267Z" fill="#512BD4"/>
<path d="M205.905 205.291C208.152 203.022 211.192 202.016 214.157 202.262C215.912 205.495 217.014 209.733 217.111 214.389C217.164 217.3 216.811 220.04 216.158 222.513C212.669 223.519 208.752 222.662 205.979 219.922C201.912 215.909 201.88 209.348 205.905 205.291Z" fill="#8065E0"/>
<path d="M294.996 204.285C297.255 202.016 300.294 200.999 303.259 201.256C305.164 204.628 306.309 209.209 306.256 214.239C306.224 216.808 305.892 219.259 305.303 221.485C301.793 222.523 297.843 221.678 295.061 218.916C291.004 214.892 290.972 208.342 294.996 204.285Z" fill="#8065E0"/>
<path d="M11.6342 357.017C10.9171 354.716 -5.72611 300.141 21.3204 258.903C36.9468 235.078 63.3083 221.035 99.6664 217.15L102.449 243.276C74.3431 246.273 54.4676 256.345 43.3579 273.202C23.0971 303.941 36.5722 348.733 36.7113 349.183L11.6342 357.017Z" fill="url(#paint9_linear)"/>
<path d="M95.1498 252.802C109.502 252.802 121.137 241.167 121.137 226.815C121.137 212.463 109.502 200.828 95.1498 200.828C80.7976 200.828 69.1628 212.463 69.1628 226.815C69.1628 241.167 80.7976 252.802 95.1498 252.802Z" fill="url(#paint10_radial)"/>
<path d="M72.0098 334.434L33.4683 329.307C26.597 328.397 20.2929 333.214 19.3725 340.085C18.4627 346.956 23.279 353.26 30.1504 354.181L68.6919 359.308C75.5632 360.217 81.8673 355.401 82.7878 348.53C83.6975 341.658 78.8705 335.344 72.0098 334.434Z" fill="#8A6FE8"/>
<path d="M3.73535 367.185L7.35297 393.076C8.36975 399.968 14.7702 404.731 21.6629 403.725C28.5556 402.708 33.3185 396.308 32.3124 389.415L28.5984 362.861L3.73535 367.185Z" fill="#8A6FE8"/>
<path d="M15.5194 374.988L34.849 405.427C38.6058 411.292 46.4082 413.005 52.2735 409.248C58.1387 405.491 59.8512 397.689 56.0945 391.823L41.7953 369.144L15.5194 374.988Z" fill="#8A6FE8"/>
<path d="M26.0511 363.739L51.8026 389.019C56.7688 393.911 64.7532 393.846 69.6445 388.88C74.5358 383.914 74.4715 375.929 69.516 371.038L43.2937 345.297L26.0511 363.739Z" fill="#8A6FE8"/>
<path d="M26.4043 381.912C40.987 381.912 52.8086 370.091 52.8086 355.508C52.8086 340.925 40.987 329.104 26.4043 329.104C11.8216 329.104 0 340.925 0 355.508C0 370.091 11.8216 381.912 26.4043 381.912Z" fill="url(#paint11_radial)"/>
<path d="M184.73 63.6308L157.819 66.5892L158.561 38.5412L177.888 36.4178L184.73 63.6308Z" fill="#8A6FE8"/>
<path d="M170.018 41.647C180.455 39.521 187.193 29.3363 185.067 18.8988C182.941 8.46126 172.757 1.72345 162.319 3.84944C151.882 5.97543 145.144 16.1601 147.27 26.5976C149.396 37.0351 159.58 43.773 170.018 41.647Z" fill="#D8CFF7"/>
<path d="M196.885 79.385C198.102 79.2464 198.948 78.091 198.684 76.8997C195.851 64.2818 183.923 55.5375 170.773 56.9926C157.622 58.4371 147.886 69.5735 147.865 82.4995C147.863 83.7232 148.949 84.6597 150.168 84.5316L196.885 79.385Z" fill="url(#paint12_radial)"/>
<defs>
<radialGradient id="paint0_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(382.004 103.457) scale(26.4058)">
<stop stop-color="#8065E0"/>
<stop offset="1" stop-color="#512BD4"/>
</radialGradient>
<linearGradient id="paint1_linear" x1="214.439" y1="303.482" x2="236.702" y2="409.505" gradientUnits="userSpaceOnUse">
<stop stop-color="#522CD5"/>
<stop offset="0.4397" stop-color="#8A6FE8"/>
</linearGradient>
<linearGradient id="paint2_linear" x1="231.673" y1="404.144" x2="297.805" y2="522.048" gradientUnits="userSpaceOnUse">
<stop stop-color="#522CD5"/>
<stop offset="0.4397" stop-color="#8A6FE8"/>
</linearGradient>
<radialGradient id="paint3_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(280.957 469.555) rotate(-0.260742) scale(45.8326)">
<stop offset="0.034" stop-color="#522CD5"/>
<stop offset="0.9955" stop-color="#8A6FE8"/>
</radialGradient>
<linearGradient id="paint4_linear" x1="166.061" y1="303.491" x2="144.763" y2="409.709" gradientUnits="userSpaceOnUse">
<stop stop-color="#522CD5"/>
<stop offset="0.4397" stop-color="#8A6FE8"/>
</linearGradient>
<linearGradient id="paint5_linear" x1="146.739" y1="407.302" x2="147.246" y2="518.627" gradientUnits="userSpaceOnUse">
<stop stop-color="#522CD5"/>
<stop offset="0.4397" stop-color="#8A6FE8"/>
</linearGradient>
<radialGradient id="paint6_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(148.63 470.023) rotate(179.739) scale(50.2476)">
<stop offset="0.034" stop-color="#522CD5"/>
<stop offset="0.9955" stop-color="#8A6FE8"/>
</radialGradient>
<radialGradient id="paint7_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(219.219 153.929) rotate(179.739) scale(140.935)">
<stop offset="0.4744" stop-color="#A08BE8"/>
<stop offset="0.8618" stop-color="#8065E0"/>
</radialGradient>
<radialGradient id="paint8_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(314.861 158.738) rotate(179.739) scale(146.053)">
<stop offset="0.0933" stop-color="#E1DFDD"/>
<stop offset="0.6573" stop-color="white"/>
</radialGradient>
<linearGradient id="paint9_linear" x1="54.1846" y1="217.159" x2="54.1846" y2="357.022" gradientUnits="userSpaceOnUse">
<stop offset="0.3344" stop-color="#9780E6"/>
<stop offset="0.8488" stop-color="#8A6FE8"/>
</linearGradient>
<radialGradient id="paint10_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(90.3494 218.071) rotate(-0.260742) scale(25.9924)">
<stop stop-color="#8065E0"/>
<stop offset="1" stop-color="#512BD4"/>
</radialGradient>
<radialGradient id="paint11_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(25.805 345.043) scale(26.4106)">
<stop stop-color="#8065E0"/>
<stop offset="1" stop-color="#512BD4"/>
</radialGradient>
<radialGradient id="paint12_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(169.113 67.3662) rotate(-32.2025) scale(21.0773)">
<stop stop-color="#8065E0"/>
<stop offset="1" stop-color="#512BD4"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,15 @@
Any raw assets you want to be deployed with your application can be placed in
this directory (and child directories). Deployment of the asset to your application
is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
These files will be deployed with your package and will be accessible using Essentials:
async Task LoadMauiAsset()
{
using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
using var reader = new StreamReader(stream);
var contents = reader.ReadToEnd();
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,59 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
<OutputType>Exe</OutputType>
<RootNamespace>SilverOS.Welcome.App</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDefaultCssItems>false</EnableDefaultCssItems>
<Nullable>enable</Nullable>
<!-- Display name -->
<ApplicationTitle>SilverOS Welcome</ApplicationTitle>
<!-- App Identifier -->
<ApplicationId>uk.silverlabs.silveros.welcome</ApplicationId>
<!-- Versions -->
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<!-- Unpackaged (no MSIX) -->
<WindowsPackageType>None</WindowsPackageType>
<SupportedOSPlatformVersion>10.0.19041.0</SupportedOSPlatformVersion>
</PropertyGroup>
<ItemGroup>
<!-- App Icon -->
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
<!-- Splash Screen -->
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
<!-- Images -->
<MauiImage Include="Resources\Images\*" />
<MauiImage Update="Resources\Images\dotnet_bot.svg" BaseSize="168,208" />
<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />
<!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.9" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SilverOS.Welcome.Core\SilverOS.Welcome.Core.csproj" />
<ProjectReference Include="..\SilverOS.Welcome.UI\SilverOS.Welcome.UI.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,852 @@
/* ===================================================================
SilverOS Welcome — Mercury Aesthetic
Theme: precision dark, slate-steel dominant, electric-ice accent.
Typography: "DM Mono" for headings/UI text, fallback to Courier
variant stacks for the "terminal-built" feel of a security OS.
Atmosphere: layered radial + linear gradients, subtle scanline texture.
Motion: staggered CSS entrance on .step reveal — one orchestrated
arrival, not scattered micro-animations.
=================================================================== */
/* ── Web Fonts (bundled locally — offline-safe, no CDN dependency) ──── */
/* DM Mono — latin subset only; weights 300/400/500 + italic 300 */
@font-face {
font-family: 'DM Mono';
font-style: italic;
font-weight: 300;
font-display: swap;
src: url('../fonts/dm-mono-italic-300.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'DM Mono';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('../fonts/dm-mono-300.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'DM Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../fonts/dm-mono-400.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'DM Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('../fonts/dm-mono-500.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Inter — latin subset only; weights 300/400/500 */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('../fonts/inter-300.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../fonts/inter-400.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('../fonts/inter-500.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* ── Design Tokens ──────────────────────────────────────────────────── */
:root {
/* Palette */
--clr-void: #0b0f14; /* near-black background */
--clr-surface: #111720; /* card / panel surface */
--clr-surface-2: #19202e; /* raised surface */
--clr-border: #1e2a3a; /* subtle border */
--clr-border-hi: #2a3d56; /* highlighted border */
--clr-accent: #00d4ff; /* electric ice — primary CTA, progress */
--clr-accent-dim: #0099bb; /* dimmer accent for hover states */
--clr-accent-glow: rgba(0,212,255,0.18);
--clr-success: #00e5a0; /* completion green */
--clr-warn: #f5a623; /* error / warning amber */
--clr-text-hi: #e8edf5; /* high-emphasis text */
--clr-text-mid: #8fa4bc; /* mid-emphasis: labels, subtitles */
--clr-text-lo: #4a5f78; /* low-emphasis: placeholders, hints */
/* Typography */
--font-mono: 'DM Mono', 'Fira Code', 'Consolas', monospace;
--font-ui: 'Inter', system-ui, -apple-system, sans-serif;
/* Geometry */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
/* Motion */
--ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
--ease-in: cubic-bezier(0.64, 0, 0.78, 0);
}
/* ── Reset & Base ───────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
background-color: var(--clr-void);
color: var(--clr-text-hi);
font-family: var(--font-ui);
font-size: 15px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* Atmospheric background — layered radial glow on deep void */
body {
background:
radial-gradient(ellipse 80% 60% at 50% -10%, rgba(0,100,180,0.18) 0%, transparent 70%),
radial-gradient(ellipse 50% 40% at 90% 100%, rgba(0,180,140,0.08) 0%, transparent 60%),
var(--clr-void);
min-height: 100vh;
}
/* ── Blazor error overlay (keep readable) ──────────────────────────── */
#blazor-error-ui {
background: #1a0a0a;
border-top: 2px solid var(--clr-warn);
color: var(--clr-warn);
bottom: 0;
box-shadow: 0 -2px 12px rgba(245,166,35,0.15);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
font-family: var(--font-mono);
font-size: 0.8rem;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
color: var(--clr-text-mid);
}
.blazor-error-boundary {
background: #1a0a0a;
border: 1px solid var(--clr-warn);
padding: 1rem 1rem 1rem 3.7rem;
color: var(--clr-warn);
border-radius: var(--radius-md);
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
/* ── Validation ─────────────────────────────────────────────────────── */
h1:focus { outline: none; }
.valid.modified:not([type=checkbox]) {
outline: 1px solid var(--clr-success);
}
.invalid {
outline: 1px solid var(--clr-warn);
}
.validation-message {
color: var(--clr-warn);
font-size: 0.75rem;
font-family: var(--font-mono);
}
/* ══════════════════════════════════════════════════════════════════════
WIZARD CHROME
══════════════════════════════════════════════════════════════════════ */
.wizard {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
max-width: 760px;
margin: 0 auto;
}
/* ── Step indicator ─────────────────────────────────────────────────── */
.wizard-header {
padding: 1.5rem 2rem 1rem;
border-bottom: 1px solid var(--clr-border);
background: linear-gradient(to bottom, rgba(17,23,32,0.95), transparent);
position: sticky;
top: 0;
z-index: 10;
}
.wizard-steps-indicator {
display: flex;
gap: 0;
align-items: center;
}
.wizard-step-dot {
font-family: var(--font-mono);
font-size: 0.7rem;
font-weight: 400;
letter-spacing: 0.05em;
color: var(--clr-text-lo);
padding: 0.25rem 0.75rem;
border-radius: var(--radius-sm);
transition: color 0.25s var(--ease-out), background 0.25s var(--ease-out);
position: relative;
white-space: nowrap;
}
.wizard-step-dot + .wizard-step-dot::before {
content: '';
position: absolute;
left: -2px;
color: var(--clr-border-hi);
font-size: 0.9rem;
top: 50%;
transform: translateY(-50%);
}
.wizard-step-dot.done {
color: var(--clr-success);
}
.wizard-step-dot.done::after {
content: ' ✓';
font-size: 0.6rem;
}
.wizard-step-dot.active {
color: var(--clr-accent);
background: var(--clr-accent-glow);
font-weight: 500;
}
/* ── Wizard body ────────────────────────────────────────────────────── */
.wizard-body {
padding: 2.5rem 2rem;
overflow-y: auto;
}
/* ── Wizard footer ──────────────────────────────────────────────────── */
.wizard-footer {
padding: 1.25rem 2rem;
border-top: 1px solid var(--clr-border);
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(to top, rgba(11,15,20,0.98), transparent);
gap: 1rem;
}
/* ── Loading / error states ─────────────────────────────────────────── */
.loading {
font-family: var(--font-mono);
color: var(--clr-text-lo);
font-size: 0.85rem;
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.error-panel {
border: 1px solid var(--clr-warn);
border-radius: var(--radius-md);
padding: 1.5rem;
background: rgba(245,166,35,0.06);
}
.error {
color: var(--clr-warn);
font-weight: 500;
margin-bottom: 0.4rem;
}
.error-detail {
color: var(--clr-text-lo);
font-family: var(--font-mono);
font-size: 0.75rem;
margin-bottom: 1rem;
}
/* ══════════════════════════════════════════════════════════════════════
BUTTONS
══════════════════════════════════════════════════════════════════════ */
.btn-primary,
.btn-secondary {
font-family: var(--font-mono);
font-size: 0.8rem;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 0.6rem 1.4rem;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
transition:
background 0.2s var(--ease-out),
box-shadow 0.2s var(--ease-out),
opacity 0.15s;
outline-offset: 3px;
}
.btn-primary {
background: var(--clr-accent);
color: var(--clr-void);
}
.btn-primary:hover:not(:disabled) {
background: #1ae0ff;
box-shadow: 0 0 18px var(--clr-accent-glow);
}
.btn-primary:focus-visible {
outline: 2px solid var(--clr-accent);
}
.btn-primary:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.btn-secondary {
background: transparent;
color: var(--clr-text-mid);
border: 1px solid var(--clr-border-hi);
}
.btn-secondary:hover:not(:disabled) {
background: var(--clr-surface-2);
color: var(--clr-text-hi);
border-color: var(--clr-accent-dim);
}
.btn-secondary:focus-visible {
outline: 2px solid var(--clr-accent);
}
.btn-secondary:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* ══════════════════════════════════════════════════════════════════════
STEP ENTRANCE ANIMATION (orchestrated stagger)
══════════════════════════════════════════════════════════════════════ */
.step {
animation: step-enter 0.45s var(--ease-out) both;
}
@keyframes step-enter {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* heading and subtitle arrive slightly after the container */
.step h1 {
font-family: var(--font-mono);
font-size: 1.6rem;
font-weight: 300;
letter-spacing: -0.02em;
color: var(--clr-text-hi);
line-height: 1.2;
margin-bottom: 0.4rem;
animation: step-enter 0.5s 0.05s var(--ease-out) both;
}
.step-subtitle {
font-family: var(--font-ui);
font-size: 0.9rem;
color: var(--clr-text-mid);
margin-bottom: 2rem;
animation: step-enter 0.5s 0.1s var(--ease-out) both;
}
/* ══════════════════════════════════════════════════════════════════════
WELCOME STEP
══════════════════════════════════════════════════════════════════════ */
.welcome-step {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 50vh;
}
.welcome-hero {
animation: step-enter 0.55s 0.05s var(--ease-out) both;
}
.welcome-hero h1 {
font-size: 2.2rem;
font-weight: 300;
color: var(--clr-text-hi);
margin-bottom: 0.5rem;
/* Accent underline */
background: linear-gradient(90deg, var(--clr-accent) 0%, var(--clr-success) 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: step-enter 0.55s 0.1s var(--ease-out) both;
}
.tagline {
font-size: 1.05rem;
color: var(--clr-text-mid);
font-style: italic;
margin-bottom: 1.5rem;
animation: step-enter 0.5s 0.15s var(--ease-out) both;
}
.time-estimate {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--clr-text-lo);
margin-top: 1.5rem;
animation: step-enter 0.5s 0.2s var(--ease-out) both;
}
/* ══════════════════════════════════════════════════════════════════════
FLAVOUR STEP
══════════════════════════════════════════════════════════════════════ */
.flavour-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
margin-top: 0.5rem;
}
.flavour-card {
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: var(--radius-lg);
padding: 1.5rem;
cursor: pointer;
transition:
border-color 0.2s var(--ease-out),
box-shadow 0.2s var(--ease-out),
background 0.2s var(--ease-out);
position: relative;
overflow: hidden;
}
.flavour-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, var(--clr-accent-glow) 0%, transparent 60%);
opacity: 0;
transition: opacity 0.25s var(--ease-out);
border-radius: inherit;
}
.flavour-card:hover {
border-color: var(--clr-border-hi);
box-shadow: 0 0 0 1px var(--clr-border-hi), 0 4px 20px rgba(0,0,0,0.4);
}
.flavour-card:hover::before {
opacity: 0.5;
}
.flavour-card.selected {
border-color: var(--clr-accent);
box-shadow: 0 0 0 1px var(--clr-accent), 0 0 28px var(--clr-accent-glow);
background: var(--clr-surface-2);
}
.flavour-card.selected::before {
opacity: 1;
}
.flavour-card h3 {
font-family: var(--font-mono);
font-size: 0.95rem;
font-weight: 500;
color: var(--clr-text-hi);
letter-spacing: 0.03em;
margin-bottom: 0.5rem;
}
.flavour-card.selected h3 {
color: var(--clr-accent);
}
.flavour-card p {
font-size: 0.82rem;
color: var(--clr-text-mid);
line-height: 1.5;
}
/* Staggered card entrance */
.flavour-card:nth-child(1) { animation: step-enter 0.45s 0.1s var(--ease-out) both; }
.flavour-card:nth-child(2) { animation: step-enter 0.45s 0.18s var(--ease-out) both; }
.flavour-card:nth-child(3) { animation: step-enter 0.45s 0.26s var(--ease-out) both; }
.flavour-card:nth-child(4) { animation: step-enter 0.45s 0.34s var(--ease-out) both; }
/* ══════════════════════════════════════════════════════════════════════
ACCOUNT STEP / FORM FIELDS
══════════════════════════════════════════════════════════════════════ */
.field-group {
margin-bottom: 1.4rem;
animation: step-enter 0.4s var(--ease-out) both;
}
.field-group:nth-child(2) { animation-delay: 0.06s; }
.field-group:nth-child(3) { animation-delay: 0.12s; }
.field-group:nth-child(4) { animation-delay: 0.18s; }
.field-group label {
display: block;
font-family: var(--font-mono);
font-size: 0.72rem;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--clr-text-mid);
margin-bottom: 0.4rem;
}
.field-group input[type="text"],
.field-group input[type="password"] {
width: 100%;
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: var(--radius-sm);
padding: 0.65rem 0.9rem;
font-family: var(--font-mono);
font-size: 0.9rem;
color: var(--clr-text-hi);
outline: none;
transition:
border-color 0.2s var(--ease-out),
box-shadow 0.2s var(--ease-out);
}
.field-group input:focus {
border-color: var(--clr-accent);
box-shadow: 0 0 0 3px var(--clr-accent-glow);
}
.field-group input::placeholder {
color: var(--clr-text-lo);
font-style: italic;
}
.field-error {
display: block;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--clr-warn);
margin-top: 0.3rem;
}
/* ══════════════════════════════════════════════════════════════════════
PREFS STEP
══════════════════════════════════════════════════════════════════════ */
.prefs-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.pref-item {
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: var(--radius-md);
padding: 0.9rem 1.1rem;
animation: step-enter 0.4s var(--ease-out) both;
}
.pref-item:nth-child(2) { animation-delay: 0.07s; }
.pref-item:nth-child(3) { animation-delay: 0.14s; }
.pref-item label {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
font-size: 0.88rem;
color: var(--clr-text-hi);
}
.pref-item input[type="checkbox"] {
width: 1rem;
height: 1rem;
accent-color: var(--clr-accent);
cursor: pointer;
flex-shrink: 0;
}
.pref-item small {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--clr-text-lo);
}
/* ══════════════════════════════════════════════════════════════════════
APPLY STEP
══════════════════════════════════════════════════════════════════════ */
.apply-step {
display: flex;
flex-direction: column;
gap: 2rem;
min-height: 40vh;
}
.apply-header h1 {
font-size: 1.7rem;
}
/* Ready state */
.apply-ready {
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: var(--radius-lg);
padding: 2rem;
text-align: center;
animation: step-enter 0.4s 0.1s var(--ease-out) both;
}
.apply-ready-text {
color: var(--clr-text-mid);
font-size: 0.9rem;
margin-bottom: 1.5rem;
}
.btn-start {
min-width: 140px;
}
/* Progress state */
.apply-progress-container {
animation: step-enter 0.35s var(--ease-out) both;
}
.apply-stage-label {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--clr-accent);
letter-spacing: 0.04em;
margin-bottom: 0.6rem;
}
.apply-progress-track {
width: 100%;
height: 6px;
background: var(--clr-surface-2);
border-radius: 99px;
overflow: hidden;
border: 1px solid var(--clr-border);
}
.apply-progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--clr-accent) 0%, var(--clr-success) 100%);
border-radius: 99px;
transition: width 0.4s var(--ease-out);
box-shadow: 0 0 10px var(--clr-accent-glow);
}
.apply-percent-label {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--clr-text-lo);
text-align: right;
margin-top: 0.3rem;
}
/* Complete state */
.apply-complete {
display: flex;
align-items: center;
gap: 0.75rem;
background: rgba(0,229,160,0.08);
border: 1px solid var(--clr-success);
border-radius: var(--radius-md);
padding: 1rem 1.25rem;
animation: step-enter 0.4s var(--ease-out) both;
}
.apply-complete-icon {
font-size: 1.4rem;
color: var(--clr-success);
line-height: 1;
}
.apply-complete p {
color: var(--clr-success);
font-family: var(--font-mono);
font-size: 0.85rem;
}
/* Error state */
.apply-error {
background: rgba(245,166,35,0.07);
border: 1px solid var(--clr-warn);
border-radius: var(--radius-md);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
animation: step-enter 0.35s var(--ease-out) both;
}
.apply-error-icon {
font-size: 1.5rem;
color: var(--clr-warn);
line-height: 1;
}
.apply-error-title {
font-family: var(--font-mono);
font-size: 0.85rem;
font-weight: 500;
color: var(--clr-warn);
}
.apply-error-detail {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--clr-text-mid);
margin-bottom: 0.5rem;
word-break: break-word;
}
.btn-retry {
align-self: flex-start;
}
/* ══════════════════════════════════════════════════════════════════════
DONE STEP
══════════════════════════════════════════════════════════════════════ */
.done-step {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
justify-content: center;
min-height: 50vh;
gap: 1.5rem;
}
.done-step h1 {
background: linear-gradient(90deg, var(--clr-success) 0%, var(--clr-accent) 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 2.2rem;
}
.done-step p {
color: var(--clr-text-mid);
max-width: 440px;
font-size: 0.92rem;
}
.btn-restart {
min-width: 160px;
background: linear-gradient(90deg, var(--clr-accent), var(--clr-success));
color: var(--clr-void);
font-weight: 500;
}
.btn-restart:hover:not(:disabled) {
box-shadow: 0 0 28px rgba(0,229,160,0.25), 0 0 18px var(--clr-accent-glow);
opacity: 0.92;
}
/* ── Accessibility / focus safety ───────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.step,
.step h1,
.step-subtitle,
.welcome-hero,
.welcome-hero h1,
.tagline,
.time-estimate,
.flavour-card,
.field-group,
.pref-item,
.apply-progress-container,
.apply-ready,
.apply-complete,
.apply-error {
animation: none !important;
transition: none !important;
}
}
/* ── MAUI layout safety ─────────────────────────────────────────────── */
.status-bar-safe-area {
display: none;
}
@supports (-webkit-touch-callout: none) {
.status-bar-safe-area {
display: flex;
position: sticky;
top: 0;
height: env(safe-area-inset-top);
background-color: var(--clr-void);
width: 100%;
z-index: 1;
}
.flex-column, .navbar-brand {
padding-left: env(safe-area-inset-left);
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>SilverOS.Welcome.App</title>
<base href="/" />
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="css/app.css" />
<link rel="stylesheet" href="SilverOS.Welcome.App.styles.css" />
<link rel="icon" href="data:,">
</head>
<body>
<div class="status-bar-safe-area"></div>
<div id="app">Loading...</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webview.js" autostart="false"></script>
</body>
</html>

View File

@@ -0,0 +1,25 @@
namespace SilverOS.Welcome.Core.Apply;
public sealed class AccountService(IProcessRunner runner) : IAccountService
{
public async Task CreateAccountsAsync(string user, string password, string adminPassword, CancellationToken ct = default)
{
// Daily account = Standard User (Users group only — NOT Administrators).
await Ps($"$p=ConvertTo-SecureString '{Esc(password)}' -AsPlainText -Force; " +
$"New-LocalUser -Name '{Esc(user)}' -Password $p -FullName '{Esc(user)}' -AccountNeverExpires; " +
$"Add-LocalGroupMember -Group 'Users' -Member '{Esc(user)}'", "Daily account creation", ct);
// Separate elevation account.
await Ps($"$a=ConvertTo-SecureString '{Esc(adminPassword)}' -AsPlainText -Force; " +
$"New-LocalUser -Name 'SilverOS Admin' -Password $a -AccountNeverExpires; " +
$"Add-LocalGroupMember -Group 'Administrators' -Member 'SilverOS Admin'", "Admin account creation", ct);
}
// $ErrorActionPreference='Stop' turns the (otherwise non-terminating) cmdlet errors into a
// non-zero exit so EnsureSuccess can surface them instead of silently continuing.
private async Task Ps(string script, string operation, CancellationToken ct)
{
var r = await runner.RunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -Command \"$ErrorActionPreference='Stop'; {script}\"", ct);
r.EnsureSuccess(operation);
}
private static string Esc(string s) => s.Replace("'", "''");
}

View File

@@ -0,0 +1,2 @@
namespace SilverOS.Welcome.Core.Apply;
public sealed record ApplyProgress(string Stage, int Percent);

View File

@@ -0,0 +1,4 @@
using SilverOS.Welcome.Core.Flavours;
namespace SilverOS.Welcome.Core.Apply;
public sealed record ApplyRequest(FlavourManifest Flavour, string Username, string Password,
string AdminPassword, string BitLockerPin, string BootstrapUser);

View File

@@ -0,0 +1,39 @@
using System.Text.Json;
using SilverOS.Welcome.Core.Flavours;
namespace SilverOS.Welcome.Core.Apply;
public sealed class ApplyService(IProcessRunner runner, IAccountService accounts,
IBitLockerService bitlocker, IBootstrapService bootstrap, string hardeningDir) : IApplyService
{
public async Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default)
{
progress.Report(new("Applying hardening", 10));
// Pass modules as a single bare CSV token (e.g. 00,03,05).
// powershell.exe -File receives single-quoted tokens as one literal string, not an array,
// so Invoke-Hardening.ps1 accepts [string]$Modules and splits on ',' internally.
var mods = string.Join(",", req.Flavour.Hardening.Modules);
var pjson = JsonSerializer.Serialize(req.Flavour.Hardening.Params).Replace("\"", "\\\"");
var script = Path.Combine(hardeningDir, "Invoke-Hardening.ps1");
var res = await runner.RunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -File \"{script}\" -Modules {mods} -ParamsJson \"{pjson}\"", ct);
if (res.ExitCode != 0)
{
// Only expose exit code + first non-empty stderr line (capped) — never raw full stderr.
var firstLine = res.StdErr
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault()?.Trim() ?? string.Empty;
if (firstLine.Length > 200) firstLine = firstLine[..200];
throw new InvalidOperationException($"Hardening failed (exit {res.ExitCode}): {firstLine}");
}
progress.Report(new("Creating your account", 55));
await accounts.CreateAccountsAsync(req.Username, req.Password, req.AdminPassword, ct);
progress.Report(new("Encrypting the disk", 75));
await bitlocker.EnableAsync(req.BitLockerPin, ct);
progress.Report(new("Finishing up", 95));
await bootstrap.TearDownAsync(req.BootstrapUser, ct); // last — only after success
progress.Report(new("Done", 100));
}
}

View File

@@ -0,0 +1,46 @@
namespace SilverOS.Welcome.Core.Apply;
public sealed class BitLockerService(IProcessRunner runner) : IBitLockerService
{
public async Task EnableAsync(string pin, CancellationToken ct = default)
{
var p = pin.Replace("'", "''");
// 1. Set the FVE "Require additional authentication at startup" policy so the
// TPM+PIN protector is permitted. Without this, Enable-BitLocker -TpmAndPinProtector
// silently degrades to TPM-only (boots with no PIN prompt).
// 2. Add the TPM+PIN protector — Enable-BitLocker if the volume is still decrypted,
// or Add-BitLockerKeyProtector if Windows automatic device-encryption already
// encrypted it with a TPM-only protector.
// 3. Remove any TPM-only protector (only once a TPM+PIN protector is confirmed present)
// so the device actually requires the PIN at pre-boot.
var script = string.Concat(
"$fve='HKLM:\\SOFTWARE\\Policies\\Microsoft\\FVE'; ",
"New-Item -Path $fve -Force | Out-Null; ",
"New-ItemProperty -Path $fve -Name UseAdvancedStartup -Value 1 -PropertyType DWord -Force | Out-Null; ",
"New-ItemProperty -Path $fve -Name EnableBDEWithNoTPM -Value 0 -PropertyType DWord -Force | Out-Null; ",
"New-ItemProperty -Path $fve -Name UseTPM -Value 2 -PropertyType DWord -Force | Out-Null; ",
"New-ItemProperty -Path $fve -Name UseTPMPIN -Value 2 -PropertyType DWord -Force | Out-Null; ",
"New-ItemProperty -Path $fve -Name UseTPMKey -Value 2 -PropertyType DWord -Force | Out-Null; ",
"New-ItemProperty -Path $fve -Name UseTPMKeyPIN -Value 2 -PropertyType DWord -Force | Out-Null; ",
"$mp=$env:SystemDrive; ",
"$p=ConvertTo-SecureString '", p, "' -AsPlainText -Force; ",
"$v=Get-BitLockerVolume -MountPoint $mp; ",
"if ($v.VolumeStatus -eq 'FullyDecrypted') { ",
"Enable-BitLocker -MountPoint $mp -EncryptionMethod XtsAes256 -TpmAndPinProtector -Pin $p -SkipHardwareTest } ",
"elseif (-not ($v.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'TpmPin' })) { ",
"Add-BitLockerKeyProtector -MountPoint $mp -TpmAndPinProtector -Pin $p }; ",
"$kp=(Get-BitLockerVolume -MountPoint $mp).KeyProtector; ",
"if ($kp | Where-Object { $_.KeyProtectorType -eq 'TpmPin' }) { ",
"$kp | Where-Object { $_.KeyProtectorType -eq 'Tpm' } | ForEach-Object { ",
"Remove-BitLockerKeyProtector -MountPoint $mp -KeyProtectorId $_.KeyProtectorId | Out-Null } }; ",
// Outcome check: fail loudly (non-zero exit) if a TPM+PIN protector is not present at
// the end — this is what actually matters (a benign non-terminating warning alone
// must not pass, and a real failure must not stay silent).
"$ok=(Get-BitLockerVolume -MountPoint $mp).KeyProtector | Where-Object { $_.KeyProtectorType -eq 'TpmPin' }; ",
"if (-not $ok) { Write-Error 'TPM+PIN protector not present after enrollment'; exit 1 }");
var r = await runner.RunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -Command \"{script}\"", ct);
r.EnsureSuccess("BitLocker enrollment");
}
}

View File

@@ -0,0 +1,24 @@
namespace SilverOS.Welcome.Core.Apply;
public sealed class BootstrapService(IProcessRunner runner) : IBootstrapService
{
public async Task TearDownAsync(string bootstrapUser, CancellationToken ct = default)
{
const string key = "'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon'";
// DefaultPassword may legitimately be absent → keep its per-cmdlet -EA SilentlyContinue.
await Ps($"Set-ItemProperty {key} -Name AutoAdminLogon -Value 0; " +
$"Remove-ItemProperty {key} -Name DefaultPassword -EA SilentlyContinue", "Disable auto-logon", ct);
var u = Esc(bootstrapUser);
await Ps($"Remove-LocalUser -Name '{u}' -EA SilentlyContinue", "Remove bootstrap account", ct);
}
private static string Esc(string s) => s.Replace("'", "''");
// $ErrorActionPreference='Stop' surfaces unexpected hard errors (e.g. a bad registry path);
// the intentional per-cmdlet -EA SilentlyContinue above still overrides it for the known
// best-effort cleanups.
private async Task Ps(string s, string operation, CancellationToken ct)
{
var r = await runner.RunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -Command \"$ErrorActionPreference='Stop'; {s}\"", ct);
r.EnsureSuccess(operation);
}
}

View File

@@ -0,0 +1,2 @@
namespace SilverOS.Welcome.Core.Apply;
public interface IAccountService { Task CreateAccountsAsync(string user, string password, string adminPassword, CancellationToken ct = default); }

View File

@@ -0,0 +1,2 @@
namespace SilverOS.Welcome.Core.Apply;
public interface IApplyService { Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default); }

View File

@@ -0,0 +1,2 @@
namespace SilverOS.Welcome.Core.Apply;
public interface IBitLockerService { Task EnableAsync(string pin, CancellationToken ct = default); }

View File

@@ -0,0 +1,2 @@
namespace SilverOS.Welcome.Core.Apply;
public interface IBootstrapService { Task TearDownAsync(string bootstrapUser, CancellationToken ct = default); }

View File

@@ -0,0 +1,6 @@
namespace SilverOS.Welcome.Core.Apply;
public readonly record struct ProcessResult(int ExitCode, string StdOut, string StdErr);
public interface IProcessRunner
{
Task<ProcessResult> RunAsync(string file, string args, CancellationToken ct = default);
}

View File

@@ -0,0 +1,20 @@
namespace SilverOS.Welcome.Core.Apply;
internal static class ProcessResultExtensions
{
/// <summary>
/// Throws an <see cref="InvalidOperationException"/> with a scrubbed message when the
/// process exited non-zero, so a failed privileged step surfaces to the wizard (and a
/// failed apply does not proceed to bootstrap teardown) instead of failing silently.
/// </summary>
public static void EnsureSuccess(in this ProcessResult result, string operation)
{
if (result.ExitCode == 0) return;
var firstLine = (result.StdErr ?? string.Empty)
.Split('\n')
.Select(l => l.Trim())
.FirstOrDefault(l => l.Length > 0) ?? string.Empty;
if (firstLine.Length > 200) firstLine = firstLine[..200];
throw new InvalidOperationException($"{operation} failed (exit {result.ExitCode}): {firstLine}");
}
}

View File

@@ -0,0 +1,19 @@
using System.Diagnostics;
namespace SilverOS.Welcome.Core.Apply;
public sealed class ProcessRunner : IProcessRunner
{
public async Task<ProcessResult> RunAsync(string file, string args, CancellationToken ct = default)
{
using var p = new Process { StartInfo = new ProcessStartInfo(file, args)
{
RedirectStandardOutput = true, RedirectStandardError = true,
UseShellExecute = false, CreateNoWindow = true
}};
p.Start();
var outT = p.StandardOutput.ReadToEndAsync(ct);
var errT = p.StandardError.ReadToEndAsync(ct);
await p.WaitForExitAsync(ct);
return new ProcessResult(p.ExitCode, await outT, await errT);
}
}

View File

@@ -0,0 +1,25 @@
using System.Text.Json;
namespace SilverOS.Welcome.Core.Flavours;
public sealed class FlavourValidationException(string message) : Exception(message);
public sealed class FlavourLoader : IFlavourLoader
{
public IReadOnlyList<FlavourManifest> Load(string directory)
{
var list = new List<FlavourManifest>();
foreach (var file in Directory.EnumerateFiles(directory, "*.json").OrderBy(f => f))
{
FlavourManifest m;
try { m = JsonSerializer.Deserialize<FlavourManifest>(File.ReadAllText(file), FlavourManifest.JsonOptions)
?? throw new FlavourValidationException($"{Path.GetFileName(file)}: empty"); }
catch (JsonException ex) { throw new FlavourValidationException($"{Path.GetFileName(file)}: {ex.Message}"); }
if (string.IsNullOrWhiteSpace(m.Id)) throw new FlavourValidationException($"{Path.GetFileName(file)}: missing id");
if (m.Hardening.Modules.Count == 0) throw new FlavourValidationException($"{Path.GetFileName(file)}: no hardening modules");
list.Add(m);
}
if (list.Count == 0) throw new FlavourValidationException("no flavour manifests found");
if (list.Count(m => m.IsDefault) != 1) throw new FlavourValidationException("exactly one flavour must be isDefault");
return list.OrderByDescending(m => m.IsDefault).ThenBy(m => m.Label).ToList();
}
}

View File

@@ -0,0 +1,30 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SilverOS.Welcome.Core.Flavours;
public sealed record FlavourManifest
{
public string Id { get; init; } = "";
public string Label { get; init; } = "";
public string Description { get; init; } = "";
public bool IsDefault { get; init; }
public HardeningSpec Hardening { get; init; } = new();
public IReadOnlyList<string> AppSet { get; init; } = Array.Empty<string>();
public IReadOnlyDictionary<string, JsonElement> Settings { get; init; }
= new Dictionary<string, JsonElement>();
public static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
}
public sealed record HardeningSpec
{
public IReadOnlyList<string> Modules { get; init; } = Array.Empty<string>();
public IReadOnlyDictionary<string, string> Params { get; init; }
= new Dictionary<string, string>();
}

View File

@@ -0,0 +1,2 @@
namespace SilverOS.Welcome.Core.Flavours;
public interface IFlavourLoader { IReadOnlyList<FlavourManifest> Load(string directory); }

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,135 @@
@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)
{
<div class="error-panel">
<p class="error">Couldn't load device profiles. Please reinstall SilverOS.</p>
<p class="error-detail"><small>@_error</small></p>
<button class="btn-secondary" @onclick="LoadFlavours">Retry</button>
</div>
}
else
{
@switch (_currentStep)
{
case 0:
<WelcomeStep />
break;
case 1:
<FlavourStep Flavours="_flavours" />
break;
case 2:
<AccountStep OnValidityChanged="@(v => { _accountValid = v; StateHasChanged(); })" />
break;
case 3:
<PrefsStep />
break;
case 4:
<ApplyStep OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" />
break;
case 5:
<DoneStep />
break;
}
}
</div>
<div class="wizard-footer">
<button class="btn-secondary"
disabled="@(_currentStep == 0 || _applyRunning)"
@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 bool _applyRunning = false;
private bool _accountValid = false;
private string? _error;
private IReadOnlyList<FlavourManifest> _flavours = Array.Empty<FlavourManifest>();
private bool CanGoNext => _currentStep switch
{
1 => State.Flavour is not null,
2 => _accountValid,
_ => true
};
protected override Task OnInitializedAsync() => LoadFlavours();
private Task LoadFlavours()
{
_error = null;
_loading = true;
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--;
}
void AdvanceToDone()
{
// Called by ApplyStep when configuration completes successfully.
_currentStep = _stepTitles.Length - 1;
StateHasChanged();
}
}

View File

@@ -0,0 +1,90 @@
@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

@@ -0,0 +1,120 @@
@inject IApplyService ApplyService
@inject WizardState State
<div class="step apply-step">
<div class="apply-header">
<h1>Applying Configuration</h1>
<p class="step-subtitle">Your SilverOS is being configured. This may take a few minutes.</p>
</div>
@if (_errorMessage is not null)
{
<div class="apply-error">
<div class="apply-error-icon">&#x26A0;</div>
<p class="apply-error-title">Configuration failed</p>
<p class="apply-error-detail">@SanitiseForDisplay(_errorMessage)</p>
<button class="btn-primary btn-retry" @onclick="() => _ = StartAsync()">Retry</button>
</div>
}
else if (!_running && !_complete)
{
<div class="apply-ready">
<p class="apply-ready-text">Ready to apply your selections. Click Start to begin.</p>
<button class="btn-primary btn-start" @onclick="() => _ = StartAsync()" disabled="@_running">Start</button>
</div>
}
else
{
<div class="apply-progress-container">
<div class="apply-stage-label">@_stageLabel</div>
<div class="apply-progress-track">
<div class="apply-progress-bar" style="width: @(_percent)%"></div>
</div>
<div class="apply-percent-label">@(_percent)%</div>
</div>
@if (_complete)
{
<div class="apply-complete">
<div class="apply-complete-icon">&#x2713;</div>
<p>Configuration complete.</p>
</div>
}
}
</div>
@code {
[Parameter] public EventCallback OnComplete { get; set; }
[Parameter] public EventCallback<bool> OnRunningChanged { get; set; }
private bool _running;
private bool _complete;
private int _percent;
private string _stageLabel = "Preparing…";
private string? _errorMessage;
private const int ErrorDisplayMaxLength = 200;
/// <summary>
/// Strips newlines and caps length so a multi-line or huge error message
/// cannot dump raw output into the UI.
/// </summary>
private static string SanitiseForDisplay(string message)
{
var single = message.ReplaceLineEndings(" ").Trim();
return single.Length <= ErrorDisplayMaxLength
? single
: single[..ErrorDisplayMaxLength] + "…";
}
public async Task StartAsync()
{
// Re-entrancy guard: prevent a second overlapping apply if already running
// (e.g. rapid double-click on Retry).
if (_running) return;
_running = true;
_complete = false;
_errorMessage = null;
_percent = 0;
_stageLabel = "Preparing…";
StateHasChanged();
await OnRunningChanged.InvokeAsync(true);
var req = new ApplyRequest(
Flavour: State.Flavour!,
Username: State.Username,
Password: State.Password,
AdminPassword: State.AdminPassword,
BitLockerPin: State.BitLockerPin,
BootstrapUser: "sm-bootstrap");
var progress = new Progress<ApplyProgress>(p =>
{
_ = InvokeAsync(() =>
{
_percent = p.Percent;
_stageLabel = p.Stage;
StateHasChanged();
});
});
try
{
await ApplyService.RunAsync(req, progress);
_complete = true;
_running = false;
_percent = 100;
StateHasChanged();
await OnRunningChanged.InvokeAsync(false);
await OnComplete.InvokeAsync();
}
catch (Exception ex)
{
_running = false;
_errorMessage = ex.Message;
StateHasChanged();
await OnRunningChanged.InvokeAsync(false);
}
}
}

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,11 @@
<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

@@ -0,0 +1,17 @@
using SilverOS.Welcome.Core.Flavours;
namespace SilverOS.Welcome.App.Components;
public sealed class WizardState
{
public FlavourManifest? Flavour { get; set; }
public string Username { get; set; } = "";
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

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net9.0-windows</TargetFramework>
<RootNamespace>SilverOS.Welcome.App</RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SilverOS.Welcome.Core\SilverOS.Welcome.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,13 @@
@using System.Linq
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using SilverOS.Welcome.App
@using SilverOS.Welcome.App.Components
@using SilverOS.Welcome.App.Components.Steps
@using SilverOS.Welcome.Core.Flavours
@using SilverOS.Welcome.Core.Apply

View File

@@ -0,0 +1,103 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using SilverOS.Welcome.App.Components;
using SilverOS.Welcome.App.Components.Steps;
using Xunit;
public class AccountStepTests : TestContext
{
// Helper: register WizardState and render AccountStep with an OnValidityChanged capture.
private (IRenderedComponent<AccountStep> cut, Func<bool?> lastValidity) RenderStep(WizardState? state = null)
{
var wizardState = state ?? new WizardState();
Services.AddSingleton(wizardState);
bool? captured = null;
var cut = RenderComponent<AccountStep>(p =>
p.Add(s => s.OnValidityChanged,
EventCallback.Factory.Create<bool>(this, v => captured = v)));
return (cut, () => captured);
}
[Fact]
public void OnValidityChanged_fires_false_on_initial_mount_with_empty_fields()
{
var (_, lastValidity) = RenderStep();
Assert.NotNull(lastValidity());
Assert.False(lastValidity(), "Step should be invalid on first mount (empty fields).");
}
[Fact]
public void OnValidityChanged_fires_true_after_all_valid_inputs_are_entered()
{
var (cut, lastValidity) = RenderStep();
// Simulate user filling in all four fields.
cut.Find("#username").Input("alice");
cut.Find("#password").Input("Secret1!");
cut.Find("#adminpassword").Input("Admin1!");
cut.Find("#bitlockerpin").Input("123456");
Assert.True(lastValidity(), "Step should be valid after all fields are correctly filled.");
}
[Fact]
public void OnValidityChanged_fires_false_when_a_field_is_cleared_after_being_valid()
{
var (cut, lastValidity) = RenderStep();
cut.Find("#username").Input("alice");
cut.Find("#password").Input("Secret1!");
cut.Find("#adminpassword").Input("Admin1!");
cut.Find("#bitlockerpin").Input("123456");
Assert.True(lastValidity()); // sanity
// Clear a required field — must revert to invalid.
cut.Find("#username").Input("");
Assert.False(lastValidity(), "Step should become invalid again when a required field is cleared.");
}
[Fact]
public void OnValidityChanged_fires_false_when_pin_is_non_numeric_or_too_short()
{
var (cut, lastValidity) = RenderStep();
cut.Find("#username").Input("alice");
cut.Find("#password").Input("Secret1!");
cut.Find("#adminpassword").Input("Admin1!");
// Too short — 5 digits.
cut.Find("#bitlockerpin").Input("12345");
Assert.False(lastValidity(), "PIN with only 5 digits must be invalid.");
// Non-numeric.
cut.Find("#bitlockerpin").Input("abc123");
Assert.False(lastValidity(), "Non-numeric PIN must be invalid.");
// Exactly 6 digits — valid.
cut.Find("#bitlockerpin").Input("123456");
Assert.True(lastValidity(), "Exactly 6 numeric digits is valid.");
}
[Fact]
public void OnValidityChanged_fires_true_on_mount_when_wizard_state_already_populated()
{
var prefilledState = new WizardState
{
Username = "alice",
Password = "Secret1!",
AdminPassword = "Admin1!",
BitLockerPin = "123456"
};
var (_, lastValidity) = RenderStep(prefilledState);
Assert.True(lastValidity(),
"Step should fire valid=true on mount when WizardState already has valid values (Back→Forward re-mount).");
}
}

View File

@@ -0,0 +1,113 @@
using Moq;
using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Flavours;
using Xunit;
/// <summary>
/// Real integration test: proves that ApplyService passes -Modules with the correct
/// encoding so that Invoke-Hardening.ps1's subset filter actually works through the
/// real ProcessStartInfo / PowerShell boundary.
///
/// SAFETY: only harmless dummy .ps1 files are executed — never the real 0*.ps1 hardening
/// modules. Invoke-Hardening.ps1 is copied into a temp dir and run against dummy stubs.
/// </summary>
public class ApplyServiceHardeningIntegrationTests
{
/// <summary>Walk up from the test binary to find the repo root (same as ShippedFlavoursTests).</summary>
private static string HardeningDir()
{
var d = AppContext.BaseDirectory;
while (d is not null && !Directory.Exists(Path.Combine(d, "windows", "hardening")))
d = Directory.GetParent(d)?.FullName;
return Path.Combine(d!, "windows", "hardening");
}
[Fact]
public async Task Subset_filter_runs_only_requested_modules_via_real_powershell()
{
// ---- Arrange: set up a temp sandbox ----
var tmp = Path.Combine(Path.GetTempPath(), $"sm_integ_{Guid.NewGuid():N}");
Directory.CreateDirectory(tmp);
try
{
// Copy the REAL Invoke-Hardening.ps1 (the one we just patched) into the temp dir.
var realInvoke = Path.Combine(HardeningDir(), "Invoke-Hardening.ps1");
File.Copy(realInvoke, Path.Combine(tmp, "Invoke-Hardening.ps1"));
// Create harmless dummy module stubs. Each just appends its prefix to ran.txt.
var ranFile = Path.Combine(tmp, "ran.txt").Replace("\\", "\\\\");
foreach (var (prefix, name) in new[] {
("00", "00-a.ps1"),
("03", "03-b.ps1"),
("05", "05-c.ps1"),
("07", "07-d.ps1"),
})
{
// Single quotes around prefix so the string itself is written, not executed.
await File.WriteAllTextAsync(
Path.Combine(tmp, name),
$"'RAN {prefix}' | Out-File -Append \"{ranFile.Replace("\\\\", "\\\\")}\"");
}
// Dummy Verify script — no-op so Invoke-Hardening.ps1's Verify step succeeds.
await File.WriteAllTextAsync(
Path.Combine(tmp, "Verify-SilverMetalWindows.ps1"),
"# no-op verify");
// ---- Arrange: mocked services so apply completes without touching real OS ----
var acct = new Mock<IAccountService>();
acct.Setup(a => a.CreateAccountsAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var bl = new Mock<IBitLockerService>();
bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var boot = new Mock<IBootstrapService>();
boot.Setup(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var sut = new ApplyService(
runner: new ProcessRunner(),
accounts: acct.Object,
bitlocker: bl.Object,
bootstrap: boot.Object,
hardeningDir: tmp);
// Flavour requests modules 00 and 05 only — 03 and 07 must be skipped.
var flavour = new FlavourManifest
{
Id = "test",
Hardening = new HardeningSpec { Modules = new[] { "00", "05" } }
};
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap");
// ---- Act ----
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
// ---- Assert: ran.txt should contain only 00 and 05 markers ----
Assert.True(File.Exists(Path.Combine(tmp, "ran.txt")),
"ran.txt was not created — no module ran at all (subset filter matched nothing)");
var ran = await File.ReadAllTextAsync(Path.Combine(tmp, "ran.txt"));
Assert.Contains("RAN 00", ran, StringComparison.Ordinal);
Assert.Contains("RAN 05", ran, StringComparison.Ordinal);
Assert.DoesNotContain("RAN 03", ran, StringComparison.Ordinal);
Assert.DoesNotContain("RAN 07", ran, StringComparison.Ordinal);
// ---- Assert: the rest of the apply pipeline also completed ----
acct.Verify(a => a.CreateAccountsAsync(
"alice", "pw", "adminpw", It.IsAny<CancellationToken>()), Times.Once);
bl.Verify(b => b.EnableAsync("123456", It.IsAny<CancellationToken>()), Times.Once);
boot.Verify(b => b.TearDownAsync("sm-bootstrap", It.IsAny<CancellationToken>()), Times.Once);
}
finally
{
// Clean up — ignore errors (locked files etc.) to avoid masking test failure.
try { Directory.Delete(tmp, recursive: true); } catch { /* ignore */ }
}
}
}

View File

@@ -0,0 +1,42 @@
using Moq;
using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Flavours;
using Xunit;
public class ApplyServiceTests
{
[Fact]
public async Task Runs_modules_then_accounts_then_bitlocker_then_bootstrap_last()
{
var order = new List<string>();
var run = new Mock<IProcessRunner>();
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string,string,CancellationToken>((_, a, _) => { if (a.Contains("Invoke-Hardening")) order.Add("modules"); })
.ReturnsAsync(new ProcessResult(0, "", ""));
var acct = new Mock<IAccountService>(); acct.Setup(a => a.CreateAccountsAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("accounts")).Returns(Task.CompletedTask);
var bl = new Mock<IBitLockerService>(); bl.Setup(b => b.EnableAsync(It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
var boot = new Mock<IBootstrapService>(); boot.Setup(b => b.TearDownAsync(It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("bootstrap")).Returns(Task.CompletedTask);
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, "C:\\hard");
var flavour = new FlavourManifest { Id="daily-driver", Hardening = new HardeningSpec { Modules = new[]{"00"} } };
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap");
var progress = new List<string>();
await sut.RunAsync(req, new Progress<ApplyProgress>(p => progress.Add(p.Stage)));
Assert.Equal(new[]{"modules","accounts","bitlocker","bootstrap"}, order);
Assert.Contains("Applying hardening", progress);
}
[Fact]
public async Task Does_not_tear_down_bootstrap_if_account_creation_fails()
{
var run = new Mock<IProcessRunner>(); run.Setup(r => r.RunAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).ReturnsAsync(new ProcessResult(0,"",""));
var acct = new Mock<IAccountService>(); acct.Setup(a => a.CreateAccountsAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("boom"));
var bl = new Mock<IBitLockerService>(); var boot = new Mock<IBootstrapService>();
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, "C:\\hard");
var req = new ApplyRequest(new FlavourManifest{ Hardening=new HardeningSpec{Modules=new[]{"00"}}}, "a","b","c","123456","sm-bootstrap");
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.RunAsync(req, new Progress<ApplyProgress>(_ => {})));
boot.Verify(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
}

View File

@@ -0,0 +1,96 @@
using Moq;
using SilverOS.Welcome.Core.Apply;
public class ApplyServicesTests
{
private static Mock<IProcessRunner> Ok()
{
var m = new Mock<IProcessRunner>();
m.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "", ""));
return m;
}
private static Mock<IProcessRunner> Fail()
{
var m = new Mock<IProcessRunner>();
m.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(1, "", "the operation failed"));
return m;
}
[Fact]
public async Task AccountService_throws_on_nonzero_exit()
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
new AccountService(Fail().Object).CreateAccountsAsync("alice", "pw", "apw"));
}
[Fact]
public async Task BitLockerService_throws_on_nonzero_exit()
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
new BitLockerService(Fail().Object).EnableAsync("123456"));
}
[Fact]
public async Task BootstrapService_throws_on_nonzero_exit()
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
new BootstrapService(Fail().Object).TearDownAsync("sm-bootstrap"));
}
[Fact]
public async Task AccountService_creates_standard_daily_and_admin()
{
var run = Ok();
await new AccountService(run.Object).CreateAccountsAsync("alice", "pw1", "adminpw");
// daily user is a Standard user (added to Users, NOT Administrators)
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("New-LocalUser") && s.Contains("alice")), It.IsAny<CancellationToken>()));
// negative: the daily-user New-LocalUser call must never mention Administrators
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("New-LocalUser") && s.Contains("alice") && !s.Contains("Administrators")),
It.IsAny<CancellationToken>()), Times.Once);
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("'SilverOS Admin'") && s.Contains("Administrators")), It.IsAny<CancellationToken>()));
}
[Fact]
public async Task BitLockerService_enables_tpm_and_pin()
{
var run = Ok();
await new BitLockerService(run.Object).EnableAsync("123456");
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("Enable-BitLocker") && s.Contains("TpmAndPinProtector")), It.IsAny<CancellationToken>()));
}
[Fact]
public async Task BitLockerService_sets_fve_pin_policy_and_strips_tpm_only_protector()
{
var run = Ok();
await new BitLockerService(run.Object).EnableAsync("123456");
// Sets the FVE "require additional authentication at startup" policy so the
// TPM+PIN protector actually applies (otherwise it silently degrades to TPM-only).
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("UseAdvancedStartup") && s.Contains("UseTPMPIN")), It.IsAny<CancellationToken>()));
// Handles a volume already encrypted by Windows auto-device-encryption (TPM-only)
// by adding the TPM+PIN protector instead of failing.
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("Add-BitLockerKeyProtector")), It.IsAny<CancellationToken>()));
// Removes any TPM-only protector so the device requires the PIN at pre-boot.
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("Remove-BitLockerKeyProtector")), It.IsAny<CancellationToken>()));
}
[Fact]
public async Task BootstrapService_removes_autologon_and_account()
{
var run = Ok();
await new BootstrapService(run.Object).TearDownAsync("sm-bootstrap");
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("AutoAdminLogon") && s.Contains("0")), It.IsAny<CancellationToken>()));
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("Remove-LocalUser") && s.Contains("sm-bootstrap")), It.IsAny<CancellationToken>()));
}
}

View File

@@ -0,0 +1,72 @@
using Bunit;
using Moq;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using SilverOS.Welcome.App.Components;
using SilverOS.Welcome.App.Components.Steps;
using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Flavours;
using Xunit;
public class ApplyStepTests : TestContext
{
[Fact]
public async Task Calls_apply_with_the_wizard_selections()
{
var apply = new Mock<IApplyService>();
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);
var cut = RenderComponent<ApplyStep>();
await cut.InvokeAsync(() => cut.Instance.StartAsync());
apply.Verify(a => a.RunAsync(
It.Is<ApplyRequest>(r => r.Username == "alice" && r.Flavour.Id == "daily-driver"),
It.IsAny<IProgress<ApplyProgress>>(),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task OnComplete_invoked_when_apply_succeeds()
{
var apply = new Mock<IApplyService>();
apply.Setup(a => a.RunAsync(It.IsAny<ApplyRequest>(), It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
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);
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());
Assert.True(completed);
}
[Fact]
public async Task Shows_error_and_retry_button_when_apply_fails()
{
var apply = new Mock<IApplyService>();
apply.Setup(a => a.RunAsync(It.IsAny<ApplyRequest>(), It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Module 03 failed"));
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);
var cut = RenderComponent<ApplyStep>();
await cut.InvokeAsync(() => cut.Instance.StartAsync());
Assert.Contains("Module 03 failed", cut.Markup);
Assert.NotNull(cut.Find(".btn-retry"));
}
}

View File

@@ -0,0 +1,38 @@
using SilverOS.Welcome.Core.Flavours;
using Xunit;
public class FlavourLoaderTests
{
private static string WriteTemp(params (string name, string json)[] files)
{
var dir = Directory.CreateTempSubdirectory("flav").FullName;
foreach (var (name, json) in files) File.WriteAllText(Path.Combine(dir, name), json);
return dir;
}
[Fact]
public void Loads_all_manifests_sorted_with_default_first()
{
var dir = WriteTemp(
("privacy-max.json", """{ "id":"privacy-max","label":"Privacy-Max","hardening":{"modules":["00"]} }"""),
("daily-driver.json", """{ "id":"daily-driver","label":"Daily-Driver","isDefault":true,"hardening":{"modules":["00"]} }"""));
var loaded = new FlavourLoader().Load(dir);
Assert.Equal(2, loaded.Count);
Assert.Equal("daily-driver", loaded[0].Id); // default first
}
[Fact]
public void Throws_when_a_manifest_has_no_id()
{
var dir = WriteTemp(("bad.json", """{ "label":"No Id","hardening":{"modules":["00"]} }"""));
var ex = Assert.Throws<FlavourValidationException>(() => new FlavourLoader().Load(dir));
Assert.Contains("bad.json", ex.Message);
}
[Fact]
public void Throws_when_no_default_flavour_present()
{
var dir = WriteTemp(("a.json", """{ "id":"a","label":"A","hardening":{"modules":["00"]} }"""));
Assert.Throws<FlavourValidationException>(() => new FlavourLoader().Load(dir));
}
}

View File

@@ -0,0 +1,25 @@
using System.Text.Json;
using SilverOS.Welcome.Core.Flavours;
using Xunit;
public class FlavourManifestTests
{
[Fact]
public void Deserializes_a_full_manifest()
{
var json = """
{
"id": "daily-driver", "label": "Daily-Driver",
"description": "Balanced.", "isDefault": true,
"hardening": { "modules": ["00","03","05"], "params": { "wdac": "audit" } },
"appSet": ["SilverBrowser"], "settings": { "autoLock": 120 }
}
""";
var m = JsonSerializer.Deserialize<FlavourManifest>(json, FlavourManifest.JsonOptions)!;
Assert.Equal("daily-driver", m.Id);
Assert.True(m.IsDefault);
Assert.Equal(new[] { "00", "03", "05" }, m.Hardening.Modules);
Assert.Equal("audit", m.Hardening.Params["wdac"]);
Assert.Contains("SilverBrowser", m.AppSet);
}
}

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

@@ -0,0 +1,14 @@
using SilverOS.Welcome.Core.Apply;
using Xunit;
public class ProcessRunnerTests
{
[Fact]
public async Task Runs_powershell_and_captures_output_and_exit()
{
var r = await new ProcessRunner().RunAsync(
"powershell.exe", "-NoProfile -Command \"Write-Output hello; exit 3\"");
Assert.Equal(3, r.ExitCode);
Assert.Contains("hello", r.StdOut);
}
}

View File

@@ -0,0 +1,21 @@
using SilverOS.Welcome.Core.Flavours;
using Xunit;
public class ShippedFlavoursTests
{
private static string FlavoursDir()
{
var d = AppContext.BaseDirectory;
while (d is not null && !Directory.Exists(Path.Combine(d, "windows", "flavours")))
d = Directory.GetParent(d)?.FullName;
return Path.Combine(d!, "windows", "flavours");
}
[Fact]
public void All_shipped_flavours_are_valid_and_one_is_default()
{
var loaded = new FlavourLoader().Load(FlavoursDir());
Assert.Equal(4, loaded.Count);
Assert.Equal("daily-driver", loaded[0].Id);
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net9.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</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" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SilverOS.Welcome.UI\SilverOS.Welcome.UI.csproj" />
<ProjectReference Include="..\..\src\SilverOS.Welcome.Core\SilverOS.Welcome.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace SilverOS.Welcome.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}