diff --git a/AUTOUNATTEND.md b/AUTOUNATTEND.md index 97d16c8..a3e75a6 100644 --- a/AUTOUNATTEND.md +++ b/AUTOUNATTEND.md @@ -1,6 +1,6 @@ # AutoUnattend.xml Explained -This file automates parts of Windows 11 setup and applies tweaks during installation. It runs before you even log in for the first time. +This file automates only the minimal parts of Windows 11 setup needed for this repo. It intentionally avoids aggressive install-time customization so Windows Setup stays as reliable as possible. ## How It Works @@ -14,35 +14,7 @@ Runs while Windows is being installed to the disk. - Leaves disk and partition selection to the user in Windows Setup - Configures language/locale to en-US -### 2. specialize (Pre-OOBE Phase) - -Runs after Windows is installed but before you see the setup screen. - -**Bloatware Removal:** -- Bing Search -- Office Hub -- OneNote -- Skype -- Solitaire Collection -- Microsoft Teams -- Recall feature - -**System Tweaks:** -- Disable Chat auto-install -- Set password to never expire -- Set PowerShell execution policy to RemoteSigned -- Empty Start menu pins -- Disable sticky keys prompt - -**Default User Settings (applies to all accounts):** -- Show file extensions -- Show hidden files -- Left-align taskbar -- Disable mouse pointer precision -- Classic right-click context menu -- Search icon-only mode - -### 3. oobeSystem (First Boot) +### 2. oobeSystem (First Boot) Runs when Windows boots for the first time. @@ -59,33 +31,22 @@ Runs when Windows boots for the first time. ## What Happens on First Login -1. Windows applies user-specific tweaks (classic context menu, search icon) -2. Explorer restarts to apply changes +1. Windows completes the normal account setup flow +2. The system waits for network connectivity 3. **bootstrap.ps1 runs automatically** via FirstLogonCommands -The embedded tweaks handle OS-level stuff during install. Bootstrap handles app installation via WinGet and Sophia Script tweaks. +Install-time tweaking is intentionally minimal. Bootstrap handles app installation via WinGet and system customization via Sophia Script after first login. **If bootstrap fails or you want to re-run it:** Just run `C:\Setup\bootstrap.ps1` manually. ## Customization -All the tweaks are defined in the `` section at the bottom of the XML file. The scripts are embedded directly in the XML. - -**To add a tweak:** Edit the relevant script section (Specialize.ps1, DefaultUser.ps1, or UserOnce.ps1) - -**To remove a tweak:** Delete or comment out the script block - -**To add/remove bloatware:** Edit the `$selectors` array in RemovePackages.ps1 +This file is intentionally minimal. If you add more install-time customization later, keep it small and retest Setup carefully. ## Logs If something goes wrong, check these logs: -- `C:\Windows\Setup\Scripts\Specialize.log` - Bloatware removal and system tweaks -- `C:\Windows\Setup\Scripts\RemovePackages.log` - App removal details -- `C:\Windows\Setup\Scripts\RemoveFeatures.log` - Feature removal details -- `C:\Windows\Setup\Scripts\DefaultUser.log` - Default user registry tweaks -- `%TEMP%\UserOnce.log` - Per-user tweaks log - `C:\Windows\Panther\setupact.log` - Windows setup log ## Security Note diff --git a/autounattend.xml b/autounattend.xml index f7418ad..59e20f3 100644 --- a/autounattend.xml +++ b/autounattend.xml @@ -3,11 +3,9 @@ @@ -23,17 +21,12 @@ - - - - OnError - true - + 1 @@ -51,38 +44,6 @@ - - - - - - 1 - powershell.exe -WindowStyle Normal -NoProfile -Command "$xml = [xml]::new(); $xml.Load('C:\Windows\Panther\unattend.xml'); $sb = [scriptblock]::Create( $xml.unattend.Extensions.ExtractScript ); Invoke-Command -ScriptBlock $sb -ArgumentList $xml;" - - - - 2 - powershell.exe -WindowStyle Normal -NoProfile -Command "Get-Content -LiteralPath 'C:\Windows\Setup\Scripts\Specialize.ps1' -Raw | Invoke-Expression;" - - - - 3 - reg.exe load "HKU\DefaultUser" "C:\Users\Default\NTUSER.DAT" - - - - 4 - powershell.exe -WindowStyle Normal -NoProfile -Command "Get-Content -LiteralPath 'C:\Windows\Setup\Scripts\DefaultUser.ps1' -Raw | Invoke-Expression;" - - - - 5 - reg.exe unload "HKU\DefaultUser" - - - - - @@ -93,7 +54,6 @@ - 1 Set PowerShell Execution Policy @@ -112,275 +72,4 @@ - - - -param( - [xml] $Document -); - -foreach( $file in $Document.unattend.Extensions.File ) { - $path = [System.Environment]::ExpandEnvironmentVariables( $file.GetAttribute( 'path' ) ); - mkdir -Path( $path | Split-Path -Parent ) -ErrorAction 'SilentlyContinue'; - $encoding = switch( [System.IO.Path]::GetExtension( $path ) ) { - { $_ -in '.ps1', '.xml' } { [System.Text.Encoding]::UTF8; } - { $_ -in '.reg', '.vbs', '.js' } { [System.Text.UnicodeEncoding]::new( $false, $true ); } - default { [System.Text.Encoding]::Default; } - }; - $bytes = $encoding.GetPreamble() + $encoding.GetBytes( $file.InnerText.Trim() ); - [System.IO.File]::WriteAllBytes( $path, $bytes ); -} - - -$selectors = @( - 'Microsoft.BingSearch'; - 'Microsoft.MicrosoftOfficeHub'; - 'Microsoft.Office.OneNote'; - 'Microsoft.SkypeApp'; - 'Microsoft.MicrosoftSolitaireCollection'; - 'MicrosoftTeams'; - 'MSTeams'; -); -$getCommand = { - Get-AppxProvisionedPackage -Online; -}; -$filterCommand = { - $_.DisplayName -eq $selector; -}; -$removeCommand = { - [CmdletBinding()] - param( - [Parameter( Mandatory, ValueFromPipeline )] - $InputObject - ); - process { - $InputObject | Remove-AppxProvisionedPackage -AllUsers -Online -ErrorAction 'Continue'; - } -}; -$type = 'Package'; -$logfile = 'C:\Windows\Setup\Scripts\RemovePackages.log'; -& { - $installed = & $getCommand; - foreach( $selector in $selectors ) { - $result = [ordered] @{ - Selector = $selector; - }; - $found = $installed | Where-Object -FilterScript $filterCommand; - if( $found ) { - $result.Output = $found | & $removeCommand; - if( $? ) { - $result.Message = "$type removed."; - } else { - $result.Message = "$type not removed."; - $result.Error = $Error[0]; - } - } else { - $result.Message = "$type not installed."; - } - $result | ConvertTo-Json -Depth 3 -Compress; - } -} *>&1 >> $logfile; - - -$selectors = @( - 'Recall'; -); -$getCommand = { - Get-WindowsOptionalFeature -Online | Where-Object -Property 'State' -NotIn -Value @( - 'Disabled'; - 'DisabledWithPayloadRemoved'; - ); -}; -$filterCommand = { - $_.FeatureName -eq $selector; -}; -$removeCommand = { - [CmdletBinding()] - param( - [Parameter( Mandatory, ValueFromPipeline )] - $InputObject - ); - process { - $InputObject | Disable-WindowsOptionalFeature -Online -Remove -NoRestart -ErrorAction 'Continue'; - } -}; -$type = 'Feature'; -$logfile = 'C:\Windows\Setup\Scripts\RemoveFeatures.log'; -& { - $installed = & $getCommand; - foreach( $selector in $selectors ) { - $result = [ordered] @{ - Selector = $selector; - }; - $found = $installed | Where-Object -FilterScript $filterCommand; - if( $found ) { - $result.Output = $found | & $removeCommand; - if( $? ) { - $result.Message = "$type removed."; - } else { - $result.Message = "$type not removed."; - $result.Error = $Error[0]; - } - } else { - $result.Message = "$type not installed."; - } - $result | ConvertTo-Json -Depth 3 -Compress; - } -} *>&1 >> $logfile; - - -$json = '{"pinnedList":[]}'; -$key = 'Registry::HKLM\SOFTWARE\Microsoft\PolicyManager\current\device\Start'; -New-Item -Path $key -ItemType 'Directory' -ErrorAction 'SilentlyContinue'; -Set-ItemProperty -LiteralPath $key -Name 'ConfigureStartPins' -Value $json -Type 'String'; - - -$scripts = @( - { - reg.exe add "HKLM\SYSTEM\Setup\MoSetup" /v AllowUpgradesWithUnsupportedTPMOrCPU /t REG_DWORD /d 1 /f; - }; - { - reg.exe add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Communications" /v ConfigureChatAutoInstall /t REG_DWORD /d 0 /f; - }; - { - Get-Content -LiteralPath 'C:\Windows\Setup\Scripts\RemovePackages.ps1' -Raw | Invoke-Expression; - }; - { - Get-Content -LiteralPath 'C:\Windows\Setup\Scripts\RemoveFeatures.ps1' -Raw | Invoke-Expression; - }; - { - net.exe accounts /maxpwage:UNLIMITED; - }; - { - Set-ExecutionPolicy -Scope 'LocalMachine' -ExecutionPolicy 'RemoteSigned' -Force; - }; - { - Get-Content -LiteralPath 'C:\Windows\Setup\Scripts\SetStartPins.ps1' -Raw | Invoke-Expression; - }; - { - reg.exe add "HKU\.DEFAULT\Control Panel\Accessibility\StickyKeys" /v Flags /t REG_SZ /d 10 /f; - }; -); - -& { - [float] $complete = 0; - [float] $increment = 100 / $scripts.Count; - foreach( $script in $scripts ) { - Write-Progress -Activity 'Running scripts to customize your Windows installation. Do not close this window.' -PercentComplete $complete; - '*** Will now execute command «{0}».' -f $( - $str = $script.ToString().Trim() -replace '\s+', ' '; - $max = 100; - if( $str.Length -le $max ) { - $str; - } else { - $str.Substring( 0, $max - 1 ) + '…'; - } - ); - $start = [datetime]::Now; - & $script; - '*** Finished executing command after {0:0} ms.' -f [datetime]::Now.Subtract( $start ).TotalMilliseconds; - "`r`n" * 3; - $complete += $increment; - } -} *>&1 >> "C:\Windows\Setup\Scripts\Specialize.log"; - - -$scripts = @( - { - $params = @{ - Path = 'Registry::HKCU\Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32'; - ErrorAction = 'SilentlyContinue'; - Force = $true; - }; - New-Item @params; - Set-ItemProperty @params -Name '(Default)' -Value '' -Type 'String'; - }; - { - Set-ItemProperty -LiteralPath 'Registry::HKCU\Software\Microsoft\Windows\CurrentVersion\Search' -Name 'SearchboxTaskbarMode' -Type 'DWord' -Value 1; - }; - { - Get-Process -Name 'explorer' -ErrorAction 'SilentlyContinue' | Where-Object -FilterScript { - $_.SessionId -eq ( Get-Process -Id $PID ).SessionId; - } | Stop-Process -Force; - }; -); - -& { - [float] $complete = 0; - [float] $increment = 100 / $scripts.Count; - foreach( $script in $scripts ) { - Write-Progress -Activity 'Running scripts to configure this user account. Do not close this window.' -PercentComplete $complete; - '*** Will now execute command «{0}».' -f $( - $str = $script.ToString().Trim() -replace '\s+', ' '; - $max = 100; - if( $str.Length -le $max ) { - $str; - } else { - $str.Substring( 0, $max - 1 ) + '…'; - } - ); - $start = [datetime]::Now; - & $script; - '*** Finished executing command after {0:0} ms.' -f [datetime]::Now.Subtract( $start ).TotalMilliseconds; - "`r`n" * 3; - $complete += $increment; - } -} *>&1 >> "$env:TEMP\UserOnce.log"; - - -$scripts = @( - { - reg.exe add "HKU\DefaultUser\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" /v "HideFileExt" /t REG_DWORD /d 0 /f; - }; - { - reg.exe add "HKU\DefaultUser\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" /v "Hidden" /t REG_DWORD /d 1 /f; - }; - { - reg.exe add "HKU\DefaultUser\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" /v "ShowSuperHidden" /t REG_DWORD /d 1 /f; - }; - { - reg.exe add "HKU\DefaultUser\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" /v TaskbarAl /t REG_DWORD /d 0 /f; - }; - { - $params = @{ - LiteralPath = 'Registry::HKU\DefaultUser\Control Panel\Mouse'; - Type = 'String'; - Value = 0; - Force = $true; - }; - Set-ItemProperty @params -Name 'MouseSpeed'; - Set-ItemProperty @params -Name 'MouseThreshold1'; - Set-ItemProperty @params -Name 'MouseThreshold2'; - }; - { - reg.exe add "HKU\DefaultUser\Control Panel\Accessibility\StickyKeys" /v Flags /t REG_SZ /d 10 /f; - }; - { - reg.exe add "HKU\DefaultUser\Software\Microsoft\Windows\CurrentVersion\RunOnce" /v "UnattendedSetup" /t REG_SZ /d "powershell.exe -WindowStyle Normal -NoProfile -Command \"\"Get-Content -LiteralPath 'C:\Windows\Setup\Scripts\UserOnce.ps1' -Raw | Invoke-Expression;\"\"" /f; - }; -); - -& { - [float] $complete = 0; - [float] $increment = 100 / $scripts.Count; - foreach( $script in $scripts ) { - Write-Progress -Activity 'Running scripts to modify the default user''s registry hive. Do not close this window.' -PercentComplete $complete; - '*** Will now execute command «{0}».' -f $( - $str = $script.ToString().Trim() -replace '\s+', ' '; - $max = 100; - if( $str.Length -le $max ) { - $str; - } else { - $str.Substring( 0, $max - 1 ) + '…'; - } - ); - $start = [datetime]::Now; - & $script; - '*** Finished executing command after {0:0} ms.' -f [datetime]::Now.Subtract( $start ).TotalMilliseconds; - "`r`n" * 3; - $complete += $increment; - } -} *>&1 >> "C:\Windows\Setup\Scripts\DefaultUser.log"; - - diff --git a/bootstrap.ps1 b/bootstrap.ps1 index d8e848c..d58fa05 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -38,7 +38,7 @@ $SophiaVersion = "7.1.4" $SophiaZipName = "Sophia.Script.for.Windows.11.v$SophiaVersion.zip" $SophiaDownloadUrl = "https://github.com/farag2/Sophia-Script-for-Windows/releases/download/$SophiaVersion/$SophiaZipName" $FailedInstallsLog = Join-Path $SetupPath "failed-installs.log" -$StepIds = @("winget", "repo", "sophia", "registry", "shortcut", "restoreShortcut", "optionalShortcut", "optionalWinget", "summary") +$StepIds = @("winget", "repo", "sophia", "postInstallTweaks", "registry", "shortcut", "restoreShortcut", "optionalShortcut", "optionalWinget", "summary") $SetupState = $null $SummaryItems = [System.Collections.Generic.List[object]]::new() $FailedItems = [System.Collections.Generic.List[object]]::new() @@ -575,6 +575,140 @@ function New-DesktopShortcut { $shortcut.Save() } +function Set-RegistryValueSafe { + param( + [Parameter(Mandatory)] + [string]$Path, + + [Parameter(Mandatory)] + [string]$Name, + + [Parameter(Mandatory)] + [object]$Value, + + [Parameter(Mandatory)] + [string]$Type + ) + + if (-not (Test-Path $Path)) { + New-Item -Path $Path -Force | Out-Null + } + + Set-ItemProperty -Path $Path -Name $Name -Value $Value -Type $Type -Force +} + +function Remove-ProvisionedAppIfPresent { + param([Parameter(Mandatory)][string]$DisplayName) + + $matches = Get-AppxProvisionedPackage -Online | Where-Object { $_.DisplayName -eq $DisplayName } + if (-not $matches) { + Write-Log "Provisioned app not present: $DisplayName" -Level INFO + return $true + } + + foreach ($match in $matches) { + try { + $null = Remove-AppxProvisionedPackage -Online -PackageName $match.PackageName -AllUsers -ErrorAction Stop + Write-Log "Removed provisioned app: $DisplayName" -Level SUCCESS + } + catch { + Write-Log "Failed to remove provisioned app ${DisplayName}: $($_.Exception.Message)" -Level WARNING + Add-FailedItem -Category "Post-Install Tweaks" -Item $DisplayName -Reason "Provisioned app removal failed" + return $false + } + } + + return $true +} + +function Disable-OptionalFeatureIfPresent { + param([Parameter(Mandatory)][string]$FeatureName) + + try { + $feature = Get-WindowsOptionalFeature -Online -FeatureName $FeatureName -ErrorAction Stop + } + catch { + Write-Log "Optional feature not found or unavailable: $FeatureName" -Level INFO + return $true + } + + if ($feature.State -in @("Disabled", "DisabledWithPayloadRemoved")) { + Write-Log "Optional feature already disabled: $FeatureName" -Level INFO + return $true + } + + try { + $null = Disable-WindowsOptionalFeature -Online -FeatureName $FeatureName -Remove -NoRestart -ErrorAction Stop + Write-Log "Disabled optional feature: $FeatureName" -Level SUCCESS + return $true + } + catch { + Write-Log "Failed to disable optional feature ${FeatureName}: $($_.Exception.Message)" -Level WARNING + Add-FailedItem -Category "Post-Install Tweaks" -Item $FeatureName -Reason "Optional feature removal failed" + return $false + } +} + +function Invoke-PostInstallTweaks { + $allSucceeded = $true + + $appsToRemove = @( + "Microsoft.BingSearch", + "Microsoft.MicrosoftOfficeHub", + "Microsoft.Office.OneNote", + "Microsoft.SkypeApp", + "Microsoft.MicrosoftSolitaireCollection", + "MicrosoftTeams", + "MSTeams" + ) + + foreach ($app in $appsToRemove) { + if (-not (Remove-ProvisionedAppIfPresent -DisplayName $app)) { + $allSucceeded = $false + } + } + + if (-not (Disable-OptionalFeatureIfPresent -FeatureName "Recall")) { + $allSucceeded = $false + } + + try { + Set-RegistryValueSafe -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Communications" -Name "ConfigureChatAutoInstall" -Value 0 -Type DWord + Set-RegistryValueSafe -Path "HKLM:\SOFTWARE\Microsoft\PolicyManager\current\device\Start" -Name "ConfigureStartPins" -Value '{"pinnedList":[]}' -Type String + Set-RegistryValueSafe -Path "HKU:\.DEFAULT\Control Panel\Accessibility\StickyKeys" -Name "Flags" -Value "10" -Type String + + Set-RegistryValueSafe -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" -Name "HideFileExt" -Value 0 -Type DWord + Set-RegistryValueSafe -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" -Name "Hidden" -Value 1 -Type DWord + Set-RegistryValueSafe -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" -Name "ShowSuperHidden" -Value 1 -Type DWord + Set-RegistryValueSafe -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" -Name "TaskbarAl" -Value 0 -Type DWord + Set-RegistryValueSafe -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Search" -Name "SearchboxTaskbarMode" -Value 1 -Type DWord + Set-RegistryValueSafe -Path "HKCU:\Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32" -Name "(Default)" -Value "" -Type String + Set-RegistryValueSafe -Path "HKCU:\Control Panel\Mouse" -Name "MouseSpeed" -Value "0" -Type String + Set-RegistryValueSafe -Path "HKCU:\Control Panel\Mouse" -Name "MouseThreshold1" -Value "0" -Type String + Set-RegistryValueSafe -Path "HKCU:\Control Panel\Mouse" -Name "MouseThreshold2" -Value "0" -Type String + Set-RegistryValueSafe -Path "HKCU:\Control Panel\Accessibility\StickyKeys" -Name "Flags" -Value "10" -Type String + + Write-Log "Applied post-install debloat and user tweaks" -Level SUCCESS + } + catch { + Write-Log "Failed to apply post-install registry tweaks: $($_.Exception.Message)" -Level WARNING + Add-FailedItem -Category "Post-Install Tweaks" -Item "Registry Tweaks" -Reason $_.Exception.Message + $allSucceeded = $false + } + + try { + Get-Process -Name explorer -ErrorAction SilentlyContinue | Where-Object { + $_.SessionId -eq (Get-Process -Id $PID).SessionId + } | Stop-Process -Force -ErrorAction SilentlyContinue + Write-Log "Restarted Explorer to apply user tweaks" -Level SUCCESS + } + catch { + Write-Log "Failed to restart Explorer after user tweaks: $($_.Exception.Message)" -Level WARNING + } + + return $allSucceeded +} + function Find-BackupManifest { $drives = Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue | Where-Object { $_.Root -ne "$($env:SystemDrive)\" @@ -852,16 +986,41 @@ try { } } + $stepId = "postInstallTweaks" + if ($OptionalAppsOnly) { + Write-Log "Step 4: Skipping post-install tweaks (optional apps only mode)" -Level INFO + } + elseif (-not (Should-RunStep -StepId $stepId)) { + Write-Log "Step 4: Skipping post-install tweaks (already completed)" -Level INFO + Add-SummaryItem -Step "Post-Install Tweaks" -Status "OK" -Message "Skipped (already completed)" + } + elseif ($DryRun) { + Write-Log "Step 4: Dry run - skipping post-install tweaks" -Level WARNING + Add-SummaryItem -Step "Post-Install Tweaks" -Status "WARN" -Message "Dry run: post-install tweaks skipped" + Set-StepState -StepId $stepId -Status "pending" -Message "Dry run: post-install tweaks skipped" + } + else { + $tweaksSucceeded = Invoke-PostInstallTweaks + if ($tweaksSucceeded) { + Add-SummaryItem -Step "Post-Install Tweaks" -Status "OK" -Message "Debloat and user tweaks applied" + Set-StepState -StepId $stepId -Status "done" -Message "Debloat and user tweaks applied" + } + else { + Add-SummaryItem -Step "Post-Install Tweaks" -Status "WARN" -Message "Some tweaks failed - see Failed Installs.txt" + Set-StepState -StepId $stepId -Status "failed" -Message "Some tweaks failed" + } + } + $stepId = "registry" if ($OptionalAppsOnly) { - Write-Log "Step 4: Skipping registry fallback (optional apps only mode)" -Level INFO + Write-Log "Step 5: Skipping registry fallback (optional apps only mode)" -Level INFO } elseif (-not (Should-RunStep -StepId $stepId)) { - Write-Log "Step 4: Skipping registry fallback (already completed)" -Level INFO + Write-Log "Step 5: Skipping registry fallback (already completed)" -Level INFO Add-SummaryItem -Step "Registry" -Status "OK" -Message "Skipped (already completed)" } elseif ($DryRun) { - Write-Log "Step 4: Dry run - skipping registry fallback" -Level WARNING + Write-Log "Step 5: Dry run - skipping registry fallback" -Level WARNING Add-SummaryItem -Step "Registry" -Status "WARN" -Message "Dry run: registry changes skipped" Set-StepState -StepId $stepId -Status "pending" -Message "Dry run: registry changes skipped" } @@ -902,14 +1061,14 @@ try { $stepId = "shortcut" if ($OptionalAppsOnly) { - Write-Log "Step 5: Skipping desktop shortcut (optional apps only mode)" -Level INFO + Write-Log "Step 6: Skipping desktop shortcut (optional apps only mode)" -Level INFO } elseif (-not (Should-RunStep -StepId $stepId)) { - Write-Log "Step 5: Skipping desktop shortcut (already completed)" -Level INFO + Write-Log "Step 6: Skipping desktop shortcut (already completed)" -Level INFO Add-SummaryItem -Step "Shortcut" -Status "OK" -Message "Skipped (already completed)" } elseif ($DryRun) { - Write-Log "Step 5: Dry run - skipping desktop shortcut" -Level WARNING + Write-Log "Step 6: Dry run - skipping desktop shortcut" -Level WARNING Add-SummaryItem -Step "Shortcut" -Status "WARN" -Message "Dry run: shortcut not created" Set-StepState -StepId $stepId -Status "pending" -Message "Dry run: shortcut not created" } @@ -931,14 +1090,14 @@ try { $stepId = "restoreShortcut" if ($OptionalAppsOnly) { - Write-Log "Step 6: Skipping restore shortcut (optional apps only mode)" -Level INFO + Write-Log "Step 7: Skipping restore shortcut (optional apps only mode)" -Level INFO } elseif (-not (Should-RunStep -StepId $stepId)) { - Write-Log "Step 6: Skipping restore shortcut (already completed)" -Level INFO + Write-Log "Step 7: Skipping restore shortcut (already completed)" -Level INFO Add-SummaryItem -Step "Restore" -Status "OK" -Message "Skipped (already completed)" } elseif ($DryRun) { - Write-Log "Step 6: Dry run - skipping restore shortcut" -Level WARNING + Write-Log "Step 7: Dry run - skipping restore shortcut" -Level WARNING Add-SummaryItem -Step "Restore" -Status "WARN" -Message "Dry run: restore shortcut not created" Set-StepState -StepId $stepId -Status "pending" -Message "Dry run: restore shortcut not created" } @@ -964,14 +1123,14 @@ try { $stepId = "optionalShortcut" if ($OptionalAppsOnly) { - Write-Log "Step 7: Skipping optional apps shortcut (optional apps only mode)" -Level INFO + Write-Log "Step 8: Skipping optional apps shortcut (optional apps only mode)" -Level INFO } elseif (-not (Should-RunStep -StepId $stepId)) { - Write-Log "Step 7: Skipping optional apps shortcut (already completed)" -Level INFO + Write-Log "Step 8: Skipping optional apps shortcut (already completed)" -Level INFO Add-SummaryItem -Step "Optional Apps Shortcut" -Status "OK" -Message "Skipped (already completed)" } elseif ($DryRun) { - Write-Log "Step 7: Dry run - skipping optional apps shortcut" -Level WARNING + Write-Log "Step 8: Dry run - skipping optional apps shortcut" -Level WARNING Add-SummaryItem -Step "Optional Apps Shortcut" -Status "WARN" -Message "Dry run: optional shortcut not created" Set-StepState -StepId $stepId -Status "pending" -Message "Dry run: optional shortcut not created" } @@ -998,11 +1157,11 @@ try { $stepId = "optionalWinget" if (-not (Should-RunStep -StepId $stepId) -and -not $OptionalAppsOnly) { - Write-Log "Step 8: Skipping optional apps (already completed)" -Level INFO + Write-Log "Step 9: Skipping optional apps (already completed)" -Level INFO Add-SummaryItem -Step "Optional Apps" -Status "OK" -Message "Skipped (already completed)" } elseif ($DryRun) { - Write-Log "Step 8: Dry run - skipping optional apps import" -Level WARNING + Write-Log "Step 9: Dry run - skipping optional apps import" -Level WARNING Add-SummaryItem -Step "Optional Apps" -Status "WARN" -Message "Dry run: optional apps skipped" Set-StepState -StepId $stepId -Status "pending" -Message "Dry run: optional apps skipped" } @@ -1033,10 +1192,10 @@ try { $stepId = "summary" if (-not (Should-RunStep -StepId $stepId)) { - Write-Log "Step 9: Skipping summary report (already completed)" -Level INFO + Write-Log "Step 10: Skipping summary report (already completed)" -Level INFO } elseif ($DryRun) { - Write-Log "Step 9: Dry run - skipping summary report" -Level WARNING + Write-Log "Step 10: Dry run - skipping summary report" -Level WARNING Set-StepState -StepId $stepId -Status "pending" -Message "Dry run: summary skipped" } else { diff --git a/build-iso.ps1 b/build-iso.ps1 index 0537fa2..da9d958 100644 --- a/build-iso.ps1 +++ b/build-iso.ps1 @@ -191,6 +191,8 @@ try { # Validate required files exist Write-Step "Validating project files" + $validateUnattendScript = Join-Path $ScriptRoot "validate-unattend.ps1" + $requiredFiles = @{ "autounattend.xml" = Join-Path $ScriptRoot "autounattend.xml" "bootstrap.ps1" = Join-Path $ScriptRoot "bootstrap.ps1" @@ -228,6 +230,11 @@ try { # Validate source ISO checksum if provided Validate-SourceIsoHash -IsoPath $SourceISO -ExpectedHash $SourceIsoHash + # Validate autounattend.xml before doing any expensive ISO work + Write-Step "Validating autounattend.xml" + & $validateUnattendScript -UnattendPath $requiredFiles["autounattend.xml"] -SourceISO $SourceISO + Write-Success "autounattend.xml validation passed" + # Find oscdimg.exe $oscdimgPath = Find-OscdImg -DownloadUrl $OscdimgDownloadUrl diff --git a/tests/Autounattend.Tests.ps1 b/tests/Autounattend.Tests.ps1 index 3c6b2d2..a8e1534 100644 --- a/tests/Autounattend.Tests.ps1 +++ b/tests/Autounattend.Tests.ps1 @@ -15,6 +15,11 @@ Describe "autounattend.xml static checks" { $fileContent | Should -Not -Match "0" } + It "does not include an empty product key block" { + $fileContent | Should -Not -Match "" + $fileContent | Should -Not -Match "" + } + It "sets execution policy for bootstrap" { $fileContent | Should -Match "Set-ExecutionPolicy" } diff --git a/tests/Bootstrap.Tests.ps1 b/tests/Bootstrap.Tests.ps1 index 9932a60..1034829 100644 --- a/tests/Bootstrap.Tests.ps1 +++ b/tests/Bootstrap.Tests.ps1 @@ -38,6 +38,17 @@ Describe "bootstrap.ps1 static checks" { $scriptContent | Should -Match "registry\.json" } + It "migrates unattended debloat and user tweaks into bootstrap" { + $scriptContent | Should -Match "postInstallTweaks" + $scriptContent | Should -Match "Remove-AppxProvisionedPackage" + $scriptContent | Should -Match "Disable-WindowsOptionalFeature" + $scriptContent | Should -Match "ConfigureStartPins" + $scriptContent | Should -Match "SearchboxTaskbarMode" + $scriptContent | Should -Match "HideFileExt" + $scriptContent | Should -Match "TaskbarAl" + $scriptContent | Should -Match "InprocServer32" + } + It "checks network with ping and HTTPS fallback" { $scriptContent | Should -Match "1\.1\.1\.1" $scriptContent | Should -Match "Test-NetConnection" diff --git a/tests/BuildIso.Tests.ps1 b/tests/BuildIso.Tests.ps1 index 50ad52e..3c8ca7a 100644 --- a/tests/BuildIso.Tests.ps1 +++ b/tests/BuildIso.Tests.ps1 @@ -37,6 +37,15 @@ Describe "build-iso.ps1 static checks" { $scriptContent | Should -Match "skipping optional apps payload" } + It "validates autounattend before ISO build" { + $scriptContent | Should -Match "validate-unattend\.ps1" + $scriptContent | Should -Match "autounattend\.xml validation passed" + } + + It "passes source ISO into unattend validation" { + ($scriptContent -like '*-SourceISO $SourceISO*') | Should -BeTrue + } + It "does not reference MountDir anymore" { $scriptContent.Contains('$MountDir') | Should -BeFalse } diff --git a/tests/ValidateUnattend.Tests.ps1 b/tests/ValidateUnattend.Tests.ps1 new file mode 100644 index 0000000..a18878e --- /dev/null +++ b/tests/ValidateUnattend.Tests.ps1 @@ -0,0 +1,35 @@ +Describe "validate-unattend.ps1 static checks" { + BeforeAll { + $scriptPath = Resolve-Path (Join-Path $PSScriptRoot "..\validate-unattend.ps1") + $scriptContent = Get-Content $scriptPath -Raw + } + + It "checks for empty product key blocks" { + $scriptContent | Should -Match "empty ProductKey block" + } + + It "requires the Windows SIM schema DLL" { + $scriptContent | Should -Match "Windows SIM schema DLL not found" + $scriptContent | Should -Match "microsoft\.componentstudio\.componentplatforminterface\.dll" + } + + It "performs schema validation before policy checks" { + $scriptContent | Should -Match "Test-XmlAgainstSchema" + $scriptContent | Should -Match "passed schema validation" + } + + It "blocks hardcoded disk selection" { + $scriptContent | Should -Match "DiskConfiguration" + $scriptContent | Should -Match "InstallTo" + } + + It "validates source image metadata when ISO is provided" { + $scriptContent | Should -Match "Get-WindowsImage" + $scriptContent | Should -Match "install\.wim" + $scriptContent | Should -Match "install\.esd" + } + + It "requires bootstrap first logon command" { + $scriptContent | Should -Match "C:\\Setup\\bootstrap\.ps1" + } +} diff --git a/validate-unattend.ps1 b/validate-unattend.ps1 new file mode 100644 index 0000000..a5d577b --- /dev/null +++ b/validate-unattend.ps1 @@ -0,0 +1,218 @@ +#Requires -RunAsAdministrator + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateScript({ Test-Path $_ -PathType Leaf })] + [string]$UnattendPath, + + [Parameter(Mandatory = $false)] + [ValidateScript({ Test-Path $_ -PathType Leaf })] + [string]$SourceISO, + + [Parameter(Mandatory = $false)] + [string]$SchemaDllPath +) + +$ErrorActionPreference = "Stop" + +function Write-Info { + param([string]$Message) + Write-Host "[INFO] $Message" +} + +function Write-Success { + param([string]$Message) + Write-Host "[ OK ] $Message" -ForegroundColor Green +} + +function Find-UnattendSchemaDll { + param([string]$OverridePath) + + if ($OverridePath) { + if (-not (Test-Path $OverridePath -PathType Leaf)) { + throw "Specified schema DLL was not found: $OverridePath" + } + + return (Resolve-Path $OverridePath).Path + } + + $candidatePaths = @( + "${env:ProgramFiles(x86)}\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\WSIM\amd64\microsoft.componentstudio.componentplatforminterface.dll", + "${env:ProgramFiles(x86)}\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\WSIM\x86\microsoft.componentstudio.componentplatforminterface.dll", + "${env:ProgramFiles(x86)}\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\WSIM\arm64\microsoft.componentstudio.componentplatforminterface.dll", + "${env:ProgramFiles}\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\WSIM\amd64\microsoft.componentstudio.componentplatforminterface.dll" + ) + + foreach ($candidatePath in $candidatePaths) { + if (Test-Path $candidatePath -PathType Leaf) { + return $candidatePath + } + } + + throw "Windows SIM schema DLL not found. Install the Windows ADK Deployment Tools + Windows System Image Manager, or pass -SchemaDllPath." +} + +function Get-UnattendSchemaString { + param([string]$DllPath) + + $assembly = [Reflection.Assembly]::LoadFile($DllPath) + $resourceManagerName = ($assembly.GetManifestResourceNames() | Where-Object { $_ -like '*.resources' } | Select-Object -First 1) + if (-not $resourceManagerName) { + throw "Unable to find embedded resources in schema DLL: $DllPath" + } + + $resourceManager = [System.Resources.ResourceManager]::new($resourceManagerName.Replace('.resources', ''), $assembly) + $cultureCandidates = @( + [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.CultureInfo]::GetCultureInfo('en-US'), + [System.Globalization.CultureInfo]::GetCultureInfo('en-GB') + ) + + foreach ($culture in $cultureCandidates) { + $resourceSet = $resourceManager.GetResourceSet($culture, $true, $true) + if (-not $resourceSet) { + continue + } + + foreach ($entry in $resourceSet) { + if ($entry.Name -like 'Unattend*') { + return [System.Text.Encoding]::ASCII.GetString($entry.Value) + } + } + } + + throw "Unable to extract unattend schema from $DllPath" +} + +function Test-XmlAgainstSchema { + param( + [string]$XmlPath, + [string]$SchemaText + ) + + $validationErrors = [System.Collections.Generic.List[string]]::new() + $eventHandler = [System.Xml.Schema.ValidationEventHandler]{ + param($sender, $eventArgs) + $validationErrors.Add($eventArgs.Message) + } + + $settings = [System.Xml.XmlReaderSettings]::new() + $settings.ValidationType = [System.Xml.ValidationType]::Schema + $settings.ValidationFlags = [System.Xml.Schema.XmlSchemaValidationFlags]::ProcessIdentityConstraints + $settings.add_ValidationEventHandler($eventHandler) + + $schemaReader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($SchemaText)) + try { + $settings.Schemas.Add('urn:schemas-microsoft-com:unattend', $schemaReader) | Out-Null + } + finally { + $schemaReader.Dispose() + } + + $xmlReader = [System.Xml.XmlReader]::Create($XmlPath, $settings) + try { + while ($xmlReader.Read()) { + } + } + finally { + $xmlReader.Dispose() + } + + if ($validationErrors.Count -gt 0) { + throw ("Schema validation failed: " + ($validationErrors -join ' | ')) + } +} + +function Assert-UnattendPolicy { + param( + [xml]$Document, + $NamespaceManager + ) + + $emptyProductKey = $Document.SelectSingleNode("//u:ProductKey[normalize-space(u:Key)='']", $NamespaceManager) + if ($emptyProductKey) { + throw "autounattend.xml contains an empty ProductKey block. Remove ProductKey entirely instead of leaving ." + } + + if ($Document.SelectSingleNode('//u:DiskConfiguration', $NamespaceManager)) { + throw "autounattend.xml must not contain DiskConfiguration. Disk and partition selection should remain manual." + } + + if ($Document.SelectSingleNode('//u:InstallTo', $NamespaceManager)) { + throw "autounattend.xml must not contain InstallTo. Disk and partition selection should remain manual." + } + + $bootstrapCommand = $Document.SelectSingleNode("//u:FirstLogonCommands/u:SynchronousCommand/u:CommandLine[contains(., 'C:\Setup\bootstrap.ps1')]", $NamespaceManager) + if (-not $bootstrapCommand) { + throw "autounattend.xml must include a FirstLogonCommands entry that runs C:\Setup\bootstrap.ps1." + } + + Write-Success "Unattend policy checks passed" +} + +function Test-SourceIsoImage { + param([string]$IsoPath) + + if (-not $IsoPath) { + Write-Info "Source ISO not provided; skipping image validation" + return + } + + Write-Info "Validating Windows image inside source ISO" + $mount = $null + $resolvedIsoPath = (Resolve-Path $IsoPath).Path + + try { + $mount = Mount-DiskImage -ImagePath $resolvedIsoPath -PassThru + $driveLetter = ($mount | Get-Volume).DriveLetter + if (-not $driveLetter) { + throw "Mounted ISO does not have an accessible drive letter." + } + + $sourceRoot = "${driveLetter}:\" + $wimPath = Join-Path $sourceRoot 'sources\install.wim' + $esdPath = Join-Path $sourceRoot 'sources\install.esd' + $imagePath = if (Test-Path $wimPath) { $wimPath } elseif (Test-Path $esdPath) { $esdPath } else { $null } + + if (-not $imagePath) { + throw "Source ISO is missing sources\install.wim or sources\install.esd." + } + + $imageInfo = Get-WindowsImage -ImagePath $imagePath -ErrorAction Stop + if (-not $imageInfo) { + throw "Unable to read Windows image metadata from $imagePath." + } + + $editionNames = @($imageInfo | Select-Object -ExpandProperty ImageName) + Write-Success "Windows image validation passed: found $($editionNames.Count) edition(s)" + Write-Info ("Editions: " + ($editionNames -join ', ')) + } + finally { + if ($mount) { + Dismount-DiskImage -ImagePath $resolvedIsoPath -ErrorAction SilentlyContinue | Out-Null + } + } +} + +$resolvedUnattendPath = (Resolve-Path $UnattendPath).Path +Write-Info "Validating unattend file: $resolvedUnattendPath" + +$xmlContent = Get-Content -Path $resolvedUnattendPath -Raw +$document = [System.Xml.XmlDocument]::new() +$document.PreserveWhitespace = $true +$document.LoadXml($xmlContent) +Write-Success "autounattend.xml is well-formed XML" + +$resolvedSchemaDllPath = Find-UnattendSchemaDll -OverridePath $SchemaDllPath +Write-Info "Using schema DLL: $resolvedSchemaDllPath" +$schemaText = Get-UnattendSchemaString -DllPath $resolvedSchemaDllPath +Test-XmlAgainstSchema -XmlPath $resolvedUnattendPath -SchemaText $schemaText +Write-Success "autounattend.xml passed schema validation" + +$namespaceManager = [System.Xml.XmlNamespaceManager]::new($document.NameTable) +[void]$namespaceManager.AddNamespace('u', 'urn:schemas-microsoft-com:unattend') +Assert-UnattendPolicy -Document $document -NamespaceManager $namespaceManager +Test-SourceIsoImage -IsoPath $SourceISO + +Write-Success "Unattend validation completed successfully"