From 6de45a38b993a5bf6da30f70b94efee09a7b742b Mon Sep 17 00:00:00 2001 From: Phuc To <122270423+hiphucto@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:06:17 -0700 Subject: [PATCH] Fix UI helper errors during headless -Preset and -Config runs (#4941) * Make UI helpers no-op when no window exists The -Preset and -Config paths run Invoke-WinUtilAutoRun before the XAML form is created and before PresentationCore is loaded, so every call into Invoke-WPFUIThread failed with InvokeMethodOnNull and every call into Set-WinUtilTweaksProgressIndicator failed to resolve [Windows.Visibility]. The errors were non-terminating, so tweaks still applied, but each run filled the log with noise. Loading presentationframework earlier does not help: Visibility lives in PresentationCore, which only loads once a WPF object is instantiated, and the XAML-named progress controls do not exist in these paths either. Guarding the two helpers covers Invoke-WPFtweaksbutton, Invoke-WPFundoall and Invoke-WPFFeatureInstall, which the existing per-call-site $hasUI convention never reached. * Suppress stray console output from automation runs Invoke-WPFRunspace returns an IAsyncResult that no caller uses, and Set-WinUtilRegistry was the only New-PSDrive call site that did not suppress its output. A GUI click handler discards both, but the -Preset and -Config paths call the workflows directly, so an async handle dump and a PSDrive table landed in the user's log. The handle itself is kept because pester/runspace.Tests.ps1 asserts that Invoke-WPFRunspace returns a single IAsyncResult, so the suppression goes at the four call sites reachable from Invoke-WinUtilAutoRun. * Add project learning for window-free UI helpers --- AGENTS.md | 1 + functions/private/Set-WinUtilRegistry.ps1 | 2 +- .../Set-WinUtilTweaksProgressIndicator.ps1 | 4 + functions/public/Invoke-WPFAppxRemoval.ps1 | 2 +- functions/public/Invoke-WPFFeatureInstall.ps1 | 2 +- functions/public/Invoke-WPFInstall.ps1 | 2 +- functions/public/Invoke-WPFUIThread.ps1 | 4 + functions/public/Invoke-WPFtweaksbutton.ps1 | 2 +- pester/headless-ui.Tests.ps1 | 107 ++++++++++++++++++ 9 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 pester/headless-ui.Tests.ps1 diff --git a/AGENTS.md b/AGENTS.md index 907dd4ed..ca951623 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -163,6 +163,7 @@ When the user corrects an agent approach, add or tighten one concrete rule here - Import Pester 5.8.0 before running tests so `Invoke-Pester -Output Detailed -CI` does not resolve to Windows' inbox Pester 3.4.0. - Keep package install/uninstall process launches simple unless explicitly requested; do not add a separate stdout/stderr process logging helper for winget or Chocolatey. - When the active log file is owned by `Start-Transcript`, do not call `Add-Content` against that file; write to host output so the transcript captures the line in the same log file without recording a terminating-error diagnostic. +- Keep UI helpers such as `Invoke-WPFUIThread` and `Set-WinUtilTweaksProgressIndicator` safe to call without a window; the `-Preset` and `-Config` paths run the workflows before the form is created and before PresentationCore is loaded. - Log install/uninstall package names and package-manager IDs before queuing background runspace work; do not rely on runspace host output for the package identity. - For Win11 Creator, start each new ISO modification in a fresh `WinUtil_Win11ISO_*` temp directory; existing-work detection is only for resuming/exporting already modified media. - For Win11 Creator driver injection, keep offline WIM servicing to one mount, one `/Add-Driver`, and one commit; do not export editions or run unrelated WIM cleanup, and reject damaged metadata before ISO export. diff --git a/functions/private/Set-WinUtilRegistry.ps1 b/functions/private/Set-WinUtilRegistry.ps1 index b15b3e59..d3b63d33 100644 --- a/functions/private/Set-WinUtilRegistry.ps1 +++ b/functions/private/Set-WinUtilRegistry.ps1 @@ -28,7 +28,7 @@ function Set-WinUtilRegistry { ) try { - if(!(Test-Path 'HKU:\')) {New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS} + if(!(Test-Path 'HKU:\')) {New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS | Out-Null} If (!(Test-Path $Path)) { Write-Host "$Path was not found. Creating..." diff --git a/functions/private/Set-WinUtilTweaksProgressIndicator.ps1 b/functions/private/Set-WinUtilTweaksProgressIndicator.ps1 index 9d17742d..c0ea9469 100644 --- a/functions/private/Set-WinUtilTweaksProgressIndicator.ps1 +++ b/functions/private/Set-WinUtilTweaksProgressIndicator.ps1 @@ -18,6 +18,10 @@ function Set-WinUtilTweaksProgressIndicator { [int]$Percent ) + if ($null -eq $sync.form -or $null -eq $sync.form.Dispatcher) { + return + } + $indicatorVisible = if ($Visible) { [Windows.Visibility]::Visible } else { [Windows.Visibility]::Collapsed } $indicatorLabel = $Label $hasLabel = $PSBoundParameters.ContainsKey('Label') diff --git a/functions/public/Invoke-WPFAppxRemoval.ps1 b/functions/public/Invoke-WPFAppxRemoval.ps1 index 23090c4e..e7760ec9 100644 --- a/functions/public/Invoke-WPFAppxRemoval.ps1 +++ b/functions/public/Invoke-WPFAppxRemoval.ps1 @@ -96,5 +96,5 @@ function Invoke-WPFAppxRemoval { $sync.ProcessRunning = $false } - } + } | Out-Null } diff --git a/functions/public/Invoke-WPFFeatureInstall.ps1 b/functions/public/Invoke-WPFFeatureInstall.ps1 index 6efdc235..981feaee 100644 --- a/functions/public/Invoke-WPFFeatureInstall.ps1 +++ b/functions/public/Invoke-WPFFeatureInstall.ps1 @@ -36,5 +36,5 @@ function Invoke-WPFFeatureInstall { Write-Host "--- Features are Installed ---" Write-Host "--- A Reboot may be required ---" Write-Host "===================================" - } + } | Out-Null } diff --git a/functions/public/Invoke-WPFInstall.ps1 b/functions/public/Invoke-WPFInstall.ps1 index 0d62a80b..78f93960 100644 --- a/functions/public/Invoke-WPFInstall.ps1 +++ b/functions/public/Invoke-WPFInstall.ps1 @@ -110,5 +110,5 @@ function Invoke-WPFInstall { } $sync.ProcessRunning = $False } - } + } | Out-Null } diff --git a/functions/public/Invoke-WPFUIThread.ps1 b/functions/public/Invoke-WPFUIThread.ps1 index 8f42e286..b5b8c30d 100644 --- a/functions/public/Invoke-WPFUIThread.ps1 +++ b/functions/public/Invoke-WPFUIThread.ps1 @@ -1,3 +1,7 @@ function Invoke-WPFUIThread ($ScriptBlock) { + if ($null -eq $sync.form -or $null -eq $sync.form.Dispatcher) { + return + } + $sync.form.Dispatcher.Invoke([action]$ScriptBlock) } diff --git a/functions/public/Invoke-WPFtweaksbutton.ps1 b/functions/public/Invoke-WPFtweaksbutton.ps1 index d803b02a..838fde83 100644 --- a/functions/public/Invoke-WPFtweaksbutton.ps1 +++ b/functions/public/Invoke-WPFtweaksbutton.ps1 @@ -88,5 +88,5 @@ function Invoke-WPFtweaksbutton { Write-Host "-- Tweaks are Finished ---" Write-Host "=================================" Write-WinUtilLog -Component "Tweaks" -Message "Tweaks workflow completed." - } + } | Out-Null } diff --git a/pester/headless-ui.Tests.ps1 b/pester/headless-ui.Tests.ps1 new file mode 100644 index 00000000..10954988 --- /dev/null +++ b/pester/headless-ui.Tests.ps1 @@ -0,0 +1,107 @@ +#=========================================================================== +# Tests - UI Helpers During Headless (-Preset / -Config) Runs +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + + if (-not ("Windows.Visibility" -as [type])) { + Add-Type @" +namespace Windows +{ + public enum Visibility + { + Visible, + Collapsed + } +} +"@ + } + + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFUIThread.ps1") + . (Join-Path $script:repoRoot "functions\private\Set-WinUtilTweaksProgressIndicator.ps1") + + function script:New-WinUtilFakeForm { + $dispatcher = New-Object psobject + $dispatcher | Add-Member -MemberType NoteProperty -Name InvokeCount -Value 0 + $dispatcher | Add-Member -MemberType ScriptMethod -Name Invoke -Value { + param($Action) + + $null = $Action + $this.InvokeCount++ + } + + $form = New-Object psobject + $form | Add-Member -MemberType NoteProperty -Name Dispatcher -Value $dispatcher + return $form + } + + function script:New-WinUtilFakeIndicatorControlSet { + @{ + Bar = [pscustomobject]@{ Visibility = [Windows.Visibility]::Collapsed } + Label = [pscustomobject]@{ Text = "" } + Value = [pscustomobject]@{ Value = 0 } + } + } +} + +Describe "Invoke-WPFUIThread without a window" { + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + } + + It "does nothing when the automation paths run before the form is created" { + $script:sync = [Hashtable]::Synchronized(@{}) + $script:blockRan = $false + + { Invoke-WPFUIThread -ScriptBlock { $script:blockRan = $true } } | Should -Not -Throw + $script:blockRan | Should -BeFalse + } + + It "does nothing when the form exists but has no dispatcher" { + $script:sync = [Hashtable]::Synchronized(@{ Form = [pscustomobject]@{ Dispatcher = $null } }) + $script:blockRan = $false + + { Invoke-WPFUIThread -ScriptBlock { $script:blockRan = $true } } | Should -Not -Throw + $script:blockRan | Should -BeFalse + } + + It "still marshals onto the dispatcher when a window exists" { + $form = New-WinUtilFakeForm + $script:sync = [Hashtable]::Synchronized(@{ Form = $form }) + + Invoke-WPFUIThread -ScriptBlock { } + + $form.Dispatcher.InvokeCount | Should -Be 1 + } +} + +Describe "Set-WinUtilTweaksProgressIndicator without a window" { + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + } + + It "returns before resolving WPF types when the form is missing" { + $script:sync = [Hashtable]::Synchronized(@{}) + + { Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Creating restore point" -Percent 0 } | Should -Not -Throw + } + + It "still updates the indicator controls when a window exists" { + $controls = New-WinUtilFakeIndicatorControlSet + $script:sync = [Hashtable]::Synchronized(@{ + Form = New-WinUtilFakeForm + WPFTweaksProgressBar = $controls.Bar + WPFTweaksProgressLabel = $controls.Label + WPFTweaksProgressValue = $controls.Value + }) + + Mock Invoke-WPFUIThread { & $ScriptBlock } + + Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Applying WPFTweaksTelemetry (1/17)" -Percent 42 + + $controls.Bar.Visibility | Should -Be ([Windows.Visibility]::Visible) + $controls.Label.Text | Should -Be "Applying WPFTweaksTelemetry (1/17)" + $controls.Value.Value | Should -Be 42 + } +}