Compare commits
10 Commits
72e401113a
...
6c96e92fa5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c96e92fa5 | ||
|
|
a82ca271a0 | ||
|
|
084dd6a1d7 | ||
|
|
9d05a4a223 | ||
|
|
5bc345b1bd | ||
|
|
e88e476cd6 | ||
|
|
2730b29cb6 | ||
|
|
f3d66fb9d3 | ||
|
|
bd1e2885df | ||
|
|
42d86734b0 |
255
windows/collector/Collector.ps1
Normal file
255
windows/collector/Collector.ps1
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
# SilverMetal WinPE pre-config collector (WinForms UI). Runs in WinPE under the
|
||||||
|
# ADK WinPE-NetFx + WinPE-PowerShell optional components. ASCII body only (WinPE
|
||||||
|
# PowerShell 5.1 mis-parses smart quotes / em-dashes). On Finish it generates the
|
||||||
|
# answer file and launches legacy Setup; on Cancel / error it exits 1 so the
|
||||||
|
# wrapping Start-Collector.cmd falls back to the default autounattend.xml.
|
||||||
|
|
||||||
|
try {
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
|
||||||
|
|
||||||
|
# Validation helpers + answer-file generator live next to this script under X:\sm\.
|
||||||
|
. (Join-Path $PSScriptRoot 'Test-SmInput.ps1')
|
||||||
|
. (Join-Path $PSScriptRoot 'New-SmAnswerFile.ps1')
|
||||||
|
|
||||||
|
# --- palette ---------------------------------------------------------------
|
||||||
|
$colBg = [Drawing.Color]::FromArgb(18, 18, 22)
|
||||||
|
$colPanel = [Drawing.Color]::FromArgb(28, 28, 34)
|
||||||
|
$colText = [Drawing.Color]::FromArgb(232, 232, 238)
|
||||||
|
$colMuted = [Drawing.Color]::FromArgb(150, 150, 162)
|
||||||
|
$colAccent = [Drawing.Color]::FromArgb(96, 200, 170)
|
||||||
|
$colError = [Drawing.Color]::FromArgb(236, 110, 110)
|
||||||
|
$colField = [Drawing.Color]::FromArgb(40, 40, 48)
|
||||||
|
|
||||||
|
$fontBase = New-Object Drawing.Font('Segoe UI', 11)
|
||||||
|
$fontTitle = New-Object Drawing.Font('Segoe UI Semibold', 26, [Drawing.FontStyle]::Bold)
|
||||||
|
$fontLabel = New-Object Drawing.Font('Segoe UI', 10)
|
||||||
|
|
||||||
|
# --- form ------------------------------------------------------------------
|
||||||
|
$form = New-Object Windows.Forms.Form
|
||||||
|
$form.Text = 'SilverMetal Setup'
|
||||||
|
$form.WindowState = 'Maximized'
|
||||||
|
$form.FormBorderStyle = 'None'
|
||||||
|
$form.BackColor = $colBg
|
||||||
|
$form.ForeColor = $colText
|
||||||
|
$form.Font = $fontBase
|
||||||
|
$form.KeyPreview = $true
|
||||||
|
|
||||||
|
# Helpers to build consistent controls.
|
||||||
|
function New-Label([string]$text, [int]$x, [int]$y, [int]$w = 220) {
|
||||||
|
$l = New-Object Windows.Forms.Label
|
||||||
|
$l.Text = $text; $l.AutoSize = $false
|
||||||
|
$l.Location = New-Object Drawing.Point($x, $y)
|
||||||
|
$l.Size = New-Object Drawing.Size($w, 24)
|
||||||
|
$l.ForeColor = $colMuted; $l.Font = $fontLabel
|
||||||
|
$form.Controls.Add($l); $l
|
||||||
|
}
|
||||||
|
function New-Field([int]$x, [int]$y, [int]$w = 360, [bool]$mask = $false) {
|
||||||
|
$t = New-Object Windows.Forms.TextBox
|
||||||
|
$t.Location = New-Object Drawing.Point($x, $y)
|
||||||
|
$t.Size = New-Object Drawing.Size($w, 28)
|
||||||
|
$t.BackColor = $colField; $t.ForeColor = $colText
|
||||||
|
$t.BorderStyle = 'FixedSingle'; $t.Font = $fontBase
|
||||||
|
if ($mask) { $t.UseSystemPasswordChar = $true }
|
||||||
|
$form.Controls.Add($t); $t
|
||||||
|
}
|
||||||
|
|
||||||
|
# Title + subtitle (left rail).
|
||||||
|
$title = New-Object Windows.Forms.Label
|
||||||
|
$title.Text = 'SilverMetal'
|
||||||
|
$title.Font = $fontTitle; $title.ForeColor = $colAccent
|
||||||
|
$title.AutoSize = $true
|
||||||
|
$title.Location = New-Object Drawing.Point(80, 70)
|
||||||
|
$form.Controls.Add($title)
|
||||||
|
|
||||||
|
$subtitle = New-Object Windows.Forms.Label
|
||||||
|
$subtitle.Text = 'Pre-install configuration. Set your account, machine and security options before Windows installs.'
|
||||||
|
$subtitle.Font = $fontLabel; $subtitle.ForeColor = $colMuted
|
||||||
|
$subtitle.AutoSize = $false
|
||||||
|
$subtitle.Location = New-Object Drawing.Point(82, 120)
|
||||||
|
$subtitle.Size = New-Object Drawing.Size(640, 24)
|
||||||
|
$form.Controls.Add($subtitle)
|
||||||
|
|
||||||
|
# --- left column fields ----------------------------------------------------
|
||||||
|
$colX = 80; $fldX = 80; $y = 180; $rowH = 64
|
||||||
|
|
||||||
|
New-Label 'Display name' $colX $y | Out-Null
|
||||||
|
$txtDisplay = New-Field $fldX ($y + 26)
|
||||||
|
$y += $rowH
|
||||||
|
|
||||||
|
New-Label 'Username' $colX $y | Out-Null
|
||||||
|
$txtUser = New-Field $fldX ($y + 26)
|
||||||
|
$y += $rowH
|
||||||
|
|
||||||
|
New-Label 'Password' $colX $y | Out-Null
|
||||||
|
$txtPass = New-Field $fldX ($y + 26) 360 $true
|
||||||
|
$y += $rowH
|
||||||
|
|
||||||
|
New-Label 'Confirm password' $colX $y | Out-Null
|
||||||
|
$txtPassC = New-Field $fldX ($y + 26) 360 $true
|
||||||
|
$y += $rowH
|
||||||
|
|
||||||
|
New-Label 'Computer name' $colX $y | Out-Null
|
||||||
|
$txtComputer = New-Field $fldX ($y + 26)
|
||||||
|
$y += $rowH
|
||||||
|
|
||||||
|
# --- right column: flavour + BitLocker -------------------------------------
|
||||||
|
$rX = 540; $rY = 180
|
||||||
|
|
||||||
|
$grpFlavour = New-Object Windows.Forms.GroupBox
|
||||||
|
$grpFlavour.Text = 'Flavour'
|
||||||
|
$grpFlavour.ForeColor = $colMuted
|
||||||
|
$grpFlavour.Location = New-Object Drawing.Point($rX, $rY)
|
||||||
|
$grpFlavour.Size = New-Object Drawing.Size(360, 220)
|
||||||
|
$form.Controls.Add($grpFlavour)
|
||||||
|
|
||||||
|
# 'essentials' is the always-on baseline role (no flavour manifest), not a selectable
|
||||||
|
# flavour -- the four below are the real flavours; essentials apps ship with every one.
|
||||||
|
$flavours = @('developer', 'journalist', 'daily-driver', 'privacy-max')
|
||||||
|
$radioFlavours = @()
|
||||||
|
$fy = 32
|
||||||
|
foreach ($f in $flavours) {
|
||||||
|
$rb = New-Object Windows.Forms.RadioButton
|
||||||
|
$rb.Text = $f; $rb.ForeColor = $colText; $rb.Font = $fontBase
|
||||||
|
$rb.Location = New-Object Drawing.Point(20, $fy)
|
||||||
|
$rb.Size = New-Object Drawing.Size(320, 28)
|
||||||
|
$rb.Tag = $f
|
||||||
|
if ($f -eq 'daily-driver') { $rb.Checked = $true }
|
||||||
|
$grpFlavour.Controls.Add($rb)
|
||||||
|
$radioFlavours += $rb
|
||||||
|
$fy += 36
|
||||||
|
}
|
||||||
|
|
||||||
|
$chkBitLocker = New-Object Windows.Forms.CheckBox
|
||||||
|
$chkBitLocker.Text = 'Enable BitLocker (TPM + PIN)'
|
||||||
|
$chkBitLocker.ForeColor = $colText; $chkBitLocker.Font = $fontBase
|
||||||
|
$chkBitLocker.AutoSize = $true
|
||||||
|
$chkBitLocker.Location = New-Object Drawing.Point($rX, ($rY + 240))
|
||||||
|
$form.Controls.Add($chkBitLocker)
|
||||||
|
|
||||||
|
$lblPin = New-Label 'BitLocker PIN' $rX ($rY + 280) | Out-Null
|
||||||
|
$lblPin = $form.Controls[$form.Controls.Count - 1]
|
||||||
|
$txtPin = New-Field ($rX) ($rY + 306) 360 $true
|
||||||
|
$lblPinC = New-Label 'Confirm PIN' $rX ($rY + 348) | Out-Null
|
||||||
|
$lblPinC = $form.Controls[$form.Controls.Count - 1]
|
||||||
|
$txtPinC = New-Field ($rX) ($rY + 374) 360 $true
|
||||||
|
|
||||||
|
# PIN fields disabled until BitLocker is checked.
|
||||||
|
$setPinEnabled = {
|
||||||
|
$on = $chkBitLocker.Checked
|
||||||
|
$txtPin.Enabled = $on; $txtPinC.Enabled = $on
|
||||||
|
$fg = if ($on) { $colMuted } else { [Drawing.Color]::FromArgb(90, 90, 98) }
|
||||||
|
$lblPin.ForeColor = $fg; $lblPinC.ForeColor = $fg
|
||||||
|
}
|
||||||
|
$chkBitLocker.Add_CheckedChanged($setPinEnabled)
|
||||||
|
& $setPinEnabled
|
||||||
|
|
||||||
|
# --- status line -----------------------------------------------------------
|
||||||
|
$lblStatus = New-Object Windows.Forms.Label
|
||||||
|
$lblStatus.Text = ''
|
||||||
|
$lblStatus.ForeColor = $colError; $lblStatus.Font = $fontBase
|
||||||
|
$lblStatus.AutoSize = $false
|
||||||
|
$lblStatus.Location = New-Object Drawing.Point(80, ($y + 8))
|
||||||
|
$lblStatus.Size = New-Object Drawing.Size(820, 28)
|
||||||
|
$form.Controls.Add($lblStatus)
|
||||||
|
|
||||||
|
# --- buttons ---------------------------------------------------------------
|
||||||
|
function Style-Button($b, $primary) {
|
||||||
|
$b.FlatStyle = 'Flat'; $b.Font = $fontBase
|
||||||
|
$b.Size = New-Object Drawing.Size(150, 40)
|
||||||
|
$b.FlatAppearance.BorderSize = 1
|
||||||
|
if ($primary) {
|
||||||
|
$b.BackColor = $colAccent; $b.ForeColor = $colBg
|
||||||
|
$b.FlatAppearance.BorderColor = $colAccent
|
||||||
|
} else {
|
||||||
|
$b.BackColor = $colPanel; $b.ForeColor = $colText
|
||||||
|
$b.FlatAppearance.BorderColor = $colMuted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$btnDefaults = New-Object Windows.Forms.Button
|
||||||
|
$btnDefaults.Text = 'Use defaults'
|
||||||
|
Style-Button $btnDefaults $false
|
||||||
|
$btnDefaults.Location = New-Object Drawing.Point(80, ($y + 48))
|
||||||
|
$form.Controls.Add($btnDefaults)
|
||||||
|
|
||||||
|
$btnCancel = New-Object Windows.Forms.Button
|
||||||
|
$btnCancel.Text = 'Cancel'
|
||||||
|
Style-Button $btnCancel $false
|
||||||
|
$btnCancel.Location = New-Object Drawing.Point(580, ($y + 48))
|
||||||
|
$form.Controls.Add($btnCancel)
|
||||||
|
|
||||||
|
$btnFinish = New-Object Windows.Forms.Button
|
||||||
|
$btnFinish.Text = 'Finish'
|
||||||
|
Style-Button $btnFinish $true
|
||||||
|
$btnFinish.Location = New-Object Drawing.Point(750, ($y + 48))
|
||||||
|
$form.Controls.Add($btnFinish)
|
||||||
|
|
||||||
|
# --- behaviour -------------------------------------------------------------
|
||||||
|
$btnDefaults.Add_Click({
|
||||||
|
$txtDisplay.Text = 'SilverMetal User'
|
||||||
|
$txtUser.Text = 'silver'
|
||||||
|
$txtComputer.Text = 'SILVER-PC'
|
||||||
|
# Leave passwords blank on purpose: the user must set them.
|
||||||
|
$txtPass.Text = ''; $txtPassC.Text = ''
|
||||||
|
foreach ($rb in $radioFlavours) { $rb.Checked = ($rb.Tag -eq 'daily-driver') }
|
||||||
|
$chkBitLocker.Checked = $false
|
||||||
|
$txtPin.Text = ''; $txtPinC.Text = ''
|
||||||
|
$lblStatus.ForeColor = $colMuted
|
||||||
|
$lblStatus.Text = 'Defaults applied. Set a password to continue.'
|
||||||
|
})
|
||||||
|
|
||||||
|
$btnCancel.Add_Click({ [Environment]::Exit(1) })
|
||||||
|
$form.Add_KeyDown({ if ($_.KeyCode -eq 'Escape') { [Environment]::Exit(1) } })
|
||||||
|
|
||||||
|
$btnFinish.Add_Click({
|
||||||
|
$lblStatus.ForeColor = $colError
|
||||||
|
|
||||||
|
$rUser = Test-SmUsername $txtUser.Text
|
||||||
|
if (-not $rUser.Ok) { $lblStatus.Text = $rUser.Message; return }
|
||||||
|
|
||||||
|
$rPass = Test-SmPassword $txtPass.Text $txtPassC.Text
|
||||||
|
if (-not $rPass.Ok) { $lblStatus.Text = $rPass.Message; return }
|
||||||
|
|
||||||
|
$rComp = Test-SmComputerName $txtComputer.Text
|
||||||
|
if (-not $rComp.Ok) { $lblStatus.Text = $rComp.Message; return }
|
||||||
|
|
||||||
|
$blEnabled = $chkBitLocker.Checked
|
||||||
|
$pin = ''
|
||||||
|
if ($blEnabled) {
|
||||||
|
$rPin = Test-SmPin $txtPin.Text $txtPinC.Text
|
||||||
|
if (-not $rPin.Ok) { $lblStatus.Text = $rPin.Message; return }
|
||||||
|
$pin = $txtPin.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
$flavour = 'daily-driver'
|
||||||
|
foreach ($rb in $radioFlavours) { if ($rb.Checked) { $flavour = [string]$rb.Tag } }
|
||||||
|
|
||||||
|
$display = if ([string]::IsNullOrWhiteSpace($txtDisplay.Text)) { $txtUser.Text } else { $txtDisplay.Text }
|
||||||
|
|
||||||
|
$lblStatus.ForeColor = $colAccent
|
||||||
|
$lblStatus.Text = 'Generating answer file and starting Windows Setup...'
|
||||||
|
$form.Refresh()
|
||||||
|
|
||||||
|
$xml = New-SmAnswerFile -DisplayName $display -Username $txtUser.Text -Password $txtPass.Text `
|
||||||
|
-ComputerName $txtComputer.Text -Flavour $flavour `
|
||||||
|
-BitLockerEnable $blEnabled -BitLockerPin $pin
|
||||||
|
|
||||||
|
Set-Content -Path 'X:\sm\unattend.generated.xml' -Value $xml -Encoding UTF8
|
||||||
|
|
||||||
|
try { [void][xml](Get-Content 'X:\sm\unattend.generated.xml' -Raw) }
|
||||||
|
catch { [Environment]::Exit(1) } # bad XML -> fall back to default answer file
|
||||||
|
|
||||||
|
$setup = if (Test-Path 'X:\sources\setup.exe') { 'X:\sources\setup.exe' } else { 'X:\setup.exe' }
|
||||||
|
Start-Process -FilePath $setup -ArgumentList '/unattend:X:\sm\unattend.generated.xml' -Wait
|
||||||
|
[Environment]::Exit(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
[void]$form.ShowDialog()
|
||||||
|
# If the form closes without Finish/Cancel handling exiting, treat as cancel.
|
||||||
|
[Environment]::Exit(1)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# Any failure -> exit 1 so Start-Collector.cmd falls back to the default answer file.
|
||||||
|
[Environment]::Exit(1)
|
||||||
|
}
|
||||||
105
windows/collector/New-SmAnswerFile.ps1
Normal file
105
windows/collector/New-SmAnswerFile.ps1
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
# Pure generator: collected values -> Windows Setup answer-file XML string.
|
||||||
|
# No WinForms dependency (unit-testable). Mirrors the legacy autounattend.xml but with
|
||||||
|
# ONE real local-admin account (no sm-bootstrap) and an embedded preconfig.json that a
|
||||||
|
# specialize-pass command writes to C:\ProgramData\SilverMetal\preconfig.json.
|
||||||
|
|
||||||
|
function New-SmAnswerFile {
|
||||||
|
param(
|
||||||
|
[string]$DisplayName, [string]$Username, [string]$Password,
|
||||||
|
[string]$ComputerName,
|
||||||
|
[string]$InputLocale = '0809:00000809', [string]$SystemLocale = 'en-GB',
|
||||||
|
[string]$UiLanguage = 'en-US', [string]$UserLocale = 'en-GB',
|
||||||
|
[string]$Flavour, [bool]$BitLockerEnable = $false, [string]$BitLockerPin = ''
|
||||||
|
)
|
||||||
|
|
||||||
|
$pre = [ordered]@{
|
||||||
|
schemaVersion = 1
|
||||||
|
flavour = $Flavour
|
||||||
|
bitlocker = [ordered]@{ enable = [bool]$BitLockerEnable; pin = $BitLockerPin }
|
||||||
|
apps = [ordered]@{ useFlavourDefaults = $true }
|
||||||
|
}
|
||||||
|
$preJson = ($pre | ConvertTo-Json -Depth 6 -Compress)
|
||||||
|
$preB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($preJson))
|
||||||
|
|
||||||
|
function Esc([string]$s) { [Security.SecurityElement]::Escape($s) }
|
||||||
|
# Escape ONLY the characters XML element content requires (& < >). Unlike
|
||||||
|
# SecurityElement::Escape this leaves single/double quotes literal, so the
|
||||||
|
# embedded command keeps a working FromBase64String('...') literal.
|
||||||
|
function EscContent([string]$s) { $s.Replace('&','&').Replace('<','<').Replace('>','>') }
|
||||||
|
$dn = Esc $DisplayName; $un = Esc $Username; $pw = Esc $Password; $cn = Esc $ComputerName
|
||||||
|
|
||||||
|
$writePre = "powershell -NoProfile -ExecutionPolicy Bypass -Command "" New-Item -ItemType Directory -Force 'C:\ProgramData\SilverMetal' | Out-Null; [IO.File]::WriteAllBytes('C:\ProgramData\SilverMetal\preconfig.json', [Convert]::FromBase64String('$preB64')) """
|
||||||
|
|
||||||
|
@"
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<unattend xmlns="urn:schemas-microsoft-com:unattend">
|
||||||
|
<settings pass="windowsPE">
|
||||||
|
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<SetupUILanguage><UILanguage>$UiLanguage</UILanguage></SetupUILanguage>
|
||||||
|
<InputLocale>$InputLocale</InputLocale><SystemLocale>$SystemLocale</SystemLocale>
|
||||||
|
<UILanguage>$UiLanguage</UILanguage><UserLocale>$UserLocale</UserLocale>
|
||||||
|
</component>
|
||||||
|
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<DiskConfiguration>
|
||||||
|
<WillShowUI>OnError</WillShowUI>
|
||||||
|
<Disk wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
|
||||||
|
<DiskID>0</DiskID><WillWipeDisk>true</WillWipeDisk>
|
||||||
|
<CreatePartitions>
|
||||||
|
<CreatePartition wcm:action="add"><Order>1</Order><Type>EFI</Type><Size>300</Size></CreatePartition>
|
||||||
|
<CreatePartition wcm:action="add"><Order>2</Order><Type>MSR</Type><Size>16</Size></CreatePartition>
|
||||||
|
<CreatePartition wcm:action="add"><Order>3</Order><Type>Primary</Type><Extend>true</Extend></CreatePartition>
|
||||||
|
</CreatePartitions>
|
||||||
|
<ModifyPartitions>
|
||||||
|
<ModifyPartition wcm:action="add"><Order>1</Order><PartitionID>1</PartitionID><Label>System</Label><Format>FAT32</Format></ModifyPartition>
|
||||||
|
<ModifyPartition wcm:action="add"><Order>2</Order><PartitionID>2</PartitionID></ModifyPartition>
|
||||||
|
<ModifyPartition wcm:action="add"><Order>3</Order><PartitionID>3</PartitionID><Label>Windows</Label><Format>NTFS</Format><Letter>C</Letter></ModifyPartition>
|
||||||
|
</ModifyPartitions>
|
||||||
|
</Disk>
|
||||||
|
</DiskConfiguration>
|
||||||
|
<ImageInstall><OSImage>
|
||||||
|
<InstallTo><DiskID>0</DiskID><PartitionID>3</PartitionID></InstallTo>
|
||||||
|
<InstallFrom><MetaData wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Key>/IMAGE/INDEX</Key><Value>1</Value></MetaData></InstallFrom>
|
||||||
|
</OSImage></ImageInstall>
|
||||||
|
<UserData><AcceptEula>true</AcceptEula></UserData>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
<settings pass="specialize">
|
||||||
|
<component name="Microsoft-Windows-Deployment" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<RunSynchronous>
|
||||||
|
<RunSynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
|
||||||
|
<Order>1</Order>
|
||||||
|
<Path>$(EscContent $writePre)</Path>
|
||||||
|
<Description>Write SilverMetal preconfig</Description>
|
||||||
|
</RunSynchronousCommand>
|
||||||
|
</RunSynchronous>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
<settings pass="oobeSystem">
|
||||||
|
<component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<InputLocale>$InputLocale</InputLocale><SystemLocale>$SystemLocale</SystemLocale>
|
||||||
|
<UILanguage>$UiLanguage</UILanguage><UILanguageFallback>$UiLanguage</UILanguageFallback><UserLocale>$UserLocale</UserLocale>
|
||||||
|
</component>
|
||||||
|
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<OOBE><HideEULAPage>true</HideEULAPage><HideOEMRegistrationScreen>true</HideOEMRegistrationScreen><HideOnlineAccountScreens>true</HideOnlineAccountScreens><HideLocalAccountScreen>true</HideLocalAccountScreen><HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE><ProtectYourPC>3</ProtectYourPC></OOBE>
|
||||||
|
<UserAccounts><LocalAccounts>
|
||||||
|
<LocalAccount wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
|
||||||
|
<Name>$un</Name><Group>Administrators</Group><DisplayName>$dn</DisplayName>
|
||||||
|
<Password><Value>$pw</Value><PlainText>true</PlainText></Password>
|
||||||
|
</LocalAccount>
|
||||||
|
</LocalAccounts></UserAccounts>
|
||||||
|
<AutoLogon><Enabled>true</Enabled><LogonCount>1</LogonCount><Username>$un</Username><Password><Value>$pw</Value><PlainText>true</PlainText></Password></AutoLogon>
|
||||||
|
<ComputerName>$cn</ComputerName>
|
||||||
|
<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 "Start-Process -FilePath 'C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe' -Verb RunAs"</CommandLine>
|
||||||
|
<Description>Launch SilverMetal toolbox (run-once)</Description>
|
||||||
|
</SynchronousCommand>
|
||||||
|
</FirstLogonCommands>
|
||||||
|
<RegisteredOwner>SilverMetal</RegisteredOwner><RegisteredOrganization>SilverLABS</RegisteredOrganization>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
</unattend>
|
||||||
|
"@
|
||||||
|
}
|
||||||
15
windows/collector/Start-Collector.cmd
Normal file
15
windows/collector/Start-Collector.cmd
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
@echo off
|
||||||
|
set "SETUP=X:\sources\setup.exe"
|
||||||
|
if not exist "%SETUP%" set "SETUP=X:\setup.exe"
|
||||||
|
REM WinPE entry point. SM_UNATTENDED=1 -> skip the UI and launch Setup with the default
|
||||||
|
REM answer file (used by CI / non-interactive builds).
|
||||||
|
if "%SM_UNATTENDED%"=="1" (
|
||||||
|
start /wait "%SETUP%" /unattend:X:\autounattend.xml
|
||||||
|
exit /b 0
|
||||||
|
)
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File X:\sm\Collector.ps1
|
||||||
|
if errorlevel 1 (
|
||||||
|
REM Collector failed or was cancelled -> fall back to the default answer file so install still proceeds.
|
||||||
|
start /wait "%SETUP%" /unattend:X:\autounattend.xml
|
||||||
|
)
|
||||||
|
exit /b 0
|
||||||
2
windows/collector/winpeshl.ini
Normal file
2
windows/collector/winpeshl.ini
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[LaunchApps]
|
||||||
|
%SYSTEMDRIVE%\sm\Start-Collector.cmd
|
||||||
@@ -118,6 +118,18 @@ function Invoke-ForceLegacySetup {
|
|||||||
# unreliable when setup is launched via the CmdLine override (legacy Setup
|
# unreliable when setup is launched via the CmdLine override (legacy Setup
|
||||||
# otherwise still shows the language page).
|
# otherwise still shows the language page).
|
||||||
Copy-Item (Join-Path $PSScriptRoot 'autounattend\autounattend.xml') (Join-Path $bootmnt 'autounattend.xml') -Force
|
Copy-Item (Join-Path $PSScriptRoot 'autounattend\autounattend.xml') (Join-Path $bootmnt 'autounattend.xml') -Force
|
||||||
|
# Add WinPE .NET + PowerShell so the collector (WinForms) can run in WinPE.
|
||||||
|
$adk = 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Windows Preinstallation Environment\amd64\WinPE_OCs'
|
||||||
|
foreach ($oc in @('WinPE-WMI.cab','WinPE-NetFx.cab','WinPE-Scripting.cab','WinPE-PowerShell.cab')) {
|
||||||
|
$cab = Join-Path $adk $oc
|
||||||
|
if (Test-Path $cab) { Add-WindowsPackage -Path $bootmnt -PackagePath $cab | Out-Null; Write-Host " added WinPE OC: $oc" }
|
||||||
|
else { Write-Warning " WinPE OC missing on runner: $cab (collector needs the ADK WinPE add-on; boot.wim assertions will fail without it)" }
|
||||||
|
}
|
||||||
|
# Stage the collector + winpeshl so WinPE launches it instead of Setup.
|
||||||
|
$smDir = Join-Path $bootmnt 'sm'; $null = New-Item -ItemType Directory -Force $smDir
|
||||||
|
Copy-Item (Join-Path $PSScriptRoot '..\collector\*') $smDir -Recurse -Force
|
||||||
|
Copy-Item (Join-Path $smDir 'winpeshl.ini') (Join-Path $bootmnt 'Windows\System32\winpeshl.ini') -Force
|
||||||
|
Write-Host " staged collector to boot.wim \sm\ + winpeshl.ini"
|
||||||
$setup = if (Test-Path (Join-Path $bootmnt 'sources\setup.exe')) { 'X:\sources\setup.exe' } else { 'X:\setup.exe' }
|
$setup = if (Test-Path (Join-Path $bootmnt 'sources\setup.exe')) { 'X:\sources\setup.exe' } else { 'X:\setup.exe' }
|
||||||
$cmdline = "$setup /unattend:X:\autounattend.xml"
|
$cmdline = "$setup /unattend:X:\autounattend.xml"
|
||||||
$hive = Join-Path $bootmnt 'Windows\System32\config\SYSTEM'
|
$hive = Join-Path $bootmnt 'Windows\System32\config\SYSTEM'
|
||||||
|
|||||||
@@ -33,5 +33,10 @@ if exist "C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe" (
|
|||||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%HARD%\Invoke-Hardening.ps1" >> "%LOG%" 2>&1
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%HARD%\Invoke-Hardening.ps1" >> "%LOG%" 2>&1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
REM Plaintext-password hygiene: delete the cached answer file that holds the
|
||||||
|
REM local account password in clear text. Runs as SYSTEM after accounts exist.
|
||||||
|
del /f /q "%WINDIR%\Panther\unattend.xml" 2>nul
|
||||||
|
del /f /q "%WINDIR%\Panther\Unattend\unattend.xml" 2>nul
|
||||||
|
|
||||||
echo [%DATE% %TIME%] SilverMetal first-boot done >> "%LOG%"
|
echo [%DATE% %TIME%] SilverMetal first-boot done >> "%LOG%"
|
||||||
exit /b 0
|
exit /b 0
|
||||||
|
|||||||
@@ -56,6 +56,20 @@ try {
|
|||||||
}
|
}
|
||||||
} finally { Dismount-WindowsImage -Path $mount -Discard | Out-Null }
|
} finally { Dismount-WindowsImage -Path $mount -Discard | Out-Null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# boot.wim must carry the WinPE collector + winpeshl (the pre-config front-end).
|
||||||
|
$bootwim = "$drive\sources\boot.wim"
|
||||||
|
Assert 'boot.wim present' (Test-Path $bootwim)
|
||||||
|
if (Test-Path $bootwim) {
|
||||||
|
$bmount = Join-Path $env:TEMP ('sm-assert-boot-' + [guid]::NewGuid().ToString('N'))
|
||||||
|
New-Item -ItemType Directory -Force $bmount | Out-Null
|
||||||
|
Mount-WindowsImage -ImagePath $bootwim -Index 2 -Path $bmount -ReadOnly | Out-Null
|
||||||
|
try {
|
||||||
|
Assert 'collector staged in boot.wim' (Test-Path (Join-Path $bmount 'sm\Collector.ps1'))
|
||||||
|
Assert 'winpeshl.ini set' (Test-Path (Join-Path $bmount 'Windows\System32\winpeshl.ini'))
|
||||||
|
Assert 'answer-file generator staged' (Test-Path (Join-Path $bmount 'sm\New-SmAnswerFile.ps1'))
|
||||||
|
} finally { Dismount-WindowsImage -Path $bmount -Discard | Out-Null; Remove-Item $bmount -Recurse -Force -EA SilentlyContinue }
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
Dismount-DiskImage -ImagePath $IsoPath | Out-Null
|
Dismount-DiskImage -ImagePath $IsoPath | Out-Null
|
||||||
Remove-Item $mount -Recurse -Force -EA SilentlyContinue
|
Remove-Item $mount -Recurse -Force -EA SilentlyContinue
|
||||||
|
|||||||
@@ -30,3 +30,40 @@ Describe 'Test-SmComputerName' {
|
|||||||
It 'rejects > 15 chars' { (Test-SmComputerName ('A'*16)).Ok | Should -BeFalse }
|
It 'rejects > 15 chars' { (Test-SmComputerName ('A'*16)).Ok | Should -BeFalse }
|
||||||
It 'rejects illegal chars' { (Test-SmComputerName 'bad name').Ok | Should -BeFalse }
|
It 'rejects illegal chars' { (Test-SmComputerName 'bad name').Ok | Should -BeFalse }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Describe 'New-SmAnswerFile' {
|
||||||
|
BeforeAll {
|
||||||
|
. (Join-Path $PSScriptRoot '..\collector\New-SmAnswerFile.ps1')
|
||||||
|
$cfg = @{
|
||||||
|
DisplayName = 'Jamie'; Username = 'jamie'; Password = 'Sup3rPass!'
|
||||||
|
ComputerName = 'SILVER-01'
|
||||||
|
InputLocale = '0809:00000809'; SystemLocale = 'en-GB'; UiLanguage = 'en-US'; UserLocale = 'en-GB'
|
||||||
|
Flavour = 'developer'; BitLockerEnable = $true; BitLockerPin = '246810'
|
||||||
|
}
|
||||||
|
$script:xml = New-SmAnswerFile @cfg
|
||||||
|
}
|
||||||
|
It 'is valid XML' { { [xml]$script:xml } | Should -Not -Throw }
|
||||||
|
It 'creates the real account in Administrators' {
|
||||||
|
$script:xml | Should -Match '<Name>jamie</Name>'
|
||||||
|
$script:xml | Should -Match '<Group>Administrators</Group>'
|
||||||
|
}
|
||||||
|
It 'does NOT contain sm-bootstrap' { $script:xml | Should -Not -Match 'sm-bootstrap' }
|
||||||
|
It 'sets AutoLogon once as the user' {
|
||||||
|
$script:xml | Should -Match '<LogonCount>1</LogonCount>'
|
||||||
|
$script:xml | Should -Match '<Username>jamie</Username>'
|
||||||
|
}
|
||||||
|
It 'sets the computer name' { $script:xml | Should -Match '<ComputerName>SILVER-01</ComputerName>' }
|
||||||
|
It 'keeps WillWipeDisk for disk 0' { $script:xml | Should -Match '<WillWipeDisk>true</WillWipeDisk>' }
|
||||||
|
It 'embeds a base64 preconfig write in specialize' {
|
||||||
|
$script:xml | Should -Match 'preconfig\.json'
|
||||||
|
$script:xml | Should -Match 'FromBase64String'
|
||||||
|
}
|
||||||
|
It 'embedded preconfig round-trips with the flavour and pin' {
|
||||||
|
$m = [regex]::Match($script:xml, "FromBase64String\('([^']+)'\)")
|
||||||
|
$m.Success | Should -BeTrue
|
||||||
|
$json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($m.Groups[1].Value)) | ConvertFrom-Json
|
||||||
|
$json.flavour | Should -Be 'developer'
|
||||||
|
$json.bitlocker.pin | Should -Be '246810'
|
||||||
|
}
|
||||||
|
It 'launches the toolbox in FirstLogonCommands' { $script:xml | Should -Match 'SilverOS\.Welcome\.App\.exe' }
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using SilverOS.Welcome.Core.Apply;
|
using SilverOS.Welcome.Core.Apply;
|
||||||
using SilverOS.Welcome.Core.Apps;
|
using SilverOS.Welcome.Core.Apps;
|
||||||
using SilverOS.Welcome.Core.Flavours;
|
using SilverOS.Welcome.Core.Flavours;
|
||||||
|
using SilverOS.Welcome.Core.Preconfig;
|
||||||
using SilverOS.Welcome.App.Components;
|
using SilverOS.Welcome.App.Components;
|
||||||
|
|
||||||
namespace SilverOS.Welcome.App;
|
namespace SilverOS.Welcome.App;
|
||||||
@@ -32,22 +33,16 @@ public static class MauiProgram
|
|||||||
builder.Logging.AddDebug();
|
builder.Logging.AddDebug();
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
var hardeningDir = @"C:\Windows\Setup\Scripts\hardening";
|
|
||||||
builder.Services.AddSingleton<IProcessRunner, ProcessRunner>();
|
builder.Services.AddSingleton<IProcessRunner, ProcessRunner>();
|
||||||
builder.Services.AddSingleton<IFlavourLoader, FlavourLoader>();
|
builder.Services.AddSingleton<IFlavourLoader, FlavourLoader>();
|
||||||
builder.Services.AddSingleton<IAppCatalog, AppCatalog>();
|
builder.Services.AddSingleton<IAppCatalog, AppCatalog>();
|
||||||
builder.Services.AddSingleton<IAccountService, AccountService>();
|
|
||||||
builder.Services.AddSingleton<IBitLockerService, BitLockerService>();
|
builder.Services.AddSingleton<IBitLockerService, BitLockerService>();
|
||||||
builder.Services.AddSingleton<IBootstrapService, BootstrapService>();
|
|
||||||
var appsDir = Path.Combine(AppContext.BaseDirectory, "apps");
|
var appsDir = Path.Combine(AppContext.BaseDirectory, "apps");
|
||||||
builder.Services.AddSingleton<IAppInstaller>(sp => new AppInstaller(sp.GetRequiredService<IProcessRunner>(), appsDir));
|
builder.Services.AddSingleton<IAppInstaller>(sp => new AppInstaller(sp.GetRequiredService<IProcessRunner>(), appsDir));
|
||||||
builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
|
builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
|
||||||
sp.GetRequiredService<IProcessRunner>(),
|
|
||||||
sp.GetRequiredService<IAccountService>(),
|
|
||||||
sp.GetRequiredService<IBitLockerService>(),
|
sp.GetRequiredService<IBitLockerService>(),
|
||||||
sp.GetRequiredService<IBootstrapService>(),
|
sp.GetRequiredService<IAppInstaller>()));
|
||||||
sp.GetRequiredService<IAppInstaller>(),
|
builder.Services.AddSingleton<IPreconfigStore>(_ => new PreconfigStore(@"C:\ProgramData\SilverMetal"));
|
||||||
hardeningDir));
|
|
||||||
builder.Services.AddScoped<WizardState>();
|
builder.Services.AddScoped<WizardState>();
|
||||||
|
|
||||||
return builder.Build();
|
return builder.Build();
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
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("'", "''");
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using SilverOS.Welcome.Core.Apps;
|
|
||||||
using SilverOS.Welcome.Core.Flavours;
|
using SilverOS.Welcome.Core.Flavours;
|
||||||
|
using SilverOS.Welcome.Core.Apps;
|
||||||
namespace SilverOS.Welcome.Core.Apply;
|
namespace SilverOS.Welcome.Core.Apply;
|
||||||
public sealed record ApplyRequest(FlavourManifest Flavour, string Username, string Password,
|
// Toolbox model: the account is created by Windows Setup (WinPE collector), and hardening
|
||||||
string AdminPassword, string BitLockerPin, string BootstrapUser,
|
// runs from SetupComplete. Apply only installs apps + enrols BitLocker.
|
||||||
IReadOnlyList<AppCatalogEntry> Apps);
|
public sealed record ApplyRequest(FlavourManifest Flavour, string BitLockerPin, IReadOnlyList<AppCatalogEntry> Apps);
|
||||||
|
|||||||
@@ -1,45 +1,21 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using SilverOS.Welcome.Core.Apps;
|
using SilverOS.Welcome.Core.Apps;
|
||||||
using SilverOS.Welcome.Core.Flavours;
|
|
||||||
namespace SilverOS.Welcome.Core.Apply;
|
namespace SilverOS.Welcome.Core.Apply;
|
||||||
|
|
||||||
public sealed class ApplyService(IProcessRunner runner, IAccountService accounts,
|
// Toolbox Apply pipeline: apps -> bitlocker -> done.
|
||||||
IBitLockerService bitlocker, IBootstrapService bootstrap, IAppInstaller installer,
|
// Account creation moved to Windows Setup (WinPE collector); OS hardening runs from
|
||||||
string hardeningDir) : IApplyService
|
// SetupComplete; sm-bootstrap teardown is owned by Setup, not the toolbox.
|
||||||
|
public sealed class ApplyService(IBitLockerService bitlocker, IAppInstaller installer) : IApplyService
|
||||||
{
|
{
|
||||||
public async Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default)
|
public async Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
progress.Report(new("Applying hardening", 10));
|
progress.Report(new("Installing apps", 30));
|
||||||
// Pass modules as a single bare CSV token (e.g. 00,03,05).
|
await installer.InstallAsync(req.Apps, progress, ct); // continue-on-failure; never throws
|
||||||
// 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.
|
if (!string.IsNullOrWhiteSpace(req.BitLockerPin))
|
||||||
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.
|
progress.Report(new("Encrypting the disk", 75));
|
||||||
var firstLine = res.StdErr
|
await bitlocker.EnableAsync(req.BitLockerPin, ct);
|
||||||
.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("Installing apps", 70));
|
|
||||||
await installer.InstallAsync(req.Apps, progress, ct); // continue-on-failure; never throws
|
|
||||||
|
|
||||||
progress.Report(new("Encrypting the disk", 75));
|
|
||||||
await bitlocker.EnableAsync(req.BitLockerPin, ct);
|
|
||||||
|
|
||||||
progress.Report(new("Finishing up", 95));
|
|
||||||
await bootstrap.RevertKioskAsync(ct); // revert kiosk before account deletion (SID must still resolve)
|
|
||||||
await bootstrap.TearDownAsync(req.BootstrapUser, ct); // last — only after success
|
|
||||||
progress.Report(new("Done", 100));
|
progress.Report(new("Done", 100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
namespace SilverOS.Welcome.Core.Apply;
|
|
||||||
public sealed class BootstrapService(IProcessRunner runner) : IBootstrapService
|
|
||||||
{
|
|
||||||
// Lockdown revert is BEST-EFFORT (like TearDownAsync): -EA SilentlyContinue throughout.
|
|
||||||
// Don't fail the apply over a missing WMI class / key. Must run BEFORE TearDownAsync.
|
|
||||||
public async Task RevertKioskAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
// Disable the Keyboard Filter rules so the real end-user's Win key / task-switch /
|
|
||||||
// Alt+F4 etc. work again (Explorer is already the shell — nothing to undo there).
|
|
||||||
await Ps(
|
|
||||||
"$c='root\\\\standardcimv2\\\\embedded';" +
|
|
||||||
"foreach($k in @('Win','Win+L','Ctrl+Esc','Ctrl+Win+F','Win+R','Alt+Tab','Ctrl+Shift+Esc','Alt+F4')){" +
|
|
||||||
"$p=Get-CimInstance -Namespace $c -ClassName WEKF_PredefinedKey -Filter \"Id='$k'\" -EA SilentlyContinue;" +
|
|
||||||
"if($p){$p.Enabled=$false; Set-CimInstance -InputObject $p -EA SilentlyContinue}" +
|
|
||||||
"}",
|
|
||||||
ct);
|
|
||||||
// Revert escape policies set by Configure-Kiosk.ps1.
|
|
||||||
await Ps(
|
|
||||||
"$s='HKLM:\\\\SOFTWARE\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Policies\\\\System';" +
|
|
||||||
"Remove-ItemProperty $s -Name DisableTaskMgr,DisableLockWorkstation,HideFastUserSwitching -EA SilentlyContinue;" +
|
|
||||||
// Restore SECURE UAC for the real end-user (the kiosk auto-approved unsigned elevation).
|
|
||||||
"Set-ItemProperty $s -Name ConsentPromptBehaviorAdmin -Value 2 -Type DWord -EA SilentlyContinue;" +
|
|
||||||
"Set-ItemProperty $s -Name PromptOnSecureDesktop -Value 1 -Type DWord -EA SilentlyContinue",
|
|
||||||
ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Teardown is BEST-EFFORT (unlike Account/BitLocker which are strict): the answer file's
|
|
||||||
// AutoLogon LogonCount=1 already neutralises auto-logon after the first logon (Windows clears
|
|
||||||
// AutoAdminLogon itself), so these Winlogon cleanups must not fail the whole apply. The op that
|
|
||||||
// matters — removing the sm-bootstrap account — runs regardless and is tolerant too.
|
|
||||||
public async Task TearDownAsync(string bootstrapUser, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
const string w = "'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon'";
|
|
||||||
await Ps($"Set-ItemProperty -Path {w} -Name AutoAdminLogon -Value '0' -EA SilentlyContinue; " +
|
|
||||||
$"Remove-ItemProperty -Path {w} -Name DefaultPassword -EA SilentlyContinue; " +
|
|
||||||
$"Remove-ItemProperty -Path {w} -Name DefaultUserName -EA SilentlyContinue; " +
|
|
||||||
$"Remove-ItemProperty -Path {w} -Name DefaultDomainName -EA SilentlyContinue", ct);
|
|
||||||
var u = Esc(bootstrapUser);
|
|
||||||
// Best-effort in-session removal (usually no-ops — you can't delete the account
|
|
||||||
// you're logged in as), THEN defer the real removal to a SYSTEM startup task that
|
|
||||||
// runs on next boot, when sm-bootstrap is no longer logged on. It removes the
|
|
||||||
// account + profile, then unregisters itself.
|
|
||||||
// Disable immediately (in-session, takes effect at once so the account is unusable
|
|
||||||
// and shows as disabled), then best-effort delete; the deferred task does the real
|
|
||||||
// delete on next boot when it isn't logged on.
|
|
||||||
await Ps($"Disable-LocalUser -Name '{u}' -EA SilentlyContinue; Remove-LocalUser -Name '{u}' -EA SilentlyContinue", ct);
|
|
||||||
var cleanup =
|
|
||||||
$"Remove-LocalUser -Name '{u}' -ErrorAction SilentlyContinue; " +
|
|
||||||
$"Get-CimInstance Win32_UserProfile | Where-Object {{ $_.LocalPath -like '*\\{u}' }} | Remove-CimInstance -ErrorAction SilentlyContinue; " +
|
|
||||||
"Unregister-ScheduledTask -TaskName 'SilverMetalBootstrapCleanup' -Confirm:$false -ErrorAction SilentlyContinue";
|
|
||||||
var b64 = Convert.ToBase64String(System.Text.Encoding.Unicode.GetBytes(cleanup));
|
|
||||||
// Register-ScheduledTask (not schtasks.exe) — schtasks /tr caps at 261 chars and
|
|
||||||
// silently failed with the encoded payload, so the task was never created.
|
|
||||||
await Ps("$a=New-ScheduledTaskAction -Execute 'powershell.exe' -Argument " +
|
|
||||||
$"'-NoProfile -ExecutionPolicy Bypass -EncodedCommand {b64}'; " +
|
|
||||||
"$t=New-ScheduledTaskTrigger -AtStartup; " +
|
|
||||||
"$p=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest; " +
|
|
||||||
"Register-ScheduledTask -TaskName 'SilverMetalBootstrapCleanup' -Action $a -Trigger $t -Principal $p -Force | Out-Null", ct);
|
|
||||||
}
|
|
||||||
private static string Esc(string s) => s.Replace("'", "''");
|
|
||||||
private Task Ps(string s, CancellationToken ct) =>
|
|
||||||
runner.RunAsync("powershell.exe", $"-NoProfile -ExecutionPolicy Bypass -Command \"{s}\"", ct);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
namespace SilverOS.Welcome.Core.Apply;
|
|
||||||
public interface IAccountService { Task CreateAccountsAsync(string user, string password, string adminPassword, CancellationToken ct = default); }
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace SilverOS.Welcome.Core.Apply;
|
|
||||||
public interface IBootstrapService
|
|
||||||
{
|
|
||||||
Task RevertKioskAsync(CancellationToken ct = default);
|
|
||||||
Task TearDownAsync(string bootstrapUser, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SilverOS.Welcome.Core.Preconfig;
|
||||||
|
|
||||||
|
public interface IPreconfigStore
|
||||||
|
{
|
||||||
|
Preconfig? Load(); // null if missing/corrupt (fail-open)
|
||||||
|
void ClearPin(); // rewrite preconfig without the BitLocker pin
|
||||||
|
bool IsConfigured(); // configured marker present?
|
||||||
|
void MarkConfigured(); // write the configured marker
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace SilverOS.Welcome.Core.Preconfig;
|
||||||
|
|
||||||
|
public sealed record BitlockerConfig { public bool Enable { get; init; } public string? Pin { get; init; } }
|
||||||
|
public sealed record AppsConfig { public bool UseFlavourDefaults { get; init; } = true; public IReadOnlyList<string>? Selected { get; init; } }
|
||||||
|
|
||||||
|
public sealed record Preconfig
|
||||||
|
{
|
||||||
|
public int SchemaVersion { get; init; } = 1;
|
||||||
|
public string Flavour { get; init; } = "";
|
||||||
|
public BitlockerConfig Bitlocker { get; init; } = new();
|
||||||
|
public AppsConfig Apps { get; init; } = new();
|
||||||
|
|
||||||
|
public static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace SilverOS.Welcome.Core.Preconfig;
|
||||||
|
|
||||||
|
public sealed class PreconfigStore(string dir) : IPreconfigStore
|
||||||
|
{
|
||||||
|
private string File_ => Path.Combine(dir, "preconfig.json");
|
||||||
|
private string Marker => Path.Combine(dir, "configured");
|
||||||
|
|
||||||
|
public Preconfig? Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(File_)) return null;
|
||||||
|
return JsonSerializer.Deserialize<Preconfig>(File.ReadAllText(File_), Preconfig.JsonOptions);
|
||||||
|
}
|
||||||
|
catch (JsonException) { return null; } // fail-open
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearPin()
|
||||||
|
{
|
||||||
|
var p = Load();
|
||||||
|
if (p is null) return;
|
||||||
|
var stripped = p with { Bitlocker = p.Bitlocker with { Pin = null } };
|
||||||
|
File.WriteAllText(File_, JsonSerializer.Serialize(stripped, Preconfig.JsonOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsConfigured() => File.Exists(Marker);
|
||||||
|
public void MarkConfigured() { Directory.CreateDirectory(dir); File.WriteAllText(Marker, "1"); }
|
||||||
|
}
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
@using SilverOS.Welcome.App.Components.Steps
|
@using SilverOS.Welcome.App.Components.Steps
|
||||||
@using SilverOS.Welcome.Core.Flavours
|
@using SilverOS.Welcome.Core.Flavours
|
||||||
@using SilverOS.Welcome.Core.Apps
|
@using SilverOS.Welcome.Core.Apps
|
||||||
|
@using SilverOS.Welcome.Core.Preconfig
|
||||||
@inject IFlavourLoader FlavourLoader
|
@inject IFlavourLoader FlavourLoader
|
||||||
@inject IAppCatalog AppCatalog
|
@inject IAppCatalog AppCatalog
|
||||||
|
@inject IPreconfigStore PreconfigStore
|
||||||
@inject WizardState State
|
@inject WizardState State
|
||||||
|
|
||||||
|
@if (_toolboxHome)
|
||||||
|
{
|
||||||
|
<div class="toolbox-home">
|
||||||
|
<h1>SilverMetal</h1>
|
||||||
|
<p class="toolbox-home-subtitle">Your device is set up and ready to go.</p>
|
||||||
|
<button class="btn-secondary" @onclick="ReRunSetup">Re-run setup</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
<div class="wizard">
|
<div class="wizard">
|
||||||
<div class="wizard-header">
|
<div class="wizard-header">
|
||||||
<div class="wizard-steps-indicator">
|
<div class="wizard-steps-indicator">
|
||||||
@@ -45,15 +57,12 @@
|
|||||||
<AppsStep Apps="_catalog.AppsForRole(State.Flavour?.Id ?? string.Empty)" />
|
<AppsStep Apps="_catalog.AppsForRole(State.Flavour?.Id ?? string.Empty)" />
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
<AccountStep OnValidityChanged="@(v => { _accountValid = v; StateHasChanged(); })" />
|
|
||||||
break;
|
|
||||||
case 4:
|
|
||||||
<PrefsStep />
|
<PrefsStep />
|
||||||
break;
|
break;
|
||||||
case 5:
|
case 4:
|
||||||
<ApplyStep OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" />
|
<ApplyStep AutoStart="_autoApply" OnComplete="AdvanceToDone" OnRunningChanged="@(v => { _applyRunning = v; StateHasChanged(); })" />
|
||||||
break;
|
break;
|
||||||
case 6:
|
case 5:
|
||||||
<DoneStep />
|
<DoneStep />
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -66,7 +75,7 @@
|
|||||||
@onclick="Back">
|
@onclick="Back">
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
@if (_currentStep < _stepTitles.Length - 1 && _currentStep != 5)
|
@if (_currentStep < _stepTitles.Length - 1 && _currentStep != 4)
|
||||||
{
|
{
|
||||||
<button class="btn-primary"
|
<button class="btn-primary"
|
||||||
disabled="@(!CanGoNext)"
|
disabled="@(!CanGoNext)"
|
||||||
@@ -76,9 +85,10 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private static readonly string[] _stepTitles = { "Welcome", "Flavour", "Apps", "Account", "Prefs", "Apply", "Done" };
|
private static readonly string[] _stepTitles = { "Welcome", "Flavour", "Apps", "Prefs", "Apply", "Done" };
|
||||||
|
|
||||||
// Flavours dir: baked alongside the exe at publish time.
|
// Flavours dir: baked alongside the exe at publish time.
|
||||||
private static readonly string FlavoursDir = Path.Combine(
|
private static readonly string FlavoursDir = Path.Combine(
|
||||||
@@ -93,7 +103,8 @@
|
|||||||
private int _currentStep = 0;
|
private int _currentStep = 0;
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
private bool _applyRunning = false;
|
private bool _applyRunning = false;
|
||||||
private bool _accountValid = false;
|
private bool _toolboxHome = false;
|
||||||
|
private bool _autoApply = false;
|
||||||
private string? _error;
|
private string? _error;
|
||||||
private IReadOnlyList<FlavourManifest> _flavours = Array.Empty<FlavourManifest>();
|
private IReadOnlyList<FlavourManifest> _flavours = Array.Empty<FlavourManifest>();
|
||||||
|
|
||||||
@@ -101,11 +112,15 @@
|
|||||||
{
|
{
|
||||||
1 => State.Flavour is not null,
|
1 => State.Flavour is not null,
|
||||||
// 2 = Apps step is always valid (never blocks Next).
|
// 2 = Apps step is always valid (never blocks Next).
|
||||||
3 => _accountValid,
|
|
||||||
_ => true
|
_ => true
|
||||||
};
|
};
|
||||||
|
|
||||||
protected override Task OnInitializedAsync() => LoadFlavours();
|
protected override Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
LoadFlavours();
|
||||||
|
SeedFromPreconfig();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
private Task LoadFlavours()
|
private Task LoadFlavours()
|
||||||
{
|
{
|
||||||
@@ -127,6 +142,46 @@
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Runs AFTER flavours + catalog are loaded (order matters): decides run-mode and,
|
||||||
|
// on a first run, pre-seeds wizard state from the WinPE collector's choices.
|
||||||
|
private void SeedFromPreconfig()
|
||||||
|
{
|
||||||
|
var pre = PreconfigStore.Load();
|
||||||
|
|
||||||
|
if (PreconfigStore.IsConfigured())
|
||||||
|
{
|
||||||
|
// Already ran once -> open the minimal toolbox-home landing, never auto-apply.
|
||||||
|
_toolboxHome = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pre is null)
|
||||||
|
return; // fail-open: no preconfig -> normal wizard with flavour defaults.
|
||||||
|
|
||||||
|
// Match the collector's flavour by id; fall back to the loaded default if absent.
|
||||||
|
State.Flavour = _flavours.FirstOrDefault(f => f.Id == pre.Flavour)
|
||||||
|
?? _flavours.FirstOrDefault(f => f.IsDefault)
|
||||||
|
?? _flavours.FirstOrDefault();
|
||||||
|
|
||||||
|
foreach (var id in _catalog.DefaultSelectionForRole(pre.Flavour))
|
||||||
|
State.SelectedApps.Add(id);
|
||||||
|
|
||||||
|
if (pre.Bitlocker.Enable && !string.IsNullOrEmpty(pre.Bitlocker.Pin))
|
||||||
|
State.BitLockerPin = pre.Bitlocker.Pin;
|
||||||
|
|
||||||
|
// First run with a preconfig: skip the manual walkthrough. Jump straight to the
|
||||||
|
// Apply step and signal it to auto-start (spec §4d: auto-runs once, shows progress
|
||||||
|
// + recovery key, then Done).
|
||||||
|
_currentStep = 4;
|
||||||
|
_autoApply = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReRunSetup()
|
||||||
|
{
|
||||||
|
_toolboxHome = false;
|
||||||
|
_currentStep = 0;
|
||||||
|
}
|
||||||
|
|
||||||
void Next()
|
void Next()
|
||||||
{
|
{
|
||||||
if (_currentStep < _stepTitles.Length - 1)
|
if (_currentStep < _stepTitles.Length - 1)
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
@inject WizardState State
|
|
||||||
|
|
||||||
<div class="step account-step">
|
|
||||||
|
|
||||||
<h1>Set Up Your Account</h1>
|
|
||||||
<p class="step-subtitle">Create your daily-use account and administrator credentials.</p>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="username">Daily Username</label>
|
|
||||||
<input id="username" type="text" placeholder="e.g. alice"
|
|
||||||
value="@State.Username"
|
|
||||||
@oninput="OnUsernameInput" />
|
|
||||||
@if (_touched.Contains("username") && _errors.TryGetValue("username", out var ue))
|
|
||||||
{
|
|
||||||
<span class="field-error">@ue</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="password">Daily Password</label>
|
|
||||||
<input id="password" type="password"
|
|
||||||
value="@State.Password"
|
|
||||||
@oninput="OnPasswordInput" />
|
|
||||||
@if (_touched.Contains("password") && _errors.TryGetValue("password", out var pe))
|
|
||||||
{
|
|
||||||
<span class="field-error">@pe</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="adminpassword">Administrator Password</label>
|
|
||||||
<input id="adminpassword" type="password"
|
|
||||||
value="@State.AdminPassword"
|
|
||||||
@oninput="OnAdminPasswordInput" />
|
|
||||||
@if (_touched.Contains("adminpassword") && _errors.TryGetValue("adminpassword", out var ae))
|
|
||||||
{
|
|
||||||
<span class="field-error">@ae</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="bitlockerpin">BitLocker PIN <small>(numeric, 6+ digits)</small></label>
|
|
||||||
<input id="bitlockerpin" type="password" inputmode="numeric" pattern="[0-9]*"
|
|
||||||
value="@State.BitLockerPin"
|
|
||||||
@oninput="OnPinInput" />
|
|
||||||
@if (_touched.Contains("bitlockerpin") && _errors.TryGetValue("bitlockerpin", out var be))
|
|
||||||
{
|
|
||||||
<span class="field-error">@be</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private readonly Dictionary<string, string> _errors = new();
|
|
||||||
private readonly HashSet<string> _touched = new();
|
|
||||||
|
|
||||||
/// <summary>Notifies the wizard host whenever validity changes (and on initial mount).</summary>
|
|
||||||
[Parameter] public EventCallback<bool> OnValidityChanged { get; set; }
|
|
||||||
|
|
||||||
/// <summary>True when all fields are valid.</summary>
|
|
||||||
public bool IsValid { get; private set; }
|
|
||||||
|
|
||||||
protected override void OnInitialized() => Validate();
|
|
||||||
|
|
||||||
private void OnUsernameInput(ChangeEventArgs e) { State.Username = e.Value?.ToString() ?? ""; _touched.Add("username"); Validate(); }
|
|
||||||
private void OnPasswordInput(ChangeEventArgs e) { State.Password = e.Value?.ToString() ?? ""; _touched.Add("password"); Validate(); }
|
|
||||||
private void OnAdminPasswordInput(ChangeEventArgs e) { State.AdminPassword = e.Value?.ToString() ?? ""; _touched.Add("adminpassword"); Validate(); }
|
|
||||||
private void OnPinInput(ChangeEventArgs e) { State.BitLockerPin = e.Value?.ToString() ?? ""; _touched.Add("bitlockerpin"); Validate(); }
|
|
||||||
|
|
||||||
void Validate()
|
|
||||||
{
|
|
||||||
_errors.Clear();
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(State.Username))
|
|
||||||
_errors["username"] = "Daily username is required.";
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(State.Password))
|
|
||||||
_errors["password"] = "Password is required.";
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(State.AdminPassword))
|
|
||||||
_errors["adminpassword"] = "Administrator password is required.";
|
|
||||||
|
|
||||||
var pin = State.BitLockerPin ?? "";
|
|
||||||
if (!System.Text.RegularExpressions.Regex.IsMatch(pin, @"^\d{6,}$"))
|
|
||||||
_errors["bitlockerpin"] = "BitLocker PIN must be all digits and at least 6 digits long.";
|
|
||||||
|
|
||||||
IsValid = _errors.Count == 0;
|
|
||||||
_ = OnValidityChanged.InvokeAsync(IsValid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
@using SilverOS.Welcome.Core.Apps
|
@using SilverOS.Welcome.Core.Apps
|
||||||
|
@using SilverOS.Welcome.Core.Preconfig
|
||||||
@inject IApplyService ApplyService
|
@inject IApplyService ApplyService
|
||||||
@inject IAppCatalog AppCatalog
|
@inject IAppCatalog AppCatalog
|
||||||
|
@inject IPreconfigStore PreconfigStore
|
||||||
@inject WizardState State
|
@inject WizardState State
|
||||||
|
|
||||||
<div class="step apply-step">
|
<div class="step apply-step">
|
||||||
@@ -48,8 +50,10 @@
|
|||||||
@code {
|
@code {
|
||||||
[Parameter] public EventCallback OnComplete { get; set; }
|
[Parameter] public EventCallback OnComplete { get; set; }
|
||||||
[Parameter] public EventCallback<bool> OnRunningChanged { get; set; }
|
[Parameter] public EventCallback<bool> OnRunningChanged { get; set; }
|
||||||
|
[Parameter] public bool AutoStart { get; set; }
|
||||||
|
|
||||||
private bool _running;
|
private bool _running;
|
||||||
|
private bool _autoStarted;
|
||||||
private bool _complete;
|
private bool _complete;
|
||||||
private int _percent;
|
private int _percent;
|
||||||
private string _stageLabel = "Preparing…";
|
private string _stageLabel = "Preparing…";
|
||||||
@@ -69,6 +73,18 @@
|
|||||||
: single[..ErrorDisplayMaxLength] + "…";
|
: single[..ErrorDisplayMaxLength] + "…";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
// First-run auto-apply: when the host jumps straight to this step with AutoStart,
|
||||||
|
// kick off the same apply the Start button would, exactly once. The manual path
|
||||||
|
// (AutoStart=false) is untouched.
|
||||||
|
if (firstRender && AutoStart && !_autoStarted)
|
||||||
|
{
|
||||||
|
_autoStarted = true;
|
||||||
|
await StartAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task StartAsync()
|
public async Task StartAsync()
|
||||||
{
|
{
|
||||||
// Re-entrancy guard: prevent a second overlapping apply if already running
|
// Re-entrancy guard: prevent a second overlapping apply if already running
|
||||||
@@ -86,13 +102,12 @@
|
|||||||
var apps = AppCatalog.Load(Path.Combine(AppContext.BaseDirectory, "apps"))
|
var apps = AppCatalog.Load(Path.Combine(AppContext.BaseDirectory, "apps"))
|
||||||
.All.Where(a => State.SelectedApps.Contains(a.Id)).ToList();
|
.All.Where(a => State.SelectedApps.Contains(a.Id)).ToList();
|
||||||
|
|
||||||
|
// D1: Apply is now apps+bitlocker only (account via Setup, hardening via SetupComplete).
|
||||||
|
// D2 owns the full UI rewire (run-mode / preseed); this passes the 3-arg request from
|
||||||
|
// existing State fields so the app keeps compiling.
|
||||||
var req = new ApplyRequest(
|
var req = new ApplyRequest(
|
||||||
Flavour: State.Flavour!,
|
Flavour: State.Flavour!,
|
||||||
Username: State.Username,
|
|
||||||
Password: State.Password,
|
|
||||||
AdminPassword: State.AdminPassword,
|
|
||||||
BitLockerPin: State.BitLockerPin,
|
BitLockerPin: State.BitLockerPin,
|
||||||
BootstrapUser: "sm-bootstrap",
|
|
||||||
Apps: apps);
|
Apps: apps);
|
||||||
|
|
||||||
var progress = new Progress<ApplyProgress>(p =>
|
var progress = new Progress<ApplyProgress>(p =>
|
||||||
@@ -111,6 +126,12 @@
|
|||||||
_complete = true;
|
_complete = true;
|
||||||
_running = false;
|
_running = false;
|
||||||
_percent = 100;
|
_percent = 100;
|
||||||
|
|
||||||
|
// Apply succeeded: wipe the BitLocker pin from the preconfig and stamp the
|
||||||
|
// configured marker so the next launch opens toolbox-home instead of re-applying.
|
||||||
|
PreconfigStore.ClearPin();
|
||||||
|
PreconfigStore.MarkConfigured();
|
||||||
|
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
await OnRunningChanged.InvokeAsync(false);
|
await OnRunningChanged.InvokeAsync(false);
|
||||||
await OnComplete.InvokeAsync();
|
await OnComplete.InvokeAsync();
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ public sealed class WizardState
|
|||||||
// Apps step: ids of catalog apps the user chose to install.
|
// Apps step: ids of catalog apps the user chose to install.
|
||||||
public HashSet<string> SelectedApps { get; set; } = new();
|
public HashSet<string> SelectedApps { get; set; } = new();
|
||||||
|
|
||||||
public string Username { get; set; } = "";
|
|
||||||
public string Password { get; set; } = "";
|
|
||||||
public string AdminPassword { get; set; } = "";
|
|
||||||
public string BitLockerPin { get; set; } = "";
|
public string BitLockerPin { get; set; } = "";
|
||||||
|
|
||||||
// Prefs step
|
// Prefs step
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
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).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
using Moq;
|
|
||||||
using SilverOS.Welcome.Core.Apply;
|
|
||||||
using SilverOS.Welcome.Core.Apps;
|
|
||||||
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 installer = new Mock<IAppInstaller>();
|
|
||||||
installer.Setup(i => i.InstallAsync(
|
|
||||||
It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
|
|
||||||
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(Array.Empty<AppInstallResult>());
|
|
||||||
|
|
||||||
var sut = new ApplyService(
|
|
||||||
runner: new ProcessRunner(),
|
|
||||||
accounts: acct.Object,
|
|
||||||
bitlocker: bl.Object,
|
|
||||||
bootstrap: boot.Object,
|
|
||||||
installer: installer.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", Array.Empty<AppCatalogEntry>());
|
|
||||||
|
|
||||||
// ---- 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 */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,39 +15,76 @@ public class ApplyServiceTests
|
|||||||
return installer;
|
return installer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static FlavourManifest Flavour() =>
|
||||||
|
new() { Id = "daily-driver", Hardening = new HardeningSpec { Modules = new[] { "00" } } };
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Runs_modules_then_accounts_then_bitlocker_then_bootstrap_last()
|
public async Task Runs_apps_then_bitlocker_when_pin_supplied()
|
||||||
{
|
{
|
||||||
var order = new List<string>();
|
var order = new List<string>();
|
||||||
var run = new Mock<IProcessRunner>();
|
var run = new Mock<IProcessRunner>();
|
||||||
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
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, "", ""));
|
.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>();
|
||||||
var bl = new Mock<IBitLockerService>(); bl.Setup(b => b.EnableAsync(It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
|
bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
var boot = new Mock<IBootstrapService>(); boot.Setup(b => b.TearDownAsync(It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("bootstrap")).Returns(Task.CompletedTask);
|
.Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
|
||||||
var installer = NoApps(); installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),It.IsAny<IProgress<ApplyProgress>>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty<AppInstallResult>());
|
var installer = NoApps();
|
||||||
|
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
|
||||||
|
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty<AppInstallResult>());
|
||||||
|
|
||||||
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, installer.Object, "C:\\hard");
|
var sut = new ApplyService(bl.Object, installer.Object);
|
||||||
var flavour = new FlavourManifest { Id="daily-driver", Hardening = new HardeningSpec { Modules = new[]{"00"} } };
|
var req = new ApplyRequest(Flavour(), "123456", System.Array.Empty<AppCatalogEntry>());
|
||||||
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap", System.Array.Empty<AppCatalogEntry>());
|
|
||||||
var progress = new List<string>();
|
var progress = new List<string>();
|
||||||
|
|
||||||
await sut.RunAsync(req, new Progress<ApplyProgress>(p => progress.Add(p.Stage)));
|
await sut.RunAsync(req, new Progress<ApplyProgress>(p => progress.Add(p.Stage)));
|
||||||
|
|
||||||
Assert.Equal(new[]{"modules","accounts","apps","bitlocker","bootstrap"}, order);
|
Assert.Equal(new[] { "apps", "bitlocker" }, order);
|
||||||
Assert.Contains("Applying hardening", progress);
|
Assert.Contains("Installing apps", progress);
|
||||||
|
Assert.Contains("Done", progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Does_not_tear_down_bootstrap_if_account_creation_fails()
|
public async Task Empty_pin_skips_bitlocker()
|
||||||
{
|
{
|
||||||
var run = new Mock<IProcessRunner>(); run.Setup(r => r.RunAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).ReturnsAsync(new ProcessResult(0,"",""));
|
var order = new List<string>();
|
||||||
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 run = new Mock<IProcessRunner>();
|
||||||
var bl = new Mock<IBitLockerService>(); var boot = new Mock<IBootstrapService>();
|
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, NoApps().Object, "C:\\hard");
|
.ReturnsAsync(new ProcessResult(0, "", ""));
|
||||||
var req = new ApplyRequest(new FlavourManifest{ Hardening=new HardeningSpec{Modules=new[]{"00"}}}, "a","b","c","123456","sm-bootstrap", System.Array.Empty<AppCatalogEntry>());
|
var bl = new Mock<IBitLockerService>();
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.RunAsync(req, new Progress<ApplyProgress>(_ => {})));
|
bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
boot.Verify(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
.Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
|
||||||
|
var installer = NoApps();
|
||||||
|
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
|
||||||
|
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty<AppInstallResult>());
|
||||||
|
|
||||||
|
var sut = new ApplyService(bl.Object, installer.Object);
|
||||||
|
var req = new ApplyRequest(Flavour(), "", System.Array.Empty<AppCatalogEntry>());
|
||||||
|
|
||||||
|
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "apps" }, order);
|
||||||
|
bl.Verify(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Installs_the_requested_apps()
|
||||||
|
{
|
||||||
|
var run = new Mock<IProcessRunner>();
|
||||||
|
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new ProcessResult(0, "", ""));
|
||||||
|
var bl = new Mock<IBitLockerService>();
|
||||||
|
var installer = NoApps();
|
||||||
|
var apps = new[] { new AppCatalogEntry { Id = "firefox", Name = "Firefox" } };
|
||||||
|
|
||||||
|
var sut = new ApplyService(bl.Object, installer.Object);
|
||||||
|
var req = new ApplyRequest(Flavour(), "123456", apps);
|
||||||
|
|
||||||
|
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
|
||||||
|
|
||||||
|
installer.Verify(i => i.InstallAsync(apps, It.IsAny<IProgress<ApplyProgress>>(),
|
||||||
|
It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
bl.Verify(b => b.EnableAsync("123456", It.IsAny<CancellationToken>()), Times.Once);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,6 @@ public class ApplyServicesTests
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AccountService_throws_on_nonzero_exit()
|
|
||||||
{
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
||||||
new AccountService(Fail().Object).CreateAccountsAsync("alice", "pw", "apw"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BitLockerService_throws_on_nonzero_exit()
|
public async Task BitLockerService_throws_on_nonzero_exit()
|
||||||
{
|
{
|
||||||
@@ -33,26 +26,6 @@ public class ApplyServicesTests
|
|||||||
new BitLockerService(Fail().Object).EnableAsync("123456"));
|
new BitLockerService(Fail().Object).EnableAsync("123456"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: BootstrapService is intentionally best-effort (teardown cleanups must not fail the
|
|
||||||
// apply — auto-logon is already neutralised by the answer file's LogonCount=1), so it does
|
|
||||||
// NOT throw on a non-zero exit.
|
|
||||||
|
|
||||||
[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]
|
[Fact]
|
||||||
public async Task BitLockerService_enables_tpm_and_pin()
|
public async Task BitLockerService_enables_tpm_and_pin()
|
||||||
{
|
{
|
||||||
@@ -82,15 +55,4 @@ public class ApplyServicesTests
|
|||||||
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
|
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
|
||||||
s.Contains("Shell.Application") && s.Contains("Eject")), It.IsAny<CancellationToken>()));
|
s.Contains("Shell.Application") && s.Contains("Eject")), 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>()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using SilverOS.Welcome.App.Components.Steps;
|
|||||||
using SilverOS.Welcome.Core.Apply;
|
using SilverOS.Welcome.Core.Apply;
|
||||||
using SilverOS.Welcome.Core.Apps;
|
using SilverOS.Welcome.Core.Apps;
|
||||||
using SilverOS.Welcome.Core.Flavours;
|
using SilverOS.Welcome.Core.Flavours;
|
||||||
|
using SilverOS.Welcome.Core.Preconfig;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
public class ApplyStepTests : TestContext
|
public class ApplyStepTests : TestContext
|
||||||
@@ -17,6 +18,19 @@ public class ApplyStepTests : TestContext
|
|||||||
private static void AddCatalog(IServiceCollection services) =>
|
private static void AddCatalog(IServiceCollection services) =>
|
||||||
services.AddSingleton<IAppCatalog>(new AppCatalog());
|
services.AddSingleton<IAppCatalog>(new AppCatalog());
|
||||||
|
|
||||||
|
// ApplyStep injects IPreconfigStore to clear the pin + mark configured after a
|
||||||
|
// successful apply; a no-op fake keeps these UI tests off the real filesystem.
|
||||||
|
private static void AddPreconfig(IServiceCollection services) =>
|
||||||
|
services.AddSingleton<IPreconfigStore>(new FakePreconfigStore());
|
||||||
|
|
||||||
|
private sealed class FakePreconfigStore : IPreconfigStore
|
||||||
|
{
|
||||||
|
public Preconfig? Load() => null;
|
||||||
|
public void ClearPin() { }
|
||||||
|
public bool IsConfigured() => false;
|
||||||
|
public void MarkConfigured() { }
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Calls_apply_with_the_wizard_selections()
|
public async Task Calls_apply_with_the_wizard_selections()
|
||||||
{
|
{
|
||||||
@@ -24,18 +38,16 @@ public class ApplyStepTests : TestContext
|
|||||||
var state = new WizardState
|
var state = new WizardState
|
||||||
{
|
{
|
||||||
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } },
|
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } },
|
||||||
Username = "alice",
|
|
||||||
Password = "pw",
|
|
||||||
AdminPassword = "apw",
|
|
||||||
BitLockerPin = "123456"
|
BitLockerPin = "123456"
|
||||||
};
|
};
|
||||||
Services.AddSingleton(state);
|
Services.AddSingleton(state);
|
||||||
Services.AddSingleton(apply.Object);
|
Services.AddSingleton(apply.Object);
|
||||||
AddCatalog(Services);
|
AddCatalog(Services);
|
||||||
|
AddPreconfig(Services);
|
||||||
var cut = RenderComponent<ApplyStep>();
|
var cut = RenderComponent<ApplyStep>();
|
||||||
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
||||||
apply.Verify(a => a.RunAsync(
|
apply.Verify(a => a.RunAsync(
|
||||||
It.Is<ApplyRequest>(r => r.Username == "alice" && r.Flavour.Id == "daily-driver"),
|
It.Is<ApplyRequest>(r => r.BitLockerPin == "123456" && r.Flavour.Id == "daily-driver"),
|
||||||
It.IsAny<IProgress<ApplyProgress>>(),
|
It.IsAny<IProgress<ApplyProgress>>(),
|
||||||
It.IsAny<CancellationToken>()), Times.Once);
|
It.IsAny<CancellationToken>()), Times.Once);
|
||||||
}
|
}
|
||||||
@@ -49,17 +61,45 @@ public class ApplyStepTests : TestContext
|
|||||||
var state = new WizardState
|
var state = new WizardState
|
||||||
{
|
{
|
||||||
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } },
|
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } },
|
||||||
Username = "alice", Password = "pw", AdminPassword = "apw", BitLockerPin = "123456"
|
BitLockerPin = "123456"
|
||||||
};
|
};
|
||||||
Services.AddSingleton(state);
|
Services.AddSingleton(state);
|
||||||
Services.AddSingleton(apply.Object);
|
Services.AddSingleton(apply.Object);
|
||||||
AddCatalog(Services);
|
AddCatalog(Services);
|
||||||
|
AddPreconfig(Services);
|
||||||
var completed = false;
|
var completed = false;
|
||||||
var cut = RenderComponent<ApplyStep>(p => p.Add(s => s.OnComplete, EventCallback.Factory.Create(this, () => { completed = true; })));
|
var cut = RenderComponent<ApplyStep>(p => p.Add(s => s.OnComplete, EventCallback.Factory.Create(this, () => { completed = true; })));
|
||||||
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
||||||
Assert.True(completed);
|
Assert.True(completed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutoStart_triggers_apply_once_without_a_button_click()
|
||||||
|
{
|
||||||
|
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" } } },
|
||||||
|
BitLockerPin = "123456"
|
||||||
|
};
|
||||||
|
Services.AddSingleton(state);
|
||||||
|
Services.AddSingleton(apply.Object);
|
||||||
|
AddCatalog(Services);
|
||||||
|
AddPreconfig(Services);
|
||||||
|
|
||||||
|
// AutoStart=true should fire StartAsync from OnAfterRenderAsync on first render,
|
||||||
|
// with no Start button click.
|
||||||
|
var cut = RenderComponent<ApplyStep>(p => p.Add(s => s.AutoStart, true));
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
apply.Verify(a => a.RunAsync(
|
||||||
|
It.Is<ApplyRequest>(r => r.Flavour.Id == "daily-driver"),
|
||||||
|
It.IsAny<IProgress<ApplyProgress>>(),
|
||||||
|
It.IsAny<CancellationToken>()), Times.Once));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Shows_error_and_retry_button_when_apply_fails()
|
public async Task Shows_error_and_retry_button_when_apply_fails()
|
||||||
{
|
{
|
||||||
@@ -69,11 +109,12 @@ public class ApplyStepTests : TestContext
|
|||||||
var state = new WizardState
|
var state = new WizardState
|
||||||
{
|
{
|
||||||
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } },
|
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } },
|
||||||
Username = "alice", Password = "pw", AdminPassword = "apw", BitLockerPin = "123456"
|
BitLockerPin = "123456"
|
||||||
};
|
};
|
||||||
Services.AddSingleton(state);
|
Services.AddSingleton(state);
|
||||||
Services.AddSingleton(apply.Object);
|
Services.AddSingleton(apply.Object);
|
||||||
AddCatalog(Services);
|
AddCatalog(Services);
|
||||||
|
AddPreconfig(Services);
|
||||||
var cut = RenderComponent<ApplyStep>();
|
var cut = RenderComponent<ApplyStep>();
|
||||||
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
await cut.InvokeAsync(() => cut.Instance.StartAsync());
|
||||||
Assert.Contains("Module 03 failed", cut.Markup);
|
Assert.Contains("Module 03 failed", cut.Markup);
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
using Moq;
|
|
||||||
using SilverOS.Welcome.Core.Apply;
|
|
||||||
using SilverOS.Welcome.Core.Apps;
|
|
||||||
|
|
||||||
public class BootstrapServiceRevertKioskTests
|
|
||||||
{
|
|
||||||
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 RevertKioskAsync_is_best_effort_and_does_not_throw_on_nonzero_exit()
|
|
||||||
{
|
|
||||||
// Kiosk revert is best-effort (like TearDownAsync): a non-zero exit must NOT
|
|
||||||
// fail the apply — the real user still gets Explorer regardless of WESL state.
|
|
||||||
var ex = await Record.ExceptionAsync(() =>
|
|
||||||
new BootstrapService(Fail().Object).RevertKioskAsync());
|
|
||||||
Assert.Null(ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task RevertKioskAsync_disables_keyboard_filter_rules()
|
|
||||||
{
|
|
||||||
var run = Ok();
|
|
||||||
await new BootstrapService(run.Object).RevertKioskAsync();
|
|
||||||
// First call: disable the Keyboard Filter predefined-key blocks for the real user.
|
|
||||||
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
|
|
||||||
s.Contains("WEKF_PredefinedKey") &&
|
|
||||||
s.Contains("Enabled=$false")),
|
|
||||||
It.IsAny<CancellationToken>()), Times.Once);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task RevertKioskAsync_reverts_escape_policies()
|
|
||||||
{
|
|
||||||
var run = Ok();
|
|
||||||
await new BootstrapService(run.Object).RevertKioskAsync();
|
|
||||||
// Second call: policy revert — must remove the three escape policy values.
|
|
||||||
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
|
|
||||||
s.Contains("Remove-ItemProperty") &&
|
|
||||||
s.Contains("DisableTaskMgr") &&
|
|
||||||
s.Contains("DisableLockWorkstation") &&
|
|
||||||
s.Contains("HideFastUserSwitching")),
|
|
||||||
It.IsAny<CancellationToken>()), Times.Once);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ApplyService_calls_revert_kiosk_before_teardown()
|
|
||||||
{
|
|
||||||
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.RevertKioskAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.Callback(() => order.Add("revert-kiosk"))
|
|
||||||
.Returns(Task.CompletedTask);
|
|
||||||
boot.Setup(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
||||||
.Callback(() => order.Add("teardown"))
|
|
||||||
.Returns(Task.CompletedTask);
|
|
||||||
|
|
||||||
var installer = new Mock<IAppInstaller>();
|
|
||||||
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
|
|
||||||
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(System.Array.Empty<AppInstallResult>());
|
|
||||||
|
|
||||||
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, installer.Object, "C:\\hard");
|
|
||||||
var flavour = new SilverOS.Welcome.Core.Flavours.FlavourManifest
|
|
||||||
{
|
|
||||||
Id = "daily-driver",
|
|
||||||
Hardening = new SilverOS.Welcome.Core.Flavours.HardeningSpec { Modules = new[] { "00" } }
|
|
||||||
};
|
|
||||||
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap", System.Array.Empty<AppCatalogEntry>());
|
|
||||||
|
|
||||||
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
|
|
||||||
|
|
||||||
// revert-kiosk must precede teardown so the sm-bootstrap SID still resolves.
|
|
||||||
Assert.Equal(new[] { "modules", "accounts", "bitlocker", "revert-kiosk", "teardown" }, order);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using SilverOS.Welcome.Core.Preconfig;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public class PreconfigTests
|
||||||
|
{
|
||||||
|
static string TempDir()
|
||||||
|
{
|
||||||
|
var d = Path.Combine(Path.GetTempPath(), "smpre-" + System.Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
const string Sample = """
|
||||||
|
{ "schemaVersion":1, "flavour":"developer",
|
||||||
|
"bitlocker":{"enable":true,"pin":"246810"},
|
||||||
|
"apps":{"useFlavourDefaults":true} }
|
||||||
|
""";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Loads_flavour_and_pin()
|
||||||
|
{
|
||||||
|
var dir = TempDir();
|
||||||
|
File.WriteAllText(Path.Combine(dir, "preconfig.json"), Sample);
|
||||||
|
var p = new PreconfigStore(dir).Load();
|
||||||
|
Assert.NotNull(p);
|
||||||
|
Assert.Equal("developer", p!.Flavour);
|
||||||
|
Assert.True(p.Bitlocker.Enable);
|
||||||
|
Assert.Equal("246810", p.Bitlocker.Pin);
|
||||||
|
Assert.True(p.Apps.UseFlavourDefaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Missing_or_bad_file_returns_null_not_throw()
|
||||||
|
{
|
||||||
|
Assert.Null(new PreconfigStore(TempDir()).Load()); // missing
|
||||||
|
var dir = TempDir();
|
||||||
|
File.WriteAllText(Path.Combine(dir, "preconfig.json"), "{ not json");
|
||||||
|
Assert.Null(new PreconfigStore(dir).Load()); // corrupt
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClearPin_rewrites_without_the_pin()
|
||||||
|
{
|
||||||
|
var dir = TempDir();
|
||||||
|
File.WriteAllText(Path.Combine(dir, "preconfig.json"), Sample);
|
||||||
|
var store = new PreconfigStore(dir);
|
||||||
|
store.ClearPin();
|
||||||
|
var reread = store.Load();
|
||||||
|
Assert.True(string.IsNullOrEmpty(reread!.Bitlocker.Pin));
|
||||||
|
Assert.Equal("developer", reread.Flavour); // rest preserved
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Configured_marker_roundtrips()
|
||||||
|
{
|
||||||
|
var dir = TempDir();
|
||||||
|
var store = new PreconfigStore(dir);
|
||||||
|
Assert.False(store.IsConfigured());
|
||||||
|
store.MarkConfigured();
|
||||||
|
Assert.True(store.IsConfigured());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user