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
This commit is contained in:
Phuc To
2026-08-09 13:06:17 -05:00
committed by GitHub
parent 8860caf357
commit 6de45a38b9
9 changed files with 121 additions and 5 deletions
+1
View File
@@ -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. - 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. - 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. - 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. - 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, 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. - 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.
+1 -1
View File
@@ -28,7 +28,7 @@ function Set-WinUtilRegistry {
) )
try { 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)) { If (!(Test-Path $Path)) {
Write-Host "$Path was not found. Creating..." Write-Host "$Path was not found. Creating..."
@@ -18,6 +18,10 @@ function Set-WinUtilTweaksProgressIndicator {
[int]$Percent [int]$Percent
) )
if ($null -eq $sync.form -or $null -eq $sync.form.Dispatcher) {
return
}
$indicatorVisible = if ($Visible) { [Windows.Visibility]::Visible } else { [Windows.Visibility]::Collapsed } $indicatorVisible = if ($Visible) { [Windows.Visibility]::Visible } else { [Windows.Visibility]::Collapsed }
$indicatorLabel = $Label $indicatorLabel = $Label
$hasLabel = $PSBoundParameters.ContainsKey('Label') $hasLabel = $PSBoundParameters.ContainsKey('Label')
+1 -1
View File
@@ -96,5 +96,5 @@ function Invoke-WPFAppxRemoval {
$sync.ProcessRunning = $false $sync.ProcessRunning = $false
} }
} } | Out-Null
} }
@@ -36,5 +36,5 @@ function Invoke-WPFFeatureInstall {
Write-Host "--- Features are Installed ---" Write-Host "--- Features are Installed ---"
Write-Host "--- A Reboot may be required ---" Write-Host "--- A Reboot may be required ---"
Write-Host "===================================" Write-Host "==================================="
} } | Out-Null
} }
+1 -1
View File
@@ -110,5 +110,5 @@ function Invoke-WPFInstall {
} }
$sync.ProcessRunning = $False $sync.ProcessRunning = $False
} }
} } | Out-Null
} }
+4
View File
@@ -1,3 +1,7 @@
function Invoke-WPFUIThread ($ScriptBlock) { function Invoke-WPFUIThread ($ScriptBlock) {
if ($null -eq $sync.form -or $null -eq $sync.form.Dispatcher) {
return
}
$sync.form.Dispatcher.Invoke([action]$ScriptBlock) $sync.form.Dispatcher.Invoke([action]$ScriptBlock)
} }
+1 -1
View File
@@ -88,5 +88,5 @@ function Invoke-WPFtweaksbutton {
Write-Host "-- Tweaks are Finished ---" Write-Host "-- Tweaks are Finished ---"
Write-Host "=================================" Write-Host "================================="
Write-WinUtilLog -Component "Tweaks" -Message "Tweaks workflow completed." Write-WinUtilLog -Component "Tweaks" -Message "Tweaks workflow completed."
} } | Out-Null
} }
+107
View File
@@ -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
}
}