feat(build): stage WinPE collector into boot.wim (winpeshl + WinPE-NetFx/PowerShell) with SM_UNATTENDED fallback
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
249
windows/collector/Collector.ps1
Normal file
249
windows/collector/Collector.ps1
Normal file
@@ -0,0 +1,249 @@
|
||||
#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)
|
||||
|
||||
$flavours = @('developer', 'journalist', 'daily-driver', 'privacy-max', 'essentials')
|
||||
$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
|
||||
|
||||
Start-Process -FilePath 'X:\sources\setup.exe' -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)
|
||||
}
|
||||
13
windows/collector/Start-Collector.cmd
Normal file
13
windows/collector/Start-Collector.cmd
Normal file
@@ -0,0 +1,13 @@
|
||||
@echo off
|
||||
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 X:\sources\setup.exe /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 X:\sources\setup.exe /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
|
||||
# otherwise still shows the language page).
|
||||
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' }
|
||||
$cmdline = "$setup /unattend:X:\autounattend.xml"
|
||||
$hive = Join-Path $bootmnt 'Windows\System32\config\SYSTEM'
|
||||
|
||||
Reference in New Issue
Block a user