diff --git a/windows/collector/Collector.ps1 b/windows/collector/Collector.ps1 new file mode 100644 index 0000000..512034e --- /dev/null +++ b/windows/collector/Collector.ps1 @@ -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) +} diff --git a/windows/collector/Start-Collector.cmd b/windows/collector/Start-Collector.cmd new file mode 100644 index 0000000..79f3c9d --- /dev/null +++ b/windows/collector/Start-Collector.cmd @@ -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 diff --git a/windows/collector/winpeshl.ini b/windows/collector/winpeshl.ini new file mode 100644 index 0000000..19f803c --- /dev/null +++ b/windows/collector/winpeshl.ini @@ -0,0 +1,2 @@ +[LaunchApps] +%SYSTEMDRIVE%\sm\Start-Collector.cmd diff --git a/windows/installer/build.ps1 b/windows/installer/build.ps1 index 4656af2..d3fa443 100644 --- a/windows/installer/build.ps1 +++ b/windows/installer/build.ps1 @@ -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'