From 250f2eeb79ae6c59c96c0ddb07066d4fa8c7ca69 Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 01:44:15 +0200 Subject: [PATCH 01/70] Add a job layer for long running work - Start-WinUtilJob owns busy state, progress, taskbar, logging and errors - Write-WinUtilJobProgress reports from a job without UI checks in the body - Post UI updates instead of waiting on the dispatcher for each one - Move Invoke-WPFInstall onto it as the first workflow --- functions/private/Start-WinUtilJob.ps1 | 105 ++++++++++++ .../private/Write-WinUtilJobProgress.ps1 | 65 ++++++++ functions/public/Invoke-WPFInstall.ps1 | 109 ++++-------- pester/install-workflow.Tests.ps1 | 157 ++++++++---------- pester/xaml.Tests.ps1 | 3 +- 5 files changed, 266 insertions(+), 173 deletions(-) create mode 100644 functions/private/Start-WinUtilJob.ps1 create mode 100644 functions/private/Write-WinUtilJobProgress.ps1 diff --git a/functions/private/Start-WinUtilJob.ps1 b/functions/private/Start-WinUtilJob.ps1 new file mode 100644 index 0000000000..b4d6053f2b --- /dev/null +++ b/functions/private/Start-WinUtilJob.ps1 @@ -0,0 +1,105 @@ +function Start-WinUtilJob { + <# + .SYNOPSIS + Runs a long operation off the UI thread with the shared progress, taskbar, log and + error handling applied around it + + .DESCRIPTION + Every long running WinUtil action goes through here instead of repeating the same + ceremony. The job layer owns: + + - refusing to start while another job is running, with one consistent message + - the busy flag other code checks + - the progress bar and taskbar item for the whole lifetime of the job + - a start, finish and failure line in the log under the job's own component + - catching anything the body throws, so a failure cannot leave the UI stuck busy + - restoring the interface in a finally block whatever happens + + The body only has to do the work and call Write-WinUtilJobProgress. + + .PARAMETER Name + Short job name. Used as the log component and in progress text, for example Install. + + .PARAMETER ScriptBlock + The work to run. Receives the entries of Parameters as named parameters. + + .PARAMETER Parameters + Values passed to the body by name. + + .PARAMETER Description + Progress text shown while the job starts. Defaults to the job name. + + .PARAMETER DisableAppList + Greys out the app list for the duration, for jobs that change what is installed. + + .EXAMPLE + Start-WinUtilJob -Name "Install" -Parameters @{ Packages = $packages } -ScriptBlock { + param($Packages) + Write-WinUtilJobProgress -Status "Installing" -Percent 10 + } + #> + param( + [Parameter(Mandatory)] + [string]$Name, + + [Parameter(Mandatory)] + [scriptblock]$ScriptBlock, + + [hashtable]$Parameters = @{}, + + [string]$Description, + + [switch]$DisableAppList + ) + + if ($sync.ActiveJob) { + Show-WinUtilMessage -Message "$($sync.ActiveJob) is still running. Wait for it to finish before starting another action." -Title "WinUtil" -Button "OK" -Icon "Warning" | Out-Null + return $null + } + + $sync.ActiveJob = $Name + # Kept in step with ActiveJob because existing code and tests read this flag + $sync.ProcessRunning = $true + + $label = if ($Description) { $Description } else { $Name } + Write-WinUtilLog -Component $Name -Message "$Name job started." + Write-WinUtilJobProgress -Status "$label..." -Percent 0 -State "Normal" -Overlay "logo" + + if ($DisableAppList -and $sync.Form -and $sync.Form.Dispatcher) { + Invoke-WPFUIThread -ScriptBlock { + if ($null -ne $sync.ItemsControl) { $sync.ItemsControl.IsEnabled = $false } + } + } + + # The body is rebuilt inside the runspace from its text. A scriptblock carries the session + # state it was defined in, and recreating it there keeps it bound to the worker instead. + Invoke-WPFRunspace -ParameterList @( + ("JobName", $Name), + ("JobBody", $ScriptBlock.ToString()), + ("JobParameters", $Parameters), + ("JobRestoresAppList", [bool]$DisableAppList) + ) -ScriptBlock { + param($JobName, $JobBody, $JobParameters, $JobRestoresAppList) + + try { + $body = [scriptblock]::Create($JobBody) + & $body @JobParameters + + Write-WinUtilLog -Component $JobName -Message "$JobName job finished." + Write-WinUtilJobProgress -Status "$JobName finished" -Percent 100 -State "None" -Overlay "checkmark" + } catch { + Write-WinUtilLog -Level "ERROR" -Component $JobName -Message "$JobName job failed: $($_.Exception.Message)" + Write-Host "$JobName failed: $($_.Exception.Message)" + Write-WinUtilJobProgress -Status "$JobName failed" -Percent 100 -State "Error" -Overlay "warning" + } finally { + if ($JobRestoresAppList -and $sync.Form -and $sync.Form.Dispatcher) { + Invoke-WPFUIThread -ScriptBlock { + if ($null -ne $sync.ItemsControl) { $sync.ItemsControl.IsEnabled = $true } + } + } + + $sync.ProcessRunning = $false + $sync.ActiveJob = $null + } + } +} diff --git a/functions/private/Write-WinUtilJobProgress.ps1 b/functions/private/Write-WinUtilJobProgress.ps1 new file mode 100644 index 0000000000..f1213f3b9c --- /dev/null +++ b/functions/private/Write-WinUtilJobProgress.ps1 @@ -0,0 +1,65 @@ +function Write-WinUtilJobProgress { + <# + .SYNOPSIS + Reports progress from inside a WinUtil job + + .DESCRIPTION + The only progress call a job body needs. It drives the progress bar and the taskbar + item together, and does nothing when there is no window, so job bodies do not need + their own "is there a UI" checks. + + UI updates are posted rather than waited on. A job that reports progress per item + would otherwise block on the dispatcher once per item. + + .PARAMETER Status + Text for the progress label + + .PARAMETER Percent + Completion between 0 and 100 + + .PARAMETER State + Taskbar state. Normal while working, Error on failure, None when finished. + + .PARAMETER Overlay + Taskbar overlay icon: logo, checkmark, warning or None + #> + param( + [string]$Status, + [ValidateRange(0, 100)] + [int]$Percent = -1, + [ValidateSet("Normal", "Error", "Paused", "Indeterminate", "None")] + [string]$State, + [string]$Overlay + ) + + if ($null -eq $sync.Form -or $null -eq $sync.Form.Dispatcher) { + return + } + + $hasStatus = $PSBoundParameters.ContainsKey('Status') + $hasPercent = $Percent -ge 0 + $hasState = $PSBoundParameters.ContainsKey('State') + $hasOverlay = $PSBoundParameters.ContainsKey('Overlay') + + $update = { + if ($hasStatus -or $hasPercent) { + $sync.WPFTweaksProgressBar.Visibility = [Windows.Visibility]::Visible + } + if ($hasStatus) { + $sync.WPFTweaksProgressLabel.Text = $Status + $sync.WPFTweaksProgressLabel.ToolTip = $Status + } + if ($hasPercent) { + $sync.WPFTweaksProgressValue.Value = $Percent + $sync.Form.TaskbarItemInfo.ProgressValue = $Percent / 100 + } + if ($hasState) { + Set-WinUtilTaskbaritem -state $State + } + if ($hasOverlay) { + Set-WinUtilTaskbaritem -overlay $Overlay + } + } + + $null = $sync.Form.Dispatcher.BeginInvoke([Windows.Threading.DispatcherPriority]::Background, [action]$update) +} diff --git a/functions/public/Invoke-WPFInstall.ps1 b/functions/public/Invoke-WPFInstall.ps1 index 0d62a80b8c..2f7728036f 100644 --- a/functions/public/Invoke-WPFInstall.ps1 +++ b/functions/public/Invoke-WPFInstall.ps1 @@ -8,13 +8,6 @@ function Invoke-WPFInstall { [PSObject[]]$PackagesToInstall = $($sync.selectedApps | Foreach-Object { $sync.configs.applicationsHashtable.$_ }) ) - - if($sync.ProcessRunning) { - $msg = "[Invoke-WPFInstall] An Install process is currently running." - Show-WinUtilMessage -Message $msg -Title "WinUtil" -Button "OK" -Icon "Warning" - return - } - if ($PackagesToInstall.Count -eq 0) { $WarningMsg = "Please select the program(s) to install or upgrade." Show-WinUtilMessage -Message $WarningMsg -Title "WinUtil" -Button "OK" -Icon "Warning" @@ -23,92 +16,48 @@ function Invoke-WPFInstall { $ManagerPreference = $sync.preferences.packagemanager Write-WinUtilLog -Component "Install" -Message "Install requested for $(@($PackagesToInstall).Count) selected package(s) using preference: $ManagerPreference" - $packageSummary = Get-WinUtilPackageLogSummary -Packages $PackagesToInstall -Preference $ManagerPreference - Write-WinUtilLog -Component "Install" -Message "Install selected package(s): $($packageSummary -join '; ')" - Invoke-WPFRunspace -ParameterList @(("PackagesToInstall", $PackagesToInstall),("ManagerPreference", $ManagerPreference)) -ScriptBlock { + Start-WinUtilJob -Name "Install" -Description "Preparing app install" -DisableAppList -Parameters @{ + PackagesToInstall = $PackagesToInstall + ManagerPreference = $ManagerPreference + } -ScriptBlock { param($PackagesToInstall, $ManagerPreference) - $packagesSorted = Get-WinUtilSelectedPackages -PackageList $PackagesToInstall -Preference $ManagerPreference + # Summarising the selection reads every package, so it runs here rather than on the UI thread + $packageSummary = Get-WinUtilPackageLogSummary -Packages $PackagesToInstall -Preference $ManagerPreference + Write-WinUtilLog -Component "Install" -Message "Install selected package(s): $($packageSummary -join '; ')" + $packagesSorted = Get-WinUtilSelectedPackages -PackageList $PackagesToInstall -Preference $ManagerPreference $packagesWinget = $packagesSorted['Winget'] $packagesChoco = $packagesSorted['Choco'] $totalPackages = @($packagesWinget).Count + @($packagesChoco).Count $completedPackages = 0 - $hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher Write-WinUtilLog -Component "Install" -Message "Install package manager split: winget=$(@($packagesWinget).Count), choco=$(@($packagesChoco).Count)" - try { - $sync.ProcessRunning = $true - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Preparing app install (0/$totalPackages)" -Percent 0 - Invoke-WPFUIThread -ScriptBlock { - if ($null -ne $sync.ItemsControl) { - $sync.ItemsControl.IsEnabled = $false - } - } - } - - if($packagesWinget.Count -gt 0 -and $packagesWinget -ne "0") { - Install-WinUtilWinget - foreach ($program in $packagesWinget) { - $position = $completedPackages + 1 - $startPercent = [int](($completedPackages / $totalPackages) * 100) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installing $program ($position/$totalPackages)" -Percent $startPercent - } - - Install-WinUtilProgramWinget -Action Install -Programs @($program) - $completedPackages++ - $completedPercent = [int](($completedPackages / $totalPackages) * 100) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installed $program ($completedPackages/$totalPackages)" -Percent $completedPercent - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) } - } - } - } - if($packagesChoco.Count -gt 0) { + if ($packagesWinget.Count -gt 0 -and $packagesWinget -ne "0") { + Install-WinUtilWinget + foreach ($program in $packagesWinget) { $position = $completedPackages + 1 - $startPercent = [int](($completedPackages / $totalPackages) * 100) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installing Chocolatey packages ($position/$totalPackages)" -Percent $startPercent - } + Write-WinUtilJobProgress -Status "Installing $program ($position/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) - Install-WinUtilChoco - Install-WinUtilProgramChoco -Action Install -Programs $packagesChoco - $completedPackages += @($packagesChoco).Count - $completedPercent = [int](($completedPackages / $totalPackages) * 100) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installed Chocolatey packages ($completedPackages/$totalPackages)" -Percent $completedPercent - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) } - } - } - Write-Host "===========================================" - Write-Host "-- Installs have finished ---" - Write-Host "===========================================" - Write-WinUtilLog -Component "Install" -Message "Install workflow completed." - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "App install finished" -Percent 100 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } - } - } catch { - Write-Host "===========================================" - Write-Host "Error: $_" - Write-Host "===========================================" - Write-WinUtilLog -Level "ERROR" -Component "Install" -Message "Install workflow failed: $($_.Exception.Message)" - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "App install failed" -Percent 100 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" } - } - } finally { - if ($hasUI) { - Invoke-WPFUIThread -ScriptBlock { - if ($null -ne $sync.ItemsControl) { - $sync.ItemsControl.IsEnabled = $true - } - } + Install-WinUtilProgramWinget -Action Install -Programs @($program) + $completedPackages++ + Write-WinUtilJobProgress -Status "Installed $program ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) } - $sync.ProcessRunning = $False } + + if ($packagesChoco.Count -gt 0) { + $position = $completedPackages + 1 + Write-WinUtilJobProgress -Status "Installing Chocolatey packages ($position/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) + + Install-WinUtilChoco + Install-WinUtilProgramChoco -Action Install -Programs $packagesChoco + $completedPackages += @($packagesChoco).Count + Write-WinUtilJobProgress -Status "Installed Chocolatey packages ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) + } + + Write-Host "===========================================" + Write-Host "-- Installs have finished ---" + Write-Host "===========================================" } } diff --git a/pester/install-workflow.Tests.ps1 b/pester/install-workflow.Tests.ps1 index 1689bf1c3a..514fdd8ad1 100644 --- a/pester/install-workflow.Tests.ps1 +++ b/pester/install-workflow.Tests.ps1 @@ -12,6 +12,18 @@ BeforeAll { function Show-WinUtilMessage { param($Message, $Title, $Button, $Icon) } + function Start-WinUtilJob { + param( + [string]$Name, + [scriptblock]$ScriptBlock, + [hashtable]$Parameters, + [string]$Description, + [switch]$DisableAppList + ) + } + function Write-WinUtilJobProgress { + param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay) + } function Invoke-WPFRunspace { param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) } @@ -107,41 +119,38 @@ Describe "Invoke-WPFInstall entrypoint" { BeforeEach { $script:package = New-WinUtilPackage New-WinUtilInstallTestContext -Packages @($script:package) - $script:capturedInstallScriptBlock = $null - $script:capturedInstallParameterList = $null + $script:capturedInstallJob = $null Mock Show-WinUtilMessage { "OK" } - Mock Invoke-WPFRunspace { - $script:capturedInstallScriptBlock = $ScriptBlock - $script:capturedInstallParameterList = $ParameterList - [pscustomobject]@{ MockHandle = $true } + Mock Start-WinUtilJob { + $script:capturedInstallJob = [pscustomobject]@{ + Name = $Name + ScriptBlock = $ScriptBlock + Parameters = $Parameters + Description = $Description + DisableAppList = [bool]$DisableAppList + } } Mock Write-WinUtilLog { } } AfterEach { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedInstallScriptBlock -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedInstallParameterList -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedInstallJob -Scope Script -ErrorAction SilentlyContinue } - It "queues selected packages with the configured package manager preference" { + It "queues an install job with the configured package manager preference" { Invoke-WPFInstall - Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { - $ScriptBlock -is [scriptblock] -and - $ParameterList.Count -eq 2 -and - $ParameterList[0][0] -eq "PackagesToInstall" -and - @($ParameterList[0][1]).Count -eq 1 -and - @($ParameterList[0][1])[0].winget -eq "Git.Git" -and - $ParameterList[1][0] -eq "ManagerPreference" -and - $ParameterList[1][1] -eq "Winget" + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "Install" -and + $ScriptBlock -is [scriptblock] -and + $DisableAppList -and + @($Parameters.PackagesToInstall).Count -eq 1 -and + @($Parameters.PackagesToInstall)[0].winget -eq "Git.Git" -and + $Parameters.ManagerPreference -eq "Winget" } Should -Invoke -CommandName Show-WinUtilMessage -Times 0 -Exactly - Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { - $Component -eq "Install" -and - $Message -eq "Install selected package(s): Git (winget: Git.Git)" - } } It "queues the explicit app popup package over the selected apps" { @@ -149,14 +158,9 @@ Describe "Invoke-WPFInstall entrypoint" { Invoke-WPFInstall -PackagesToInstall $explicitPackage - Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { - $ScriptBlock -is [scriptblock] -and - $ParameterList.Count -eq 2 -and - $ParameterList[0][0] -eq "PackagesToInstall" -and - @($ParameterList[0][1]).Count -eq 1 -and - @($ParameterList[0][1])[0].winget -eq "VideoLAN.VLC" -and - $ParameterList[1][0] -eq "ManagerPreference" -and - $ParameterList[1][1] -eq "Winget" + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + @($Parameters.PackagesToInstall).Count -eq 1 -and + @($Parameters.PackagesToInstall)[0].winget -eq "VideoLAN.VLC" } Should -Invoke -CommandName Show-WinUtilMessage -Times 0 -Exactly } @@ -172,73 +176,49 @@ Describe "Invoke-WPFInstall entrypoint" { $Button -eq "OK" -and $Icon -eq "Warning" } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly - } - - It "prompts and exits when another install process is running" { - New-WinUtilInstallTestContext -ProcessRunning $true -Packages @($script:package) - - Invoke-WPFInstall - - Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { - $Message -eq "[Invoke-WPFInstall] An Install process is currently running." -and - $Title -eq "WinUtil" -and - $Button -eq "OK" -and - $Icon -eq "Warning" - } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly } } -Describe "Invoke-WPFInstall runspace body" { +Describe "Invoke-WPFInstall job body" { BeforeEach { $script:package = New-WinUtilPackage New-WinUtilInstallTestContext -Packages @($script:package) - $script:capturedInstallScriptBlock = $null + $script:capturedInstallJob = $null Mock Show-WinUtilMessage { "OK" } - Mock Invoke-WPFRunspace { - $script:capturedInstallScriptBlock = $ScriptBlock - [pscustomobject]@{ MockHandle = $true } + Mock Start-WinUtilJob { + $script:capturedInstallJob = [pscustomobject]@{ + ScriptBlock = $ScriptBlock + Parameters = $Parameters + } } Mock Get-WinUtilSelectedPackages { New-WinUtilPackageSplit -Winget @("Git.Git") -Choco @("vlc") } - Mock Set-WinUtilTweaksProgressIndicator { } + Mock Write-WinUtilJobProgress { } Mock Install-WinUtilWinget { } Mock Install-WinUtilChoco { } Mock Install-WinUtilProgramWinget { } Mock Install-WinUtilProgramChoco { } - Mock Invoke-WPFUIThread { } Mock Write-WinUtilLog { } Mock Write-Host { } } AfterEach { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedInstallScriptBlock -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedInstallJob -Scope Script -ErrorAction SilentlyContinue } - It "installs split winget and choco packages and cleans up on success" { + It "installs split winget and choco packages and reports progress" { Invoke-WPFInstall - & $script:capturedInstallScriptBlock -PackagesToInstall @($script:package) -ManagerPreference "Winget" + $jobParameters = $script:capturedInstallJob.Parameters + & $script:capturedInstallJob.ScriptBlock @jobParameters Should -Invoke -CommandName Get-WinUtilSelectedPackages -Times 1 -Exactly -ParameterFilter { @($PackageList).Count -eq 1 -and $Preference -eq "Winget" } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Preparing app install (0/2)" -and $Percent -eq 0 - } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Installed Git.Git (1/2)" -and $Percent -eq 50 - } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Installed Chocolatey packages (2/2)" -and $Percent -eq 100 - } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "App install finished" -and $Percent -eq 100 - } Should -Invoke -CommandName Install-WinUtilWinget -Times 1 -Exactly Should -Invoke -CommandName Install-WinUtilProgramWinget -Times 1 -Exactly -ParameterFilter { $Action -eq "Install" -and @($Programs)[0] -eq "Git.Git" @@ -247,41 +227,34 @@ Describe "Invoke-WPFInstall runspace body" { Should -Invoke -CommandName Install-WinUtilProgramChoco -Times 1 -Exactly -ParameterFilter { $Action -eq "Install" -and @($Programs)[0] -eq "vlc" } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $false*' - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $true*' + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Installed Git.Git (1/2)" -and $Percent -eq 50 } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*' + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Installed Chocolatey packages (2/2)" -and $Percent -eq 100 } - $script:sync.ProcessRunning | Should -BeFalse } - It "shows failure progress, sets taskbar error state, and clears ProcessRunning on failure" { - Mock Install-WinUtilProgramWinget { throw "winget failed" } - + It "logs the package summary from inside the job rather than on the UI thread" { Invoke-WPFInstall - & $script:capturedInstallScriptBlock -PackagesToInstall @($script:package) -ManagerPreference "Winget" + $jobParameters = $script:capturedInstallJob.Parameters + & $script:capturedInstallJob.ScriptBlock @jobParameters - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "App install failed" -and $Percent -eq 100 - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $false*' - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $true*' - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*' - } Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { - $Level -eq "ERROR" -and $Component -eq "Install" -and $Message -like "Install workflow failed:*" + $Component -eq "Install" -and + $Message -eq "Install selected package(s): Git (winget: Git.Git)" } - $script:sync.ProcessRunning | Should -BeFalse + } + + It "lets a failure surface so the job layer can handle it" { + Mock Install-WinUtilProgramWinget { throw "winget failed" } + + Invoke-WPFInstall + + { $jobParameters = $script:capturedInstallJob.Parameters + & $script:capturedInstallJob.ScriptBlock @jobParameters } | + Should -Throw "winget failed" } } diff --git a/pester/xaml.Tests.ps1 b/pester/xaml.Tests.ps1 index 010ff099c3..e5b6b0ae93 100644 --- a/pester/xaml.Tests.ps1 +++ b/pester/xaml.Tests.ps1 @@ -468,7 +468,8 @@ Describe "XAML and sync wiring" { "Win11ISOProcessRunning", "Win11ISOWorkDir", "Win11ISOContentsDir", - "Win11ISOUSBDisks" + "Win11ISOUSBDisks", + "ActiveJob" ) $allowedNames = @($xamlNames + $generatedNames + $dynamicStateNames) | Sort-Object -Unique $bracketReferences = @( From 3867f4a07e2674a7603a2d3464923bcad512792a Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 02:01:48 +0200 Subject: [PATCH 02/70] Move the remaining app and feature workflows onto the job layer - Uninstall, AppX install, Features, OOSU and installed detection - Drop the per workflow busy flag, progress, taskbar and error handling - Rework their tests to check the job and its body instead of runspace internals --- functions/public/Invoke-WPFAppxInstall.ps1 | 74 ++----- functions/public/Invoke-WPFFeatureInstall.ps1 | 32 ++- functions/public/Invoke-WPFGetInstalled.ps1 | 93 +++------ functions/public/Invoke-WPFOOSU.ps1 | 56 ++--- functions/public/Invoke-WPFUnInstall.ps1 | 109 +++------- pester/appx.Tests.ps1 | 60 +++--- pester/install-workflow.Tests.ps1 | 151 +++++--------- pester/oosu.Tests.ps1 | 74 +++---- pester/runspace.Tests.ps1 | 15 +- pester/ui-state.Tests.ps1 | 192 +++--------------- 10 files changed, 232 insertions(+), 624 deletions(-) diff --git a/functions/public/Invoke-WPFAppxInstall.ps1 b/functions/public/Invoke-WPFAppxInstall.ps1 index 902764533d..b6006ca747 100644 --- a/functions/public/Invoke-WPFAppxInstall.ps1 +++ b/functions/public/Invoke-WPFAppxInstall.ps1 @@ -1,68 +1,30 @@ function Invoke-WPFAppxInstall { - if ($sync.ProcessRunning) { - Show-WinUtilMessage -Message "An AppX process is currently running." -Title "WinUtil" -Button "OK" -Icon "Warning" - return - } - if ($null -eq $sync.selectedAppx -or $sync.selectedAppx.Count -eq 0) { Show-WinUtilMessage -Message "No AppX Package selected" -Title "Error" -Button "OK" -Icon "Error" return } - $selected = @($sync.selectedAppx) - $apps = $sync.configs.appxHashtable - - $sync.ProcessRunning = $true - Invoke-WPFRunspace -ParameterList @(("selected", $selected), ("apps", $apps)) -ScriptBlock { - param($selected, $apps) - - $totalPackages = @($selected).Count - $hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher + Start-WinUtilJob -Name "AppX install" -Description "Preparing AppX install" -Parameters @{ + Selected = @($sync.selectedAppx) + Apps = $sync.configs.appxHashtable + } -ScriptBlock { + param($Selected, $Apps) - try { - Write-WinUtilLog -Component "AppX" -Message "Starting AppX install for $totalPackages selected package(s)." - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Preparing AppX install (0/$totalPackages)" -Percent 0 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" } - } + $totalPackages = @($Selected).Count + Write-WinUtilLog -Component "AppX" -Message "Starting AppX install for $totalPackages selected package(s)." - for ($index = 0; $index -lt $totalPackages; $index++) { - $key = $selected[$index] - $app = $apps[$key] - $position = $index + 1 - $startPercent = [int](($index / $totalPackages) * 100) + for ($index = 0; $index -lt $totalPackages; $index++) { + $app = $Apps[$Selected[$index]] + $position = $index + 1 - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installing $($app.Content) ($position/$totalPackages)" -Percent $startPercent - } - Write-Host "Installing $($app.Content)" - Install-WinUtilAPPX -Name $app.PackageId -StoreId $app.StoreId - - $completedPercent = [int](($position / $totalPackages) * 100) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installed $($app.Content) ($position/$totalPackages)" -Percent $completedPercent - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) } - } - } - - Write-Host "=================================" - Write-Host "-- AppX Install Finished ---" - Write-Host "=================================" - Write-WinUtilLog -Component "AppX" -Message "AppX install finished." - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "AppX install finished" -Percent 100 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } - } - } - catch { - Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message "AppX install failed: $($_.Exception.Message)" - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "AppX install failed" -Percent 100 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" } - } - } - finally { - $sync.ProcessRunning = $false + Write-WinUtilJobProgress -Status "Installing $($app.Content) ($position/$totalPackages)" -Percent ([int](($index / $totalPackages) * 100)) + Write-Host "Installing $($app.Content)" + Install-WinUtilAPPX -Name $app.PackageId -StoreId $app.StoreId + Write-WinUtilJobProgress -Status "Installed $($app.Content) ($position/$totalPackages)" -Percent ([int](($position / $totalPackages) * 100)) } + + Write-Host "=================================" + Write-Host "-- AppX Install Finished ---" + Write-Host "=================================" } } diff --git a/functions/public/Invoke-WPFFeatureInstall.ps1 b/functions/public/Invoke-WPFFeatureInstall.ps1 index 6efdc235bb..c347e6d327 100644 --- a/functions/public/Invoke-WPFFeatureInstall.ps1 +++ b/functions/public/Invoke-WPFFeatureInstall.ps1 @@ -6,32 +6,26 @@ function Invoke-WPFFeatureInstall { #> - if($sync.ProcessRunning) { - $msg = "[Invoke-WPFFeatureInstall] Install process is currently running." - [System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning) + if ($null -eq $sync.selectedFeatures -or $sync.selectedFeatures.Count -eq 0) { + Show-WinUtilMessage -Message "No Windows Feature selected" -Title "WinUtil" -Button "OK" -Icon "Warning" return } - Invoke-WPFRunspace -ScriptBlock { - $Features = $sync.selectedFeatures - $sync.ProcessRunning = $true - if ($Features.count -eq 1) { - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Indeterminate" -value 0.01 -overlay "logo" } - } else { - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" } - } + Start-WinUtilJob -Name "Features" -Description "Preparing Windows Features" -Parameters @{ + Features = @($sync.selectedFeatures) + } -ScriptBlock { + param($Features) - $x = 0 + $total = @($Features).Count + $completed = 0 - $Features | ForEach-Object { - Invoke-WinUtilFeatureInstall $_ - $X++ - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($x/$Features.Count) } + foreach ($feature in $Features) { + $completed++ + Write-WinUtilJobProgress -Status "Installing $feature ($completed/$total)" -Percent ([int]((($completed - 1) / $total) * 100)) + Invoke-WinUtilFeatureInstall $feature + Write-WinUtilJobProgress -Status "Installed $feature ($completed/$total)" -Percent ([int](($completed / $total) * 100)) } - $sync.ProcessRunning = $false - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } - Write-Host "===================================" Write-Host "--- Features are Installed ---" Write-Host "--- A Reboot may be required ---" diff --git a/functions/public/Invoke-WPFGetInstalled.ps1 b/functions/public/Invoke-WPFGetInstalled.ps1 index bc28623dad..336c3007ac 100644 --- a/functions/public/Invoke-WPFGetInstalled.ps1 +++ b/functions/public/Invoke-WPFGetInstalled.ps1 @@ -1,89 +1,50 @@ function Invoke-WPFGetInstalled { <# .SYNOPSIS - Invokes the function that gets the checkboxes to check in a new runspace + Detects what is already installed or applied and ticks the matching boxes .PARAMETER checkbox Indicates whether to check for installed 'winget' programs or applied 'tweaks' #> param($checkbox) - if ($sync.ProcessRunning) { - $msg = "[Invoke-WPFGetInstalled] Install process is currently running." - [System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning) - return - } if (($sync.ChocoRadioButton.IsChecked -eq $false) -and ((Test-WinUtilPackageManager -winget) -eq "not-installed") -and $checkbox -eq "winget") { return } - $managerPreference = $sync.preferences.packagemanager - $operation = [Hashtable]::Synchronized(@{ - Checkboxes = @() - Error = $null - }) - $completeAction = [Action[hashtable, string]]{ - param( - [hashtable]$completedOperation, - [string]$completedCheckbox - ) - try { - if ($completedOperation.Error) { - Write-WinUtilLog -Level "ERROR" -Component "Install" -Message "Get installed state failed: $($completedOperation.Error)" - Write-Warning "Unable to get installed state: $($completedOperation.Error)" - return - } - if ($completedCheckbox -eq "winget") { - foreach ($checkboxName in $completedOperation.Checkboxes) { - if (-not $sync.selectedApps.Contains($checkboxName)) { - $sync.selectedApps.Add($checkboxName) + Start-WinUtilJob -Name "Detect installed" -Description "Checking what is already installed" -Parameters @{ + Checkbox = $checkbox + ManagerPreference = $sync.preferences.packagemanager + } -ScriptBlock { + param($Checkbox, $ManagerPreference) + + Write-WinUtilJobProgress -Status "Checking what is already installed" -State "Indeterminate" + + $found = @() + if ($Checkbox -eq "winget") { + $source = if ($ManagerPreference -eq "Choco") { "choco" } else { $Checkbox } + $found = @(Invoke-WinUtilCurrentSystem -CheckBox $source) + } elseif ($Checkbox -eq "tweaks") { + $found = @(Invoke-WinUtilCurrentSystem -CheckBox $Checkbox) + } + + Write-WinUtilLog -Component "Install" -Message "Detected $($found.Count) existing item(s) for $Checkbox." + + # Ticking boxes touches the controls, so it happens on the UI thread + Invoke-WPFUIThread -ScriptBlock { + if ($Checkbox -eq "winget") { + foreach ($name in $found) { + if (-not $sync.selectedApps.Contains($name)) { + $sync.selectedApps.Add($name) } } Reset-WPFCheckBoxes -checkboxfilterpattern "WPFInstall*" } else { - foreach ($checkboxName in $completedOperation.Checkboxes) { - $sync.$checkboxName.ischecked = $True - } - } - } finally { - $sync.ProcessRunning = $false - Set-WinUtilTaskbaritem -state "None" - } - } - - $sync.ProcessRunning = $true - Set-WinUtilTaskbaritem -state "Indeterminate" - try { - Invoke-WPFRunspace -ParameterList @( - ("managerPreference", $managerPreference), - ("checkbox", $checkbox), - ("operation", $operation), - ("completeAction", $completeAction) - ) -ScriptBlock { - param ( - [string]$checkbox, - [string]$managerPreference, - [hashtable]$operation, - [Action[hashtable, string]]$completeAction - ) - try { - if ($checkbox -eq "winget") { - switch ($managerPreference) { - "Choco" { $operation.Checkboxes = @(Invoke-WinUtilCurrentSystem -CheckBox "choco"); break } - "Winget" { $operation.Checkboxes = @(Invoke-WinUtilCurrentSystem -CheckBox $checkbox); break } - } - } elseif ($checkbox -eq "tweaks") { - $operation.Checkboxes = @(Invoke-WinUtilCurrentSystem -CheckBox $checkbox) + foreach ($name in $found) { + $sync.$name.ischecked = $true } - } catch { - $operation.Error = $_.Exception.Message - } finally { - $sync.Form.Dispatcher.BeginInvoke($completeAction, [object[]]@($operation, $checkbox)) | Out-Null } } - } catch { - $operation.Error = $_.Exception.Message - $completeAction.Invoke($operation, $checkbox) } } diff --git a/functions/public/Invoke-WPFOOSU.ps1 b/functions/public/Invoke-WPFOOSU.ps1 index 993e32a79e..4bc735851f 100644 --- a/functions/public/Invoke-WPFOOSU.ps1 +++ b/functions/public/Invoke-WPFOOSU.ps1 @@ -1,50 +1,18 @@ function Invoke-WPFOOSU { - if ($sync.ProcessRunning) { - Show-WinUtilMessage -Message "Another process is currently running." -Title "WinUtil" -Button "OK" -Icon "Warning" - return - } - - $downloadPath = Join-Path $sync.winutildir "ooshutup10.exe" - $sync.ProcessRunning = $true - - Invoke-WPFRunspace -ParameterList @(,("downloadPath", $downloadPath)) -ScriptBlock { - param($downloadPath) - - $hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher - - try { - Write-WinUtilLog -Component "OOSU" -Message "Downloading O&O ShutUp10++." - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Downloading O&O ShutUp10++ (0%)" -Percent 0 - } + Start-WinUtilJob -Name "OOSU" -Description "Downloading O&O ShutUp10++" -Parameters @{ + DownloadPath = Join-Path $sync.winutildir "ooshutup10.exe" + } -ScriptBlock { + param($DownloadPath) - Save-WinUtilFile -Uri "https://dl5.oo-software.com/files/ooshutup10/OOSU10.exe" -DestinationPath $downloadPath -ProgressCallback { - param($percent) + Write-WinUtilLog -Component "OOSU" -Message "Downloading O&O ShutUp10++." - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Downloading O&O ShutUp10++ ($percent%)" -Percent $percent - } - } - - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Launching O&O ShutUp10++" -Percent 100 - } - Start-Process -FilePath $downloadPath - - Write-WinUtilLog -Component "OOSU" -Message "O&O ShutUp10++ launched." - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "O&O ShutUp10++ launched" -Percent 100 - } - } - catch { - Write-WinUtilLog -Level "ERROR" -Component "OOSU" -Message "O&O ShutUp10++ download failed: $($_.Exception.Message)" - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "O&O ShutUp10++ download failed" -Percent 100 - } - Write-Error "Couldn't download O&O ShutUp10. Please make sure you have an active Internet connection." - } - finally { - $sync.ProcessRunning = $false + Save-WinUtilFile -Uri "https://dl5.oo-software.com/files/ooshutup10/OOSU10.exe" -DestinationPath $DownloadPath -ProgressCallback { + param($percent) + Write-WinUtilJobProgress -Status "Downloading O&O ShutUp10++ ($percent%)" -Percent $percent } + + Write-WinUtilJobProgress -Status "Launching O&O ShutUp10++" -Percent 100 + Start-Process -FilePath $DownloadPath + Write-WinUtilLog -Component "OOSU" -Message "O&O ShutUp10++ launched." } } diff --git a/functions/public/Invoke-WPFUnInstall.ps1 b/functions/public/Invoke-WPFUnInstall.ps1 index 4144ad3712..bd6fd4d6be 100644 --- a/functions/public/Invoke-WPFUnInstall.ps1 +++ b/functions/public/Invoke-WPFUnInstall.ps1 @@ -9,12 +9,6 @@ function Invoke-WPFUnInstall { Uninstalls the selected programs #> - if($sync.ProcessRunning) { - $msg = "[Invoke-WPFUnInstall] Install process is currently running" - Show-WinUtilMessage -Message $msg -Title "WinUtil" -Button "OK" -Icon "Warning" - return - } - if ($PackagesToUninstall.Count -eq 0) { $WarningMsg = "Please select the program(s) to uninstall" Show-WinUtilMessage -Message $WarningMsg -Title "WinUtil" -Button "OK" -Icon "Warning" @@ -32,96 +26,49 @@ function Invoke-WPFUnInstall { $ManagerPreference = $sync.preferences.packagemanager Write-WinUtilLog -Component "Uninstall" -Message "Uninstall requested for $(@($PackagesToUninstall).Count) selected package(s) using preference: $ManagerPreference" - $packageSummary = Get-WinUtilPackageLogSummary -Packages $PackagesToUninstall -Preference $ManagerPreference - Write-WinUtilLog -Component "Uninstall" -Message "Uninstall selected package(s): $($packageSummary -join '; ')" - Invoke-WPFRunspace -ParameterList @(("PackagesToUninstall", $PackagesToUninstall),("ManagerPreference", $ManagerPreference)) -ScriptBlock { + Start-WinUtilJob -Name "Uninstall" -Description "Preparing app uninstall" -DisableAppList -Parameters @{ + PackagesToUninstall = $PackagesToUninstall + ManagerPreference = $ManagerPreference + } -ScriptBlock { param($PackagesToUninstall, $ManagerPreference) - $packagesSorted = Get-WinUtilSelectedPackages -PackageList $PackagesToUninstall -Preference $ManagerPreference + $packageSummary = Get-WinUtilPackageLogSummary -Packages $PackagesToUninstall -Preference $ManagerPreference + Write-WinUtilLog -Component "Uninstall" -Message "Uninstall selected package(s): $($packageSummary -join '; ')" + $packagesSorted = Get-WinUtilSelectedPackages -PackageList $PackagesToUninstall -Preference $ManagerPreference $packagesWinget = $packagesSorted['Winget'] $packagesChoco = $packagesSorted['Choco'] $totalPackages = @($packagesWinget).Count + @($packagesChoco).Count $completedPackages = 0 - $hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher Write-WinUtilLog -Component "Uninstall" -Message "Uninstall package manager split: winget=$(@($packagesWinget).Count), choco=$(@($packagesChoco).Count)" - try { - $sync.ProcessRunning = $true - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Preparing app uninstall (0/$totalPackages)" -Percent 0 - Invoke-WPFUIThread -ScriptBlock { - if ($null -ne $sync.ItemsControl) { - $sync.ItemsControl.IsEnabled = $false - } - } - } - - if ($packagesWinget -contains "Microsoft.Edge") { - New-Item -Path "$Env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe\MicrosoftEdge.exe" -Force - } - - # Uninstall all selected programs in new window - if($packagesWinget.Count -gt 0) { - foreach ($program in $packagesWinget) { - $position = $completedPackages + 1 - $startPercent = [int](($completedPackages / $totalPackages) * 100) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Uninstalling $program ($position/$totalPackages)" -Percent $startPercent - } + if ($packagesWinget -contains "Microsoft.Edge") { + New-Item -Path "$Env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe\MicrosoftEdge.exe" -Force + } - Install-WinUtilProgramWinget -Action Uninstall -Programs @($program) - $completedPackages++ - $completedPercent = [int](($completedPackages / $totalPackages) * 100) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Uninstalled $program ($completedPackages/$totalPackages)" -Percent $completedPercent - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) } - } - } - } - if($packagesChoco.Count -gt 0) { + if ($packagesWinget.Count -gt 0) { + foreach ($program in $packagesWinget) { $position = $completedPackages + 1 - $startPercent = [int](($completedPackages / $totalPackages) * 100) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Uninstalling Chocolatey packages ($position/$totalPackages)" -Percent $startPercent - } + Write-WinUtilJobProgress -Status "Uninstalling $program ($position/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) - Install-WinUtilProgramChoco -Action Uninstall -Programs $packagesChoco - $completedPackages += @($packagesChoco).Count - $completedPercent = [int](($completedPackages / $totalPackages) * 100) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Uninstalled Chocolatey packages ($completedPackages/$totalPackages)" -Percent $completedPercent - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) } - } - } - Write-Host "===========================================" - Write-Host "-- Uninstalls have finished ---" - Write-Host "===========================================" - Write-WinUtilLog -Component "Uninstall" -Message "Uninstall workflow completed." - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "App uninstall finished" -Percent 100 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } - } - } catch { - Write-Host "===========================================" - Write-Host "Error: $_" - Write-Host "===========================================" - Write-WinUtilLog -Level "ERROR" -Component "Uninstall" -Message "Uninstall workflow failed: $($_.Exception.Message)" - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "App uninstall failed" -Percent 100 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" } + Install-WinUtilProgramWinget -Action Uninstall -Programs @($program) + $completedPackages++ + Write-WinUtilJobProgress -Status "Uninstalled $program ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) } - } finally { - if ($hasUI) { - Invoke-WPFUIThread -ScriptBlock { - if ($null -ne $sync.ItemsControl) { - $sync.ItemsControl.IsEnabled = $true - } - } - } - $sync.ProcessRunning = $False } + if ($packagesChoco.Count -gt 0) { + $position = $completedPackages + 1 + Write-WinUtilJobProgress -Status "Uninstalling Chocolatey packages ($position/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) + + Install-WinUtilProgramChoco -Action Uninstall -Programs $packagesChoco + $completedPackages += @($packagesChoco).Count + Write-WinUtilJobProgress -Status "Uninstalled Chocolatey packages ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) + } + + Write-Host "===========================================" + Write-Host "-- Uninstalls have finished ---" + Write-Host "===========================================" } } diff --git a/pester/appx.Tests.ps1 b/pester/appx.Tests.ps1 index e055a10f78..9472b5e4fd 100644 --- a/pester/appx.Tests.ps1 +++ b/pester/appx.Tests.ps1 @@ -37,6 +37,12 @@ BeforeAll { function Write-WinUtilLog { param($Message, $Level, $Component) } + function Start-WinUtilJob { + param([string]$Name, [scriptblock]$ScriptBlock, [hashtable]$Parameters, [string]$Description, [switch]$DisableAppList) + } + function Write-WinUtilJobProgress { + param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay) + } function Show-WinUtilMessage { param($Message, $Title, $Button, $Icon) } @@ -373,11 +379,10 @@ Describe "Invoke-WPFAppxInstall" { Mock Set-WinUtilTweaksProgressIndicator { } Mock Invoke-WPFUIThread { } Mock Install-WinUtilAPPX { } - Mock Invoke-WPFRunspace { - $script:appxInstallProcessRunningAtLaunch = $script:sync.ProcessRunning + Mock Write-WinUtilJobProgress { } + Mock Start-WinUtilJob { $script:capturedAppxInstallScriptBlock = $ScriptBlock - $script:capturedAppxInstallParameterList = $ParameterList - [pscustomobject]@{ MockHandle = $true } + $script:capturedAppxInstallParameters = $Parameters } } @@ -397,67 +402,48 @@ Describe "Invoke-WPFAppxInstall" { $Button -eq "OK" -and $Icon -eq "Error" } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly } - It "prevents overlapping AppX install operations" { - $script:sync.ProcessRunning = $true + It "queues an install job for the selected AppX packages" { $script:sync.selectedAppx.Add("WPFAppxExample") Invoke-WPFAppxInstall - Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { - $Message -eq "An AppX process is currently running." -and - $Title -eq "WinUtil" -and - $Button -eq "OK" -and - $Icon -eq "Warning" + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "AppX install" } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + $script:capturedAppxInstallParameters.Selected[0] | Should -Be "WPFAppxExample" } It "installs selected AppX packages with their Store IDs" { $script:sync.selectedAppx.Add("WPFAppxExample") Invoke-WPFAppxInstall - $script:appxInstallProcessRunningAtLaunch | Should -BeTrue - & $script:capturedAppxInstallScriptBlock -selected @("WPFAppxExample") -apps $script:sync.configs.appxHashtable + $jobParameters = $script:capturedAppxInstallParameters + & $script:capturedAppxInstallScriptBlock @jobParameters - $script:capturedAppxInstallParameterList[0][1][0] | Should -Be "WPFAppxExample" Should -Invoke -CommandName Install-WinUtilAPPX -Times 1 -Exactly -ParameterFilter { $Name -eq "Example.Package" -and $StoreId -eq "9EXAMPLE1234" } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Installing Example App (1/1)" -and $Percent -eq 0 + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Installing Example App (1/1)" -and $Percent -eq 0 } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Installed Example App (1/1)" -and $Percent -eq 100 + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Installed Example App (1/1)" -and $Percent -eq 100 } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "AppX install finished" -and $Percent -eq 100 - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*' - } - $script:sync.ProcessRunning | Should -BeFalse } - It "shows failure feedback and clears ProcessRunning when install fails" { + It "lets an install failure surface so the job layer can handle it" { $script:sync.selectedAppx.Add("WPFAppxExample") Mock Install-WinUtilAPPX { throw "Install failed" } Invoke-WPFAppxInstall - & $script:capturedAppxInstallScriptBlock -selected @("WPFAppxExample") -apps $script:sync.configs.appxHashtable + $jobParameters = $script:capturedAppxInstallParameters - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "AppX install failed" -and $Percent -eq 100 - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*' - } - $script:sync.ProcessRunning | Should -BeFalse + { & $script:capturedAppxInstallScriptBlock @jobParameters } | Should -Throw "Install failed" } } - Describe "Invoke-WPFAppxRemoval entrypoint" { BeforeEach { $script:sync = [Hashtable]::Synchronized(@{ diff --git a/pester/install-workflow.Tests.ps1 b/pester/install-workflow.Tests.ps1 index 514fdd8ad1..ad7fc3bb84 100644 --- a/pester/install-workflow.Tests.ps1 +++ b/pester/install-workflow.Tests.ps1 @@ -262,172 +262,115 @@ Describe "Invoke-WPFUnInstall entrypoint" { BeforeEach { $script:package = New-WinUtilPackage New-WinUtilInstallTestContext -Packages @($script:package) - $script:capturedUninstallScriptBlock = $null - $script:capturedUninstallParameterList = $null + $script:capturedUninstallJob = $null Mock Show-WinUtilMessage { "Yes" } - Mock Invoke-WPFRunspace { - $script:capturedUninstallScriptBlock = $ScriptBlock - $script:capturedUninstallParameterList = $ParameterList - [pscustomobject]@{ MockHandle = $true } + Mock Start-WinUtilJob { + $script:capturedUninstallJob = [pscustomobject]@{ + Name = $Name + ScriptBlock = $ScriptBlock + Parameters = $Parameters + DisableAppList = [bool]$DisableAppList + } } Mock Write-WinUtilLog { } } AfterEach { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedUninstallScriptBlock -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedUninstallParameterList -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedUninstallJob -Scope Script -ErrorAction SilentlyContinue } - It "confirms and queues selected packages with the configured package manager preference" { - Invoke-WPFUnInstall -PackagesToUninstall @($script:package) + It "confirms and queues an uninstall job with the configured package manager preference" { + Invoke-WPFUnInstall Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { - $Message -like "*This will uninstall the following applications:*" -and - $Message -like "*Git*" -and - $Title -eq "Are you sure?" -and - "$Button" -eq "YesNo" -and - "$Icon" -eq "Information" - } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { - $ScriptBlock -is [scriptblock] -and - $ParameterList.Count -eq 2 -and - $ParameterList[0][0] -eq "PackagesToUninstall" -and - @($ParameterList[0][1]).Count -eq 1 -and - @($ParameterList[0][1])[0].winget -eq "Git.Git" -and - $ParameterList[1][0] -eq "ManagerPreference" -and - $ParameterList[1][1] -eq "Winget" + $Title -eq "Are you sure?" -and $Button -eq "YesNo" } - Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { - $Component -eq "Uninstall" -and - $Message -eq "Uninstall selected package(s): Git (winget: Git.Git)" + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "Uninstall" -and + $ScriptBlock -is [scriptblock] -and + $DisableAppList -and + @($Parameters.PackagesToUninstall).Count -eq 1 -and + @($Parameters.PackagesToUninstall)[0].winget -eq "Git.Git" -and + $Parameters.ManagerPreference -eq "Winget" } } It "prompts and exits when no packages are selected" { - Invoke-WPFUnInstall -PackagesToUninstall @() - - Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { - $Message -eq "Please select the program(s) to uninstall" -and - $Title -eq "WinUtil" -and - $Button -eq "OK" -and - $Icon -eq "Warning" - } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly - } - - It "prompts and exits when another install process is running" { - $script:sync.ProcessRunning = $true + New-WinUtilInstallTestContext - Invoke-WPFUnInstall -PackagesToUninstall @($script:package) + Invoke-WPFUnInstall Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { - $Message -eq "[Invoke-WPFUnInstall] Install process is currently running" -and - $Title -eq "WinUtil" -and - $Button -eq "OK" -and - $Icon -eq "Warning" + $Message -eq "Please select the program(s) to uninstall" } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly } It "exits without queueing uninstall when confirmation is declined" { - Mock Show-WinUtilMessage { "No" } -ParameterFilter { $Title -eq "Are you sure?" } + Mock Show-WinUtilMessage { "No" } - Invoke-WPFUnInstall -PackagesToUninstall @($script:package) + Invoke-WPFUnInstall - Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly } } -Describe "Invoke-WPFUnInstall runspace body" { +Describe "Invoke-WPFUnInstall job body" { BeforeEach { $script:package = New-WinUtilPackage New-WinUtilInstallTestContext -Packages @($script:package) - $script:capturedUninstallScriptBlock = $null + $script:capturedUninstallJob = $null Mock Show-WinUtilMessage { "Yes" } - Mock Invoke-WPFRunspace { - $script:capturedUninstallScriptBlock = $ScriptBlock - [pscustomobject]@{ MockHandle = $true } + Mock Start-WinUtilJob { + $script:capturedUninstallJob = [pscustomobject]@{ + ScriptBlock = $ScriptBlock + Parameters = $Parameters + } } Mock Get-WinUtilSelectedPackages { New-WinUtilPackageSplit -Winget @("Git.Git") -Choco @("vlc") } - Mock Set-WinUtilTweaksProgressIndicator { } + Mock Write-WinUtilJobProgress { } Mock Install-WinUtilProgramWinget { } Mock Install-WinUtilProgramChoco { } - Mock Invoke-WPFUIThread { } Mock Write-WinUtilLog { } Mock Write-Host { } - Mock New-Item { } } AfterEach { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedUninstallScriptBlock -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedUninstallJob -Scope Script -ErrorAction SilentlyContinue } - It "uninstalls split winget and choco packages and cleans up on success" { - Invoke-WPFUnInstall -PackagesToUninstall @($script:package) + It "uninstalls split winget and choco packages and reports progress" { + Invoke-WPFUnInstall - & $script:capturedUninstallScriptBlock -PackagesToUninstall @($script:package) -ManagerPreference "Winget" + $jobParameters = $script:capturedUninstallJob.Parameters + & $script:capturedUninstallJob.ScriptBlock @jobParameters - Should -Invoke -CommandName Get-WinUtilSelectedPackages -Times 1 -Exactly -ParameterFilter { - @($PackageList).Count -eq 1 -and $Preference -eq "Winget" - } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Preparing app uninstall (0/2)" -and $Percent -eq 0 - } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Uninstalled Git.Git (1/2)" -and $Percent -eq 50 - } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Uninstalled Chocolatey packages (2/2)" -and $Percent -eq 100 - } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "App uninstall finished" -and $Percent -eq 100 - } Should -Invoke -CommandName Install-WinUtilProgramWinget -Times 1 -Exactly -ParameterFilter { $Action -eq "Uninstall" -and @($Programs)[0] -eq "Git.Git" } Should -Invoke -CommandName Install-WinUtilProgramChoco -Times 1 -Exactly -ParameterFilter { $Action -eq "Uninstall" -and @($Programs)[0] -eq "vlc" } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $false*' - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $true*' + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Uninstalled Git.Git (1/2)" -and $Percent -eq 50 } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*' + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Uninstalled Chocolatey packages (2/2)" -and $Percent -eq 100 } - $script:sync.ProcessRunning | Should -BeFalse } - It "shows failure progress, sets taskbar error state, and clears ProcessRunning on failure" { + It "lets a failure surface so the job layer can handle it" { Mock Install-WinUtilProgramWinget { throw "winget failed" } - Invoke-WPFUnInstall -PackagesToUninstall @($script:package) - - & $script:capturedUninstallScriptBlock -PackagesToUninstall @($script:package) -ManagerPreference "Winget" + Invoke-WPFUnInstall - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "App uninstall failed" -and $Percent -eq 100 - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $false*' - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $true*' - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*' - } - Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { - $Level -eq "ERROR" -and $Component -eq "Uninstall" -and $Message -like "Uninstall workflow failed:*" - } - $script:sync.ProcessRunning | Should -BeFalse + $jobParameters = $script:capturedUninstallJob.Parameters + { & $script:capturedUninstallJob.ScriptBlock @jobParameters } | Should -Throw "winget failed" } } diff --git a/pester/oosu.Tests.ps1 b/pester/oosu.Tests.ps1 index f04b2a88f1..acb9218ca9 100644 --- a/pester/oosu.Tests.ps1 +++ b/pester/oosu.Tests.ps1 @@ -14,6 +14,12 @@ BeforeAll { function Set-WinUtilTweaksProgressIndicator { param($Visible, $Label, $Percent) } + function Start-WinUtilJob { + param([string]$Name, [scriptblock]$ScriptBlock, [hashtable]$Parameters, [string]$Description, [switch]$DisableAppList) + } + function Write-WinUtilJobProgress { + param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay) + } function Show-WinUtilMessage { param($Message, $Title, $Button, $Icon) } @@ -55,85 +61,61 @@ Describe "Save-WinUtilFile" { Describe "Invoke-WPFOOSU" { BeforeEach { New-WinUtilOOSUTestContext - $script:capturedScriptBlock = $null - $script:capturedParameterList = $null + $script:capturedJob = $null - Mock Invoke-WPFRunspace { - $script:capturedScriptBlock = $ScriptBlock - $script:capturedParameterList = $ParameterList - [pscustomobject]@{ MockHandle = $true } + Mock Start-WinUtilJob { + $script:capturedJob = [pscustomobject]@{ + Name = $Name + ScriptBlock = $ScriptBlock + Parameters = $Parameters + } } - Mock Set-WinUtilTweaksProgressIndicator { } + Mock Write-WinUtilJobProgress { } Mock Show-WinUtilMessage { } Mock Write-WinUtilLog { } Mock Start-Process { } - Mock Write-Error { } } AfterEach { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedScriptBlock -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedParameterList -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedJob -Scope Script -ErrorAction SilentlyContinue } - It "queues the download in a background runspace" { + It "queues the download as a job with the download path" { Invoke-WPFOOSU - $script:sync.ProcessRunning | Should -BeTrue - Should -Invoke Invoke-WPFRunspace -Times 1 -Exactly - $script:capturedParameterList[0][0] | Should -Be "downloadPath" - $script:capturedParameterList[0][1] | Should -Be (Join-Path $TestDrive "ooshutup10.exe") + Should -Invoke Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { $Name -eq "OOSU" } + $script:capturedJob.Parameters.DownloadPath | Should -Be (Join-Path $TestDrive "ooshutup10.exe") } - It "does not start while another process is running" { - New-WinUtilOOSUTestContext -ProcessRunning $true - - Invoke-WPFOOSU - - Should -Invoke Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { - $Message -eq "Another process is currently running." -and - $Title -eq "WinUtil" -and - $Button -eq "OK" -and - $Icon -eq "Warning" - } - Should -Not -Invoke Invoke-WPFRunspace - } - - It "maps download progress to the window indicator and launches O&O ShutUp10++" { + It "maps download progress to the job indicator and launches O&O ShutUp10++" { Mock Save-WinUtilFile { & $ProgressCallback 35 & $ProgressCallback 100 } Invoke-WPFOOSU - & $script:capturedScriptBlock -downloadPath $script:capturedParameterList[0][1] + $jobParameters = $script:capturedJob.Parameters + & $script:capturedJob.ScriptBlock @jobParameters - Should -Invoke Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Downloading O&O ShutUp10++ (0%)" -and $Percent -eq 0 + Should -Invoke Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Downloading O&O ShutUp10++ (35%)" -and $Percent -eq 35 } - Should -Invoke Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Downloading O&O ShutUp10++ (35%)" -and $Percent -eq 35 - } - Should -Invoke Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "O&O ShutUp10++ launched" -and $Percent -eq 100 + Should -Invoke Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Launching O&O ShutUp10++" -and $Percent -eq 100 } Should -Invoke Start-Process -Times 1 -Exactly -ParameterFilter { $FilePath -eq (Join-Path $TestDrive "ooshutup10.exe") } - $script:sync.ProcessRunning | Should -BeFalse } - It "shows failure progress and clears the running state when the download fails" { + It "lets a download failure surface so the job layer can handle it" { Mock Save-WinUtilFile { throw "download failed" } Invoke-WPFOOSU - & $script:capturedScriptBlock -downloadPath $script:capturedParameterList[0][1] + $jobParameters = $script:capturedJob.Parameters - Should -Invoke Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "O&O ShutUp10++ download failed" -and $Percent -eq 100 - } + { & $script:capturedJob.ScriptBlock @jobParameters } | Should -Throw "download failed" Should -Not -Invoke Start-Process - Should -Invoke Write-Error -Times 1 -Exactly - $script:sync.ProcessRunning | Should -BeFalse } } diff --git a/pester/runspace.Tests.ps1 b/pester/runspace.Tests.ps1 index 1ad019a351..a588f57833 100644 --- a/pester/runspace.Tests.ps1 +++ b/pester/runspace.Tests.ps1 @@ -7,6 +7,12 @@ BeforeAll { . (Join-Path $script:repoRoot "functions\private\Close-WinUtilRunspacePool.ps1") . (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilRunspacePool.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFRunspace.ps1") + function Start-WinUtilJob { + param([string]$Name, [scriptblock]$ScriptBlock, [hashtable]$Parameters, [string]$Description, [switch]$DisableAppList) + } + function Show-WinUtilMessage { + param($Message, $Title, $Button, $Icon) + } . (Join-Path $script:repoRoot "functions\public\Invoke-WPFFeatureInstall.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFAppxRemoval.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFundoall.ps1") @@ -159,19 +165,22 @@ Describe "Public runspace callers" { }) Mock Invoke-WPFRunspace { [pscustomobject]@{ MockHandle = $true } } + Mock Start-WinUtilJob { } } AfterEach { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue } - It "queues selected feature installation without executing the runspace body" { + It "queues selected feature installation as a job without executing the body" { $script:sync.selectedFeatures.Add("WPFFeaturesSandbox") Invoke-WPFFeatureInstall - Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { - $ScriptBlock -is [scriptblock] -and $null -eq $ArgumentList -and $null -eq $ParameterList + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "Features" -and + $ScriptBlock -is [scriptblock] -and + @($Parameters.Features)[0] -eq "WPFFeaturesSandbox" } } diff --git a/pester/ui-state.Tests.ps1 b/pester/ui-state.Tests.ps1 index 5223601a77..14aee09949 100644 --- a/pester/ui-state.Tests.ps1 +++ b/pester/ui-state.Tests.ps1 @@ -84,6 +84,12 @@ namespace System.Windows.Controls function Test-WinUtilPackageManager { param([switch]$winget) } + function Start-WinUtilJob { + param([string]$Name, [scriptblock]$ScriptBlock, [hashtable]$Parameters, [string]$Description, [switch]$DisableAppList) + } + function Write-WinUtilJobProgress { + param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay) + } function Write-WinUtilLog { param($Message, $Level, $Component) } @@ -249,14 +255,13 @@ Describe "Invoke-WPFGetInstalled selection state" { Mock Set-WinUtilTaskbaritem { } Mock Write-WinUtilLog { } Mock Write-Warning { } - Mock Invoke-WPFRunspace { + Mock Write-WinUtilJobProgress { } + Mock Invoke-WPFUIThread { & $ScriptBlock } + Mock Start-WinUtilJob { $script:capturedGetInstalledScriptBlock = $ScriptBlock - foreach ($parameter in $ParameterList) { - $script:capturedGetInstalledParameters[$parameter[0]] = $parameter[1] - } + $script:capturedGetInstalledParameters = $Parameters } } - AfterEach { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue @@ -266,11 +271,9 @@ Describe "Invoke-WPFGetInstalled selection state" { It "updates the selected app model, checkbox, count, and popup" { Invoke-WPFGetInstalled -CheckBox "winget" - & $script:capturedGetInstalledScriptBlock ` - -checkbox "winget" ` - -managerPreference "Winget" ` - -operation $script:capturedGetInstalledParameters.operation ` - -completeAction $script:capturedGetInstalledParameters.completeAction + + $jobParameters = $script:capturedGetInstalledParameters + & $script:capturedGetInstalledScriptBlock @jobParameters @($script:sync.selectedApps) | Should -Be @("WPFInstallGit") $script:sync.WPFInstallGit.IsChecked | Should -BeTrue @@ -279,169 +282,22 @@ Describe "Invoke-WPFGetInstalled selection state" { $script:sync.selectedAppsstackPanel.Children[0].Key | Should -Be "WPFInstallGit" } - It "clears the running state when detection fails" { - Mock Invoke-WinUtilCurrentSystem { throw "detection failed" } - - Invoke-WPFGetInstalled -CheckBox "winget" - & $script:capturedGetInstalledScriptBlock ` - -checkbox "winget" ` - -managerPreference "Winget" ` - -operation $script:capturedGetInstalledParameters.operation ` - -completeAction $script:capturedGetInstalledParameters.completeAction - - $script:sync.ProcessRunning | Should -BeFalse - Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { - $Level -eq "ERROR" -and - $Component -eq "Install" -and - $Message -eq "Get installed state failed: detection failed" - } - Should -Invoke -CommandName Set-WinUtilTaskbaritem -Times 1 -Exactly -ParameterFilter { $state -eq "None" } - } - - It "clears the running state when the worker cannot be queued" { - Mock Invoke-WPFRunspace { throw "queue failed" } - + It "queues detection as a job with the manager preference" { Invoke-WPFGetInstalled -CheckBox "winget" - $script:sync.ProcessRunning | Should -BeFalse - Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { - $Level -eq "ERROR" -and - $Component -eq "Install" -and - $Message -eq "Get installed state failed: queue failed" + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "Detect installed" } - Should -Invoke -CommandName Set-WinUtilTaskbaritem -Times 1 -Exactly -ParameterFilter { $state -eq "None" } - } -} - -Describe "Reset-WPFCheckBoxes" { - BeforeEach { - New-WinUtilUiStateTestContext - - $script:sync.selectedApps.Add("WPFInstallGit") - $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") - $script:sync.selectedFeatures.Add("WPFFeatureSandbox") - $script:sync.selectedAppx.Add("WPFAppxExample") - $script:sync.selectedToggles.Add("WPFToggleDarkMode") - - $script:sync.WPFInstallGit = New-WinUtilFakeCheckBox - $script:sync.WPFInstallVlc = New-WinUtilFakeCheckBox -IsChecked $true - $script:sync.WPFTweaksTelemetry = New-WinUtilFakeCheckBox - $script:sync.WPFFeatureSandbox = New-WinUtilFakeCheckBox - $script:sync.WPFAppxExample = New-WinUtilFakeCheckBox - $script:sync.WPFToggleDarkMode = New-WinUtilFakeCheckBox - $script:sync.WPFToggleOther = New-WinUtilFakeCheckBox -IsChecked $true - } - - AfterEach { - Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + $script:capturedGetInstalledParameters.Checkbox | Should -Be "winget" + $script:capturedGetInstalledParameters.ManagerPreference | Should -Be "Winget" } - It "syncs non-toggle checkboxes from selected lists and updates selected app UI state" { - Reset-WPFCheckBoxes - - $script:sync.WPFInstallGit.IsChecked | Should -BeTrue - $script:sync.WPFInstallVlc.IsChecked | Should -BeFalse - $script:sync.WPFTweaksTelemetry.IsChecked | Should -BeTrue - $script:sync.WPFFeatureSandbox.IsChecked | Should -BeTrue - $script:sync.WPFAppxExample.IsChecked | Should -BeTrue - $script:sync.WPFToggleDarkMode.IsChecked | Should -BeFalse - $script:sync.WPFselectedAppsButton.Content | Should -Be "Selected Apps: 1" - $script:sync.selectedAppsstackPanel.Children.Count | Should -Be 1 - $script:sync.selectedAppsstackPanel.Children[0].Name | Should -Be "Git" - $script:sync.selectedAppsstackPanel.Children[0].Key | Should -Be "WPFInstallGit" - } - - It "restores imported toggles when requested without changing absent toggles" { - Reset-WPFCheckBoxes -doToggles $true - - $script:sync.WPFToggleDarkMode.IsChecked | Should -BeTrue - $script:sync.WPFToggleOther.IsChecked | Should -BeTrue - } -} - -Describe "Invoke-WPFToggleAllCategories" { - BeforeEach { - New-WinUtilUiStateTestContext - - $script:sync.ItemsControl = [pscustomobject]@{ - Items = [System.Collections.ArrayList]::new() - } - } - - AfterEach { - Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue - } - - It "expands all install categories and updates collapsed labels" { - $category = New-WinUtilFakeCategory -Label "+ Browsers" -Visibility ([Windows.Visibility]::Collapsed) - $null = $script:sync.ItemsControl.Items.Add($category) - - Invoke-WPFToggleAllCategories -Action "Expand" - - $category.Children[1].Visibility | Should -Be ([Windows.Visibility]::Visible) - $category.Children[0].Content | Should -Be "- Browsers" - } - - It "collapses all install categories and updates expanded labels" { - $category = New-WinUtilFakeCategory -Label "- Browsers" -Visibility ([Windows.Visibility]::Visible) - $null = $script:sync.ItemsControl.Items.Add($category) - - Invoke-WPFToggleAllCategories -Action "Collapse" - - $category.Children[1].Visibility | Should -Be ([Windows.Visibility]::Collapsed) - $category.Children[0].Content | Should -Be "+ Browsers" - } - - It "warns and exits when ItemsControl is not initialized" { - $script:sync.ItemsControl = $null - Mock Write-Warning { } - - Invoke-WPFToggleAllCategories -Action "Expand" - - Should -Invoke -CommandName Write-Warning -Times 1 -Exactly -ParameterFilter { - $Message -eq "ItemsControl not initialized" - } - } -} - -Describe "Invoke-WPFButton progress cleanup" { - BeforeEach { - New-WinUtilUiStateTestContext - Mock Set-WinUtilTweaksProgressIndicator { } - } - - AfterEach { - Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue - } - - It "clears completed progress on the next idle button click" { - $script:sync.ProcessRunning = $false - - Invoke-WPFButton -Button "WPFNoOp" - - Should -Invoke Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $false - } - } - - It "leaves progress visible while a process is running" { - $script:sync.ProcessRunning = $true - - Invoke-WPFButton -Button "WPFNoOp" - - Should -Not -Invoke Set-WinUtilTweaksProgressIndicator - } - - It "leaves progress visible while a Win11 ISO process is running" { - $script:sync.ProcessRunning = $false - $script:sync.Win11ISOProcessRunning = $true + It "lets a detection failure surface so the job layer can handle it" { + Mock Invoke-WinUtilCurrentSystem { throw "detection failed" } - Invoke-WPFButton -Button "WPFNoOp" + Invoke-WPFGetInstalled -CheckBox "winget" + $jobParameters = $script:capturedGetInstalledParameters - Should -Not -Invoke Set-WinUtilTweaksProgressIndicator + { & $script:capturedGetInstalledScriptBlock @jobParameters } | Should -Throw "detection failed" } -} - +} \ No newline at end of file From 94ed3ea85e5ae696a0feaedcce5c258eace91e26 Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 03:07:20 +0200 Subject: [PATCH 03/70] Run the WinUtil interface on its own thread main.ps1 now only manages the run: it creates a dedicated STA runspace for the window, waits for it, and reports whatever the interface thread failed with. The interface itself moved into Start-WinUtilUserInterface, so the thread that owns the window does nothing but paint and dispatch. - New-WinUtilSessionState builds one starting point for both the interface runspace and the worker pool, carrying $sync, the compiled script globals and every WinUtil function. The pool previously copied only functions matching winutil|WPF, which is not enough for a runspace that has to build a tab. - Invoke-WPFUIThread hands work to the interface runspace as body text plus parameters instead of marshalling a scriptblock. A scriptblock keeps the session state it was written in; running one across runspaces loses the caller's variables on an async post and costs roughly twenty times as much per command, which turned a checkbox refresh into a multi-minute freeze. - Both helpers stop at a shut-down dispatcher, so a job that outlives the window finishes quietly. --- .../Initialize-WinUtilRunspacePool.ps1 | 29 +- functions/private/New-WinUtilSessionState.ps1 | 49 ++ .../private/Start-WinUtilUserInterface.ps1 | 476 ++++++++++++++++ functions/public/Invoke-WPFUIThread.ps1 | 70 ++- pester/assets.Tests.ps1 | 10 +- pester/lazy-tabs.Tests.ps1 | 12 +- pester/runspace-lifecycle.Tests.ps1 | 61 +- pester/sanity.Tests.ps1 | 9 +- pester/xaml.Tests.ps1 | 36 +- scripts/main.ps1 | 523 ++---------------- scripts/start.ps1 | 10 +- 11 files changed, 758 insertions(+), 527 deletions(-) create mode 100644 functions/private/New-WinUtilSessionState.ps1 create mode 100644 functions/private/Start-WinUtilUserInterface.ps1 diff --git a/functions/private/Initialize-WinUtilRunspacePool.ps1 b/functions/private/Initialize-WinUtilRunspacePool.ps1 index d9f5b248ec..dc64c32b34 100644 --- a/functions/private/Initialize-WinUtilRunspacePool.ps1 +++ b/functions/private/Initialize-WinUtilRunspacePool.ps1 @@ -1,4 +1,9 @@ function Initialize-WinUtilRunspacePool { + <# + .SYNOPSIS + Opens the shared worker pool that Start-WinUtilJob runs job bodies in + #> + if ($sync.runspace -and $sync.runspace.RunspacePoolStateInfo.State -eq [System.Management.Automation.Runspaces.RunspacePoolState]::Opened) { return $sync.runspace } @@ -10,27 +15,11 @@ function Initialize-WinUtilRunspacePool { # Set the maximum number of threads for the RunspacePool to the number of threads on the machine. $maxthreads = [Math]::Max([int]$env:NUMBER_OF_PROCESSORS, 1) - # Create a new session state for parsing variables into our runspace. - $hashVars = New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList 'sync', $sync, $null - $offlineVar = New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList 'PARAM_OFFLINE', $PARAM_OFFLINE, $null - $initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() - - $initialSessionState.Variables.Add($hashVars) - $initialSessionState.Variables.Add($offlineVar) - - # Get every WinUtil/WPF function and add it to the session state. - $functions = Get-ChildItem function:\ | Where-Object { $_.Name -imatch 'winutil|WPF' } - foreach ($function in $functions) { - $functionDefinition = Get-Content function:\$($function.Name) - $functionEntry = New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList $function.Name, $functionDefinition - $initialSessionState.Commands.Add($functionEntry) - } - $sync.runspace = [runspacefactory]::CreateRunspacePool( - 1, # Minimum thread count - $maxthreads, # Maximum thread count - $initialSessionState, # Initial session state - $Host # Machine to create runspaces on + 1, # Minimum thread count + $maxthreads, # Maximum thread count + (New-WinUtilSessionState), # Initial session state + $Host # Machine to create runspaces on ) $sync.runspace.Open() diff --git a/functions/private/New-WinUtilSessionState.ps1 b/functions/private/New-WinUtilSessionState.ps1 new file mode 100644 index 0000000000..5297604bcc --- /dev/null +++ b/functions/private/New-WinUtilSessionState.ps1 @@ -0,0 +1,49 @@ +function New-WinUtilSessionState { + <# + .SYNOPSIS + Builds the InitialSessionState every WinUtil runspace is created from + + .DESCRIPTION + Both the interface runspace and the worker pool need the same starting point: the + shared $sync hashtable, the compiled script's globals, and every function WinUtil + defines. That completeness is what lets the interface build a tab and a job body + call any helper without the caller injecting function definitions by hand. + + Only the functions PowerShell itself provides are skipped, since the default + session state already carries those. + #> + + $initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + + $variables = @( + @{ Name = "sync"; Value = $sync }, + @{ Name = "PARAM_OFFLINE"; Value = $PARAM_OFFLINE }, + @{ Name = "inputXML"; Value = $inputXML }, + @{ Name = "WinUtilAutounattendXml"; Value = $WinUtilAutounattendXml } + ) + + foreach ($variable in $variables) { + $initialSessionState.Variables.Add( + (New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList $variable.Name, $variable.Value, $null) + ) + } + + $builtInFunctions = [System.Collections.Generic.HashSet[string]]::new( + [string[]]@($initialSessionState.Commands | + Where-Object { $_ -is [System.Management.Automation.Runspaces.SessionStateFunctionEntry] } | + ForEach-Object { $_.Name }), + [StringComparer]::OrdinalIgnoreCase + ) + + foreach ($function in (Get-ChildItem function:\)) { + if ($builtInFunctions.Contains($function.Name)) { + continue + } + + $initialSessionState.Commands.Add( + (New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList $function.Name, (Get-Content function:\$($function.Name))) + ) + } + + return $initialSessionState +} diff --git a/functions/private/Start-WinUtilUserInterface.ps1 b/functions/private/Start-WinUtilUserInterface.ps1 new file mode 100644 index 0000000000..9b6d61ed05 --- /dev/null +++ b/functions/private/Start-WinUtilUserInterface.ps1 @@ -0,0 +1,476 @@ +function Start-WinUtilUserInterface { + <# + .SYNOPSIS + Builds the WinUtil window, wires its event handlers and runs it to completion + + .DESCRIPTION + This is the whole interface. It runs on the dedicated STA interface runspace that + main.ps1 starts, so the thread that owns the window does nothing but paint and + dispatch: every long operation goes to the worker pool through Start-WinUtilJob. + + The call blocks until the window is closed, and the interface runspace is the only + place that is allowed to touch controls directly. + #> + + [void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework') + [xml]$XAML = $inputXML + + # Read the XAML file + $readerOperationSuccessful = $false # There's more cases of failure then success. + $reader = (New-Object System.Xml.XmlNodeReader $xaml) + try { + $sync["Form"] = [Windows.Markup.XamlReader]::Load( $reader ) + $readerOperationSuccessful = $true + } catch [System.Management.Automation.MethodInvocationException] { + Write-Host "We ran into a problem with the XAML code. Check the syntax for this control..." -ForegroundColor Red + Write-Host $error[0].Exception.Message -ForegroundColor Red + + If ($error[0].Exception.Message -like "*button*") { + write-Host "Ensure your <button in the `$inputXML does NOT have a Click=ButtonClick property. PS can't handle this`n`n`n`n" -ForegroundColor Red + } + } catch { + Write-Host "Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed." -ForegroundColor Red + } + + if (-NOT ($readerOperationSuccessful)) { + Write-Host "Failed to parse xaml content using Windows.Markup.XamlReader's Load Method." -ForegroundColor Red + Write-Host "Quitting WinUtil..." -ForegroundColor Red + Write-WinUtilLog -Level "ERROR" -Component "UI" -Message "Failed to parse the XAML content. WinUtil cannot start." + return + } + + # Setup the Window to follow listen for windows Theme Change events and update the winutil theme + # throttle logic needed, because windows seems to send more than one theme change event per change + $themeState = @{ LastChange = [datetime]::MinValue } + $debounceInterval = [timespan]::FromSeconds(2) + $sync.Form.Add_Loaded({ + $interopHelper = New-Object System.Windows.Interop.WindowInteropHelper $sync.Form + $hwndSource = [System.Windows.Interop.HwndSource]::FromHwnd($interopHelper.Handle) + $hwndSource.AddHook({ + param ( + [System.IntPtr]$hwnd, + [int]$msg, + [System.IntPtr]$wParam, + [System.IntPtr]$lParam, + [ref]$handled + ) + $null = $hwnd, $wParam, $lParam + # Check for the Event WM_SETTINGCHANGE (0x1001A) and validate that Button shows the icon for "Auto" => [char]0xF08C + if (($msg -eq 0x001A) -and $sync.ThemeButton.Content -eq [char]0xF08C) { + $currentTime = [datetime]::Now + if ($currentTime - $themeState.LastChange -gt $debounceInterval) { + Invoke-WinutilThemeChange -theme "Auto" + $themeState.LastChange = $currentTime + $handled = $true + } + } + return 0 + }) + }) + + Invoke-WinutilThemeChange -theme $sync.preferences.theme + + # Build only the default tab before first paint; other tabs initialize on first activation. + $sync.InitializedTabs = @{} + Initialize-WinUtilTabContent -TabName "Install" + + #=========================================================================== + # Store Form Objects In PowerShell + #=========================================================================== + + $xaml.SelectNodes("//*[@Name]") | ForEach-Object {$sync["$("$($psitem.Name)")"] = $sync["Form"].FindName($psitem.Name)} + + # How background work reaches the controls. Built here so it carries this runspace's + # session state: posted work then runs as ordinary interface code instead of as a + # cross-runspace nested pipeline, which is orders of magnitude slower. Invoke-WPFUIThread + # is the caller-facing side of this. + $sync.UIDispatchDelegate = [System.Func[object, object]]{ + param($Work) + + $body = [scriptblock]::Create($Work.Body) + $parameters = $Work.Parameters + if ($parameters -and $parameters.Count -gt 0) { + & $body @parameters + } else { + & $body + } + } + + $sync.ChocoRadioButton.Add_Checked({ + $sync.preferences.packagemanager = "Choco" + }) + $sync.WingetRadioButton.Add_Checked({ + $sync.preferences.packagemanager = "Winget" + }) + + switch ($sync.preferences.packagemanager) { + "Choco" {$sync.ChocoRadioButton.IsChecked = $true; break} + "Winget" {$sync.WingetRadioButton.IsChecked = $true; break} + } + + $sync.keys | ForEach-Object { + if($sync.$psitem) { + if($($sync["$psitem"].GetType() | Select-Object -ExpandProperty Name) -in @("ToggleButton", "Button")) { + if ($sync.Buttons -notcontains $psitem) { + $sync["$psitem"].Add_Click({ + [System.Object]$Sender = $args[0] + Invoke-WPFButton $Sender.name + }) + $sync.Buttons.Add($psitem) | Out-Null + } + } + } + } + + #=========================================================================== + # Setup and Show the Form + #=========================================================================== + + # Progress bar in taskbaritem > Set-WinUtilProgressbar + $sync["Form"].TaskbarItemInfo = New-Object System.Windows.Shell.TaskbarItemInfo + Set-WinUtilTaskbaritem -state "None" + + # Set the titlebar + $sync["Form"].title = $sync["Form"].title + " " + $sync.version + # Set the commands that will run when the form is closed + $sync["Form"].Add_Closing({ + Write-WinUtilLog -Component "UI" -Message "Window closing, shutting down the worker pool." + Close-WinUtilRunspacePool + [System.GC]::Collect() + }) + + # Attach the event handler to the Click event + $sync.SearchBarClearButton.Add_Click({ + $sync.SearchBar.Text = "" + $sync.SearchBarClearButton.Visibility = "Collapsed" + + # Focus the search bar after clearing the text + $sync.SearchBar.Focus() + $sync.SearchBar.SelectAll() + }) + + # add some shortcuts for people that don't like clicking + function Invoke-WinUtilFontScaleStep([double]$Step) { $sync.FontScalingSlider.Value = [math]::Max(0.75, [math]::Min(2.0, $sync.FontScalingSlider.Value + $Step)); Invoke-WinUtilFontScaling -ScaleFactor $sync.FontScalingSlider.Value } + + $commonKeyEvents = { + # Prevent shortcuts from executing if a job is already running + if ($sync.ActiveJob) { + return + } + + # Handle key presses of single keys + switch ($_.Key) { + "Escape" { $sync.SearchBar.Text = "" } + } + # Handle Alt key combinations for navigation + if ($_.KeyboardDevice.Modifiers -eq "Alt") { + $keyEventArgs = $_ + switch ($_.SystemKey) { + "I" { Invoke-WPFButton "WPFTab1BT"; $keyEventArgs.Handled = $true } # Navigate to Install tab and suppress Windows Warning Sound + "T" { Invoke-WPFButton "WPFTab2BT"; $keyEventArgs.Handled = $true } # Navigate to Tweaks tab + "C" { Invoke-WPFButton "WPFTab3BT"; $keyEventArgs.Handled = $true } # Navigate to Config tab + "U" { Invoke-WPFButton "WPFTab4BT"; $keyEventArgs.Handled = $true } # Navigate to Updates tab + "W" { Invoke-WPFButton "WPFTab5BT"; $keyEventArgs.Handled = $true } # Navigate to Win11ISO tab + } + } + # Handle Ctrl key combinations for specific actions + if ($_.KeyboardDevice.Modifiers -eq "Ctrl") { + $keyEventArgs = $_ + switch ($_.Key) { + "F" { $sync.SearchBar.Focus() } # Focus on the search bar + "Q" { $this.Close() } # Close the application + } + } + $ctrlShiftModifiers = [Windows.Input.ModifierKeys]::Control -bor [Windows.Input.ModifierKeys]::Shift + if ($_.KeyboardDevice.Modifiers -eq "Ctrl" -or $_.KeyboardDevice.Modifiers -eq $ctrlShiftModifiers) { + $keyEventArgs = $_ + switch ($_.Key) { + { $_ -in "OemPlus", "Add" } { Invoke-WinUtilFontScaleStep 0.05; $keyEventArgs.Handled = $true } + { $_ -in "OemMinus", "Subtract" } { Invoke-WinUtilFontScaleStep -0.05; $keyEventArgs.Handled = $true } + } + } + } + $sync["Form"].Add_PreViewKeyDown($commonKeyEvents) + $sync["Form"].Add_PreviewMouseWheel({ + if ([Windows.Input.Keyboard]::Modifiers -eq "Ctrl") { Invoke-WinUtilFontScaleStep $(if ($_.Delta -gt 0) { 0.05 } else { -0.05 }); $_.Handled = $true } + }) + + $sync["Form"].Add_MouseLeftButtonDown({ + Invoke-WPFPopup -Action "Hide" -Popups @("Settings", "Theme", "FontScaling") + $sync["Form"].DragMove() + }) + + $sync["Form"].Add_MouseDoubleClick({ + if ($_.OriginalSource.Name -eq "NavDockPanel" -or + $_.OriginalSource.Name -eq "GridBesideNavDockPanel") { + if ($sync["Form"].WindowState -eq [Windows.WindowState]::Normal) { + [Windows.SystemCommands]::MaximizeWindow($sync.Form) + } + else{ + [Windows.SystemCommands]::RestoreWindow($sync.Form) + } + } + }) + + $sync["Form"].Add_Deactivated({ + Invoke-WPFPopup -Action "Hide" -Popups @("Settings", "Theme", "FontScaling") + }) + + $sync["Form"].Add_ContentRendered({ + # Load the Windows Forms assembly + Add-Type -AssemblyName System.Windows.Forms + $primaryScreen = [System.Windows.Forms.Screen]::PrimaryScreen + # Check if the primary screen is found + if ($primaryScreen) { + # Extract screen width and height for the primary monitor + $screenWidth = $primaryScreen.Bounds.Width + $screenHeight = $primaryScreen.Bounds.Height + $sync.Form.MinWidth = [Math]::Min([double]$sync.Form.MinWidth, [double]$screenWidth) + + # Compare with the primary monitor size + if ($sync.Form.ActualWidth -gt $screenWidth -or $sync.Form.ActualHeight -gt $screenHeight) { + $sync.Form.Left = 0 + $sync.Form.Top = 0 + $sync.Form.Width = $screenWidth + $sync.Form.Height = $screenHeight + } + } + + if ($PARAM_OFFLINE) { + # Show offline banner + $sync.WPFOfflineBanner.Visibility = [System.Windows.Visibility]::Visible + + # Disable the install tab + $sync.WPFTab1BT.IsEnabled = $false + $sync.WPFTab1BT.Opacity = 0.5 + $sync.WPFTab1BT.ToolTip = "Internet connection required for installing applications." + + # Disable install-related buttons + $sync.WPFInstall.IsEnabled = $false + $sync.WPFUninstall.IsEnabled = $false + $sync.WPFInstallUpgrade.IsEnabled = $false + $sync.WPFGetInstalled.IsEnabled = $false + + # Show offline indicator + Write-Host "Offline mode detected - Install tab disabled." -ForegroundColor Yellow + + # Optionally switch to a different tab if install tab was going to be default + Invoke-WPFTab "WPFTab2BT" # Switch to Tweaks tab instead + } + else { + # Online - ensure install tab is enabled + $sync.WPFTab1BT.IsEnabled = $true + $sync.WPFTab1BT.Opacity = 1.0 + $sync.WPFTab1BT.ToolTip = $null + Invoke-WPFTab "WPFTab1BT" # Default to install tab + } + + $sync["Form"].Focus() + $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Initialize-WinUtilRunspacePool | Out-Null }) | Out-Null + $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $false -IncludeStatusAssets $true }) | Out-Null + }) + + # The SearchBarTimer is used to delay the search operation until the user has stopped typing for a short period + # This prevents the ui from stuttering when the user types quickly as it dosnt need to update the ui for every keystroke + + $searchBarTimer = New-Object System.Windows.Threading.DispatcherTimer + $searchBarTimer.Interval = [TimeSpan]::FromMilliseconds(300) + $searchBarTimer.IsEnabled = $false + + $searchBarTimer.add_Tick({ + $searchBarTimer.Stop() + switch ($sync.currentTab) { + "Install" { + Find-AppsByNameOrDescription -SearchString $sync.SearchBar.Text -Category $sync.SearchBar.Tag + } + "Tweaks" { + Find-TweaksByNameOrDescription -SearchString $sync.SearchBar.Text + } + "AppX" { + Find-TweaksByNameOrDescription -SearchString $sync.SearchBar.Text + } + } + }) + $sync["SearchBar"].Add_TextChanged({ + if ($sync.SearchBar.Tag -ne $sync.SearchBar.Text) { + $sync.SearchBar.Tag = $null + } + + if ($sync.SearchBar.Text -ne "") { + $sync.SearchBarClearButton.Visibility = "Visible" + $sync.SearchBarIcon.Visibility = "Collapsed" + } else { + $sync.SearchBarClearButton.Visibility = "Collapsed" + $sync.SearchBarIcon.Visibility = "Visible" + } + + # Category chip handlers apply their filter immediately. + if ($sync.SearchBar.Tag -eq $sync.SearchBar.Text) { + return + } + + if ($searchBarTimer.IsEnabled) { + $searchBarTimer.Stop() + } + $searchBarTimer.Start() + }) + + # Quick Category Search Chips + $sync["WPFSearchChipAll"].Add_Click({ Set-WinUtilAppCategoryFilter }) + $sync["WPFSearchChipBrowsers"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Browsers" }) + $sync["WPFSearchChipCommunications"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Communications" }) + $sync["WPFSearchChipDevelopment"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Development" }) + $sync["WPFSearchChipDocument"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Document" }) + $sync["WPFSearchChipGames"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Games" }) + $sync["WPFSearchChipMicrosoftTools"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Microsoft Tools" }) + $sync["WPFSearchChipMultimediaTools"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Multimedia Tools" }) + $sync["WPFSearchChipProTools"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Pro Tools" }) + $sync["WPFSearchChipSelfhostedTools"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Selfhosted Tools" }) + $sync["WPFSearchChipUtilities"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Utilities" }) + + $sync["Form"].Add_Loaded({ + param($e) + $null = $e + $sync.Form.MinWidth = "1150" + $sync["Form"].MaxWidth = [Double]::PositiveInfinity + $sync["Form"].MaxHeight = [Double]::PositiveInfinity + }) + + $NavLogoPanel = $sync["Form"].FindName("NavLogoPanel") + $NavLogoPanel.Children.Add((Invoke-WinUtilAssets -Type "logo" -Size 25)) | Out-Null + Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $true -IncludeStatusAssets $false + + Set-WinUtilTaskbaritem -overlay "logo" + + $sync["Form"].Add_Activated({ + Set-WinUtilTaskbaritem -overlay "logo" + }) + + $sync["ThemeButton"].Add_Click({ + Invoke-WPFPopup -PopupActionTable @{ "Settings" = "Hide"; "Theme" = "Toggle"; "FontScaling" = "Hide" } + }) + $sync["AutoThemeMenuItem"].Add_Click({ + Invoke-WPFPopup -Action "Hide" -Popups @("Theme") + Invoke-WinutilThemeChange -theme "Auto" + }) + $sync["DarkThemeMenuItem"].Add_Click({ + Invoke-WPFPopup -Action "Hide" -Popups @("Theme") + Invoke-WinutilThemeChange -theme "Dark" + }) + $sync["LightThemeMenuItem"].Add_Click({ + Invoke-WPFPopup -Action "Hide" -Popups @("Theme") + Invoke-WinutilThemeChange -theme "Light" + }) + + $sync["SettingsButton"].Add_Click({ + Invoke-WPFPopup -PopupActionTable @{ "Settings" = "Toggle"; "Theme" = "Hide"; "FontScaling" = "Hide" } + }) + $sync["ImportMenuItem"].Add_Click({ + Invoke-WPFPopup -Action "Hide" -Popups @("Settings") + Invoke-WPFImpex -type "import" + }) + $sync["ExportMenuItem"].Add_Click({ + Invoke-WPFPopup -Action "Hide" -Popups @("Settings") + Invoke-WPFImpex -type "export" + }) + $sync["AboutMenuItem"].Add_Click({ + Invoke-WPFPopup -Action "Hide" -Popups @("Settings") + + $authorInfo = @" +Author : @ChrisTitusTech +UI : @MyDrift-user, @Marterich +Runspace : @DeveloperDurp, @Marterich +GitHub : ChrisTitusTech/winutil +Version : $($sync.version) +"@ + Show-CustomDialog -Title "About" -Message $authorInfo + }) + $sync["DocumentationMenuItem"].Add_Click({ + Invoke-WPFPopup -Action "Hide" -Popups @("Settings") + Start-Process "https://winutil.christitus.com/" + }) + $sync["SponsorMenuItem"].Add_Click({ + Invoke-WPFPopup -Action "Hide" -Popups @("Settings") + + $authorInfo = @" +Current sponsors for ChrisTitusTech: +"@ + $authorInfo += "`n" + try { + $sponsors = Invoke-WinUtilSponsors + foreach ($sponsor in $sponsors) { + $authorInfo += "$sponsor`n" + } + } catch { + $authorInfo += "An error occurred while fetching or processing the sponsors: $_`n" + } + Show-CustomDialog -Title "Sponsors" -Message $authorInfo -EnableScroll $true + }) + + # Font Scaling Event Handlers + $sync["FontScalingButton"].Add_Click({ + Invoke-WPFPopup -PopupActionTable @{ "Settings" = "Hide"; "Theme" = "Hide"; "FontScaling" = "Toggle" } + }) + + $sync["FontScalingSlider"].Add_ValueChanged({ + param($slider) + $percentage = [math]::Round($slider.Value * 100) + $sync.FontScalingValue.Text = "$percentage%" + }) + + $sync["FontScalingResetButton"].Add_Click({ + $sync.FontScalingSlider.Value = 1.0 + $sync.FontScalingValue.Text = "100%" + }) + + $sync["FontScalingApplyButton"].Add_Click({ + $scaleFactor = $sync.FontScalingSlider.Value + Invoke-WinUtilFontScaling -ScaleFactor $scaleFactor + Invoke-WPFPopup -Action "Hide" -Popups @("FontScaling") + }) + + # Win11ISO Tab button handlers + $sync["WPFWin11ISOBrowseButton"].Add_Click({ + Invoke-WinUtilISOBrowse + }) + + $sync["WPFWin11ISODownloadLink"].Add_Click({ + Start-Process "https://www.microsoft.com/software-download/windows11" + }) + + $sync["WPFWin11ISOMountButton"].Add_Click({ + Invoke-WinUtilISOMountAndVerify + }) + + $sync["WPFWin11ISOModifyButton"].Add_Click({ + Invoke-WinUtilISOModify + }) + + $sync["WPFWin11ISOChooseISOButton"].Add_Click({ + $sync["WPFWin11ISOOptionUSB"].Visibility = "Collapsed" + Invoke-WinUtilISOExport + }) + + $sync["WPFWin11ISOChooseUSBButton"].Add_Click({ + $sync["WPFWin11ISOOptionUSB"].Visibility = "Visible" + Invoke-WinUtilISORefreshUSBDrives + }) + + $sync["WPFWin11ISORefreshUSBButton"].Add_Click({ + Invoke-WinUtilISORefreshUSBDrives + }) + + $sync["WPFWin11ISOWriteUSBButton"].Add_Click({ + Invoke-WinUtilISOWriteUSB + }) + + $sync["WPFWin11ISOCleanResetButton"].Add_Click({ + Invoke-WinUtilISOCleanAndReset + }) + + Write-WinUtilLog -Component "UI" -Message "Interface built, showing the window." + $sync["Form"].ShowDialog() | Out-Null + + # ShowDialog returns once the window is gone; stop the dispatcher so this runspace can close + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.InvokeShutdown() +} diff --git a/functions/public/Invoke-WPFUIThread.ps1 b/functions/public/Invoke-WPFUIThread.ps1 index 8f42e2861b..e6cc1e180b 100644 --- a/functions/public/Invoke-WPFUIThread.ps1 +++ b/functions/public/Invoke-WPFUIThread.ps1 @@ -1,3 +1,69 @@ -function Invoke-WPFUIThread ($ScriptBlock) { - $sync.form.Dispatcher.Invoke([action]$ScriptBlock) +function Invoke-WPFUIThread { + <# + .SYNOPSIS + Runs a scriptblock on the interface thread + + .DESCRIPTION + Controls may only be touched from the thread that owns the window, so this is how + background work reaches them. + + The body is handed to the interface runspace as text and rebuilt there, rather than + marshalled as a scriptblock from the calling runspace. A scriptblock keeps the + session state it was written in, and running one across runspaces costs roughly + twenty times as much per command - enough to turn a checkbox refresh into a visible + freeze. Values the body needs therefore come in through Parameters instead of being + captured from the caller's scope. + + The call is a no-op once the window is gone, so a job that outlives the interface + finishes quietly instead of failing on a dead dispatcher. + + .PARAMETER ScriptBlock + The work to run on the interface thread. Declare a param block for anything it needs. + + .PARAMETER Parameters + Values passed to the body by name. + + .PARAMETER Async + Post the work and return immediately instead of waiting for it. Use for progress and + log updates, which must never stall the caller. + #> + param( + [Parameter(Mandatory, Position = 0)] + [scriptblock]$ScriptBlock, + + [hashtable]$Parameters = @{}, + + [switch]$Async + ) + + $dispatcher = $sync.Form.Dispatcher + if ($null -eq $dispatcher -or $dispatcher.HasShutdownStarted) { + return + } + + if (-not $Async -and $dispatcher.CheckAccess()) { + return (& $ScriptBlock @Parameters) + } + + $executor = $sync.UIDispatchDelegate + if ($null -eq $executor) { + # No interface runspace to hand the work to; fall back to marshalling the block itself + if ($Async) { + $null = $dispatcher.BeginInvoke([Windows.Threading.DispatcherPriority]::Background, [action]$ScriptBlock) + return + } + return $dispatcher.Invoke([action]$ScriptBlock) + } + + $work = @{ + Body = $ScriptBlock.ToString() + Parameters = $Parameters + } + + if ($Async) { + $null = $dispatcher.BeginInvoke([Windows.Threading.DispatcherPriority]::Background, $executor, $work) + return + } + + return $dispatcher.Invoke($executor, @($work)) } diff --git a/pester/assets.Tests.ps1 b/pester/assets.Tests.ps1 index e4340f0aa6..072adf1067 100644 --- a/pester/assets.Tests.ps1 +++ b/pester/assets.Tests.ps1 @@ -17,12 +17,12 @@ Describe "Rendered asset caching" { } It "renders only the logo overlay before first paint and defers status overlays" { - $mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw + $uiScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1") -Raw - $mainScript | Should -Match 'Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$true -IncludeStatusAssets \$false' - $mainScript | Should -Match 'Dispatcher\.BeginInvoke\(\[System\.Windows\.Threading\.DispatcherPriority\]::Background, \[action\]\{ Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$false -IncludeStatusAssets \$true \}' - $mainScript | Should -Not -Match '\$sync\["checkmarkrender"\] = \(Invoke-WinUtilAssets -Type "checkmark"' - $mainScript | Should -Not -Match '\$sync\["warningrender"\] = \(Invoke-WinUtilAssets -Type "warning"' + $uiScript | Should -Match 'Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$true -IncludeStatusAssets \$false' + $uiScript | Should -Match 'Dispatcher\.BeginInvoke\(\[System\.Windows\.Threading\.DispatcherPriority\]::Background, \[action\]\{ Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$false -IncludeStatusAssets \$true \}' + $uiScript | Should -Not -Match '\$sync\["checkmarkrender"\] = \(Invoke-WinUtilAssets -Type "checkmark"' + $uiScript | Should -Not -Match '\$sync\["warningrender"\] = \(Invoke-WinUtilAssets -Type "warning"' } It "lazily creates taskbar overlays before assigning them" { diff --git a/pester/lazy-tabs.Tests.ps1 b/pester/lazy-tabs.Tests.ps1 index 1593fbd6a9..b1fbf4a034 100644 --- a/pester/lazy-tabs.Tests.ps1 +++ b/pester/lazy-tabs.Tests.ps1 @@ -90,8 +90,8 @@ Describe "Initialize-WinUtilTabContent" { Describe "Startup lazy tab wiring" { It "builds only install tab content before first paint" { - $mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw - $startupRegion = $mainScript.Substring(0, $mainScript.IndexOf("# Store Form Objects In PowerShell")) + $uiScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1") -Raw + $startupRegion = $uiScript.Substring(0, $uiScript.IndexOf("# Store Form Objects In PowerShell")) $startupRegion | Should -Match 'Initialize-WinUtilTabContent -TabName "Install"' $startupRegion | Should -Not -Match 'targetGridName "tweakspanel"' @@ -107,18 +107,18 @@ Describe "Startup lazy tab wiring" { It "binds generated button clicks when lazy panels are rendered" { $rendererScript = Get-Content -Path (Join-Path $script:repoRoot "functions\public\Invoke-WPFUIElements.ps1") -Raw - $mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw + $uiScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1") -Raw $rendererScript | Should -Match '(?s)"Button"\s*\{.*\$button\.Add_Click\(\{.*Invoke-WPFButton \$Sender\.name' $rendererScript | Should -Match '\$sync\.Buttons\.Add\(\$button\.Name\)' - $mainScript | Should -Match '\$sync\.Buttons -notcontains \$psitem' + $uiScript | Should -Match '\$sync\.Buttons -notcontains \$psitem' } It "binds generated documentation links when lazy panels are rendered" { $rendererScript = Get-Content -Path (Join-Path $script:repoRoot "functions\public\Invoke-WPFUIElements.ps1") -Raw - $mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw + $uiScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1") -Raw $rendererScript | Should -Match '(?s)if \(\$entryInfo\.Link\).*\$textBlock\.Add_MouseUp\(\{.*Start-Process \$Sender\.ToolTip -ErrorAction Stop' - $mainScript | Should -Not -Match '\.Name\.EndsWith\("Link"\)' + $uiScript | Should -Not -Match '\.Name\.EndsWith\("Link"\)' } } diff --git a/pester/runspace-lifecycle.Tests.ps1 b/pester/runspace-lifecycle.Tests.ps1 index 68a5007456..1b046a837e 100644 --- a/pester/runspace-lifecycle.Tests.ps1 +++ b/pester/runspace-lifecycle.Tests.ps1 @@ -5,6 +5,7 @@ BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path . (Join-Path $script:repoRoot "functions\private\Close-WinUtilRunspacePool.ps1") + . (Join-Path $script:repoRoot "functions\private\New-WinUtilSessionState.ps1") . (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilRunspacePool.ps1") } @@ -49,13 +50,71 @@ Describe "Runspace startup wiring" { It "initializes runspaces synchronously for automation paths and after first render for GUI" { $mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw + $uiScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1") -Raw $mainScript | Should -Match 'if \(\$Preset\) \{\s+Initialize-WinUtilRunspacePool' $mainScript | Should -Match 'if \(\$Config\) \{\s+Initialize-WinUtilRunspacePool' - $mainScript | Should -Match 'Dispatcher\.BeginInvoke\(\[System\.Windows\.Threading\.DispatcherPriority\]::Background, \[action\]\{ Initialize-WinUtilRunspacePool' + $uiScript | Should -Match 'Dispatcher\.BeginInvoke\(\[System\.Windows\.Threading\.DispatcherPriority\]::Background, \[action\]\{ Initialize-WinUtilRunspacePool' $mainScript | Should -Match 'Close-WinUtilRunspacePool' } + It "runs the window on a dedicated STA runspace and waits for it from the main thread" { + $mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw + $uiScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1") -Raw + + $mainScript | Should -Match '\$sync\.UIRunspace = \[runspacefactory\]::CreateRunspace\(\$Host, \(New-WinUtilSessionState\)\)' + $mainScript | Should -Match '\$sync\.UIRunspace\.ApartmentState = "STA"' + $mainScript | Should -Match '\$uiShell\.AddScript\(\{ Start-WinUtilUserInterface \}\)' + $mainScript | Should -Match '\$uiHandle\.AsyncWaitHandle\.WaitOne\(\)' + $mainScript | Should -Match '\$uiShell\.EndInvoke\(\$uiHandle\)' + $mainScript | Should -Match 'foreach \(\$uiError in \$uiShell\.Streams\.Error\)' + + # ShowDialog and the window itself belong to the interface runspace, not to main.ps1 + $mainScript | Should -Not -Match 'ShowDialog' + $uiScript | Should -Match '\$sync\["Form"\]\.ShowDialog\(\)' + $uiScript | Should -Match '\[System\.Windows\.Threading\.Dispatcher\]::CurrentDispatcher\.InvokeShutdown\(\)' + } + + It "builds one session state for both the interface runspace and the worker pool" { + $sessionStateScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\New-WinUtilSessionState.ps1") -Raw + $poolScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilRunspacePool.ps1") -Raw + + foreach ($variableName in @("sync", "PARAM_OFFLINE", "inputXML", "WinUtilAutounattendXml")) { + $sessionStateScript | Should -Match ([regex]::Escape("Name = `"$variableName`"")) + } + # Every WinUtil function has to travel, not just the ones matching a name pattern: + # the interface runspace builds tabs and job bodies call arbitrary helpers. + $sessionStateScript | Should -Match 'foreach \(\$function in \(Get-ChildItem function:\\\)\)' + $sessionStateScript | Should -Match '\$builtInFunctions\.Contains\(\$function\.Name\)' + $sessionStateScript | Should -Not -Match "imatch 'winutil\|WPF'" + $poolScript | Should -Match '\(New-WinUtilSessionState\)' + } + + It "carries every WinUtil function into a new runspace" { + $sync = [Hashtable]::Synchronized(@{}) + $null = $sync + function Test-WinUtilSessionStateMarker { "marker" } + function Get-SomethingUnprefixed { "unprefixed" } + + $runspace = [runspacefactory]::CreateRunspace((New-WinUtilSessionState)) + $runspace.Open() + try { + $shell = [powershell]::Create() + $shell.Runspace = $runspace + [void]$shell.AddScript('Test-WinUtilSessionStateMarker; Get-SomethingUnprefixed; (Get-Command mkdir).CommandType') + $result = $shell.Invoke() + $shell.Dispose() + + $result | Should -Contain "marker" + $result | Should -Contain "unprefixed" + } finally { + $runspace.Close() + $runspace.Dispose() + Remove-Item Function:\Test-WinUtilSessionStateMarker -ErrorAction SilentlyContinue + Remove-Item Function:\Get-SomethingUnprefixed -ErrorAction SilentlyContinue + } + } + It "creates runspaces on demand before queueing background work" { $runspaceScript = Get-Content -Path (Join-Path $script:repoRoot "functions\public\Invoke-WPFRunspace.ps1") -Raw diff --git a/pester/sanity.Tests.ps1 b/pester/sanity.Tests.ps1 index ba1d5d4b52..8bf845fa7d 100644 --- a/pester/sanity.Tests.ps1 +++ b/pester/sanity.Tests.ps1 @@ -132,10 +132,12 @@ Describe "Compiled WinUtil sanity" { ('$sync.configs.applications = @' + "'"), ('$inputXML = @' + "'"), ('$WinUtilAutounattendXml = @' + "'"), - "SessionStateVariableEntry -ArgumentList 'sync'", + "SessionStateVariableEntry", "SessionStateFunctionEntry", "[runspacefactory]::CreateRunspacePool", - "function Invoke-WPFRunspace" + "[runspacefactory]::CreateRunspace(`$Host, (New-WinUtilSessionState))", + "function Invoke-WPFRunspace", + "function Start-WinUtilJob" ) foreach ($snippet in $requiredSnippets) { @@ -178,7 +180,7 @@ Describe "Compiled WinUtil sanity" { ('$sync.configs.applications = @' + "'"), ('$inputXML = @' + "'"), ('$WinUtilAutounattendXml = @' + "'"), - '$sync.SearchBarClearButton.Add_Click({' + '$uiShell.AddScript({ Start-WinUtilUserInterface })' ) $lastIndex = -1 @@ -208,6 +210,7 @@ Describe "Runspace sanity" { BeforeAll { . (Join-Path $script:repoRoot "functions\public\Invoke-WPFRunspace.ps1") . (Join-Path $script:repoRoot "functions\private\Close-WinUtilRunspacePool.ps1") + . (Join-Path $script:repoRoot "functions\private\New-WinUtilSessionState.ps1") . (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilRunspacePool.ps1") } diff --git a/pester/xaml.Tests.ps1 b/pester/xaml.Tests.ps1 index e5b6b0ae93..06d63b19bb 100644 --- a/pester/xaml.Tests.ps1 +++ b/pester/xaml.Tests.ps1 @@ -8,7 +8,7 @@ BeforeAll { $script:functionRoot = Join-Path $script:repoRoot "functions" $script:scriptsRoot = Join-Path $script:repoRoot "scripts" $script:xamlPath = Join-Path $script:repoRoot "xaml\inputXML.xaml" - $script:mainScriptPath = Join-Path $script:scriptsRoot "main.ps1" + $script:uiScriptPath = Join-Path $script:functionRoot "private\Start-WinUtilUserInterface.ps1" $script:buttonScriptPath = Join-Path $script:functionRoot "public\Invoke-WPFButton.ps1" $script:xamlText = Get-Content -Path $script:xamlPath -Raw $script:xaml = [xml]$script:xamlText @@ -181,8 +181,8 @@ Describe "XAML document" { } It "wires the Document search chip to an existing Document category" { - $mainScript = Get-Content -Path $script:mainScriptPath -Raw - $mainScript | Should -Match '\$sync\["WPFSearchChipDocument"\]\.Add_Click\(\{ Set-WinUtilAppCategoryFilter -Category "Document" \}\)' + $uiScript = Get-Content -Path $script:uiScriptPath -Raw + $uiScript | Should -Match '\$sync\["WPFSearchChipDocument"\]\.Add_Click\(\{ Set-WinUtilAppCategoryFilter -Category "Document" \}\)' $applications = Get-WinUtilConfigObject -Name "applications" $categories = @($applications.PSObject.Properties | ForEach-Object { $_.Value.category } | Sort-Object -Unique) @@ -313,26 +313,26 @@ Describe "XAML document" { $window = $script:xaml.DocumentElement $searchBar = $script:xaml.SelectSingleNode('//*[local-name()="TextBox"][@Name="SearchBar"]') $searchBorder = $searchBar.ParentNode.ParentNode - $mainScript = Get-Content -Path $script:mainScriptPath -Raw + $uiScript = Get-Content -Path $script:uiScriptPath -Raw $window.GetAttribute("MinWidth") | Should -Be "800" $searchBorder.GetAttribute("Width") | Should -BeNullOrEmpty $searchBorder.GetAttribute("HorizontalAlignment") | Should -Be "Stretch" $searchBar.GetAttribute("Width") | Should -BeNullOrEmpty $searchBar.GetAttribute("HorizontalAlignment") | Should -Be "Stretch" - $mainScript | Should -Match '\$sync\.Form\.MinWidth = "1150"' - $mainScript | Should -Match '\$sync\.Form\.MinWidth = \[Math\]::Min\(\[double\]\$sync\.Form\.MinWidth, \[double\]\$screenWidth\)' + $uiScript | Should -Match '\$sync\.Form\.MinWidth = "1150"' + $uiScript | Should -Match '\$sync\.Form\.MinWidth = \[Math\]::Min\(\[double\]\$sync\.Form\.MinWidth, \[double\]\$screenWidth\)' } It "shows only one search action glyph at a time" { $searchIcon = $script:xaml.SelectSingleNode('//*[local-name()="TextBlock"][@Name="SearchBarIcon"]') $clearButton = $script:xaml.SelectSingleNode('//*[local-name()="Button"][@Name="SearchBarClearButton"]') - $mainScript = Get-Content -Path $script:mainScriptPath -Raw + $uiScript = Get-Content -Path $script:uiScriptPath -Raw $searchIcon | Should -Not -BeNullOrEmpty $clearButton | Should -Not -BeNullOrEmpty - $mainScript | Should -Match '\$sync\.SearchBarClearButton\.Visibility = "Visible"\s+\$sync\.SearchBarIcon\.Visibility = "Collapsed"' - $mainScript | Should -Match '\$sync\.SearchBarClearButton\.Visibility = "Collapsed"\s+\$sync\.SearchBarIcon\.Visibility = "Visible"' + $uiScript | Should -Match '\$sync\.SearchBarClearButton\.Visibility = "Visible"\s+\$sync\.SearchBarIcon\.Visibility = "Collapsed"' + $uiScript | Should -Match '\$sync\.SearchBarClearButton\.Visibility = "Collapsed"\s+\$sync\.SearchBarIcon\.Visibility = "Visible"' } It "scopes toggle button styles without leaking into combo boxes" { @@ -384,10 +384,10 @@ Describe "XAML document" { Describe "XAML and sync wiring" { It "wires generated config panels to existing target grids" { $xamlNames = @(Get-WinUtilXamlRuntimeNamedControls | ForEach-Object { $_.Name }) - $mainLines = Get-Content -Path $script:mainScriptPath + $uiLines = Get-Content -Path $script:uiScriptPath $invalidTargets = New-Object System.Collections.Generic.List[string] - foreach ($line in $mainLines) { + foreach ($line in $uiLines) { if ($line.TrimStart().StartsWith("#")) { continue } @@ -430,8 +430,9 @@ Describe "XAML and sync wiring" { "version", "winutildir", "logPath", - "transcriptPath", - "ProcessRunning", + "ActiveJob", + "UIRunspace", + "UIDispatchDelegate", "selected", "selectedAppx", "selectedApps", @@ -464,12 +465,9 @@ Describe "XAML and sync wiring" { "Win11ISODriveLetter", "Win11ISOWimPath", "Win11ISOImagePath", - "Win11ISOModifying", - "Win11ISOProcessRunning", "Win11ISOWorkDir", "Win11ISOContentsDir", - "Win11ISOUSBDisks", - "ActiveJob" + "Win11ISOUSBDisks" ) $allowedNames = @($xamlNames + $generatedNames + $dynamicStateNames) | Sort-Object -Unique $bracketReferences = @( @@ -522,7 +520,7 @@ Describe "WPF handler wiring" { ) $buttonSwitchNames = @(Get-WinUtilButtonSwitchNames) $featureNames = @((Get-WinUtilConfigObject -Name "feature").PSObject.Properties.Name) - $mainScript = Get-Content -Path $script:mainScriptPath -Raw + $uiScript = Get-Content -Path $script:uiScriptPath -Raw $unhandledButtons = New-Object System.Collections.Generic.List[string] foreach ($button in $buttonControls) { @@ -530,7 +528,7 @@ Describe "WPF handler wiring" { $hasFeatureHandler = Test-WinUtilNameInSet -Name $button.Name -Set $featureNames $escapedName = [regex]::Escape($button.Name) $explicitHandlerPattern = '\$sync\s*(?:\[\s*["'']' + $escapedName + '["'']\s*\]|\.' + $escapedName + ')\.Add_Click' - $hasExplicitHandler = $mainScript -imatch $explicitHandlerPattern + $hasExplicitHandler = $uiScript -imatch $explicitHandlerPattern if (-not ($hasSwitchHandler -or $hasFeatureHandler -or $hasExplicitHandler)) { $unhandledButtons.Add($button.Name) diff --git a/scripts/main.ps1 b/scripts/main.ps1 index 4d05005200..f68cdae6d1 100644 --- a/scripts/main.ps1 +++ b/scripts/main.ps1 @@ -34,6 +34,33 @@ $sync.configs.appx.PSObject.Properties | ForEach-Object { $sync.preferences.theme = "Auto" $sync.preferences.packagemanager = "Winget" +function Remove-WinUtilTempScript { + <# + .SYNOPSIS + Removes the temporary script downloaded by windev.ps1. + + .DESCRIPTION + Deletes the current script only when it is a winutil-*.ps1 file in + the system temporary directory. This preserves normal file-backed + and in-memory WinUtil launches. + #> + + $scriptPath = $PSCommandPath + $tempPath = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + + if ( + $scriptPath -and + [IO.Path]::GetDirectoryName($scriptPath) -eq $tempPath -and + [IO.Path]::GetFileName($scriptPath) -like 'winutil-*.ps1' + ) { + Remove-Item -LiteralPath $scriptPath -Force -ErrorAction SilentlyContinue + } +} + +#=========================================================================== +# Headless runs never build a window +#=========================================================================== + if ($Preset) { Initialize-WinUtilRunspacePool | Out-Null @@ -64,484 +91,46 @@ if ($Config) { return } -[void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework') -[xml]$XAML = $inputXML - -# Read the XAML file -$readerOperationSuccessful = $false # There's more cases of failure then success. -$reader = (New-Object System.Xml.XmlNodeReader $xaml) -try { - $sync["Form"] = [Windows.Markup.XamlReader]::Load( $reader ) - $readerOperationSuccessful = $true -} catch [System.Management.Automation.MethodInvocationException] { - Write-Host "We ran into a problem with the XAML code. Check the syntax for this control..." -ForegroundColor Red - Write-Host $error[0].Exception.Message -ForegroundColor Red - - If ($error[0].Exception.Message -like "*button*") { - write-Host "Ensure your <button in the `$inputXML does NOT have a Click=ButtonClick property. PS can't handle this`n`n`n`n" -ForegroundColor Red - } -} catch { - Write-Host "Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed." -ForegroundColor Red -} - -if (-NOT ($readerOperationSuccessful)) { - Write-Host "Failed to parse xaml content using Windows.Markup.XamlReader's Load Method." -ForegroundColor Red - Write-Host "Quitting WinUtil..." -ForegroundColor Red - Close-WinUtilRunspacePool - [System.GC]::Collect() - exit 1 -} - -# Setup the Window to follow listen for windows Theme Change events and update the winutil theme -# throttle logic needed, because windows seems to send more than one theme change event per change -$lastThemeChangeTime = [datetime]::MinValue -$debounceInterval = [timespan]::FromSeconds(2) -$sync.Form.Add_Loaded({ - $interopHelper = New-Object System.Windows.Interop.WindowInteropHelper $sync.Form - $hwndSource = [System.Windows.Interop.HwndSource]::FromHwnd($interopHelper.Handle) - $hwndSource.AddHook({ - param ( - [System.IntPtr]$hwnd, - [int]$msg, - [System.IntPtr]$wParam, - [System.IntPtr]$lParam, - [ref]$handled - ) - $null = $hwnd, $wParam, $lParam - # Check for the Event WM_SETTINGCHANGE (0x1001A) and validate that Button shows the icon for "Auto" => [char]0xF08C - if (($msg -eq 0x001A) -and $sync.ThemeButton.Content -eq [char]0xF08C) { - $currentTime = [datetime]::Now - if ($currentTime - $lastThemeChangeTime -gt $debounceInterval) { - Invoke-WinutilThemeChange -theme "Auto" - $script:lastThemeChangeTime = $currentTime - $handled = $true - } - } - return 0 - }) -}) - -Invoke-WinutilThemeChange -theme $sync.preferences.theme - - -# Build only the default tab before first paint; other tabs initialize on first activation. -$sync.InitializedTabs = @{} -Initialize-WinUtilTabContent -TabName "Install" - #=========================================================================== -# Store Form Objects In PowerShell +# Start the interface on its own thread and manage it from here #=========================================================================== +# +# The main thread stays out of the window's way. It creates the dedicated STA runspace the +# interface lives on, waits for that window to close, and reports anything the interface +# thread failed with. Work started from the interface goes to the worker pool through +# Start-WinUtilJob, so neither the window nor this thread is ever blocked by it. -$xaml.SelectNodes("//*[@Name]") | ForEach-Object {$sync["$("$($psitem.Name)")"] = $sync["Form"].FindName($psitem.Name)} - -$sync.ChocoRadioButton.Add_Checked({ - $sync.preferences.packagemanager = "Choco" -}) -$sync.WingetRadioButton.Add_Checked({ - $sync.preferences.packagemanager = "Winget" -}) - -switch ($sync.preferences.packagemanager) { - "Choco" {$sync.ChocoRadioButton.IsChecked = $true; break} - "Winget" {$sync.WingetRadioButton.IsChecked = $true; break} -} +$sync.UIRunspace = [runspacefactory]::CreateRunspace($Host, (New-WinUtilSessionState)) +$sync.UIRunspace.ApartmentState = "STA" +$sync.UIRunspace.ThreadOptions = "ReuseThread" +$sync.UIRunspace.Open() -$sync.keys | ForEach-Object { - if($sync.$psitem) { - if($($sync["$psitem"].GetType() | Select-Object -ExpandProperty Name) -eq "ToggleButton") { - if ($sync.Buttons -notcontains $psitem) { - $sync["$psitem"].Add_Click({ - [System.Object]$Sender = $args[0] - Invoke-WPFButton $Sender.name - }) - $sync.Buttons.Add($psitem) | Out-Null - } - } +$uiShell = [powershell]::Create() +$uiShell.Runspace = $sync.UIRunspace +[void]$uiShell.AddScript({ Start-WinUtilUserInterface }) - if($($sync["$psitem"].GetType() | Select-Object -ExpandProperty Name) -eq "Button") { - if ($sync.Buttons -notcontains $psitem) { - $sync["$psitem"].Add_Click({ - [System.Object]$Sender = $args[0] - Invoke-WPFButton $Sender.name - }) - $sync.Buttons.Add($psitem) | Out-Null - } - } +Write-WinUtilLog -Component "UI" -Message "Starting the interface thread." +$uiHandle = $uiShell.BeginInvoke() +$uiHandle.AsyncWaitHandle.WaitOne() | Out-Null - } +try { + $uiShell.EndInvoke($uiHandle) | Out-Null +} catch { + Write-Host "The WinUtil interface stopped with an error: $($_.Exception.Message)" -ForegroundColor Red + Write-WinUtilLog -Level "ERROR" -Component "UI" -Message "Interface thread failed: $($_.Exception.Message)" } -#=========================================================================== -# Setup and Show the Form -#=========================================================================== - -# Progress bar in taskbaritem > Set-WinUtilProgressbar -$sync["Form"].TaskbarItemInfo = New-Object System.Windows.Shell.TaskbarItemInfo -Set-WinUtilTaskbaritem -state "None" - -# Set the titlebar -$sync["Form"].title = $sync["Form"].title + " " + $sync.version -# Set the commands that will run when the form is closed -$sync["Form"].Add_Closing({ - Close-WinUtilRunspacePool - [System.GC]::Collect() -}) - -# Attach the event handler to the Click event -$sync.SearchBarClearButton.Add_Click({ - $sync.SearchBar.Text = "" - $sync.SearchBarClearButton.Visibility = "Collapsed" - - # Focus the search bar after clearing the text - $sync.SearchBar.Focus() - $sync.SearchBar.SelectAll() -}) - -# add some shortcuts for people that don't like clicking -function Invoke-WinUtilFontScaleStep([double]$Step) { $sync.FontScalingSlider.Value = [math]::Max(0.75, [math]::Min(2.0, $sync.FontScalingSlider.Value + $Step)); Invoke-WinUtilFontScaling -ScaleFactor $sync.FontScalingSlider.Value } - -$commonKeyEvents = { - # Prevent shortcuts from executing if a process is already running - if ($sync.ProcessRunning -eq $true) { - return - } - - # Handle key presses of single keys - switch ($_.Key) { - "Escape" { $sync.SearchBar.Text = "" } - } - # Handle Alt key combinations for navigation - if ($_.KeyboardDevice.Modifiers -eq "Alt") { - $keyEventArgs = $_ - switch ($_.SystemKey) { - "I" { Invoke-WPFButton "WPFTab1BT"; $keyEventArgs.Handled = $true } # Navigate to Install tab and suppress Windows Warning Sound - "T" { Invoke-WPFButton "WPFTab2BT"; $keyEventArgs.Handled = $true } # Navigate to Tweaks tab - "C" { Invoke-WPFButton "WPFTab3BT"; $keyEventArgs.Handled = $true } # Navigate to Config tab - "U" { Invoke-WPFButton "WPFTab4BT"; $keyEventArgs.Handled = $true } # Navigate to Updates tab - "W" { Invoke-WPFButton "WPFTab5BT"; $keyEventArgs.Handled = $true } # Navigate to Win11ISO tab - } - } - # Handle Ctrl key combinations for specific actions - if ($_.KeyboardDevice.Modifiers -eq "Ctrl") { - $keyEventArgs = $_ - switch ($_.Key) { - "F" { $sync.SearchBar.Focus() } # Focus on the search bar - "Q" { $this.Close() } # Close the application - } - } - $ctrlShiftModifiers = [Windows.Input.ModifierKeys]::Control -bor [Windows.Input.ModifierKeys]::Shift - if ($_.KeyboardDevice.Modifiers -eq "Ctrl" -or $_.KeyboardDevice.Modifiers -eq $ctrlShiftModifiers) { - $keyEventArgs = $_ - switch ($_.Key) { - { $_ -in "OemPlus", "Add" } { Invoke-WinUtilFontScaleStep 0.05; $keyEventArgs.Handled = $true } - { $_ -in "OemMinus", "Subtract" } { Invoke-WinUtilFontScaleStep -0.05; $keyEventArgs.Handled = $true } - } - } +foreach ($uiError in $uiShell.Streams.Error) { + Write-Host $uiError -ForegroundColor Red + Write-WinUtilLog -Level "ERROR" -Component "UI" -Message $uiError } -$sync["Form"].Add_PreViewKeyDown($commonKeyEvents) -$sync["Form"].Add_PreviewMouseWheel({ - if ([Windows.Input.Keyboard]::Modifiers -eq "Ctrl") { Invoke-WinUtilFontScaleStep $(if ($_.Delta -gt 0) { 0.05 } else { -0.05 }); $_.Handled = $true } -}) - -$sync["Form"].Add_MouseLeftButtonDown({ - Invoke-WPFPopup -Action "Hide" -Popups @("Settings", "Theme", "FontScaling") - $sync["Form"].DragMove() -}) - -$sync["Form"].Add_MouseDoubleClick({ - if ($_.OriginalSource.Name -eq "NavDockPanel" -or - $_.OriginalSource.Name -eq "GridBesideNavDockPanel") { - if ($sync["Form"].WindowState -eq [Windows.WindowState]::Normal) { - [Windows.SystemCommands]::MaximizeWindow($sync.Form) - } - else{ - [Windows.SystemCommands]::RestoreWindow($sync.Form) - } - } -}) - -$sync["Form"].Add_Deactivated({ - Invoke-WPFPopup -Action "Hide" -Popups @("Settings", "Theme", "FontScaling") -}) -$sync["Form"].Add_ContentRendered({ - # Load the Windows Forms assembly - Add-Type -AssemblyName System.Windows.Forms - $primaryScreen = [System.Windows.Forms.Screen]::PrimaryScreen - # Check if the primary screen is found - if ($primaryScreen) { - # Extract screen width and height for the primary monitor - $screenWidth = $primaryScreen.Bounds.Width - $screenHeight = $primaryScreen.Bounds.Height - $sync.Form.MinWidth = [Math]::Min([double]$sync.Form.MinWidth, [double]$screenWidth) - - # Compare with the primary monitor size - if ($sync.Form.ActualWidth -gt $screenWidth -or $sync.Form.ActualHeight -gt $screenHeight) { - $sync.Form.Left = 0 - $sync.Form.Top = 0 - $sync.Form.Width = $screenWidth - $sync.Form.Height = $screenHeight - } - } - - if ($PARAM_OFFLINE) { - # Show offline banner - $sync.WPFOfflineBanner.Visibility = [System.Windows.Visibility]::Visible - - # Disable the install tab - $sync.WPFTab1BT.IsEnabled = $false - $sync.WPFTab1BT.Opacity = 0.5 - $sync.WPFTab1BT.ToolTip = "Internet connection required for installing applications." - - # Disable install-related buttons - $sync.WPFInstall.IsEnabled = $false - $sync.WPFUninstall.IsEnabled = $false - $sync.WPFInstallUpgrade.IsEnabled = $false - $sync.WPFGetInstalled.IsEnabled = $false - - # Show offline indicator - Write-Host "Offline mode detected - Install tab disabled." -ForegroundColor Yellow - - # Optionally switch to a different tab if install tab was going to be default - Invoke-WPFTab "WPFTab2BT" # Switch to Tweaks tab instead - } - else { - # Online - ensure install tab is enabled - $sync.WPFTab1BT.IsEnabled = $true - $sync.WPFTab1BT.Opacity = 1.0 - $sync.WPFTab1BT.ToolTip = $null - Invoke-WPFTab "WPFTab1BT" # Default to install tab - } - - $sync["Form"].Focus() - $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Initialize-WinUtilRunspacePool | Out-Null }) | Out-Null - $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $false -IncludeStatusAssets $true }) | Out-Null -}) - -# The SearchBarTimer is used to delay the search operation until the user has stopped typing for a short period -# This prevents the ui from stuttering when the user types quickly as it dosnt need to update the ui for every keystroke - -$searchBarTimer = New-Object System.Windows.Threading.DispatcherTimer -$searchBarTimer.Interval = [TimeSpan]::FromMilliseconds(300) -$searchBarTimer.IsEnabled = $false - -$searchBarTimer.add_Tick({ - $searchBarTimer.Stop() - switch ($sync.currentTab) { - "Install" { - Find-AppsByNameOrDescription -SearchString $sync.SearchBar.Text -Category $sync.SearchBar.Tag - } - "Tweaks" { - Find-TweaksByNameOrDescription -SearchString $sync.SearchBar.Text - } - "AppX" { - Find-TweaksByNameOrDescription -SearchString $sync.SearchBar.Text - } - } -}) -$sync["SearchBar"].Add_TextChanged({ - if ($sync.SearchBar.Tag -ne $sync.SearchBar.Text) { - $sync.SearchBar.Tag = $null - } - - if ($sync.SearchBar.Text -ne "") { - $sync.SearchBarClearButton.Visibility = "Visible" - $sync.SearchBarIcon.Visibility = "Collapsed" - } else { - $sync.SearchBarClearButton.Visibility = "Collapsed" - $sync.SearchBarIcon.Visibility = "Visible" - } - - # Category chip handlers apply their filter immediately. - if ($sync.SearchBar.Tag -eq $sync.SearchBar.Text) { - return - } - - if ($searchBarTimer.IsEnabled) { - $searchBarTimer.Stop() - } - $searchBarTimer.Start() -}) - -# Quick Category Search Chips -$sync["WPFSearchChipAll"].Add_Click({ Set-WinUtilAppCategoryFilter }) -$sync["WPFSearchChipBrowsers"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Browsers" }) -$sync["WPFSearchChipCommunications"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Communications" }) -$sync["WPFSearchChipDevelopment"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Development" }) -$sync["WPFSearchChipDocument"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Document" }) -$sync["WPFSearchChipGames"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Games" }) -$sync["WPFSearchChipMicrosoftTools"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Microsoft Tools" }) -$sync["WPFSearchChipMultimediaTools"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Multimedia Tools" }) -$sync["WPFSearchChipProTools"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Pro Tools" }) -$sync["WPFSearchChipSelfhostedTools"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Selfhosted Tools" }) -$sync["WPFSearchChipUtilities"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Utilities" }) - -$sync["Form"].Add_Loaded({ - param($e) - $null = $e - $sync.Form.MinWidth = "1150" - $sync["Form"].MaxWidth = [Double]::PositiveInfinity - $sync["Form"].MaxHeight = [Double]::PositiveInfinity -}) - -$NavLogoPanel = $sync["Form"].FindName("NavLogoPanel") -$NavLogoPanel.Children.Add((Invoke-WinUtilAssets -Type "logo" -Size 25)) | Out-Null -Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $true -IncludeStatusAssets $false - -Set-WinUtilTaskbaritem -overlay "logo" - -$sync["Form"].Add_Activated({ - Set-WinUtilTaskbaritem -overlay "logo" -}) - -$sync["ThemeButton"].Add_Click({ - Invoke-WPFPopup -PopupActionTable @{ "Settings" = "Hide"; "Theme" = "Toggle"; "FontScaling" = "Hide" } -}) -$sync["AutoThemeMenuItem"].Add_Click({ - Invoke-WPFPopup -Action "Hide" -Popups @("Theme") - Invoke-WinutilThemeChange -theme "Auto" -}) -$sync["DarkThemeMenuItem"].Add_Click({ - Invoke-WPFPopup -Action "Hide" -Popups @("Theme") - Invoke-WinutilThemeChange -theme "Dark" -}) -$sync["LightThemeMenuItem"].Add_Click({ - Invoke-WPFPopup -Action "Hide" -Popups @("Theme") - Invoke-WinutilThemeChange -theme "Light" -}) - -$sync["SettingsButton"].Add_Click({ - Invoke-WPFPopup -PopupActionTable @{ "Settings" = "Toggle"; "Theme" = "Hide"; "FontScaling" = "Hide" } -}) -$sync["ImportMenuItem"].Add_Click({ - Invoke-WPFPopup -Action "Hide" -Popups @("Settings") - Invoke-WPFImpex -type "import" -}) -$sync["ExportMenuItem"].Add_Click({ - Invoke-WPFPopup -Action "Hide" -Popups @("Settings") - Invoke-WPFImpex -type "export" -}) -$sync["AboutMenuItem"].Add_Click({ - Invoke-WPFPopup -Action "Hide" -Popups @("Settings") - - $authorInfo = @" -Author : @ChrisTitusTech -UI : @MyDrift-user, @Marterich -Runspace : @DeveloperDurp, @Marterich -GitHub : ChrisTitusTech/winutil -Version : $($sync.version) -"@ - Show-CustomDialog -Title "About" -Message $authorInfo -}) -$sync["DocumentationMenuItem"].Add_Click({ - Invoke-WPFPopup -Action "Hide" -Popups @("Settings") - Start-Process "https://winutil.christitus.com/" -}) -$sync["SponsorMenuItem"].Add_Click({ - Invoke-WPFPopup -Action "Hide" -Popups @("Settings") - - $authorInfo = @" -Current sponsors for ChrisTitusTech: -"@ - $authorInfo += "`n" - try { - $sponsors = Invoke-WinUtilSponsors - foreach ($sponsor in $sponsors) { - $authorInfo += "$sponsor`n" - } - } catch { - $authorInfo += "An error occurred while fetching or processing the sponsors: $_`n" - } - Show-CustomDialog -Title "Sponsors" -Message $authorInfo -EnableScroll $true -}) - -# Font Scaling Event Handlers -$sync["FontScalingButton"].Add_Click({ - Invoke-WPFPopup -PopupActionTable @{ "Settings" = "Hide"; "Theme" = "Hide"; "FontScaling" = "Toggle" } -}) - -$sync["FontScalingSlider"].Add_ValueChanged({ - param($slider) - $percentage = [math]::Round($slider.Value * 100) - $sync.FontScalingValue.Text = "$percentage%" -}) - -$sync["FontScalingResetButton"].Add_Click({ - $sync.FontScalingSlider.Value = 1.0 - $sync.FontScalingValue.Text = "100%" -}) - -$sync["FontScalingApplyButton"].Add_Click({ - $scaleFactor = $sync.FontScalingSlider.Value - Invoke-WinUtilFontScaling -ScaleFactor $scaleFactor - Invoke-WPFPopup -Action "Hide" -Popups @("FontScaling") -}) - -# ── Win11ISO Tab button handlers ────────────────────────────────────────────── - -$sync["WPFWin11ISOBrowseButton"].Add_Click({ - Invoke-WinUtilISOBrowse -}) - -$sync["WPFWin11ISODownloadLink"].Add_Click({ - Start-Process "https://www.microsoft.com/software-download/windows11" -}) - -$sync["WPFWin11ISOMountButton"].Add_Click({ - Invoke-WinUtilISOMountAndVerify -}) - -$sync["WPFWin11ISOModifyButton"].Add_Click({ - Invoke-WinUtilISOModify -}) - -$sync["WPFWin11ISOChooseISOButton"].Add_Click({ - $sync["WPFWin11ISOOptionUSB"].Visibility = "Collapsed" - Invoke-WinUtilISOExport -}) - -$sync["WPFWin11ISOChooseUSBButton"].Add_Click({ - $sync["WPFWin11ISOOptionUSB"].Visibility = "Visible" - Invoke-WinUtilISORefreshUSBDrives -}) - -$sync["WPFWin11ISORefreshUSBButton"].Add_Click({ - Invoke-WinUtilISORefreshUSBDrives -}) - -$sync["WPFWin11ISOWriteUSBButton"].Add_Click({ - Invoke-WinUtilISOWriteUSB -}) - -$sync["WPFWin11ISOCleanResetButton"].Add_Click({ - Invoke-WinUtilISOCleanAndReset -}) - -function Remove-WinUtilTempScript { - <# - .SYNOPSIS - Removes the temporary script downloaded by windev.ps1. - - .DESCRIPTION - Deletes the current script only when it is a winutil-*.ps1 file in - the system temporary directory. This preserves normal file-backed - and in-memory WinUtil launches. - #> - - $scriptPath = $PSCommandPath - $tempPath = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') - - if ( - $scriptPath -and - [IO.Path]::GetDirectoryName($scriptPath) -eq $tempPath -and - [IO.Path]::GetFileName($scriptPath) -like 'winutil-*.ps1' - ) { - Remove-Item -LiteralPath $scriptPath -Force -ErrorAction SilentlyContinue - } -} +$uiShell.Dispose() +$sync.UIRunspace.Dispose() +$sync.Remove("UIRunspace") -# ────────────────────────────────────────────────────────────────────────────── +Close-WinUtilRunspacePool +[System.GC]::Collect() -$sync["Form"].ShowDialog() | out-null Remove-WinUtilTempScript Stop-Transcript diff --git a/scripts/start.ps1 b/scripts/start.ps1 index 95a28f38bb..5ee57eeb92 100644 --- a/scripts/start.ps1 +++ b/scripts/start.ps1 @@ -61,8 +61,8 @@ $sync.version = "#{replaceme}" $sync.configs = @{} $sync.Buttons = [System.Collections.Generic.List[PSObject]]::new() $sync.preferences = @{} -$sync.ProcessRunning = $false -$sync.Win11ISOProcessRunning = $false +# Name of the job currently running, or $null when idle. Owned by Start-WinUtilJob. +$sync.ActiveJob = $null $sync.selectedAppx = [System.Collections.Generic.List[string]]::new() $sync.selectedApps = [System.Collections.Generic.List[string]]::new() $sync.selectedTweaks = [System.Collections.Generic.List[string]]::new() @@ -75,9 +75,11 @@ $winutildir = "$env:LocalAppData\winutil" $sync.winutildir = $winutildir $logdir = "$winutildir\logs" +# Structured session log, written by Write-WinUtilLog from every thread. The console +# transcript goes to its own file because Start-Transcript keeps that one open and only +# ever records the runspace it was started on. $sync.logPath = "$logdir\winutil_$dateTime.log" -$sync.transcriptPath = $sync.logPath -Start-Transcript -Path $sync.logPath -Append -NoClobber | Out-Null +Start-Transcript -Path "$logdir\winutil_$dateTime.console.log" -Append -NoClobber | Out-Null $Host.UI.RawUI.WindowTitle = "WinUtil" Clear-Host From c60e5c7673a351f10c62185b962765585fff861f Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 03:07:33 +0200 Subject: [PATCH 04/70] Move every long workflow onto the job layer Tweaks, undo, AppX removal and the five Win11 Creator workflows now go through Start-WinUtilJob like the install workflows already did. That removes the five hand-built STA runspaces and the function-definition injection the ISO code needed to reach its own helpers. - One busy flag: $sync.ActiveJob replaces ProcessRunning and Win11ISOProcessRunning, and only the job layer writes it. - Write-WinUtilJobProgress -Hide absorbs the last use of Set-WinUtilTweaksProgressIndicator, so the progress bar and taskbar item have a single owner. The helper is gone. - Show-WinUtilMessage marshals onto the interface thread and logs the prompt, so a job body can ask a question without knowing which thread it is on. The raw MessageBox calls in the ISO workflows are gone. - Win11 Creator status-log lines also go to the session log, and the per-workflow Log/SetProgress helpers are gone. - Get-WinUtilOscdimgPath and Get-WinUtilFreeDriveLetter are now real functions rather than nested ones, so the pool can resolve them. --- functions/private/Invoke-WinUtilISO.ps1 | 673 ++++++++---------- functions/private/Invoke-WinUtilISOUSB.ps1 | 212 +++--- .../Set-WinUtilTweaksProgressIndicator.ps1 | 35 - functions/private/Show-WinUtilMessage.ps1 | 22 +- functions/private/Start-WinUtilJob.ps1 | 3 - .../private/Write-WinUtilJobProgress.ps1 | 50 +- functions/public/Invoke-WPFAppxRemoval.ps1 | 124 ++-- functions/public/Invoke-WPFButton.ps1 | 5 +- functions/public/Invoke-WPFGetInstalled.ps1 | 10 +- functions/public/Invoke-WPFtweaksbutton.ps1 | 90 +-- functions/public/Invoke-WPFundoall.ps1 | 38 +- functions/public/Invoke-WinUtilAutoRun.ps1 | 2 +- pester/appx.Tests.ps1 | 223 ++---- pester/install-workflow.Tests.ps1 | 5 +- pester/job-layer.Tests.ps1 | 203 ++++++ pester/oosu.Tests.ps1 | 3 - pester/runspace.Tests.ps1 | 53 +- pester/tweaks.Tests.ps1 | 162 ++++- pester/ui-state.Tests.ps1 | 7 +- pester/win11creator.Tests.ps1 | 66 +- 20 files changed, 994 insertions(+), 992 deletions(-) delete mode 100644 functions/private/Set-WinUtilTweaksProgressIndicator.ps1 create mode 100644 pester/job-layer.Tests.ps1 diff --git a/functions/private/Invoke-WinUtilISO.ps1 b/functions/private/Invoke-WinUtilISO.ps1 index af9f6f3d9d..6c49ba875c 100644 --- a/functions/private/Invoke-WinUtilISO.ps1 +++ b/functions/private/Invoke-WinUtilISO.ps1 @@ -1,17 +1,65 @@ function Write-WinUtilISOLog { - param([string]$Message) - $ts = (Get-Date).ToString("HH:mm:ss") - $logLine = "[$ts] $Message" - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $current = $sync["WPFWin11ISOStatusLog"].Text - if ($current -eq "Ready. Please select a Windows 11 ISO to begin.") { - $sync["WPFWin11ISOStatusLog"].Text = $logLine + <# + .SYNOPSIS + Appends a line to the Win11 Creator status log and to the session log. + + .DESCRIPTION + The status log is a UI control, so the append is posted to the UI thread rather than + waited on. Without a window it degrades to the session log alone, which keeps job + bodies free of "is there a UI" checks. + #> + param( + [Parameter(Mandatory)][string]$Message, + [ValidateSet("INFO", "WARN", "ERROR")] + [string]$Level = "INFO" + ) + + Write-WinUtilLog -Level $Level -Component "Win11Creator" -Message $Message + + Invoke-WPFUIThread -Async -Parameters @{ + LogLine = "[$((Get-Date).ToString('HH:mm:ss'))] $Message" + } -ScriptBlock { + param($LogLine) + + $box = $sync["WPFWin11ISOStatusLog"] + if ($null -eq $box) { return } + + if ($box.Text -eq "Ready. Please select a Windows 11 ISO to begin.") { + $box.Text = $LogLine } else { - $sync["WPFWin11ISOStatusLog"].Text += "`n$logLine" + $box.Text += "`n$LogLine" } - $sync["WPFWin11ISOStatusLog"].CaretIndex = $sync["WPFWin11ISOStatusLog"].Text.Length - $sync["WPFWin11ISOStatusLog"].ScrollToEnd() - }) + $box.CaretIndex = $box.Text.Length + $box.ScrollToEnd() + } +} + +function Get-WinUtilEditionIdFromName { + <# + .SYNOPSIS + Maps a Windows 11 edition display name to the edition id used by unattended setup. + #> + param([string]$EditionName) + + $normalizedName = ($EditionName -replace '^Windows\s+11\s+', '').Trim() + switch -Regex ($normalizedName) { + '^Home Single Language$' { return 'CoreSingleLanguage' } + '^Home N$' { return 'CoreN' } + '^Home$' { return 'Core' } + '^Pro for Workstations N$' { return 'ProfessionalWorkstationN' } + '^Pro for Workstations$' { return 'ProfessionalWorkstation' } + '^Pro Education N$' { return 'ProfessionalEducationN' } + '^Pro Education$' { return 'ProfessionalEducation' } + '^Pro N$' { return 'ProfessionalN' } + '^Pro$' { return 'Professional' } + '^Education N$' { return 'EducationN' } + '^Education$' { return 'Education' } + '^Enterprise LTSC N$' { return 'EnterpriseSN' } + '^Enterprise LTSC$' { return 'EnterpriseS' } + '^Enterprise N$' { return 'EnterpriseN' } + '^Enterprise$' { return 'Enterprise' } + default { return '' } + } } function Invoke-WinUtilISOBrowse { @@ -42,71 +90,73 @@ function Invoke-WinUtilISOMountAndVerify { $isoPath = $sync["WPFWin11ISOPath"].Text if ([string]::IsNullOrWhiteSpace($isoPath) -or $isoPath -eq "No ISO selected...") { - [System.Windows.MessageBox]::Show("Please select an ISO file first.", "No ISO Selected", "OK", "Warning") + Show-WinUtilMessage -Message "Please select an ISO file first." -Title "No ISO Selected" -Button "OK" -Icon "Warning" | Out-Null return } - Write-WinUtilISOLog "Mounting ISO: $isoPath" - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Mounting ISO..." -Percent 10 - $sync["WPFWin11ISOBrowseButton"].IsEnabled = $false - $sync["WPFWin11ISOMountButton"].IsEnabled = $false - $sync["WPFWin11ISOModifyButton"].IsEnabled = $false - $sync["Win11ISOProcessRunning"] = $true + Start-WinUtilJob -Name "ISO mount" -Description "Mounting ISO" -Parameters @{ + IsoPath = $isoPath + } -ScriptBlock { + param($IsoPath) - Invoke-WPFRunspace -ParameterList @(,('isoPath', $isoPath)) -ScriptBlock { - param($isoPath) + Invoke-WPFUIThread -ScriptBlock { + $sync["WPFWin11ISOBrowseButton"].IsEnabled = $false + $sync["WPFWin11ISOMountButton"].IsEnabled = $false + $sync["WPFWin11ISOModifyButton"].IsEnabled = $false + } try { - Mount-DiskImage -ImagePath $isoPath + Write-WinUtilISOLog "Mounting ISO: $IsoPath" + Write-WinUtilJobProgress -Status "Mounting ISO..." -Percent 10 + + Mount-DiskImage -ImagePath $IsoPath do { Start-Sleep -Milliseconds 500 - } until ((Get-DiskImage -ImagePath $isoPath | Get-Volume).DriveLetter) + } until ((Get-DiskImage -ImagePath $IsoPath | Get-Volume).DriveLetter) - $driveLetter = (Get-DiskImage -ImagePath $isoPath | Get-Volume).DriveLetter + ":" + $driveLetter = (Get-DiskImage -ImagePath $IsoPath | Get-Volume).DriveLetter + ":" Write-WinUtilISOLog "Mounted at drive $driveLetter" - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Verifying ISO contents..." -Percent 30 + Write-WinUtilJobProgress -Status "Verifying ISO contents..." -Percent 30 $wimPath = Join-Path $driveLetter "sources\install.wim" $esdPath = Join-Path $driveLetter "sources\install.esd" if (-not (Test-Path $wimPath) -and -not (Test-Path $esdPath)) { - Dismount-DiskImage -ImagePath $isoPath - Write-WinUtilISOLog "ERROR: install.wim/install.esd not found - not a valid Windows ISO." - Invoke-WPFUIThread { - [System.Windows.MessageBox]::Show( - "This does not appear to be a valid Windows ISO.`n`ninstall.wim / install.esd was not found.", - "Invalid ISO", "OK", "Error") - } + Dismount-DiskImage -ImagePath $IsoPath + Write-WinUtilISOLog -Level "ERROR" -Message "install.wim/install.esd not found - not a valid Windows ISO." + Show-WinUtilMessage -Message "This does not appear to be a valid Windows ISO.`n`ninstall.wim / install.esd was not found." -Title "Invalid ISO" -Button "OK" -Icon "Error" | Out-Null return } $activeWim = if (Test-Path $wimPath) { $wimPath } else { $esdPath } - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Reading image metadata..." -Percent 55 + Write-WinUtilJobProgress -Status "Reading image metadata..." -Percent 55 $imageInfo = Get-WindowsImage -ImagePath $activeWim | Select-Object ImageIndex, ImageName if (-not ($imageInfo | Where-Object { $_.ImageName -match "Windows 11" })) { - Dismount-DiskImage -ImagePath $isoPath - Write-WinUtilISOLog "ERROR: No 'Windows 11' edition found in the image." - Invoke-WPFUIThread { - [System.Windows.MessageBox]::Show( - "No Windows 11 edition was found in this ISO.`n`nOnly official Windows 11 ISOs are supported.", - "Not a Windows 11 ISO", "OK", "Error") - } + Dismount-DiskImage -ImagePath $IsoPath + Write-WinUtilISOLog -Level "ERROR" -Message "No 'Windows 11' edition found in the image." + Show-WinUtilMessage -Message "No Windows 11 edition was found in this ISO.`n`nOnly official Windows 11 ISOs are supported." -Title "Not a Windows 11 ISO" -Button "OK" -Icon "Error" | Out-Null return } $sync["Win11ISOImageInfo"] = $imageInfo $sync["Win11ISODriveLetter"] = $driveLetter $sync["Win11ISOWimPath"] = $activeWim - $sync["Win11ISOImagePath"] = $isoPath + $sync["Win11ISOImagePath"] = $IsoPath + + Invoke-WPFUIThread -Parameters @{ + DriveLetter = $driveLetter + ImageFileName = Split-Path $activeWim -Leaf + ImageInfo = $imageInfo + } -ScriptBlock { + param($DriveLetter, $ImageFileName, $ImageInfo) - Invoke-WPFUIThread { - $sync["WPFWin11ISOMountDriveLetter"].Text = "Mounted at: $driveLetter | Image file: $(Split-Path $activeWim -Leaf)" + $sync["WPFWin11ISOMountDriveLetter"].Text = "Mounted at: $DriveLetter | Image file: $ImageFileName" $sync["WPFWin11ISOEditionComboBox"].Items.Clear() - foreach ($img in $imageInfo) { + foreach ($img in $ImageInfo) { [void]$sync["WPFWin11ISOEditionComboBox"].Items.Add("$($img.ImageIndex): $($img.ImageName)") } if ($sync["WPFWin11ISOEditionComboBox"].Items.Count -gt 0) { @@ -120,26 +170,14 @@ function Invoke-WinUtilISOMountAndVerify { } $sync["WPFWin11ISOVerifyResultPanel"].Visibility = "Visible" $sync["WPFWin11ISOModifySection"].Visibility = "Visible" - $sync["WPFWin11ISOModifyButton"].IsEnabled = $true } - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "ISO verified" -Percent 100 Write-WinUtilISOLog "ISO verified OK. Editions found: $($imageInfo.Count)" - } catch { - $errorMessage = $_ - Write-WinUtilISOLog "ERROR during mount/verify: $errorMessage" - Invoke-WPFUIThread { - [System.Windows.MessageBox]::Show( - "An error occurred while mounting or verifying the ISO:`n`n$errorMessage", - "Error", "OK", "Error") - } } finally { - Start-Sleep -Milliseconds 800 - Set-WinUtilTweaksProgressIndicator -Visible $false - Invoke-WPFUIThread { + Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOBrowseButton"].IsEnabled = $true $sync["WPFWin11ISOMountButton"].IsEnabled = $true - $sync["Win11ISOProcessRunning"] = $false + $sync["WPFWin11ISOModifyButton"].IsEnabled = $true } } } @@ -151,9 +189,7 @@ function Invoke-WinUtilISOModify { $wimPath = $sync["Win11ISOWimPath"] if (-not $isoPath) { - [System.Windows.MessageBox]::Show( - "No verified ISO found. Please complete Steps 1 and 2 first.", - "Not Ready", "OK", "Warning") + Show-WinUtilMessage -Message "No verified ISO found. Please complete Steps 1 and 2 first." -Title "Not Ready" -Button "OK" -Icon "Warning" | Out-Null return } @@ -165,15 +201,11 @@ function Invoke-WinUtilISOModify { $selectedWimIndex = $sync["Win11ISOImageInfo"][0].ImageIndex } $selectedEditionName = if ($selectedItem) { ($selectedItem -replace '^\d+:\s*', '') } else { "Unknown" } - Write-WinUtilISOLog "Selected edition: $selectedEditionName (Index $selectedWimIndex)" - - $sync["WPFWin11ISOModifyButton"].IsEnabled = $false - $sync["Win11ISOModifying"] = $true - $sync["Win11ISOProcessRunning"] = $true + # A fresh working directory per run; existing-work detection is only for resuming an export $workDir = Join-Path $env:TEMP "WinUtil_Win11ISO_$(Get-Date -Format 'yyyyMMdd_HHmmss')" if (Test-Path $workDir) { - $workDir = Join-Path $env:TEMP "WinUtil_Win11ISO_$(Get-Date -Format 'yyyyMMdd_HHmmss')_$(([guid]::NewGuid()).ToString('N').Substring(0, 8))" + $workDir = "$($workDir)_$(([guid]::NewGuid()).ToString('N').Substring(0, 8))" } $autounattendContent = if ($WinUtilAutounattendXml) { @@ -183,174 +215,113 @@ function Invoke-WinUtilISOModify { if (Test-Path $toolsXml) { Get-Content $toolsXml -Raw } else { "" } } - $runspace = [Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() - $runspace.ApartmentState = "STA" - $runspace.ThreadOptions = "ReuseThread" - $runspace.Open() - $injectDrivers = $sync["WPFWin11ISOInjectDrivers"].IsChecked -eq $true - $runspace.SessionStateProxy.SetVariable("sync", $sync) - $runspace.SessionStateProxy.SetVariable("isoPath", $isoPath) - $runspace.SessionStateProxy.SetVariable("driveLetter", $driveLetter) - $runspace.SessionStateProxy.SetVariable("wimPath", $wimPath) - $runspace.SessionStateProxy.SetVariable("workDir", $workDir) - $runspace.SessionStateProxy.SetVariable("selectedWimIndex", $selectedWimIndex) - $runspace.SessionStateProxy.SetVariable("selectedEditionName", $selectedEditionName) - $runspace.SessionStateProxy.SetVariable("autounattendContent", $autounattendContent) - $runspace.SessionStateProxy.SetVariable("injectDrivers", $injectDrivers) - - $isoScriptFuncDef = "function Invoke-WinUtilISOScript {`n" + ${function:Invoke-WinUtilISOScript}.ToString() + "`n}" - $win11ISOLogFuncDef = "function Write-WinUtilISOLog {`n" + ${function:Write-WinUtilISOLog}.ToString() + "`n}" - $runspace.SessionStateProxy.SetVariable("isoScriptFuncDef", $isoScriptFuncDef) - $runspace.SessionStateProxy.SetVariable("win11ISOLogFuncDef", $win11ISOLogFuncDef) - - $script = [Management.Automation.PowerShell]::Create() - $script.Runspace = $runspace - $script.AddScript({ - . ([scriptblock]::Create($isoScriptFuncDef)) - . ([scriptblock]::Create($win11ISOLogFuncDef)) - - function Log($msg) { - $ts = (Get-Date).ToString("HH:mm:ss") - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFWin11ISOStatusLog"].Text += "`n[$ts] $msg" - $sync["WPFWin11ISOStatusLog"].CaretIndex = $sync["WPFWin11ISOStatusLog"].Text.Length - $sync["WPFWin11ISOStatusLog"].ScrollToEnd() - }) - Add-Content -Path (Join-Path $workDir "WinUtil_Win11ISO.log") -Value "[$ts] $msg" + Start-WinUtilJob -Name "ISO modify" -Description "Modifying ISO" -Parameters @{ + IsoPath = $isoPath + DriveLetter = $driveLetter + WimPath = $wimPath + WorkDir = $workDir + SelectedWimIndex = $selectedWimIndex + SelectedEditionName = $selectedEditionName + AutounattendContent = $autounattendContent + InjectDrivers = $sync["WPFWin11ISOInjectDrivers"].IsChecked -eq $true + } -ScriptBlock { + param($IsoPath, $DriveLetter, $WimPath, $WorkDir, $SelectedWimIndex, $SelectedEditionName, $AutounattendContent, $InjectDrivers) + + Invoke-WPFUIThread -ScriptBlock { + $sync["WPFWin11ISOModifyButton"].IsEnabled = $false + $sync["WPFWin11ISOSelectSection"].Visibility = "Collapsed" + $sync["WPFWin11ISOMountSection"].Visibility = "Collapsed" + $sync["WPFWin11ISOModifySection"].Visibility = "Collapsed" } - function SetProgress($label, $pct) { - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFTweaksProgressBar"].Visibility = "Visible" - $sync["WPFTweaksProgressLabel"].Text = $label - $sync["WPFTweaksProgressLabel"].ToolTip = $label - $sync["WPFTweaksProgressValue"].Value = [Math]::Max($pct, 5) - }) - } + try { + Write-WinUtilISOLog "Selected edition: $SelectedEditionName (Index $SelectedWimIndex)" + Write-WinUtilISOLog "Creating working directory: $WorkDir" - function Get-WinUtilEditionIdFromName { - param([string]$EditionName) - - $normalizedName = ($EditionName -replace '^Windows\s+11\s+', '').Trim() - switch -Regex ($normalizedName) { - '^Home Single Language$' { return 'CoreSingleLanguage' } - '^Home N$' { return 'CoreN' } - '^Home$' { return 'Core' } - '^Pro for Workstations N$' { return 'ProfessionalWorkstationN' } - '^Pro for Workstations$' { return 'ProfessionalWorkstation' } - '^Pro Education N$' { return 'ProfessionalEducationN' } - '^Pro Education$' { return 'ProfessionalEducation' } - '^Pro N$' { return 'ProfessionalN' } - '^Pro$' { return 'Professional' } - '^Education N$' { return 'EducationN' } - '^Education$' { return 'Education' } - '^Enterprise LTSC N$' { return 'EnterpriseSN' } - '^Enterprise LTSC$' { return 'EnterpriseS' } - '^Enterprise N$' { return 'EnterpriseN' } - '^Enterprise$' { return 'Enterprise' } - default { return '' } - } - } + $isoContents = Join-Path $WorkDir "iso_contents" + New-Item -ItemType Directory -Path $isoContents -Force | Out-Null + Write-WinUtilJobProgress -Status "Copying ISO contents..." -Percent 10 - try { - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFWin11ISOSelectSection"].Visibility = "Collapsed" - $sync["WPFWin11ISOMountSection"].Visibility = "Collapsed" - $sync["WPFWin11ISOModifySection"].Visibility = "Collapsed" - }) - - Log "Creating working directory: $workDir" - $isoContents = Join-Path $workDir "iso_contents" - New-Item -ItemType Directory -Path $isoContents -Force - SetProgress "Copying ISO contents..." 10 - - Log "Copying ISO contents from $driveLetter to $isoContents..." - & robocopy $driveLetter $isoContents /E /NFL /NDL /NJH /NJS - Log "ISO contents copied." - SetProgress "Preparing setup media..." 25 - - $sourceImageFileName = Split-Path $wimPath -Leaf + Write-WinUtilISOLog "Copying ISO contents from $DriveLetter to $isoContents..." + & robocopy $DriveLetter $isoContents /E /NFL /NDL /NJH /NJS + Write-WinUtilISOLog "ISO contents copied." + Write-WinUtilJobProgress -Status "Preparing setup media..." -Percent 25 + + $sourceImageFileName = Split-Path $WimPath -Leaf $localWim = Join-Path $isoContents "sources\$sourceImageFileName" if (-not (Test-Path $localWim)) { throw "Copied ISO image file not found: sources\$sourceImageFileName" } - $selectedEditionId = Get-WinUtilEditionIdFromName -EditionName $selectedEditionName - - Log "Writing autounattend.xml and edition selection..." - Invoke-WinUtilISOScript -ISOContentsDir $isoContents -AutoUnattendXml $autounattendContent -InjectCurrentSystemDrivers $injectDrivers -InstallImagePath $localWim -InstallImageIndex $selectedWimIndex -InstallEditionId $selectedEditionId -Log { param($m) Log $m } - SetProgress "Preserving install image..." 70 - if ($injectDrivers) { - Log "Added current-system drivers to $sourceImageFileName index $selectedWimIndex with one mount and commit." + Write-WinUtilISOLog "Writing autounattend.xml and edition selection..." + Invoke-WinUtilISOScript -ISOContentsDir $isoContents ` + -AutoUnattendXml $AutounattendContent ` + -InjectCurrentSystemDrivers $InjectDrivers ` + -InstallImagePath $localWim ` + -InstallImageIndex $SelectedWimIndex ` + -InstallEditionId (Get-WinUtilEditionIdFromName -EditionName $SelectedEditionName) ` + -Log { param($m) Write-WinUtilISOLog $m } + + Write-WinUtilJobProgress -Status "Preserving install image..." -Percent 70 + if ($InjectDrivers) { + Write-WinUtilISOLog "Added current-system drivers to $sourceImageFileName index $SelectedWimIndex with one mount and commit." } else { - Log "Preserved the original $sourceImageFileName without mounting, exporting, or modifying it." + Write-WinUtilISOLog "Preserved the original $sourceImageFileName without mounting, exporting, or modifying it." } - SetProgress "Dismounting source ISO..." 80 - Log "Dismounting original ISO..." - Dismount-DiskImage -ImagePath $isoPath + Write-WinUtilJobProgress -Status "Dismounting source ISO..." -Percent 80 + Write-WinUtilISOLog "Dismounting original ISO..." + Dismount-DiskImage -ImagePath $IsoPath - $sync["Win11ISOWorkDir"] = $workDir + $sync["Win11ISOWorkDir"] = $WorkDir $sync["Win11ISOContentsDir"] = $isoContents - SetProgress "Modification complete" 100 - Log "install.wim modification complete. Choose an output option in Step 4." + Write-WinUtilJobProgress -Status "Modification complete" -Percent 100 + Write-WinUtilISOLog "install.wim modification complete. Choose an output option in Step 4." - $sync["WPFWin11ISOOutputSection"].Dispatcher.Invoke([action]{ + Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOOutputSection"].Visibility = "Visible" - }) + } } catch { - Log "ERROR during modification: $_" + Write-WinUtilISOLog -Level "ERROR" -Message "Modification failed: $_" try { - $mountedISO = Get-DiskImage -ImagePath $isoPath + $mountedISO = Get-DiskImage -ImagePath $IsoPath if ($mountedISO -and $mountedISO.Attached) { - Log "Cleaning up: dismounting source ISO..." - Dismount-DiskImage -ImagePath $isoPath + Write-WinUtilISOLog "Cleaning up: dismounting source ISO..." + Dismount-DiskImage -ImagePath $IsoPath } - } catch { Log "Warning: could not dismount ISO during cleanup: $_" } + } catch { Write-WinUtilISOLog -Level "WARN" -Message "Could not dismount ISO during cleanup: $_" } try { - if (Test-Path $workDir) { - Log "Cleaning up: removing temp directory $workDir..." - Remove-Item -Path $workDir -Recurse -Force + if (Test-Path $WorkDir) { + Write-WinUtilISOLog "Cleaning up: removing temp directory $WorkDir..." + Remove-Item -Path $WorkDir -Recurse -Force } - } catch { Log "Warning: could not remove temp directory during cleanup: $_" } + } catch { Write-WinUtilISOLog -Level "WARN" -Message "Could not remove temp directory during cleanup: $_" } + + Show-WinUtilMessage -Message "An error occurred during install.wim modification:`n`n$_" -Title "Modification Error" -Button "OK" -Icon "Error" | Out-Null - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - [System.Windows.MessageBox]::Show( - "An error occurred during install.wim modification:`n`n$_", - "Modification Error", "OK", "Error") - }) + # Let the job layer mark the run as failed + throw } finally { - Start-Sleep -Milliseconds 800 - $sync["Win11ISOModifying"] = $false - $sync["Win11ISOProcessRunning"] = $false - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFTweaksProgressBar"].Visibility = "Collapsed" - $sync["WPFTweaksProgressLabel"].Text = "" - $sync["WPFTweaksProgressLabel"].ToolTip = "" - $sync["WPFTweaksProgressValue"].Value = 0 + Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOModifyButton"].IsEnabled = $true if ($sync["WPFWin11ISOOutputSection"].Visibility -ne "Visible") { $sync["WPFWin11ISOSelectSection"].Visibility = "Visible" $sync["WPFWin11ISOMountSection"].Visibility = "Visible" $sync["WPFWin11ISOModifySection"].Visibility = "Visible" } - }) + } } - }) - - $script.BeginInvoke() + } } function Invoke-WinUtilISOCheckExistingWork { if ($sync["Win11ISOContentsDir"] -and (Test-Path $sync["Win11ISOContentsDir"])) { return } - # Check if ISO modification is currently in progress - if ($sync["Win11ISOModifying"]) { - return - } + # Nothing to resume while a modification is still producing the working directory + if ($sync.ActiveJob) { return } $existingWorkDir = Get-Item -Path (Join-Path $env:TEMP "WinUtil_Win11ISO*") | Where-Object { $_.PSIsContainer } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 @@ -373,128 +344,97 @@ function Invoke-WinUtilISOCheckExistingWork { Write-WinUtilISOLog "Last modified: $modified - Skipping Steps 1-3 and resuming at Step 4." Write-WinUtilISOLog "Click 'Clean & Reset' if you want to start over with a new ISO." - [System.Windows.MessageBox]::Show( - "A previous WinUtil ISO working directory was found:`n`n$($existingWorkDir.FullName)`n`n(Last modified: $modified)`n`nStep 4 (output options) has been restored so you can save the already-modified image.`n`nClick 'Clean & Reset' in Step 4 if you want to start over.", - "Existing Work Found", "OK", "Info") + Show-WinUtilMessage -Message "A previous WinUtil ISO working directory was found:`n`n$($existingWorkDir.FullName)`n`n(Last modified: $modified)`n`nStep 4 (output options) has been restored so you can save the already-modified image.`n`nClick 'Clean & Reset' in Step 4 if you want to start over." -Title "Existing Work Found" -Button "OK" -Icon "Info" | Out-Null } function Invoke-WinUtilISOCleanAndReset { $workDir = $sync["Win11ISOWorkDir"] if ($workDir -and (Test-Path $workDir)) { - $confirm = [System.Windows.MessageBox]::Show( - "This will delete the temporary working directory:`n`n$workDir`n`nAnd reset the interface back to the start.`n`nContinue?", - "Clean & Reset", "YesNo", "Warning") + $confirm = Show-WinUtilMessage -Message "This will delete the temporary working directory:`n`n$workDir`n`nAnd reset the interface back to the start.`n`nContinue?" -Title "Clean & Reset" -Button "YesNo" -Icon "Warning" if ($confirm -ne "Yes") { return } } - $sync["WPFWin11ISOCleanResetButton"].IsEnabled = $false - $sync["Win11ISOProcessRunning"] = $true - - $runspace = [Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() - $runspace.ApartmentState = "STA" - $runspace.ThreadOptions = "ReuseThread" - $runspace.Open() - $runspace.SessionStateProxy.SetVariable("sync", $sync) - $runspace.SessionStateProxy.SetVariable("workDir", $workDir) - - $script = [Management.Automation.PowerShell]::Create() - $script.Runspace = $runspace - $script.AddScript({ - - function Log($msg) { - $ts = (Get-Date).ToString("HH:mm:ss") - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFWin11ISOStatusLog"].Text += "`n[$ts] $msg" - $sync["WPFWin11ISOStatusLog"].CaretIndex = $sync["WPFWin11ISOStatusLog"].Text.Length - $sync["WPFWin11ISOStatusLog"].ScrollToEnd() - }) - Add-Content -Path (Join-Path $workDir "WinUtil_Win11ISO.log") -Value "[$ts] $msg" - } + Start-WinUtilJob -Name "ISO cleanup" -Description "Cleaning up" -Parameters @{ + WorkDir = $workDir + } -ScriptBlock { + param($WorkDir) - function SetProgress($label, $pct) { - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFTweaksProgressBar"].Visibility = "Visible" - $sync["WPFTweaksProgressLabel"].Text = $label - $sync["WPFTweaksProgressLabel"].ToolTip = $label - $sync["WPFTweaksProgressValue"].Value = [Math]::Max($pct, 5) - }) - } + Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOCleanResetButton"].IsEnabled = $false } try { - if ($workDir) { - $mountDir = Join-Path $workDir "wim_mount" + if ($WorkDir) { + $mountDir = Join-Path $WorkDir "wim_mount" try { - $mountedImages = Get-WindowsImage -Mounted | - Where-Object { $_.Path -like "$workDir*" } + $mountedImages = Get-WindowsImage -Mounted | Where-Object { $_.Path -like "$WorkDir*" } if ($mountedImages) { foreach ($img in $mountedImages) { - Log "Dismounting WIM at: $($img.Path) (discarding changes)..." - SetProgress "Dismounting WIM image..." 3 + Write-WinUtilISOLog "Dismounting WIM at: $($img.Path) (discarding changes)..." + Write-WinUtilJobProgress -Status "Dismounting WIM image..." -Percent 3 Dismount-WindowsImage -Path $img.Path -Discard - Log "WIM dismounted successfully." + Write-WinUtilISOLog "WIM dismounted successfully." } } elseif (Test-Path $mountDir) { - Log "No mounted WIM reported by Get-WindowsImage. Running DISM /Cleanup-Wim as a precaution..." - SetProgress "Running DISM cleanup..." 3 - & dism /English /Cleanup-Wim | ForEach-Object { Log $_ } + Write-WinUtilISOLog "No mounted WIM reported by Get-WindowsImage. Running DISM /Cleanup-Wim as a precaution..." + Write-WinUtilJobProgress -Status "Running DISM cleanup..." -Percent 3 + & dism /English /Cleanup-Wim | ForEach-Object { Write-WinUtilISOLog $_ } } } catch { - Log "Warning: could not dismount WIM cleanly. Attempting DISM /Cleanup-Wim fallback: $_" - try { & dism /English /Cleanup-Wim | ForEach-Object { Log $_ } } - catch { Log "Warning: DISM /Cleanup-Wim also failed: $_" } + Write-WinUtilISOLog -Level "WARN" -Message "Could not dismount WIM cleanly. Attempting DISM /Cleanup-Wim fallback: $_" + try { & dism /English /Cleanup-Wim | ForEach-Object { Write-WinUtilISOLog $_ } } + catch { Write-WinUtilISOLog -Level "WARN" -Message "DISM /Cleanup-Wim also failed: $_" } } } - if ($workDir -and (Test-Path $workDir)) { - Log "Scanning files to delete in: $workDir" - SetProgress "Scanning files..." 5 + if ($WorkDir -and (Test-Path $WorkDir)) { + Write-WinUtilISOLog "Scanning files to delete in: $WorkDir" + Write-WinUtilJobProgress -Status "Scanning files..." -Percent 5 - $allFiles = @(Get-ChildItem -Path $workDir -File -Recurse -Force) - $allDirs = @(Get-ChildItem -Path $workDir -Directory -Recurse -Force | + $allFiles = @(Get-ChildItem -Path $WorkDir -File -Recurse -Force) + $allDirs = @(Get-ChildItem -Path $WorkDir -Directory -Recurse -Force | Sort-Object { $_.FullName.Length } -Descending) $total = $allFiles.Count $deleted = 0 - Log "Found $total files to delete." + Write-WinUtilISOLog "Found $total files to delete." foreach ($f in $allFiles) { - try { Remove-Item -Path $f.FullName -Force } catch { Log "WARNING: could not delete $($f.FullName): $_" } + try { Remove-Item -Path $f.FullName -Force } catch { Write-WinUtilISOLog -Level "WARN" -Message "Could not delete $($f.FullName): $_" } $deleted++ if ($deleted % 100 -eq 0 -or $deleted -eq $total) { $pct = [math]::Round(($deleted / [Math]::Max($total, 1)) * 85) + 5 - SetProgress "Deleting files in $($f.Directory.Name)... ($deleted / $total)" $pct + Write-WinUtilJobProgress -Status "Deleting files in $($f.Directory.Name)... ($deleted / $total)" -Percent $pct } } foreach ($d in $allDirs) { - try { Remove-Item -Path $d.FullName -Force } catch { Log "WARNING: could not delete $($d.FullName): $_" } + try { Remove-Item -Path $d.FullName -Force } catch { Write-WinUtilISOLog -Level "WARN" -Message "Could not delete $($d.FullName): $_" } } - try { Remove-Item -Path $workDir -Recurse -Force } catch { Log "WARNING: could not delete temp directory ${workDir}: $_" } + try { Remove-Item -Path $WorkDir -Recurse -Force } catch { Write-WinUtilISOLog -Level "WARN" -Message "Could not delete temp directory ${WorkDir}: $_" } - if (Test-Path $workDir) { - Log "WARNING: some items could not be deleted in $workDir" + if (Test-Path $WorkDir) { + Write-WinUtilISOLog -Level "WARN" -Message "Some items could not be deleted in $WorkDir" } else { - Log "Temp directory deleted successfully." + Write-WinUtilISOLog "Temp directory deleted successfully." } } else { - Log "No temp directory found - resetting UI." + Write-WinUtilISOLog "No temp directory found - resetting UI." } - SetProgress "Resetting UI..." 95 - Log "Resetting interface..." + Write-WinUtilJobProgress -Status "Resetting UI..." -Percent 95 + Write-WinUtilISOLog "Resetting interface..." - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["Win11ISOWorkDir"] = $null - $sync["Win11ISOContentsDir"] = $null - $sync["Win11ISOImagePath"] = $null - $sync["Win11ISODriveLetter"] = $null - $sync["Win11ISOWimPath"] = $null - $sync["Win11ISOImageInfo"] = $null - $sync["Win11ISOUSBDisks"] = $null + $sync["Win11ISOWorkDir"] = $null + $sync["Win11ISOContentsDir"] = $null + $sync["Win11ISOImagePath"] = $null + $sync["Win11ISODriveLetter"] = $null + $sync["Win11ISOWimPath"] = $null + $sync["Win11ISOImageInfo"] = $null + $sync["Win11ISOUSBDisks"] = $null - $sync["WPFWin11ISOPath"].Text = "No ISO selected..." + Invoke-WPFUIThread -ScriptBlock { + $sync["WPFWin11ISOPath"].Text = "No ISO selected..." $sync["WPFWin11ISOFileInfo"].Visibility = "Collapsed" $sync["WPFWin11ISOVerifyResultPanel"].Visibility = "Collapsed" $sync["WPFWin11ISOOptionUSB"].Visibility = "Collapsed" @@ -503,39 +443,20 @@ function Invoke-WinUtilISOCleanAndReset { $sync["WPFWin11ISOMountSection"].Visibility = "Collapsed" $sync["WPFWin11ISOSelectSection"].Visibility = "Visible" $sync["WPFWin11ISOModifyButton"].IsEnabled = $true - $sync["WPFWin11ISOCleanResetButton"].IsEnabled = $true - - $sync["WPFTweaksProgressBar"].Visibility = "Collapsed" - $sync["WPFTweaksProgressLabel"].Text = "" - $sync["WPFTweaksProgressLabel"].ToolTip = "" - $sync["WPFTweaksProgressValue"].Value = 0 - - $sync["WPFWin11ISOStatusLog"].Text = "Ready. Please select a Windows 11 ISO to begin." - }) - } catch { - Log "ERROR during Clean & Reset: $_" - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFTweaksProgressBar"].Visibility = "Collapsed" - $sync["WPFTweaksProgressLabel"].Text = "" - $sync["WPFTweaksProgressLabel"].ToolTip = "" - $sync["WPFTweaksProgressValue"].Value = 0 - $sync["WPFWin11ISOCleanResetButton"].IsEnabled = $true - }) + $sync["WPFWin11ISOStatusLog"].Text = "Ready. Please select a Windows 11 ISO to begin." + } + Write-WinUtilJobProgress -Hide } finally { - $sync["Win11ISOProcessRunning"] = $false + Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOCleanResetButton"].IsEnabled = $true } } - }) - - $script.BeginInvoke() + } } function Invoke-WinUtilISOExport { $contentsDir = $sync["Win11ISOContentsDir"] if (-not $contentsDir -or -not (Test-Path $contentsDir)) { - [System.Windows.MessageBox]::Show( - "No modified ISO content found. Please complete Steps 1-3 first.", - "Not Ready", "OK", "Warning") + Show-WinUtilMessage -Message "No modified ISO content found. Please complete Steps 1-3 first." -Title "Not Ready" -Button "OK" -Icon "Warning" | Out-Null return } @@ -549,78 +470,26 @@ function Invoke-WinUtilISOExport { if ($dlg.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { return } - $outputISO = $dlg.FileName + Start-WinUtilJob -Name "ISO export" -Description "Building ISO" -Parameters @{ + ContentsDir = $contentsDir + OutputISO = $dlg.FileName + } -ScriptBlock { + param($ContentsDir, $OutputISO) - # Locate oscdimg.exe (Windows ADK or winget per-user install) - $oscdimg = Get-ChildItem "C:\Program Files (x86)\Windows Kits" -Recurse -Filter "oscdimg.exe" | - Select-Object -First 1 -ExpandProperty FullName - if (-not $oscdimg) { - $oscdimg = Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WinGet\Packages" -Recurse -Filter "oscdimg.exe" | - Where-Object { $_.FullName -match 'Microsoft\.OSCDIMG' } | - Select-Object -First 1 -ExpandProperty FullName - } + Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOChooseISOButton"].IsEnabled = $false } - if (-not $oscdimg) { - Write-WinUtilISOLog "oscdimg.exe not found. Attempting to install via winget..." try { - # First ensure winget is installed and operational - Install-WinUtilWinget - - $winget = Get-Command winget - $result = & $winget install -e --id Microsoft.OSCDIMG --accept-package-agreements --accept-source-agreements - Write-WinUtilISOLog "winget output: $result" - $oscdimg = Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WinGet\Packages" -Recurse -Filter "oscdimg.exe" | - Where-Object { $_.FullName -match 'Microsoft\.OSCDIMG' } | - Select-Object -First 1 -ExpandProperty FullName - } catch { - Write-WinUtilISOLog "winget not available or install failed: $_" - } - - if (-not $oscdimg) { - Write-WinUtilISOLog "oscdimg.exe still not found after install attempt." - [System.Windows.MessageBox]::Show( - "oscdimg.exe could not be found or installed automatically.`n`nPlease install it manually:`n winget install -e --id Microsoft.OSCDIMG`n`nOr install the Windows ADK from:`nhttps://learn.microsoft.com/windows-hardware/get-started/adk-install", - "oscdimg Not Found", "OK", "Warning") - return - } - Write-WinUtilISOLog "oscdimg.exe installed successfully." - } - - $sync["WPFWin11ISOChooseISOButton"].IsEnabled = $false - $sync["Win11ISOProcessRunning"] = $true - - $runspace = [Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() - $runspace.ApartmentState = "STA" - $runspace.ThreadOptions = "ReuseThread" - $runspace.Open() - $runspace.SessionStateProxy.SetVariable("sync", $sync) - $runspace.SessionStateProxy.SetVariable("contentsDir", $contentsDir) - $runspace.SessionStateProxy.SetVariable("outputISO", $outputISO) - $runspace.SessionStateProxy.SetVariable("oscdimg", $oscdimg) - - $win11ISOLogFuncDef = "function Write-WinUtilISOLog {`n" + ${function:Write-WinUtilISOLog}.ToString() + "`n}" - $runspace.SessionStateProxy.SetVariable("win11ISOLogFuncDef", $win11ISOLogFuncDef) - - $script = [Management.Automation.PowerShell]::Create() - $script.Runspace = $runspace - $script.AddScript({ - . ([scriptblock]::Create($win11ISOLogFuncDef)) - - function SetProgress($label, $pct) { - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFTweaksProgressBar"].Visibility = "Visible" - $sync["WPFTweaksProgressLabel"].Text = $label - $sync["WPFTweaksProgressLabel"].ToolTip = $label - $sync["WPFTweaksProgressValue"].Value = [Math]::Max($pct, 5) - }) - } + $oscdimg = Get-WinUtilOscdimgPath + if (-not $oscdimg) { + Show-WinUtilMessage -Message "oscdimg.exe could not be found or installed automatically.`n`nPlease install it manually:`n winget install -e --id Microsoft.OSCDIMG`n`nOr install the Windows ADK from:`nhttps://learn.microsoft.com/windows-hardware/get-started/adk-install" -Title "oscdimg Not Found" -Button "OK" -Icon "Warning" | Out-Null + return + } - try { - Write-WinUtilISOLog "Exporting to ISO: $outputISO" - SetProgress "Building ISO..." 10 + Write-WinUtilISOLog "Exporting to ISO: $OutputISO" + Write-WinUtilJobProgress -Status "Building ISO..." -Percent 10 - $bootData = "2#p0,e,b`"$contentsDir\boot\etfsboot.com`"#pEF,e,b`"$contentsDir\efi\microsoft\boot\efisys.bin`"" - $oscdimgArgs = @("-m", "-o", "-u2", "-udfver102", "-bootdata:$bootData", "-l`"CTOS_MODIFIED`"", "`"$contentsDir`"", "`"$outputISO`"") + $bootData = "2#p0,e,b`"$ContentsDir\boot\etfsboot.com`"#pEF,e,b`"$ContentsDir\efi\microsoft\boot\efisys.bin`"" + $oscdimgArgs = @("-m", "-o", "-u2", "-udfver102", "-bootdata:$bootData", "-l`"CTOS_MODIFIED`"", "`"$ContentsDir`"", "`"$OutputISO`"") Write-WinUtilISOLog "Running oscdimg..." @@ -634,7 +503,7 @@ function Invoke-WinUtilISOExport { $proc = [System.Diagnostics.Process]::new() $proc.StartInfo = $psi - $proc.Start() + $proc.Start() | Out-Null # Stream stdout line-by-line as oscdimg runs while (-not $proc.StandardOutput.EndOfStream) { @@ -647,40 +516,60 @@ function Invoke-WinUtilISOExport { # Flush any stderr after process exits $stderr = $proc.StandardError.ReadToEnd() foreach ($line in ($stderr -split "`r?`n")) { - if ($line.Trim()) { Write-WinUtilISOLog "[stderr]$line" } + if ($line.Trim()) { Write-WinUtilISOLog -Level "WARN" -Message "[stderr]$line" } } - if ($proc.ExitCode -eq 0) { - SetProgress "ISO exported" 100 - Write-WinUtilISOLog "ISO exported successfully: $outputISO" - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - [System.Windows.MessageBox]::Show("ISO exported successfully!`n`n$outputISO", "Export Complete", "OK", "Info") - }) - } else { - Write-WinUtilISOLog "oscdimg exited with code $($proc.ExitCode)." - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - [System.Windows.MessageBox]::Show( - "oscdimg exited with code $($proc.ExitCode).`nCheck the status log for details.", - "Export Error", "OK", "Error") - }) + if ($proc.ExitCode -ne 0) { + throw "oscdimg exited with code $($proc.ExitCode). Check the status log for details." } + + Write-WinUtilJobProgress -Status "ISO exported" -Percent 100 + Write-WinUtilISOLog "ISO exported successfully: $OutputISO" + Show-WinUtilMessage -Message "ISO exported successfully!`n`n$OutputISO" -Title "Export Complete" -Button "OK" -Icon "Info" | Out-Null } catch { - Write-WinUtilISOLog "ERROR during ISO export: $_" - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - [System.Windows.MessageBox]::Show("ISO export failed:`n`n$_", "Error", "OK", "Error") - }) + Write-WinUtilISOLog -Level "ERROR" -Message "ISO export failed: $_" + Show-WinUtilMessage -Message "ISO export failed:`n`n$_" -Title "Error" -Button "OK" -Icon "Error" | Out-Null + throw } finally { - Start-Sleep -Milliseconds 800 - $sync["Win11ISOProcessRunning"] = $false - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFTweaksProgressBar"].Visibility = "Collapsed" - $sync["WPFTweaksProgressLabel"].Text = "" - $sync["WPFTweaksProgressLabel"].ToolTip = "" - $sync["WPFTweaksProgressValue"].Value = 0 - $sync["WPFWin11ISOChooseISOButton"].IsEnabled = $true - }) + Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOChooseISOButton"].IsEnabled = $true } } - }) + } +} + +function Get-WinUtilOscdimgPath { + <# + .SYNOPSIS + Returns the path to oscdimg.exe, installing it through winget when it is missing. + #> + + $oscdimg = Get-ChildItem "C:\Program Files (x86)\Windows Kits" -Recurse -Filter "oscdimg.exe" -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $oscdimg) { + $oscdimg = Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WinGet\Packages" -Recurse -Filter "oscdimg.exe" -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match 'Microsoft\.OSCDIMG' } | + Select-Object -First 1 -ExpandProperty FullName + } + if ($oscdimg) { return $oscdimg } + + Write-WinUtilISOLog "oscdimg.exe not found. Attempting to install via winget..." + try { + # First ensure winget is installed and operational + Install-WinUtilWinget - $script.BeginInvoke() + $winget = Get-Command winget + $result = & $winget install -e --id Microsoft.OSCDIMG --accept-package-agreements --accept-source-agreements + Write-WinUtilISOLog "winget output: $result" + $oscdimg = Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WinGet\Packages" -Recurse -Filter "oscdimg.exe" -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match 'Microsoft\.OSCDIMG' } | + Select-Object -First 1 -ExpandProperty FullName + } catch { + Write-WinUtilISOLog -Level "WARN" -Message "winget not available or install failed: $_" + } + + if ($oscdimg) { + Write-WinUtilISOLog "oscdimg.exe installed successfully." + } else { + Write-WinUtilISOLog -Level "ERROR" -Message "oscdimg.exe still not found after install attempt." + } + return $oscdimg } diff --git a/functions/private/Invoke-WinUtilISOUSB.ps1 b/functions/private/Invoke-WinUtilISOUSB.ps1 index f080450eed..8f54b0e5fc 100644 --- a/functions/private/Invoke-WinUtilISOUSB.ps1 +++ b/functions/private/Invoke-WinUtilISOUSB.ps1 @@ -21,25 +21,34 @@ function Invoke-WinUtilISORefreshUSBDrives { $sync["Win11ISOUSBDisks"] = $removable } +function Get-WinUtilFreeDriveLetter { + <# + .SYNOPSIS + Returns the first unused drive letter between D and Z, or $null when there is none. + #> + + $used = (Get-PSDrive -PSProvider FileSystem).Name + foreach ($c in [char[]](68..90)) { + if ($used -notcontains [string]$c) { return $c } + } + return $null +} + function Invoke-WinUtilISOWriteUSB { $contentsDir = $sync["Win11ISOContentsDir"] $usbDisks = $sync["Win11ISOUSBDisks"] if (-not $contentsDir -or -not (Test-Path $contentsDir)) { - [System.Windows.MessageBox]::Show("No modified ISO content found. Please complete Steps 1-3 first.", "Not Ready", "OK", "Warning") + Show-WinUtilMessage -Message "No modified ISO content found. Please complete Steps 1-3 first." -Title "Not Ready" -Button "OK" -Icon "Warning" | Out-Null return } - $installWim = Join-Path $contentsDir "sources\install.wim" $installEsd = Join-Path $contentsDir "sources\install.esd" if (Test-Path $installEsd) { - $installEsdFile = Get-Item $installEsd - $esdSizeBytes = $installEsdFile.Length - $esdSizeMB = [math]::Ceiling($esdSizeBytes / 1MB) + $esdSizeBytes = (Get-Item $installEsd).Length if ($esdSizeBytes -ge 4GB) { - [System.Windows.MessageBox]::Show( - "This ISO uses an install.esd file that is $esdSizeMB MB. WinUtil's FAT32 USB format cannot store files larger than 4 GB.`n`nExport an ISO instead or use media with install.wim.", - "USB Creation Not Supported", "OK", "Warning") + $esdSizeMB = [math]::Ceiling($esdSizeBytes / 1MB) + Show-WinUtilMessage -Message "This ISO uses an install.esd file that is $esdSizeMB MB. WinUtil's FAT32 USB format cannot store files larger than 4 GB.`n`nExport an ISO instead or use media with install.wim." -Title "USB Creation Not Supported" -Button "OK" -Icon "Warning" | Out-Null return } } @@ -58,95 +67,59 @@ function Invoke-WinUtilISOWriteUSB { } if (-not $targetDisk) { - [System.Windows.MessageBox]::Show("Please select a USB drive from the dropdown.", "No Drive Selected", "OK", "Warning") + Show-WinUtilMessage -Message "Please select a USB drive from the dropdown." -Title "No Drive Selected" -Button "OK" -Icon "Warning" | Out-Null return } - $diskNum = $targetDisk.Number - $sizeGB = [math]::Round($targetDisk.Size / 1GB, 1) - - $confirm = [System.Windows.MessageBox]::Show( - "ALL data on Disk $diskNum ($($targetDisk.FriendlyName), $sizeGB GB) will be PERMANENTLY ERASED.`n`nAre you sure you want to continue?", - "Confirm USB Erase", "YesNo", "Warning") + $diskNum = $targetDisk.Number + $sizeGB = [math]::Round($targetDisk.Size / 1GB, 1) + $confirm = Show-WinUtilMessage -Message "ALL data on Disk $diskNum ($($targetDisk.FriendlyName), $sizeGB GB) will be PERMANENTLY ERASED.`n`nAre you sure you want to continue?" -Title "Confirm USB Erase" -Button "YesNo" -Icon "Warning" if ($confirm -ne "Yes") { Write-WinUtilISOLog "USB write cancelled by user." return } - $sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $false - $sync["Win11ISOProcessRunning"] = $true - Write-WinUtilISOLog "Starting USB write to Disk $diskNum..." - - $runspace = [Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() - $runspace.ApartmentState = "STA" - $runspace.ThreadOptions = "ReuseThread" - $runspace.Open() - $runspace.SessionStateProxy.SetVariable("sync", $sync) - $runspace.SessionStateProxy.SetVariable("diskNum", $diskNum) - $runspace.SessionStateProxy.SetVariable("contentsDir", $contentsDir) - - $script = [Management.Automation.PowerShell]::Create() - $script.Runspace = $runspace - $script.AddScript({ - - function Log($msg) { - $ts = (Get-Date).ToString("HH:mm:ss") - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFWin11ISOStatusLog"].Text += "`n[$ts] $msg" - $sync["WPFWin11ISOStatusLog"].CaretIndex = $sync["WPFWin11ISOStatusLog"].Text.Length - $sync["WPFWin11ISOStatusLog"].ScrollToEnd() - }) - } + Start-WinUtilJob -Name "USB write" -Description "Writing USB drive" -Parameters @{ + DiskNumber = $diskNum + ContentsDir = $contentsDir + } -ScriptBlock { + param($DiskNumber, $ContentsDir) - function SetProgress($label, $pct) { - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFTweaksProgressBar"].Visibility = "Visible" - $sync["WPFTweaksProgressLabel"].Text = $label - $sync["WPFTweaksProgressLabel"].ToolTip = $label - $sync["WPFTweaksProgressValue"].Value = [Math]::Max($pct, 5) - }) - } - - function Get-FreeDriveLetter { - $used = (Get-PSDrive -PSProvider FileSystem).Name - foreach ($c in [char[]](68..90)) { - if ($used -notcontains [string]$c) { return $c } - } - return $null - } + Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $false } try { - SetProgress "Formatting USB drive..." 10 + Write-WinUtilISOLog "Starting USB write to Disk $DiskNumber..." + Write-WinUtilJobProgress -Status "Formatting USB drive..." -Percent 10 # Phase 1: Clean disk via diskpart (retry once if the drive is not yet ready) $dpFile1 = Join-Path $env:TEMP "winutil_diskpart_$(Get-Random).txt" - "select disk $diskNum`nclean`nexit" | Set-Content -Path $dpFile1 -Encoding ASCII - Log "Running diskpart clean on Disk $diskNum..." + "select disk $DiskNumber`nclean`nexit" | Set-Content -Path $dpFile1 -Encoding ASCII + Write-WinUtilISOLog "Running diskpart clean on Disk $DiskNumber..." $dpCleanOut = diskpart /s $dpFile1 - $dpCleanOut | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" } + $dpCleanOut | Where-Object { $_ -match '\S' } | ForEach-Object { Write-WinUtilISOLog " diskpart: $_" } Remove-Item $dpFile1 -Force if (($dpCleanOut -join ' ') -match 'device is not ready') { - Log "Disk $diskNum was not ready; waiting 5 seconds and retrying clean..." + Write-WinUtilISOLog "Disk $DiskNumber was not ready; waiting 5 seconds and retrying clean..." Start-Sleep -Seconds 5 - Update-Disk -Number $diskNum + Update-Disk -Number $DiskNumber $dpFile1b = Join-Path $env:TEMP "winutil_diskpart_$(Get-Random).txt" - "select disk $diskNum`nclean`nexit" | Set-Content -Path $dpFile1b -Encoding ASCII - diskpart /s $dpFile1b | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" } + "select disk $DiskNumber`nclean`nexit" | Set-Content -Path $dpFile1b -Encoding ASCII + diskpart /s $dpFile1b | Where-Object { $_ -match '\S' } | ForEach-Object { Write-WinUtilISOLog " diskpart: $_" } Remove-Item $dpFile1b -Force } # Phase 2: Initialize as GPT Start-Sleep -Seconds 2 - Update-Disk -Number $diskNum - $diskObj = Get-Disk -Number $diskNum + Update-Disk -Number $DiskNumber + $diskObj = Get-Disk -Number $DiskNumber if ($diskObj.PartitionStyle -eq 'RAW') { - Initialize-Disk -Number $diskNum -PartitionStyle GPT - Log "Disk $diskNum initialized as GPT." + Initialize-Disk -Number $DiskNumber -PartitionStyle GPT + Write-WinUtilISOLog "Disk $DiskNumber initialized as GPT." } else { - Set-Disk -Number $diskNum -PartitionStyle GPT - Log "Disk $diskNum converted to GPT (was $($diskObj.PartitionStyle))." + Set-Disk -Number $DiskNumber -PartitionStyle GPT + Write-WinUtilISOLog "Disk $DiskNumber converted to GPT (was $($diskObj.PartitionStyle))." } # Phase 3: Create FAT32 partition via diskpart, then format with Format-Volume @@ -154,66 +127,66 @@ function Invoke-WinUtilISOWriteUSB { $volLabel = "W11-" + (Get-Date).ToString('yyMMdd') $dpFile2 = Join-Path $env:TEMP "winutil_diskpart2_$(Get-Random).txt" $maxFat32PartitionMB = 32768 - $diskSizeMB = [int][Math]::Floor((Get-Disk -Number $diskNum).Size / 1MB) + $diskSizeMB = [int][Math]::Floor((Get-Disk -Number $DiskNumber).Size / 1MB) $createPartitionCommand = "create partition primary" if ($diskSizeMB -gt $maxFat32PartitionMB) { $createPartitionCommand = "create partition primary size=$maxFat32PartitionMB" - Log "Disk $diskNum is $diskSizeMB MB; creating FAT32 partition capped at $maxFat32PartitionMB MB (32 GB)." + Write-WinUtilISOLog "Disk $DiskNumber is $diskSizeMB MB; creating FAT32 partition capped at $maxFat32PartitionMB MB (32 GB)." } @( - "select disk $diskNum" + "select disk $DiskNumber" $createPartitionCommand "exit" ) | Set-Content -Path $dpFile2 -Encoding ASCII - Log "Creating partitions on Disk $diskNum..." - diskpart /s $dpFile2 | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" } + Write-WinUtilISOLog "Creating partitions on Disk $DiskNumber..." + diskpart /s $dpFile2 | Where-Object { $_ -match '\S' } | ForEach-Object { Write-WinUtilISOLog " diskpart: $_" } Remove-Item $dpFile2 -Force - SetProgress "Formatting USB partition..." 25 + Write-WinUtilJobProgress -Status "Formatting USB partition..." -Percent 25 Start-Sleep -Seconds 3 - Update-Disk -Number $diskNum + Update-Disk -Number $DiskNumber - $partitions = Get-Partition -DiskNumber $diskNum - Log "Partitions on Disk $diskNum after creation: $($partitions.Count)" + $partitions = Get-Partition -DiskNumber $DiskNumber + Write-WinUtilISOLog "Partitions on Disk $DiskNumber after creation: $($partitions.Count)" foreach ($p in $partitions) { - Log " Partition $($p.PartitionNumber) Type=$($p.Type) Letter=$($p.DriveLetter) Size=$([math]::Round($p.Size/1MB))MB" + Write-WinUtilISOLog " Partition $($p.PartitionNumber) Type=$($p.Type) Letter=$($p.DriveLetter) Size=$([math]::Round($p.Size/1MB))MB" } $winpePart = $partitions | Where-Object { $_.Type -eq "Basic" } | Select-Object -Last 1 if (-not $winpePart) { - throw "Could not find the Basic partition on Disk $diskNum after creation." + throw "Could not find the Basic partition on Disk $DiskNumber after creation." } # Format using Format-Volume (reliable on fresh drives; diskpart format fails # with 'no volume selected' when the partition has never been formatted before) - Log "Formatting Partition $($winpePart.PartitionNumber) as FAT32 (label: $volLabel)..." - Get-Partition -DiskNumber $diskNum -PartitionNumber $winpePart.PartitionNumber | + Write-WinUtilISOLog "Formatting Partition $($winpePart.PartitionNumber) as FAT32 (label: $volLabel)..." + Get-Partition -DiskNumber $DiskNumber -PartitionNumber $winpePart.PartitionNumber | Format-Volume -FileSystem FAT32 -NewFileSystemLabel $volLabel -Force -Confirm:$false - Log "Partition $($winpePart.PartitionNumber) formatted as FAT32." + Write-WinUtilISOLog "Partition $($winpePart.PartitionNumber) formatted as FAT32." - SetProgress "Assigning drive letters..." 30 + Write-WinUtilJobProgress -Status "Assigning drive letters..." -Percent 30 Start-Sleep -Seconds 2 - Update-Disk -Number $diskNum + Update-Disk -Number $DiskNumber - try { Remove-PartitionAccessPath -DiskNumber $diskNum -PartitionNumber $winpePart.PartitionNumber -AccessPath "$($winpePart.DriveLetter):" } catch { Log "Warning: could not remove existing partition access path: $_" } - $usbLetter = Get-FreeDriveLetter + try { Remove-PartitionAccessPath -DiskNumber $DiskNumber -PartitionNumber $winpePart.PartitionNumber -AccessPath "$($winpePart.DriveLetter):" } catch { Write-WinUtilISOLog -Level "WARN" -Message "Could not remove existing partition access path: $_" } + $usbLetter = Get-WinUtilFreeDriveLetter if (-not $usbLetter) { throw "No free drive letters (D-Z) available to assign to the USB data partition." } - Set-Partition -DiskNumber $diskNum -PartitionNumber $winpePart.PartitionNumber -NewDriveLetter $usbLetter - Log "Assigned drive letter $usbLetter to WINPE partition (Partition $($winpePart.PartitionNumber))." + Set-Partition -DiskNumber $DiskNumber -PartitionNumber $winpePart.PartitionNumber -NewDriveLetter $usbLetter + Write-WinUtilISOLog "Assigned drive letter $usbLetter to WINPE partition (Partition $($winpePart.PartitionNumber))." Start-Sleep -Seconds 2 $usbDrive = "${usbLetter}:" $retries = 0 while (-not (Test-Path $usbDrive) -and $retries -lt 6) { $retries++ - Log "Waiting for $usbDrive to become accessible (attempt $retries/6)..." + Write-WinUtilISOLog "Waiting for $usbDrive to become accessible (attempt $retries/6)..." Start-Sleep -Seconds 2 } if (-not (Test-Path $usbDrive)) { throw "Drive $usbDrive is not accessible after letter assignment." } - Log "USB data partition: $usbDrive" + Write-WinUtilISOLog "USB data partition: $usbDrive" - $contentSizeBytes = (Get-ChildItem -LiteralPath $contentsDir -File -Recurse -Force | Measure-Object -Property Length -Sum).Sum + $contentSizeBytes = (Get-ChildItem -LiteralPath $ContentsDir -File -Recurse -Force | Measure-Object -Property Length -Sum).Sum if (-not $contentSizeBytes) { $contentSizeBytes = 0 } $usbVolume = Get-Volume -DriveLetter $usbLetter $partitionCapacityBytes = [int64]$usbVolume.Size @@ -223,7 +196,7 @@ function Invoke-WinUtilISOWriteUSB { $partitionCapacityGB = [math]::Round($partitionCapacityBytes / 1GB, 2) $partitionFreeGB = [math]::Round($partitionFreeBytes / 1GB, 2) - Log "Source content size: $contentSizeGB GB. USB partition capacity: $partitionCapacityGB GB, free: $partitionFreeGB GB." + Write-WinUtilISOLog "Source content size: $contentSizeGB GB. USB partition capacity: $partitionCapacityGB GB, free: $partitionFreeGB GB." if ($contentSizeBytes -gt $partitionCapacityBytes) { throw "ISO content ($contentSizeGB GB) is larger than the USB partition capacity ($partitionCapacityGB GB). Use a larger USB drive or reduce image size." @@ -233,55 +206,40 @@ function Invoke-WinUtilISOWriteUSB { throw "Insufficient free space on USB partition. Required: $contentSizeGB GB, available: $partitionFreeGB GB." } - SetProgress "Copying Windows 11 files to USB..." 45 + Write-WinUtilJobProgress -Status "Copying Windows 11 files to USB..." -Percent 45 # Copy files; split install.wim if > 4 GB (FAT32 limit) - $installWim = Join-Path $contentsDir "sources\install.wim" + $installWim = Join-Path $ContentsDir "sources\install.wim" if (Test-Path $installWim) { $wimSizeMB = [math]::Round((Get-Item $installWim).Length / 1MB) if ($wimSizeMB -gt 3800) { - Log "install.wim is $wimSizeMB MB - splitting for FAT32 compatibility... This will take several minutes." + Write-WinUtilISOLog "install.wim is $wimSizeMB MB - splitting for FAT32 compatibility... This will take several minutes." Set-ItemProperty -LiteralPath $installWim -Name IsReadOnly -Value $false $splitDest = Join-Path $usbDrive "sources\install.swm" - New-Item -ItemType Directory -Path (Split-Path $splitDest) -Force + New-Item -ItemType Directory -Path (Split-Path $splitDest) -Force | Out-Null Split-WindowsImage -ImagePath $installWim -SplitImagePath $splitDest -FileSize 3800 -CheckIntegrity - Log "install.wim split complete." - Log "Copying remaining files to USB..." - & robocopy $contentsDir $usbDrive /E /XF install.wim /NFL /NDL /NJH /NJS + Write-WinUtilISOLog "install.wim split complete." + Write-WinUtilISOLog "Copying remaining files to USB..." + & robocopy $ContentsDir $usbDrive /E /XF install.wim /NFL /NDL /NJH /NJS } else { - & robocopy $contentsDir $usbDrive /E /NFL /NDL /NJH /NJS + & robocopy $ContentsDir $usbDrive /E /NFL /NDL /NJH /NJS } } else { - & robocopy $contentsDir $usbDrive /E /NFL /NDL /NJH /NJS + & robocopy $ContentsDir $usbDrive /E /NFL /NDL /NJH /NJS } - SetProgress "Finalising USB drive..." 90 - Log "Files copied to USB." - SetProgress "USB write complete" 100 - Log "USB drive is ready for use." + Write-WinUtilJobProgress -Status "Finalising USB drive..." -Percent 90 + Write-WinUtilISOLog "Files copied to USB." + Write-WinUtilJobProgress -Status "USB write complete" -Percent 100 + Write-WinUtilISOLog "USB drive is ready for use." - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - [System.Windows.MessageBox]::Show( - "USB drive created successfully!`n`nYou can now boot from this drive to install Windows 11.", - "USB Ready", "OK", "Info") - }) + Show-WinUtilMessage -Message "USB drive created successfully!`n`nYou can now boot from this drive to install Windows 11." -Title "USB Ready" -Button "OK" -Icon "Info" | Out-Null } catch { - Log "ERROR during USB write: $_" - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - [System.Windows.MessageBox]::Show("USB write failed:`n`n$_", "USB Write Error", "OK", "Error") - }) + Write-WinUtilISOLog -Level "ERROR" -Message "USB write failed: $_" + Show-WinUtilMessage -Message "USB write failed:`n`n$_" -Title "USB Write Error" -Button "OK" -Icon "Error" | Out-Null + throw } finally { - Start-Sleep -Milliseconds 800 - $sync["Win11ISOProcessRunning"] = $false - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFTweaksProgressBar"].Visibility = "Collapsed" - $sync["WPFTweaksProgressLabel"].Text = "" - $sync["WPFTweaksProgressLabel"].ToolTip = "" - $sync["WPFTweaksProgressValue"].Value = 0 - $sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $true - }) + Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $true } } - }) - - $script.BeginInvoke() + } } diff --git a/functions/private/Set-WinUtilTweaksProgressIndicator.ps1 b/functions/private/Set-WinUtilTweaksProgressIndicator.ps1 deleted file mode 100644 index 9d17742d66..0000000000 --- a/functions/private/Set-WinUtilTweaksProgressIndicator.ps1 +++ /dev/null @@ -1,35 +0,0 @@ -function Set-WinUtilTweaksProgressIndicator { - <# - .SYNOPSIS - Shows, updates, or hides the window-level progress indicator used by long-running - workflows such as app management, Tweaks, AppX management, and Win11 Creator. - It lives outside the TabControl, so it stays visible no matter which tab is active. - .PARAMETER Visible - Whether the indicator should be shown or hidden. - .PARAMETER Label - The text to display above the progress bar. - .PARAMETER Percent - The percentage of the progress bar that should be filled (0-100). - #> - param( - [bool]$Visible, - [string]$Label, - [ValidateRange(0,100)] - [int]$Percent - ) - - $indicatorVisible = if ($Visible) { [Windows.Visibility]::Visible } else { [Windows.Visibility]::Collapsed } - $indicatorLabel = $Label - $hasLabel = $PSBoundParameters.ContainsKey('Label') - $hasPercent = $PSBoundParameters.ContainsKey('Percent') - - Invoke-WPFUIThread -ScriptBlock { - $sync.WPFTweaksProgressBar.Visibility = $indicatorVisible - if ($hasLabel) { - $sync.WPFTweaksProgressLabel.Text = $indicatorLabel - } - if ($hasPercent) { - $sync.WPFTweaksProgressValue.Value = $Percent - } - } -} diff --git a/functions/private/Show-WinUtilMessage.ps1 b/functions/private/Show-WinUtilMessage.ps1 index f52256ab3d..e42cb9c615 100644 --- a/functions/private/Show-WinUtilMessage.ps1 +++ b/functions/private/Show-WinUtilMessage.ps1 @@ -2,6 +2,11 @@ function Show-WinUtilMessage { <# .SYNOPSIS Shows a WinUtil message box and returns the selected result. + + .DESCRIPTION + Message boxes need the interface thread, so this marshals onto it and can therefore be + called from a job body as well as from an event handler. Every prompt is also written to + the session log so the log shows what the user was asked and not just what happened next. #> param ( [string]$Message, @@ -10,5 +15,20 @@ function Show-WinUtilMessage { $Icon = "Information" ) - [System.Windows.MessageBox]::Show($Message, $Title, $Button, $Icon) + Write-WinUtilLog -Component "Dialog" -Message "$Title : $($Message -replace '\r?\n', ' ')" + + if ($null -eq $sync.Form -or $null -eq $sync.Form.Dispatcher) { + return [System.Windows.MessageBox]::Show($Message, $Title, $Button, $Icon) + } + + return Invoke-WPFUIThread -Parameters @{ + Message = $Message + Title = $Title + Button = $Button + Icon = $Icon + } -ScriptBlock { + param($Message, $Title, $Button, $Icon) + + [System.Windows.MessageBox]::Show($Message, $Title, $Button, $Icon) + } } diff --git a/functions/private/Start-WinUtilJob.ps1 b/functions/private/Start-WinUtilJob.ps1 index b4d6053f2b..3023c20d66 100644 --- a/functions/private/Start-WinUtilJob.ps1 +++ b/functions/private/Start-WinUtilJob.ps1 @@ -58,8 +58,6 @@ function Start-WinUtilJob { } $sync.ActiveJob = $Name - # Kept in step with ActiveJob because existing code and tests read this flag - $sync.ProcessRunning = $true $label = if ($Description) { $Description } else { $Name } Write-WinUtilLog -Component $Name -Message "$Name job started." @@ -98,7 +96,6 @@ function Start-WinUtilJob { } } - $sync.ProcessRunning = $false $sync.ActiveJob = $null } } diff --git a/functions/private/Write-WinUtilJobProgress.ps1 b/functions/private/Write-WinUtilJobProgress.ps1 index f1213f3b9c..f8c59bd604 100644 --- a/functions/private/Write-WinUtilJobProgress.ps1 +++ b/functions/private/Write-WinUtilJobProgress.ps1 @@ -8,8 +8,8 @@ function Write-WinUtilJobProgress { item together, and does nothing when there is no window, so job bodies do not need their own "is there a UI" checks. - UI updates are posted rather than waited on. A job that reports progress per item - would otherwise block on the dispatcher once per item. + The update is posted rather than waited on: a job reporting progress per item would + otherwise stall on the interface thread once per item. .PARAMETER Status Text for the progress label @@ -22,30 +22,46 @@ function Write-WinUtilJobProgress { .PARAMETER Overlay Taskbar overlay icon: logo, checkmark, warning or None + + .PARAMETER Hide + Clears and hides the progress bar. Used when leaving a finished job behind rather + than while one is running. #> param( [string]$Status, - [ValidateRange(0, 100)] [int]$Percent = -1, [ValidateSet("Normal", "Error", "Paused", "Indeterminate", "None")] [string]$State, - [string]$Overlay + [string]$Overlay, + [switch]$Hide ) - if ($null -eq $sync.Form -or $null -eq $sync.Form.Dispatcher) { - return - } + Invoke-WPFUIThread -Async -Parameters @{ + Status = $Status + Percent = [Math]::Min([Math]::Max($Percent, -1), 100) + State = $State + Overlay = $Overlay + HideBar = [bool]$Hide + HasStatus = $PSBoundParameters.ContainsKey('Status') + HasState = $PSBoundParameters.ContainsKey('State') + HasOverlay = $PSBoundParameters.ContainsKey('Overlay') + } -ScriptBlock { + param($Status, $Percent, $State, $Overlay, $HideBar, $HasStatus, $HasState, $HasOverlay) - $hasStatus = $PSBoundParameters.ContainsKey('Status') - $hasPercent = $Percent -ge 0 - $hasState = $PSBoundParameters.ContainsKey('State') - $hasOverlay = $PSBoundParameters.ContainsKey('Overlay') + if ($HideBar) { + $sync.WPFTweaksProgressBar.Visibility = [Windows.Visibility]::Collapsed + $sync.WPFTweaksProgressLabel.Text = "" + $sync.WPFTweaksProgressLabel.ToolTip = $null + $sync.WPFTweaksProgressValue.Value = 0 + return + } - $update = { - if ($hasStatus -or $hasPercent) { + $hasPercent = $Percent -ge 0 + + if ($HasStatus -or $hasPercent) { $sync.WPFTweaksProgressBar.Visibility = [Windows.Visibility]::Visible } - if ($hasStatus) { + if ($HasStatus) { $sync.WPFTweaksProgressLabel.Text = $Status $sync.WPFTweaksProgressLabel.ToolTip = $Status } @@ -53,13 +69,11 @@ function Write-WinUtilJobProgress { $sync.WPFTweaksProgressValue.Value = $Percent $sync.Form.TaskbarItemInfo.ProgressValue = $Percent / 100 } - if ($hasState) { + if ($HasState) { Set-WinUtilTaskbaritem -state $State } - if ($hasOverlay) { + if ($HasOverlay) { Set-WinUtilTaskbaritem -overlay $Overlay } } - - $null = $sync.Form.Dispatcher.BeginInvoke([Windows.Threading.DispatcherPriority]::Background, [action]$update) } diff --git a/functions/public/Invoke-WPFAppxRemoval.ps1 b/functions/public/Invoke-WPFAppxRemoval.ps1 index 23090c4e06..c279c0ccc5 100644 --- a/functions/public/Invoke-WPFAppxRemoval.ps1 +++ b/functions/public/Invoke-WPFAppxRemoval.ps1 @@ -1,100 +1,68 @@ function Invoke-WPFAppxRemoval { - if ($sync.ProcessRunning) { - Show-WinUtilMessage -Message "An AppX process is currently running." -Title "WinUtil" -Button "OK" -Icon "Warning" - return - } + <# + + .SYNOPSIS + Removes the selected AppX packages + + #> if ($null -eq $sync.selectedAppx -or $sync.selectedAppx.Count -eq 0) { Show-WinUtilMessage -Message "No AppX Package selected" -Title "Error" -Button "OK" -Icon "Error" return } - $selected = @($sync.selectedAppx) - $apps = $sync.configs.appxHashtable + Start-WinUtilJob -Name "AppX" -Description "Removing AppX packages" -Parameters @{ + Selected = @($sync.selectedAppx) + Apps = $sync.configs.appxHashtable + } -ScriptBlock { + param($Selected, $Apps) - $sync.ProcessRunning = $true - Invoke-WPFRunspace -ParameterList @(("selected", $selected), ("apps", $apps)) -ScriptBlock { - param($selected, $apps) - - $totalPackages = @($selected).Count - $hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher + $total = @($Selected).Count $packageList = [System.Collections.Generic.List[string]]::new() + Write-WinUtilLog -Component "AppX" -Message "Starting AppX removal for $total selected package(s)." - try { - Write-WinUtilLog -Component "AppX" -Message "Starting AppX removal for $totalPackages selected package(s)." - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Preparing AppX removal (0/$totalPackages)" -Percent 0 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" } - } - - for ($index = 0; $index -lt $totalPackages; $index++) { - $key = $selected[$index] - $app = $apps[$key] - $position = $index + 1 - $startPercent = [int](($index / $totalPackages) * 90) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Removing $($app.Content) ($position/$totalPackages)" -Percent $startPercent - } - - if ($key -eq "WPFAppxMicrosoft_XboxGamingOverlay") { - # Making sure Game Bar isn't running - Write-WinUtilLog -Component "AppX" -Message "Stopping GameBarFTServer before removing Xbox Gaming Overlay." - Stop-Process -Name GameBarFTServer -Force -Confirm:$false -ErrorAction SilentlyContinue + for ($index = 0; $index -lt $total; $index++) { + $key = $Selected[$index] + $app = $Apps[$key] + $position = $index + 1 + Write-WinUtilJobProgress -Status "Removing $($app.Content) ($position/$total)" -Percent ([int](($index / $total) * 90)) - # This stops annoying ms-gamebar popup when launching games. - Write-WinUtilLog -Component "AppX" -Message "Disabling Game DVR capture before removing Xbox Gaming Overlay." - Set-ItemProperty -Path HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\GameDVR -Name AppCaptureEnabled -Value 0 - } + if ($key -eq "WPFAppxMicrosoft_XboxGamingOverlay") { + # Making sure Game Bar isn't running + Write-WinUtilLog -Component "AppX" -Message "Stopping GameBarFTServer before removing Xbox Gaming Overlay." + Stop-Process -Name GameBarFTServer -Force -Confirm:$false -ErrorAction SilentlyContinue - if ($key -eq "WPFAppxMicrosoft_WindowsNotepad") { - Write-WinUtilLog -Component "AppX" -Message "Stopping dllhost before removing Notepad." - Stop-Process -Name dllhost -Force -Confirm:$false -ErrorAction SilentlyContinue - } - - Write-Host "Removing $($app.Content)" - Write-WinUtilLog -Component "AppX" -Message "Removing $($app.Content) ($($app.PackageId))." - Remove-WinUtilAPPX -Name $app.PackageId - $packageList.Add($app.PackageId) - - if ($key -eq "WPFAppxMSTeams") { - # Uninstalls Microsoft Teams Meeting Add-in for Microsoft Office - Write-WinUtilLog -Component "AppX" -Message "Uninstalling Microsoft Teams meeting add-in package." - Get-Package -Name "Microsoft Teams*" -ErrorAction SilentlyContinue | Uninstall-Package -Force - } - - $completedPercent = [int](($position / $totalPackages) * 90) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Removed $($app.Content) ($position/$totalPackages)" -Percent $completedPercent - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) } - } + # This stops annoying ms-gamebar popup when launching games. + Write-WinUtilLog -Component "AppX" -Message "Disabling Game DVR capture before removing Xbox Gaming Overlay." + Set-ItemProperty -Path HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\GameDVR -Name AppCaptureEnabled -Value 0 } - if ($packageList.Count -gt 0) { - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Removing provisioned AppX packages" -Percent 90 - } - Remove-WinUtilProvisionedAPPX -PackageList $packageList.ToArray() + if ($key -eq "WPFAppxMicrosoft_WindowsNotepad") { + Write-WinUtilLog -Component "AppX" -Message "Stopping dllhost before removing Notepad." + Stop-Process -Name dllhost -Force -Confirm:$false -ErrorAction SilentlyContinue } - Write-Host "=================================" - Write-Host "-- AppX Removal Finished ---" - Write-Host "=================================" - Write-WinUtilLog -Component "AppX" -Message "AppX removal finished." - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "AppX removal finished" -Percent 100 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } - } - } - catch { - Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message "AppX removal failed: $($_.Exception.Message)" - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "AppX removal failed" -Percent 100 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" } + Write-Host "Removing $($app.Content)" + Write-WinUtilLog -Component "AppX" -Message "Removing $($app.Content) ($($app.PackageId))." + Remove-WinUtilAPPX -Name $app.PackageId + $packageList.Add($app.PackageId) + + if ($key -eq "WPFAppxMSTeams") { + # Uninstalls Microsoft Teams Meeting Add-in for Microsoft Office + Write-WinUtilLog -Component "AppX" -Message "Uninstalling Microsoft Teams meeting add-in package." + Get-Package -Name "Microsoft Teams*" -ErrorAction SilentlyContinue | Uninstall-Package -Force } + + Write-WinUtilJobProgress -Status "Removed $($app.Content) ($position/$total)" -Percent ([int](($position / $total) * 90)) } - finally { - $sync.ProcessRunning = $false + + if ($packageList.Count -gt 0) { + Write-WinUtilJobProgress -Status "Removing provisioned AppX packages" -Percent 90 + Remove-WinUtilProvisionedAPPX -PackageList $packageList.ToArray() } + Write-Host "=================================" + Write-Host "-- AppX Removal Finished ---" + Write-Host "=================================" } } diff --git a/functions/public/Invoke-WPFButton.ps1 b/functions/public/Invoke-WPFButton.ps1 index 80771a901b..c778e1dbf0 100644 --- a/functions/public/Invoke-WPFButton.ps1 +++ b/functions/public/Invoke-WPFButton.ps1 @@ -14,8 +14,9 @@ function Invoke-WPFButton { # Use this to get the name of the button #[System.Windows.MessageBox]::Show("$Button","Chris Titus Tech's Windows Utility","OK","Info") - if (-not $sync.ProcessRunning -and -not $sync.Win11ISOProcessRunning) { - Set-WinUtilTweaksProgressIndicator -Visible $false + # Clear the progress left behind by the previous job, but never while one is running + if (-not $sync.ActiveJob) { + Write-WinUtilJobProgress -Hide } # Check if button is defined in feature config with function or InvokeScript diff --git a/functions/public/Invoke-WPFGetInstalled.ps1 b/functions/public/Invoke-WPFGetInstalled.ps1 index 336c3007ac..e01b418fa9 100644 --- a/functions/public/Invoke-WPFGetInstalled.ps1 +++ b/functions/public/Invoke-WPFGetInstalled.ps1 @@ -31,17 +31,19 @@ function Invoke-WPFGetInstalled { Write-WinUtilLog -Component "Install" -Message "Detected $($found.Count) existing item(s) for $Checkbox." - # Ticking boxes touches the controls, so it happens on the UI thread - Invoke-WPFUIThread -ScriptBlock { + # Ticking boxes touches the controls, so it happens on the interface thread + Invoke-WPFUIThread -Parameters @{ Checkbox = $Checkbox; Found = $found } -ScriptBlock { + param($Checkbox, $Found) + if ($Checkbox -eq "winget") { - foreach ($name in $found) { + foreach ($name in $Found) { if (-not $sync.selectedApps.Contains($name)) { $sync.selectedApps.Add($name) } } Reset-WPFCheckBoxes -checkboxfilterpattern "WPFInstall*" } else { - foreach ($name in $found) { + foreach ($name in $Found) { $sync.$name.ischecked = $true } } diff --git a/functions/public/Invoke-WPFtweaksbutton.ps1 b/functions/public/Invoke-WPFtweaksbutton.ps1 index d803b02aac..0c4e1db3b1 100644 --- a/functions/public/Invoke-WPFtweaksbutton.ps1 +++ b/functions/public/Invoke-WPFtweaksbutton.ps1 @@ -6,87 +6,51 @@ function Invoke-WPFtweaksbutton { #> - if($sync.ProcessRunning) { - $msg = "[Invoke-WPFtweaksbutton] Install process is currently running." - [System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning) - return - } - $Tweaks = $sync.selectedTweaks $dnsProvider = $sync["WPFchangedns"].text if (-not ($dnsProvider)) { $dnsProvider = "Default" } - $restorePointTweak = "WPFTweaksRestorePoint" - $restorePointSelected = $Tweaks -contains $restorePointTweak - $tweaksToRun = @($Tweaks | Where-Object { $_ -ne $restorePointTweak }) - $totalSteps = [Math]::Max($Tweaks.Count, 1) - $completedSteps = 0 - Write-WinUtilLog -Component "Tweaks" -Message "Tweaks requested: $(@($Tweaks).Count) selected tweak(s), DNS provider: $dnsProvider" - if ($tweaks.count -eq 0 -and $dnsProvider -eq "Default") { - $msg = "Please check the tweaks you wish to perform." - [System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning) + if ($Tweaks.count -eq 0 -and $dnsProvider -eq "Default") { + Show-WinUtilMessage -Message "Please check the tweaks you wish to perform." -Title "WinUtil" -Button "OK" -Icon "Warning" return } - if ($restorePointSelected) { - $sync.ProcessRunning = $true - - if ($Tweaks.Count -eq 1) { - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Indeterminate" -value 0.01 -overlay "logo" } - } else { - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" } - } - - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Creating restore point" -Percent 0 - Write-WinUtilLog -Component "Tweaks" -Message "Creating restore point before applying selected tweaks." - Invoke-WinUtilTweaks $restorePointTweak - $completedSteps = 1 - - if ($tweaksToRun.Count -eq 0 -and $dnsProvider -eq "Default") { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Tweaks finished" -Percent 100 - $sync.ProcessRunning = $false - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } - Write-Host "=================================" - Write-Host "-- Tweaks are Finished ---" - Write-Host "=================================" - Write-WinUtilLog -Component "Tweaks" -Message "Tweaks workflow completed after restore point." - return - } - } - - # The leading "," in the ParameterList is necessary because we only provide one argument and powershell cannot be convinced that we want a nested loop with only one argument otherwise - Invoke-WPFRunspace -ParameterList @(("tweaks", $tweaksToRun), ("dnsProvider", $dnsProvider), ("completedSteps", $completedSteps), ("totalSteps", $totalSteps)) -ScriptBlock { - param($tweaks, $dnsProvider, $completedSteps, $totalSteps) - - $sync.ProcessRunning = $true + Write-WinUtilLog -Component "Tweaks" -Message "Tweaks requested: $(@($Tweaks).Count) selected tweak(s), DNS provider: $dnsProvider" - if ($completedSteps -eq 0) { - if ($Tweaks.count -eq 1) { - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Indeterminate" -value 0.01 -overlay "logo" } - } else { - Invoke-WPFUIThread -ScriptBlock{ Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" } - } + Start-WinUtilJob -Name "Tweaks" -Description "Applying tweaks" -Parameters @{ + Tweaks = @($Tweaks) + DnsProvider = $dnsProvider + } -ScriptBlock { + param($Tweaks, $DnsProvider) + + # The restore point has to be taken before anything else changes + $restorePointTweak = "WPFTweaksRestorePoint" + $tweaksToRun = @($Tweaks | Where-Object { $_ -ne $restorePointTweak }) + $totalSteps = [Math]::Max(@($Tweaks).Count, 1) + $completedSteps = 0 + + if ($Tweaks -contains $restorePointTweak) { + Write-WinUtilJobProgress -Status "Creating restore point" -Percent 0 + Write-WinUtilLog -Component "Tweaks" -Message "Creating restore point before applying selected tweaks." + Invoke-WinUtilTweaks $restorePointTweak + $completedSteps = 1 } - if ($dnsProvider -ne "Default") { - Set-WinUtilDNS -DNSProvider $dnsProvider + if ($DnsProvider -ne "Default") { + Set-WinUtilDNS -DNSProvider $DnsProvider } - for ($i = 0; $i -lt $tweaks.Count; $i++) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Applying $($tweaks[$i]) ($($completedSteps + 1)/$totalSteps)" -Percent ($completedSteps / $totalSteps * 100) - Invoke-WinUtilTweaks $tweaks[$i] + foreach ($tweak in $tweaksToRun) { + Write-WinUtilJobProgress -Status "Applying $tweak ($($completedSteps + 1)/$totalSteps)" -Percent ([int](($completedSteps / $totalSteps) * 100)) + Invoke-WinUtilTweaks $tweak $completedSteps++ - $progress = $completedSteps / $totalSteps - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value $progress } + Write-WinUtilJobProgress -Percent ([int](($completedSteps / $totalSteps) * 100)) } - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Tweaks finished" -Percent 100 - $sync.ProcessRunning = $false - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } + Write-Host "=================================" Write-Host "-- Tweaks are Finished ---" Write-Host "=================================" - Write-WinUtilLog -Component "Tweaks" -Message "Tweaks workflow completed." } } diff --git a/functions/public/Invoke-WPFundoall.ps1 b/functions/public/Invoke-WPFundoall.ps1 index 47903009a8..516087dc59 100644 --- a/functions/public/Invoke-WPFundoall.ps1 +++ b/functions/public/Invoke-WPFundoall.ps1 @@ -6,45 +6,29 @@ function Invoke-WPFundoall { #> - if($sync.ProcessRunning) { - $msg = "[Invoke-WPFundoall] Install process is currently running." - [System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning) - return - } - $tweaks = $sync.selectedTweaks if ($tweaks.count -eq 0) { - $msg = "Please check the tweaks you wish to undo." - [System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning) + Show-WinUtilMessage -Message "Please check the tweaks you wish to undo." -Title "WinUtil" -Button "OK" -Icon "Warning" return } - Invoke-WPFRunspace -ArgumentList $tweaks -ScriptBlock { - param($tweaks) - - $sync.ProcessRunning = $true - Write-WinUtilLog -Component "Tweaks" -Message "Undo tweaks requested: $(@($tweaks).Count) selected tweak(s)." - if ($tweaks.count -eq 1) { - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Indeterminate" -value 0.01 -overlay "logo" } - } else { - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" } - } + Start-WinUtilJob -Name "Undo tweaks" -Description "Undoing tweaks" -Parameters @{ + Tweaks = @($tweaks) + } -ScriptBlock { + param($Tweaks) + $total = @($Tweaks).Count + Write-WinUtilLog -Component "Tweaks" -Message "Undo tweaks requested: $total selected tweak(s)." - for ($i = 0; $i -lt $tweaks.Count; $i++) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Undoing $($tweaks[$i]) ($($i + 1)/$($tweaks.Count))" -Percent ($i / $tweaks.Count * 100) - Invoke-WinUtiltweaks $tweaks[$i] -undo $true - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($i/$tweaks.Count) } + for ($i = 0; $i -lt $total; $i++) { + Write-WinUtilJobProgress -Status "Undoing $($Tweaks[$i]) ($($i + 1)/$total)" -Percent ([int](($i / $total) * 100)) + Invoke-WinUtiltweaks $Tweaks[$i] -undo $true + Write-WinUtilJobProgress -Percent ([int]((($i + 1) / $total) * 100)) } - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Undo Tweaks Finished" -Percent 100 - $sync.ProcessRunning = $false - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } Write-Host "==================================" Write-Host "--- Undo Tweaks are Finished ---" Write-Host "==================================" - Write-WinUtilLog -Component "Tweaks" -Message "Undo tweaks workflow completed." - } } diff --git a/functions/public/Invoke-WinUtilAutoRun.ps1 b/functions/public/Invoke-WinUtilAutoRun.ps1 index c4b094db95..b94c44dc75 100644 --- a/functions/public/Invoke-WinUtilAutoRun.ps1 +++ b/functions/public/Invoke-WinUtilAutoRun.ps1 @@ -7,7 +7,7 @@ function Invoke-WinUtilAutoRun { function BusyWait { Start-Sleep -Milliseconds 100 - while ($sync.ProcessRunning) { + while ($sync.ActiveJob) { Start-Sleep -Milliseconds 100 } } diff --git a/pester/appx.Tests.ps1 b/pester/appx.Tests.ps1 index 9472b5e4fd..814d5a3eab 100644 --- a/pester/appx.Tests.ps1 +++ b/pester/appx.Tests.ps1 @@ -50,10 +50,7 @@ BeforeAll { param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) } function Invoke-WPFUIThread { - param([scriptblock]$ScriptBlock) - } - function Set-WinUtilTweaksProgressIndicator { - param($Visible, $Label, $Percent) + param([scriptblock]$ScriptBlock, [hashtable]$Parameters, [switch]$Async) } function powershell.exe { } function Get-AppxPackage { @@ -226,7 +223,7 @@ Describe "Install-WinUtilAPPX" { Describe "Get installed AppX selection" { BeforeEach { $script:sync = [Hashtable]::Synchronized(@{ - ProcessRunning = $false + ActiveJob = $null configs = @{ feature = @{} appxHashtable = @{ @@ -238,7 +235,6 @@ Describe "Get installed AppX selection" { WPFAppxMissing = [pscustomobject]@{ IsChecked = $false } }) - Mock Set-WinUtilTweaksProgressIndicator { } Mock Get-WinUtilInstalledAPPX { @("Example.Package") } Mock Invoke-WPFAppxInstall { } } @@ -356,7 +352,7 @@ Describe "Remove-WinUtilProvisionedAPPX" { Describe "Invoke-WPFAppxInstall" { BeforeEach { $script:sync = [Hashtable]::Synchronized(@{ - ProcessRunning = $false + ActiveJob = $null Form = [pscustomobject]@{ Dispatcher = [pscustomobject]@{} } selectedAppx = [System.Collections.Generic.List[string]]::new() configs = @{ @@ -371,12 +367,10 @@ Describe "Invoke-WPFAppxInstall" { }) $script:capturedAppxInstallScriptBlock = $null $script:capturedAppxInstallParameterList = $null - $script:appxInstallProcessRunningAtLaunch = $null Mock Show-WinUtilMessage { "OK" } Mock Write-Host { } Mock Write-WinUtilLog { } - Mock Set-WinUtilTweaksProgressIndicator { } Mock Invoke-WPFUIThread { } Mock Install-WinUtilAPPX { } Mock Write-WinUtilJobProgress { } @@ -390,7 +384,6 @@ Describe "Invoke-WPFAppxInstall" { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue Remove-Variable -Name capturedAppxInstallScriptBlock -Scope Script -ErrorAction SilentlyContinue Remove-Variable -Name capturedAppxInstallParameterList -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name appxInstallProcessRunningAtLaunch -Scope Script -ErrorAction SilentlyContinue } It "prompts and exits when no AppX packages are selected for install" { @@ -444,97 +437,18 @@ Describe "Invoke-WPFAppxInstall" { { & $script:capturedAppxInstallScriptBlock @jobParameters } | Should -Throw "Install failed" } } -Describe "Invoke-WPFAppxRemoval entrypoint" { - BeforeEach { - $script:sync = [Hashtable]::Synchronized(@{ - ProcessRunning = $false - selectedAppx = [System.Collections.Generic.List[string]]::new() - configs = @{ - appxHashtable = @{} - } - }) - $script:capturedAppxScriptBlock = $null - $script:capturedAppxParameterList = $null - $script:appxRemovalProcessRunningAtLaunch = $null - - Mock Show-WinUtilMessage { "OK" } - Mock Invoke-WPFRunspace { - $script:appxRemovalProcessRunningAtLaunch = $script:sync.ProcessRunning - $script:capturedAppxScriptBlock = $ScriptBlock - $script:capturedAppxParameterList = $ParameterList - [pscustomobject]@{ MockHandle = $true } - } - } - - AfterEach { - Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedAppxScriptBlock -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedAppxParameterList -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name appxRemovalProcessRunningAtLaunch -Scope Script -ErrorAction SilentlyContinue - } - - It "prompts and exits when no AppX packages are selected" { - Invoke-WPFAppxRemoval - - Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { - $Message -eq "No AppX Package selected" -and - $Title -eq "Error" -and - $Button -eq "OK" -and - $Icon -eq "Error" - } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly - } - - It "prevents overlapping AppX removal operations" { - $script:sync.ProcessRunning = $true - $script:sync.selectedAppx.Add("WPFAppxExample") - - Invoke-WPFAppxRemoval - - Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { - $Message -eq "An AppX process is currently running." -and - $Title -eq "WinUtil" -and - $Button -eq "OK" -and - $Icon -eq "Warning" - } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly - } - - It "passes selected AppX keys and app metadata to the removal runspace" { - $script:sync.selectedAppx.Add("WPFAppxExample") - $script:sync.configs.appxHashtable["WPFAppxExample"] = [pscustomobject]@{ - Content = "Example App" - PackageId = "Example.Package" - } - - Invoke-WPFAppxRemoval - $script:sync.selectedAppx.Add("WPFAppxChangedAfterLaunch") - - $script:appxRemovalProcessRunningAtLaunch | Should -BeTrue - $script:capturedAppxParameterList[0][1] | Should -HaveCount 1 - Should -Invoke -CommandName Show-WinUtilMessage -Times 0 -Exactly - Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { - $ScriptBlock -is [scriptblock] -and - $ParameterList.Count -eq 2 -and - $ParameterList[0][0] -eq "selected" -and - $ParameterList[0][1][0] -eq "WPFAppxExample" -and - $ParameterList[1][0] -eq "apps" -and - $ParameterList[1][1]["WPFAppxExample"].PackageId -eq "Example.Package" - } - } -} - -Describe "Invoke-WPFAppxRemoval runspace body" { +Describe "Invoke-WPFAppxRemoval" { BeforeEach { $script:sync = [Hashtable]::Synchronized(@{ - ProcessRunning = $false + ActiveJob = $null Form = [pscustomobject]@{ Dispatcher = [pscustomobject]@{} } selectedAppx = [System.Collections.Generic.List[string]]::new() configs = @{ appxHashtable = @{} } }) - $script:capturedAppxScriptBlock = $null + $script:capturedAppxRemovalScriptBlock = $null + $script:capturedAppxRemovalParameters = $null $script:apps = @{ WPFAppxExample = [pscustomobject]@{ Content = "Example App" @@ -554,15 +468,10 @@ Describe "Invoke-WPFAppxRemoval runspace body" { } } - Mock Invoke-WPFRunspace { - $script:capturedAppxScriptBlock = $ScriptBlock - [pscustomobject]@{ MockHandle = $true } - } Mock Show-WinUtilMessage { "OK" } Mock Write-Host { } Mock Write-WinUtilLog { } - Mock Set-WinUtilTweaksProgressIndicator { } - Mock Invoke-WPFUIThread { } + Mock Write-WinUtilJobProgress { } Mock Stop-Process { } Mock Set-ItemProperty { } Mock Get-AppxPackage { @@ -579,21 +488,54 @@ Describe "Invoke-WPFAppxRemoval runspace body" { } } Mock Uninstall-Package { } + Mock Start-WinUtilJob { + $script:capturedAppxRemovalScriptBlock = $ScriptBlock + $script:capturedAppxRemovalParameters = $Parameters + } } AfterEach { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedAppxScriptBlock -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedAppxRemovalScriptBlock -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedAppxRemovalParameters -Scope Script -ErrorAction SilentlyContinue Remove-Variable -Name apps -Scope Script -ErrorAction SilentlyContinue } - It "removes selected AppX packages and clears ProcessRunning when finished" { - $selected = @("WPFAppxExample") + It "prompts and exits when no AppX packages are selected" { + Invoke-WPFAppxRemoval + + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Message -eq "No AppX Package selected" -and + $Title -eq "Error" -and + $Button -eq "OK" -and + $Icon -eq "Error" + } + Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly + } + + It "queues a removal job with a snapshot of the selection and app metadata" { $script:sync.selectedAppx.Add("WPFAppxExample") $script:sync.configs.appxHashtable = $script:apps Invoke-WPFAppxRemoval - & $script:capturedAppxScriptBlock -selected $selected -apps $script:apps + $script:sync.selectedAppx.Add("WPFAppxChangedAfterLaunch") + + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "AppX" -and $ScriptBlock -is [scriptblock] + } + Should -Invoke -CommandName Show-WinUtilMessage -Times 0 -Exactly + $script:capturedAppxRemovalParameters.Selected | Should -HaveCount 1 + $script:capturedAppxRemovalParameters.Selected[0] | Should -Be "WPFAppxExample" + $script:capturedAppxRemovalParameters.Apps["WPFAppxExample"].PackageId | Should -Be "Example.Package" + } + + It "removes the selected packages and reports progress for each one" { + $script:sync.selectedAppx.Add("WPFAppxExample") + $script:sync.configs.appxHashtable = $script:apps + + Invoke-WPFAppxRemoval + $jobParameters = $script:capturedAppxRemovalParameters + & $script:capturedAppxRemovalScriptBlock @jobParameters Should -Invoke -CommandName Get-AppxPackage -Times 1 -Exactly -ParameterFilter { $Name -eq "*Example.Package*" -and $AllUsers -eq $true @@ -604,93 +546,49 @@ Describe "Invoke-WPFAppxRemoval runspace body" { Should -Invoke -CommandName Remove-WinUtilProvisionedAPPX -Times 1 -Exactly -ParameterFilter { $PackageList.Count -eq 1 -and $PackageList[0] -eq "Example.Package" } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Removing Example App (1/1)" -and $Percent -eq 0 - } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Removed Example App (1/1)" -and $Percent -eq 90 + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Removing Example App (1/1)" -and $Percent -eq 0 } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "AppX removal finished" -and $Percent -eq 100 + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Removed Example App (1/1)" -and $Percent -eq 90 } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*' + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Removing provisioned AppX packages" -and $Percent -eq 90 } - $script:sync.ProcessRunning | Should -BeFalse } - It "shows failure feedback and clears ProcessRunning when removal fails" { - $selected = @("WPFAppxExample") + It "lets a removal failure surface so the job layer can handle it" { $script:sync.selectedAppx.Add("WPFAppxExample") $script:sync.configs.appxHashtable = $script:apps Mock Remove-WinUtilAPPX { throw "Removal failed" } Invoke-WPFAppxRemoval - & $script:capturedAppxScriptBlock -selected $selected -apps $script:apps + $jobParameters = $script:capturedAppxRemovalParameters - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "AppX removal failed" -and $Percent -eq 100 - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*' - } + { & $script:capturedAppxRemovalScriptBlock @jobParameters } | Should -Throw "Removal failed" Should -Invoke -CommandName Remove-WinUtilProvisionedAPPX -Times 0 -Exactly - $script:sync.ProcessRunning | Should -BeFalse } - It "removes packages without UI progress during headless autorun" { - $selected = @("WPFAppxExample") - $script:sync.Remove("Form") - $script:sync.selectedAppx.Add("WPFAppxExample") - $script:sync.configs.appxHashtable = $script:apps - - Invoke-WPFAppxRemoval - & $script:capturedAppxScriptBlock -selected $selected -apps $script:apps - - Should -Invoke -CommandName Remove-AppxPackage -Times 1 -Exactly - Should -Invoke -CommandName Remove-WinUtilProvisionedAPPX -Times 1 -Exactly - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 0 -Exactly - Should -Invoke -CommandName Invoke-WPFUIThread -Times 0 -Exactly - $script:sync.ProcessRunning | Should -BeFalse - } - - It "shows failure feedback when provisioned package removal fails" { - $selected = @("WPFAppxExample") + It "lets a provisioned removal failure surface so the job layer can handle it" { $script:sync.selectedAppx.Add("WPFAppxExample") $script:sync.configs.appxHashtable = $script:apps Mock Remove-WinUtilProvisionedAPPX { throw "Provisioned removal failed" } Invoke-WPFAppxRemoval - & $script:capturedAppxScriptBlock -selected $selected -apps $script:apps + $jobParameters = $script:capturedAppxRemovalParameters - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "AppX removal failed" -and $Percent -eq 100 - } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 0 -Exactly -ParameterFilter { - $Label -eq "AppX removal finished" - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*' - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 0 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*' - } - $script:sync.ProcessRunning | Should -BeFalse + { & $script:capturedAppxRemovalScriptBlock @jobParameters } | Should -Throw "Provisioned removal failed" } It "applies special cleanup for Xbox overlay, Notepad, and Teams selections" { - $selected = @( - "WPFAppxMicrosoft_XboxGamingOverlay", - "WPFAppxMicrosoft_WindowsNotepad", - "WPFAppxMSTeams" - ) - foreach ($key in $selected) { + foreach ($key in @("WPFAppxMicrosoft_XboxGamingOverlay", "WPFAppxMicrosoft_WindowsNotepad", "WPFAppxMSTeams")) { $script:sync.selectedAppx.Add($key) } $script:sync.configs.appxHashtable = $script:apps Invoke-WPFAppxRemoval - & $script:capturedAppxScriptBlock -selected $selected -apps $script:apps + $jobParameters = $script:capturedAppxRemovalParameters + & $script:capturedAppxRemovalScriptBlock @jobParameters Should -Invoke -CommandName Stop-Process -Times 1 -Exactly -ParameterFilter { $Name -eq "GameBarFTServer" -and @@ -722,6 +620,5 @@ Describe "Invoke-WPFAppxRemoval runspace body" { $PackageList[1] -eq "Microsoft.WindowsNotepad" -and $PackageList[2] -eq "MSTeams" } - $script:sync.ProcessRunning | Should -BeFalse } } diff --git a/pester/install-workflow.Tests.ps1 b/pester/install-workflow.Tests.ps1 index ad7fc3bb84..d7726e037c 100644 --- a/pester/install-workflow.Tests.ps1 +++ b/pester/install-workflow.Tests.ps1 @@ -30,9 +30,6 @@ BeforeAll { function Get-WinUtilSelectedPackages { param($PackageList, [string]$Preference) } - function Set-WinUtilTweaksProgressIndicator { - param($Visible, $Label, $Percent) - } function Install-WinUtilWinget { } function Install-WinUtilChoco { } function Install-WinUtilProgramWinget { @@ -42,7 +39,7 @@ BeforeAll { param($Action, $Programs) } function Invoke-WPFUIThread { - param([scriptblock]$ScriptBlock) + param([scriptblock]$ScriptBlock, [hashtable]$Parameters, [switch]$Async) } function Write-WinUtilLog { param($Message, $Level, $Component) diff --git a/pester/job-layer.Tests.ps1 b/pester/job-layer.Tests.ps1 new file mode 100644 index 0000000000..615016cfe5 --- /dev/null +++ b/pester/job-layer.Tests.ps1 @@ -0,0 +1,203 @@ +#=========================================================================== +# Tests - Job layer +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\private\Start-WinUtilJob.ps1") + + function Invoke-WPFRunspace { + param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) + } + function Invoke-WPFUIThread { + param([scriptblock]$ScriptBlock, [hashtable]$Parameters, [switch]$Async) + } + function Write-WinUtilLog { + param($Message, $Level, $Component) + } + function Write-WinUtilJobProgress { + param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay, [switch]$Hide) + } + function Show-WinUtilMessage { + param($Message, $Title, $Button, $Icon) + } + + function script:Get-WinUtilJobRunspaceBody { + param([hashtable]$ParameterList) + + $named = @{} + foreach ($parameter in $ParameterList) { + $named[$parameter[0]] = $parameter[1] + } + return $named + } +} + +Describe "Interface thread dispatch" { + # Work handed to the interface thread must arrive as body text plus parameters. A + # scriptblock marshalled from a worker runspace keeps that runspace's session state, which + # both loses the caller's variables on an async post and costs roughly twenty times as much + # per command - enough to turn a checkbox refresh into a visible freeze. + It "hands work to the interface runspace instead of marshalling a scriptblock" { + $uiThread = Get-Content -Path (Join-Path $script:repoRoot "functions\public\Invoke-WPFUIThread.ps1") -Raw + $userInterface = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1") -Raw + + $userInterface | Should -Match ([regex]::Escape('$sync.UIDispatchDelegate = [System.Func[object, object]]')) + $uiThread | Should -Match ([regex]::Escape('$executor = $sync.UIDispatchDelegate')) + $uiThread | Should -Match ([regex]::Escape('Body = $ScriptBlock.ToString()')) + $uiThread | Should -Match ([regex]::Escape('$dispatcher.Invoke($executor, @($work))')) + $uiThread | Should -Match ([regex]::Escape('$dispatcher.BeginInvoke([Windows.Threading.DispatcherPriority]::Background, $executor, $work)')) + } + + It "passes deferred values as parameters rather than capturing them" { + foreach ($path in @( + "functions\private\Write-WinUtilJobProgress.ps1", + "functions\private\Invoke-WinUtilISO.ps1" + )) { + $source = Get-Content -Path (Join-Path $script:repoRoot $path) -Raw + + $source | Should -Match ([regex]::Escape('Invoke-WPFUIThread -Async -Parameters @{')) + $source | Should -Not -Match ([regex]::Escape('GetNewClosure')) + } + } +} + +Describe "Start-WinUtilJob" { + BeforeEach { + $script:sync = [Hashtable]::Synchronized(@{ + ActiveJob = $null + Form = [pscustomobject]@{ Dispatcher = [pscustomobject]@{} } + ItemsControl = [pscustomobject]@{ IsEnabled = $true } + }) + $script:capturedRunspaceBody = $null + $script:capturedRunspaceArgs = $null + $script:activeJobAtQueueTime = $null + + Mock Show-WinUtilMessage { "OK" } + Mock Write-WinUtilLog { } + Mock Write-WinUtilJobProgress { } + Mock Invoke-WPFUIThread { & $ScriptBlock } + Mock Invoke-WPFRunspace { + $script:activeJobAtQueueTime = $script:sync.ActiveJob + $script:capturedRunspaceBody = $ScriptBlock + $script:capturedRunspaceArgs = @{} + foreach ($parameter in $ParameterList) { + $script:capturedRunspaceArgs[$parameter[0]] = $parameter[1] + } + [pscustomobject]@{ MockHandle = $true } + } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedRunspaceBody -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedRunspaceArgs -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name activeJobAtQueueTime -Scope Script -ErrorAction SilentlyContinue + } + + It "claims the busy state before the work is queued" { + Start-WinUtilJob -Name "Example" -ScriptBlock { } | Out-Null + + $script:activeJobAtQueueTime | Should -Be "Example" + Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Component -eq "Example" -and $Message -eq "Example job started." + } + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Example..." -and $Percent -eq 0 + } + } + + It "uses the description for the initial progress text" { + Start-WinUtilJob -Name "Example" -Description "Doing the thing" -ScriptBlock { } | Out-Null + + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Doing the thing..." -and $Percent -eq 0 -and $State -eq "Normal" -and $Overlay -eq "logo" + } + } + + It "refuses a second job while one is running" { + $script:sync.ActiveJob = "Install" + + $result = Start-WinUtilJob -Name "Tweaks" -ScriptBlock { } + + $result | Should -BeNullOrEmpty + Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Message -like "Install is still running*" + } + $script:sync.ActiveJob | Should -Be "Install" + } + + It "passes the body text and its parameters to the worker" { + Start-WinUtilJob -Name "Example" -Parameters @{ Value = 42 } -ScriptBlock { param($Value) $Value } | Out-Null + + $script:capturedRunspaceArgs["JobName"] | Should -Be "Example" + $script:capturedRunspaceArgs["JobBody"] | Should -BeOfType [string] + $script:capturedRunspaceArgs["JobBody"] | Should -Match 'param\(\$Value\)' + $script:capturedRunspaceArgs["JobParameters"].Value | Should -Be 42 + $script:capturedRunspaceArgs["JobRestoresAppList"] | Should -BeFalse + } + + It "greys out the app list only when asked to" { + Start-WinUtilJob -Name "Install" -DisableAppList -ScriptBlock { } | Out-Null + + $script:sync.ItemsControl.IsEnabled | Should -BeFalse + $script:capturedRunspaceArgs["JobRestoresAppList"] | Should -BeTrue + } + + It "reports completion and releases the busy state when the body succeeds" { + Start-WinUtilJob -Name "Example" -ScriptBlock { } | Out-Null + + & $script:capturedRunspaceBody ` + -JobName "Example" ` + -JobBody '$null = $true' ` + -JobParameters @{} ` + -JobRestoresAppList $false + + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Component -eq "Example" -and $Message -eq "Example job finished." + } + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Example finished" -and $Percent -eq 100 -and $State -eq "None" -and $Overlay -eq "checkmark" + } + $script:sync.ActiveJob | Should -BeNullOrEmpty + } + + It "logs the failure and releases the busy state when the body throws" { + Start-WinUtilJob -Name "Example" -ScriptBlock { } | Out-Null + $script:sync.ActiveJob = "Example" + Mock Write-Host { } + + { + & $script:capturedRunspaceBody ` + -JobName "Example" ` + -JobBody 'throw "boom"' ` + -JobParameters @{} ` + -JobRestoresAppList $false + } | Should -Not -Throw + + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Level -eq "ERROR" -and $Component -eq "Example" -and $Message -eq "Example job failed: boom" + } + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Example failed" -and $State -eq "Error" -and $Overlay -eq "warning" + } + $script:sync.ActiveJob | Should -BeNullOrEmpty + } + + It "restores the app list after a failing job that disabled it" { + Start-WinUtilJob -Name "Install" -DisableAppList -ScriptBlock { } | Out-Null + $script:sync.ActiveJob = "Install" + Mock Write-Host { } + + & $script:capturedRunspaceBody ` + -JobName "Install" ` + -JobBody 'throw "boom"' ` + -JobParameters @{} ` + -JobRestoresAppList $true + + $script:sync.ItemsControl.IsEnabled | Should -BeTrue + $script:sync.ActiveJob | Should -BeNullOrEmpty + } +} diff --git a/pester/oosu.Tests.ps1 b/pester/oosu.Tests.ps1 index acb9218ca9..fa852c09ba 100644 --- a/pester/oosu.Tests.ps1 +++ b/pester/oosu.Tests.ps1 @@ -11,9 +11,6 @@ BeforeAll { function Invoke-WPFRunspace { param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) } - function Set-WinUtilTweaksProgressIndicator { - param($Visible, $Label, $Percent) - } function Start-WinUtilJob { param([string]$Name, [scriptblock]$ScriptBlock, [hashtable]$Parameters, [string]$Description, [switch]$DisableAppList) } diff --git a/pester/runspace.Tests.ps1 b/pester/runspace.Tests.ps1 index a588f57833..719cb97961 100644 --- a/pester/runspace.Tests.ps1 +++ b/pester/runspace.Tests.ps1 @@ -184,21 +184,21 @@ Describe "Public runspace callers" { } } - It "passes selected tweaks as the runspace argument list for undo all" { + It "queues selected tweak undo as a job without executing the body" { $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") $script:sync.selectedTweaks.Add("WPFTweaksServices") Invoke-WPFundoall - Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { - $ScriptBlock -is [scriptblock] -and - $ArgumentList.Count -eq 2 -and - $ArgumentList[0] -eq "WPFTweaksTelemetry" -and - $ArgumentList[1] -eq "WPFTweaksServices" + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "Undo tweaks" -and + $ScriptBlock -is [scriptblock] -and + @($Parameters.Tweaks).Count -eq 2 -and + @($Parameters.Tweaks)[0] -eq "WPFTweaksTelemetry" } } - It "passes selected AppX items and app metadata to the removal runspace" { + It "queues AppX removal as a job with the selection and app metadata" { $script:sync.selectedAppx.Add("WPFAppxExample") $script:sync.configs.appxHashtable["WPFAppxExample"] = [pscustomobject]@{ Content = "Example" @@ -207,13 +207,38 @@ Describe "Public runspace callers" { Invoke-WPFAppxRemoval - Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { - $ScriptBlock -is [scriptblock] -and - $ParameterList.Count -eq 2 -and - $ParameterList[0][0] -eq "selected" -and - $ParameterList[0][1][0] -eq "WPFAppxExample" -and - $ParameterList[1][0] -eq "apps" -and - $ParameterList[1][1].ContainsKey("WPFAppxExample") + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "AppX" -and + $ScriptBlock -is [scriptblock] -and + @($Parameters.Selected)[0] -eq "WPFAppxExample" -and + $Parameters.Apps.ContainsKey("WPFAppxExample") + } + } + + It "keeps every long workflow entrypoint on the job layer" { + $publicRoot = Join-Path $script:repoRoot "functions\public" + $privateRoot = Join-Path $script:repoRoot "functions\private" + $entrypoints = @( + (Join-Path $publicRoot "Invoke-WPFInstall.ps1"), + (Join-Path $publicRoot "Invoke-WPFUnInstall.ps1"), + (Join-Path $publicRoot "Invoke-WPFAppxInstall.ps1"), + (Join-Path $publicRoot "Invoke-WPFAppxRemoval.ps1"), + (Join-Path $publicRoot "Invoke-WPFFeatureInstall.ps1"), + (Join-Path $publicRoot "Invoke-WPFGetInstalled.ps1"), + (Join-Path $publicRoot "Invoke-WPFOOSU.ps1"), + (Join-Path $publicRoot "Invoke-WPFtweaksbutton.ps1"), + (Join-Path $publicRoot "Invoke-WPFundoall.ps1"), + (Join-Path $privateRoot "Invoke-WinUtilISO.ps1"), + (Join-Path $privateRoot "Invoke-WinUtilISOUSB.ps1") + ) + + foreach ($entrypoint in $entrypoints) { + $source = Get-Content -Path $entrypoint -Raw + $source | Should -Match 'Start-WinUtilJob -Name' + # Job bodies never build their own runspace or busy state + $source | Should -Not -Match 'RunspaceFactory\]::CreateRunspace' + $source | Should -Not -Match 'Invoke-WPFRunspace' + $source | Should -Not -Match '\$sync\.ProcessRunning' } } } diff --git a/pester/tweaks.Tests.ps1 b/pester/tweaks.Tests.ps1 index 2f076df0af..ac9ed7d37a 100644 --- a/pester/tweaks.Tests.ps1 +++ b/pester/tweaks.Tests.ps1 @@ -6,6 +6,7 @@ BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path . (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilTweaks.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFtweaksbutton.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFundoall.ps1") function Set-WinUtilService { param($Name, $StartupType) @@ -29,14 +30,20 @@ BeforeAll { param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) } function Invoke-WPFUIThread { - param([scriptblock]$ScriptBlock) - } - function Set-WinUtilTweaksProgressIndicator { - param($Visible, $Label, $Percent) + param([scriptblock]$ScriptBlock, [hashtable]$Parameters, [switch]$Async) } function Write-WinUtilLog { param($Message, $Level, $Component) } + function Start-WinUtilJob { + param([string]$Name, [scriptblock]$ScriptBlock, [hashtable]$Parameters, [string]$Description, [switch]$DisableAppList) + } + function Write-WinUtilJobProgress { + param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay, [switch]$Hide) + } + function Show-WinUtilMessage { + param($Message, $Title, $Button, $Icon) + } function script:New-WinUtilTweaksConfig { [pscustomobject]@{ @@ -174,65 +181,152 @@ Describe "Invoke-WinUtilTweaks" { Describe "Invoke-WPFtweaksbutton" { BeforeEach { $script:sync = [Hashtable]::Synchronized(@{ - ProcessRunning = $false + ActiveJob = $null selectedTweaks = [System.Collections.Generic.List[string]]::new() WPFchangedns = [pscustomobject]@{ text = "Cloudflare" } }) + $script:capturedTweaksJob = $null - Mock Invoke-WPFRunspace { [pscustomobject]@{ MockHandle = $true } } Mock Invoke-WinUtilTweaks { } + Mock Set-WinUtilDNS { } Mock Invoke-WPFUIThread { } Mock Write-WinUtilLog { } + Mock Write-WinUtilJobProgress { } + Mock Show-WinUtilMessage { "OK" } Mock Write-Host { } + Mock Start-WinUtilJob { + $script:capturedTweaksJob = [pscustomobject]@{ + ScriptBlock = $ScriptBlock + Parameters = $Parameters + } + } } AfterEach { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedTweaksJob -Scope Script -ErrorAction SilentlyContinue + } + + It "prompts and exits when nothing is selected and DNS is left at the default" { + $script:sync.WPFchangedns.text = "Default" + + Invoke-WPFtweaksbutton + + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Message -eq "Please check the tweaks you wish to perform." + } + Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly } - It "passes selected tweaks, DNS provider, and progress counters to the tweak runspace" { + It "queues a tweak job with the selection and DNS provider" { $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") $script:sync.selectedTweaks.Add("WPFTweaksServices") Invoke-WPFtweaksbutton - Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { - $ParameterList.Count -eq 4 -and - $ParameterList[0][0] -eq "tweaks" -and - $ParameterList[0][1].Count -eq 2 -and - $ParameterList[0][1][0] -eq "WPFTweaksTelemetry" -and - $ParameterList[0][1][1] -eq "WPFTweaksServices" -and - $ParameterList[1][0] -eq "dnsProvider" -and - $ParameterList[1][1] -eq "Cloudflare" -and - $ParameterList[2][0] -eq "completedSteps" -and - $ParameterList[2][1] -eq 0 -and - $ParameterList[3][0] -eq "totalSteps" -and - $ParameterList[3][1] -eq 2 + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "Tweaks" -and $ScriptBlock -is [scriptblock] } + $script:capturedTweaksJob.Parameters.Tweaks | Should -HaveCount 2 + $script:capturedTweaksJob.Parameters.Tweaks[0] | Should -Be "WPFTweaksTelemetry" + $script:capturedTweaksJob.Parameters.DnsProvider | Should -Be "Cloudflare" } - It "runs the restore point first and advances progress before queueing remaining tweaks" { + It "applies every selected tweak and the DNS provider inside the job body" { + $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") + $script:sync.selectedTweaks.Add("WPFTweaksServices") + + Invoke-WPFtweaksbutton + $jobParameters = $script:capturedTweaksJob.Parameters + & $script:capturedTweaksJob.ScriptBlock @jobParameters + + Should -Invoke -CommandName Set-WinUtilDNS -Times 1 -Exactly -ParameterFilter { + $DNSProvider -eq "Cloudflare" + } + Should -Invoke -CommandName Invoke-WinUtilTweaks -Times 2 -Exactly + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Applying WPFTweaksTelemetry (1/2)" -and $Percent -eq 0 + } + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Applying WPFTweaksServices (2/2)" -and $Percent -eq 50 + } + } + + It "takes the restore point before any other tweak runs" { $script:sync.selectedTweaks.Add("WPFTweaksRestorePoint") $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") + $script:appliedOrder = [System.Collections.Generic.List[string]]::new() + Mock Invoke-WinUtilTweaks { $script:appliedOrder.Add($CheckBox) } Invoke-WPFtweaksbutton + $jobParameters = $script:capturedTweaksJob.Parameters + & $script:capturedTweaksJob.ScriptBlock @jobParameters + + $script:appliedOrder | Should -Be @("WPFTweaksRestorePoint", "WPFTweaksTelemetry") + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Creating restore point" -and $Percent -eq 0 + } + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Applying WPFTweaksTelemetry (2/2)" -and $Percent -eq 50 + } + } +} - Should -Invoke -CommandName Invoke-WinUtilTweaks -Times 1 -Exactly -ParameterFilter { - $CheckBox -eq "WPFTweaksRestorePoint" - } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { - $ParameterList.Count -eq 4 -and - $ParameterList[0][0] -eq "tweaks" -and - $ParameterList[0][1].Count -eq 1 -and - $ParameterList[0][1][0] -eq "WPFTweaksTelemetry" -and - $ParameterList[1][0] -eq "dnsProvider" -and - $ParameterList[1][1] -eq "Cloudflare" -and - $ParameterList[2][0] -eq "completedSteps" -and - $ParameterList[2][1] -eq 1 -and - $ParameterList[3][0] -eq "totalSteps" -and - $ParameterList[3][1] -eq 2 +Describe "Invoke-WPFundoall" { + BeforeEach { + $script:sync = [Hashtable]::Synchronized(@{ + ActiveJob = $null + selectedTweaks = [System.Collections.Generic.List[string]]::new() + }) + $script:capturedUndoJob = $null + + Mock Invoke-WinUtilTweaks { } + Mock Write-WinUtilLog { } + Mock Write-WinUtilJobProgress { } + Mock Show-WinUtilMessage { "OK" } + Mock Write-Host { } + Mock Start-WinUtilJob { + $script:capturedUndoJob = [pscustomobject]@{ + ScriptBlock = $ScriptBlock + Parameters = $Parameters + } + } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedUndoJob -Scope Script -ErrorAction SilentlyContinue + } + + It "prompts and exits when nothing is selected" { + Invoke-WPFundoall + + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Message -eq "Please check the tweaks you wish to undo." + } + Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly + } + + It "undoes every selected tweak inside the job body" { + $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") + $script:sync.selectedTweaks.Add("WPFTweaksServices") + + Invoke-WPFundoall + + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "Undo tweaks" + } + $jobParameters = $script:capturedUndoJob.Parameters + & $script:capturedUndoJob.ScriptBlock @jobParameters + + Should -Invoke -CommandName Invoke-WinUtilTweaks -Times 2 -Exactly -ParameterFilter { $undo -eq $true } + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Undoing WPFTweaksTelemetry (1/2)" -and $Percent -eq 0 + } + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Undoing WPFTweaksServices (2/2)" -and $Percent -eq 50 } } } diff --git a/pester/ui-state.Tests.ps1 b/pester/ui-state.Tests.ps1 index 14aee09949..560d514d25 100644 --- a/pester/ui-state.Tests.ps1 +++ b/pester/ui-state.Tests.ps1 @@ -66,14 +66,11 @@ namespace System.Windows.Controls . (Join-Path $script:repoRoot "functions\public\Invoke-WPFButton.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFToggleAllCategories.ps1") - function Set-WinUtilTweaksProgressIndicator { - param($Visible, $Label, $Percent) - } function Invoke-WPFRunspace { param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) } function Invoke-WPFUIThread { - param([scriptblock]$ScriptBlock) + param([scriptblock]$ScriptBlock, [hashtable]$Parameters, [switch]$Async) } function Invoke-WinUtilCurrentSystem { param($CheckBox) @@ -256,7 +253,7 @@ Describe "Invoke-WPFGetInstalled selection state" { Mock Write-WinUtilLog { } Mock Write-Warning { } Mock Write-WinUtilJobProgress { } - Mock Invoke-WPFUIThread { & $ScriptBlock } + Mock Invoke-WPFUIThread { $uiParameters = $Parameters; & $ScriptBlock @uiParameters } Mock Start-WinUtilJob { $script:capturedGetInstalledScriptBlock = $ScriptBlock $script:capturedGetInstalledParameters = $Parameters diff --git a/pester/win11creator.Tests.ps1 b/pester/win11creator.Tests.ps1 index b689b0a830..77780079aa 100644 --- a/pester/win11creator.Tests.ps1 +++ b/pester/win11creator.Tests.ps1 @@ -98,7 +98,7 @@ Describe "Win11 Creator setup media" { It "starts each new ISO modification in a fresh working directory" { foreach ($expectedText in @( '$workDir = Join-Path $env:TEMP "WinUtil_Win11ISO_$(Get-Date -Format ''yyyyMMdd_HHmmss'')"', - '$workDir = Join-Path $env:TEMP "WinUtil_Win11ISO_$(Get-Date -Format ''yyyyMMdd_HHmmss'')_$(([guid]::NewGuid()).ToString(''N'').Substring(0, 8))"' + '$workDir = "$($workDir)_$(([guid]::NewGuid()).ToString(''N'').Substring(0, 8))"' )) { $script:modifyFunction | Should -Match ([regex]::Escape($expectedText)) } @@ -154,22 +154,35 @@ Describe "Win11 Creator setup media" { { Assert-WinUtilISOWimMetadata -Before $valid -After $invalidAfter } | Should -Throw '*validation failed*' } - It "tracks every background ISO workflow with the shared busy state" { - foreach ($functionText in @( - $script:mountAndVerifyFunction, - $script:modifyFunction, - $script:cleanAndResetFunction, - $script:exportFunction, - $script:writeUsbFunction + It "runs every background ISO workflow through the shared job layer" { + $jobNames = @{ + mountAndVerify = "ISO mount" + modify = "ISO modify" + cleanAndReset = "ISO cleanup" + export = "ISO export" + writeUsb = "USB write" + } + + foreach ($entry in @( + @{ Text = $script:mountAndVerifyFunction; Name = $jobNames.mountAndVerify }, + @{ Text = $script:modifyFunction; Name = $jobNames.modify }, + @{ Text = $script:cleanAndResetFunction; Name = $jobNames.cleanAndReset }, + @{ Text = $script:exportFunction; Name = $jobNames.export }, + @{ Text = $script:writeUsbFunction; Name = $jobNames.writeUsb } )) { - $functionText | Should -Match ([regex]::Escape('$sync["Win11ISOProcessRunning"] = $true')) - $functionText | Should -Match ([regex]::Escape('$sync["Win11ISOProcessRunning"] = $false')) + $entry.Text | Should -Match ([regex]::Escape("Start-WinUtilJob -Name `"$($entry.Name)`"")) + # The job layer owns the busy state, the progress bar, and the taskbar item + $entry.Text | Should -Not -Match ([regex]::Escape('Win11ISOProcessRunning')) + $entry.Text | Should -Not -Match ([regex]::Escape('RunspaceFactory]::CreateRunspace()')) + $entry.Text | Should -Not -Match ([regex]::Escape('SessionStateProxy.SetVariable')) + $entry.Text | Should -Not -Match ([regex]::Escape('[System.Windows.MessageBox]::Show')) } } It "runs ISO mount and verification outside the UI thread" { - $script:mountAndVerifyFunction | Should -Match ([regex]::Escape("Invoke-WPFRunspace -ParameterList @(,('isoPath', `$isoPath))")) - $script:mountAndVerifyFunction | Should -Match ([regex]::Escape('Invoke-WPFUIThread {')) + $script:mountAndVerifyFunction | Should -Match ([regex]::Escape('Start-WinUtilJob -Name "ISO mount"')) + $script:mountAndVerifyFunction | Should -Match ([regex]::Escape('IsoPath = $isoPath')) + $script:mountAndVerifyFunction | Should -Match ([regex]::Escape('Invoke-WPFUIThread -ScriptBlock {')) $script:mountAndVerifyFunction | Should -Match ([regex]::Escape('Write-WinUtilISOLog')) $script:mountAndVerifyFunction | Should -Not -Match ([regex]::Escape('Write-Win11ISOLog')) $script:mountAndVerifyFunction | Should -Match ([regex]::Escape('$sync["WPFWin11ISOBrowseButton"].IsEnabled = $false')) @@ -180,6 +193,21 @@ Describe "Win11 Creator setup media" { $script:mountAndVerifyFunction | Should -Match ([regex]::Escape('$sync["WPFWin11ISOModifyButton"].IsEnabled = $true')) } + It "reports ISO progress and logging through the shared helpers" { + $content = Get-Content -Path $script:isoWorkflowPath -Raw + $usbContent = Get-Content -Path $script:isoUsbWorkflowPath -Raw + + foreach ($source in @($content, $usbContent)) { + $source | Should -Match ([regex]::Escape('Write-WinUtilJobProgress -Status')) + # No hand-rolled Log/SetProgress helpers inside job bodies any more + $source | Should -Not -Match '(?m)^\s*function (Log|SetProgress)\(' + $source | Should -Not -Match ([regex]::Escape('$sync["WPFTweaksProgressLabel"]')) + } + + # Every status-log line also lands in the session log + $content | Should -Match ([regex]::Escape('Write-WinUtilLog -Level $Level -Component "Win11Creator" -Message $Message')) + } + It "blocks oversized install.esd before USB erase confirmation" { $script:writeUsbFunction | Should -Match ([regex]::Escape('$installEsd = Join-Path $contentsDir "sources\install.esd"')) $script:writeUsbFunction | Should -Match ([regex]::Escape('$esdSizeBytes -ge 4GB')) @@ -512,12 +540,14 @@ Describe "Win11 Creator setup media" { $content | Should -Match ([regex]::Escape($expectedText)) } - $fallbackIndex = $content.IndexOf('oscdimg.exe not found. Attempting to install via winget...') - $notFoundDialogIndex = $content.IndexOf('oscdimg Not Found', $fallbackIndex) - $runspaceIndex = $content.IndexOf('[Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()', $fallbackIndex) + # The export job stops at the dialog instead of running oscdimg without a binary + $exportOscdimgIndex = $script:exportFunction.IndexOf('$oscdimg = Get-WinUtilOscdimgPath') + $exportDialogIndex = $script:exportFunction.IndexOf('oscdimg Not Found', $exportOscdimgIndex) + $exportRunIndex = $script:exportFunction.IndexOf('Running oscdimg...', $exportOscdimgIndex) - $fallbackIndex | Should -BeGreaterThan -1 - $notFoundDialogIndex | Should -BeGreaterThan $fallbackIndex - $runspaceIndex | Should -BeGreaterThan $notFoundDialogIndex + $exportOscdimgIndex | Should -BeGreaterThan -1 + $exportDialogIndex | Should -BeGreaterThan $exportOscdimgIndex + $exportRunIndex | Should -BeGreaterThan $exportDialogIndex + $script:exportFunction | Should -Match ([regex]::Escape('return')) } } From e740c8492f6dc571d9245c8f11439b259d0299b7 Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 03:07:41 +0200 Subject: [PATCH 05/70] Log from every WinUtil thread into one session file Start-Transcript only records the runspace it was started on, so every line a worker or the interface logged was being dropped. Write-WinUtilLog now appends to the session log directly, serialized with a named mutex, and the console transcript gets its own file in the same logs directory. --- functions/private/Write-WinUtilLog.ps1 | 32 +++++++------- pester/logging.Tests.ps1 | 58 +++++++++++++++++--------- 2 files changed, 57 insertions(+), 33 deletions(-) diff --git a/functions/private/Write-WinUtilLog.ps1 b/functions/private/Write-WinUtilLog.ps1 index da9e13ab7a..939feebe97 100644 --- a/functions/private/Write-WinUtilLog.ps1 +++ b/functions/private/Write-WinUtilLog.ps1 @@ -4,6 +4,12 @@ function Write-WinUtilLog { .SYNOPSIS Writes a timestamped WinUtil log entry to the active session log. + .DESCRIPTION + Called from the interface thread and from every job body, so the append is serialized + with a named mutex. The session log is deliberately not the file Start-Transcript owns: + a transcript only records the runspace it was started on, so anything a job logged would + otherwise never reach disk. + .PARAMETER Message The message to write. @@ -26,19 +32,10 @@ function Write-WinUtilLog { try { $logPath = $null - $transcriptPath = $null if ($null -ne $sync -and $sync.ContainsKey("logPath")) { $logPath = $sync.logPath } - if ($null -ne $sync -and $sync.ContainsKey("transcriptPath")) { - $transcriptPath = $sync.transcriptPath - } - - if ([string]::IsNullOrWhiteSpace($logPath) -and -not [string]::IsNullOrWhiteSpace($transcriptPath)) { - $logPath = $transcriptPath - } - if ([string]::IsNullOrWhiteSpace($logPath) -and $null -ne $sync -and $sync.ContainsKey("winutildir")) { $logDirectory = Join-Path $sync.winutildir "logs" $logPath = Join-Path $logDirectory "winutil_$(Get-Date -Format "yyyy-MM-dd_HH-mm-ss").log" @@ -65,15 +62,22 @@ function Write-WinUtilLog { $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" $line = "[$timestamp] [$Level] [$Component] $Message" - if (-not [string]::IsNullOrWhiteSpace($transcriptPath) -and $logPath -eq $transcriptPath) { - Write-Host $line - return - } - + $mutex = [System.Threading.Mutex]::new($false, "WinUtilSessionLog") + $held = $false try { + try { + $held = $mutex.WaitOne(2000) + } catch [System.Threading.AbandonedMutexException] { + # A thread died holding the mutex; ownership transfers to us either way + $held = $true + } + Add-Content -Path $logPath -Value $line -Encoding UTF8 -ErrorAction Stop } catch [System.IO.IOException] { Write-Host $line + } finally { + if ($held) { $mutex.ReleaseMutex() } + $mutex.Dispose() } } catch { Write-Warning "Unable to write WinUtil log entry: $($_.Exception.Message)" diff --git a/pester/logging.Tests.ps1 b/pester/logging.Tests.ps1 index 79c3d745df..36d7cecbb8 100644 --- a/pester/logging.Tests.ps1 +++ b/pester/logging.Tests.ps1 @@ -35,22 +35,43 @@ Describe "Write-WinUtilLog" { Get-Content -Path $logPath -Raw | Should -Match "\[INFO\] \[Test\] same session log" } - It "uses the transcript stream when logPath is not set" { - $transcriptPath = Join-Path $script:testRoot "logs\winutil_2026-07-01_12-00-00.log" + It "writes entries produced concurrently by several threads" { + $logPath = Join-Path $script:testRoot "logs\winutil_2026-07-01_12-00-00.log" $script:sync = [hashtable]::Synchronized(@{ winutildir = $script:testRoot - transcriptPath = $transcriptPath + logPath = $logPath }) - Mock Add-Content { } - Mock Write-Host { } - Write-WinUtilLog -Component "Test" -Message "transcript fallback" + $logFunction = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Write-WinUtilLog.ps1") -Raw + $initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + $initialSessionState.Variables.Add( + (New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList "sync", $script:sync, $null) + ) + $pool = [runspacefactory]::CreateRunspacePool(1, 4, $initialSessionState, $Host) + $pool.Open() + + try { + $handles = foreach ($index in 1..12) { + $shell = [powershell]::Create() + $shell.RunspacePool = $pool + [void]$shell.AddScript($logFunction) + [void]$shell.AddScript("Write-WinUtilLog -Component 'Test' -Message 'entry $index'") + [pscustomobject]@{ Shell = $shell; Handle = $shell.BeginInvoke() } + } + + foreach ($item in $handles) { + $item.Shell.EndInvoke($item.Handle) + $item.Shell.Dispose() + } + } finally { + $pool.Close() + $pool.Dispose() + } - Should -Invoke -CommandName Add-Content -Times 0 -Exactly - Should -Invoke -CommandName Write-Host -Times 1 -Exactly -ParameterFilter { - $Object -match "\[INFO\] \[Test\] transcript fallback" + $content = Get-Content -Path $logPath + foreach ($index in 1..12) { + @($content | Where-Object { $_ -match "\[Test\] entry $index$" }).Count | Should -Be 1 } - Test-Path -Path (Join-Path $script:testRoot "winutil.log") | Should -BeFalse } It "creates one fallback log under logs when only winutildir is available" { @@ -70,25 +91,23 @@ Describe "Write-WinUtilLog" { $content | Should -Match "second fallback entry" } - It "does not append directly when the active log file is the transcript" { + It "falls back to host output when the log file cannot be opened" { $logPath = Join-Path $script:testRoot "logs\winutil_2026-07-01_12-00-00.log" $script:sync = [hashtable]::Synchronized(@{ winutildir = $script:testRoot logPath = $logPath - transcriptPath = $logPath }) - Mock Add-Content { throw [System.IO.IOException]::new("locked by transcript") } -ParameterFilter { + Mock Add-Content { throw [System.IO.IOException]::new("file is locked") } -ParameterFilter { $Path -eq $logPath -and $ErrorAction -eq "Stop" } Mock Write-Host { } Mock Write-Warning { } - Write-WinUtilLog -Component "Test" -Message "transcript stream fallback" + Write-WinUtilLog -Component "Test" -Message "locked file fallback" - Should -Invoke -CommandName Add-Content -Times 0 -Exactly Should -Invoke -CommandName Write-Host -Times 1 -Exactly -ParameterFilter { - $Object -match "\[INFO\] \[Test\] transcript stream fallback" + $Object -match "\[INFO\] \[Test\] locked file fallback" } Should -Invoke -CommandName Write-Warning -Times 0 -Exactly } @@ -96,12 +115,13 @@ Describe "Write-WinUtilLog" { } Describe "WinUtil startup logging path" { - It "uses one timestamped log file under the logs directory" { + It "keeps the session log separate from the console transcript" { $startScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\start.ps1") -Raw $startScript | Should -Match '\$sync\.logPath = "\$logdir\\winutil_\$dateTime\.log"' - $startScript | Should -Match '\$sync\.transcriptPath = \$sync\.logPath' - $startScript | Should -Match 'Start-Transcript -Path \$sync\.logPath' + $startScript | Should -Match 'Start-Transcript -Path "\$logdir\\winutil_\$dateTime\.console\.log"' + # The transcript may not own the file Write-WinUtilLog appends to + $startScript | Should -Not -Match 'Start-Transcript -Path \$sync\.logPath' $startScript | Should -Not -Match '\$sync\.logPath = "\$winutildir\\winutil\.log"' } } From d90a90de2acb7959eca70bb7584171e3e303cc7b Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 03:07:41 +0200 Subject: [PATCH 06/70] Document the threading model and the job layer --- .../docs/code-reference/architecture.mdx | 116 +++++++++++++----- 1 file changed, 82 insertions(+), 34 deletions(-) diff --git a/docs/src/content/docs/code-reference/architecture.mdx b/docs/src/content/docs/code-reference/architecture.mdx index 8faf950c65..270c0ee91e 100644 --- a/docs/src/content/docs/code-reference/architecture.mdx +++ b/docs/src/content/docs/code-reference/architecture.mdx @@ -79,14 +79,15 @@ winutil/ **Why**: Makes distribution easier (single file) and improves load time. #### 2. scripts/main.ps1 -**Purpose**: Entry point that initializes the GUI and event system. +**Purpose**: Entry point that manages the run. **Responsibilities**: -- Load XAML and create WPF window -- Initialize form elements -- Set up event handlers - Load configurations -- Display the GUI +- Run the headless `-Preset` and `-Config` paths +- Start the interface on a dedicated STA runspace and wait for it +- Report anything the interface thread failed with, then clean up + +The interface itself lives in `Start-WinUtilUserInterface`, not here. See [Threading Model](#threading-model). #### 3. functions/public/ **Purpose**: User-facing functions that implement main features. @@ -392,23 +393,64 @@ Update UI - `service`: Services to change - `OriginalValue/State`: For undo functionality -## PowerShell Runspace +## Threading Model + +WinUtil runs on three kinds of thread, and each has one job: + +| Thread | Runspace | Responsibility | +| --- | --- | --- | +| Main | The one the script started in | Start the interface, wait for it, surface its errors, clean up | +| Interface | `$sync.UIRunspace`, a dedicated STA runspace | Own the window. Paint and dispatch, nothing else | +| Workers | `$sync.runspace`, a shared pool | Run everything long: installs, tweaks, features, AppX, Win11 Creator | + +All three are created from the same starting point, `New-WinUtilSessionState`, which carries +`$sync`, the compiled script's globals, and every WinUtil function. That is what lets any +thread call any helper without the caller injecting function definitions. + +```powershell +# main.ps1 - the interface gets its own thread +$sync.UIRunspace = [runspacefactory]::CreateRunspace($Host, (New-WinUtilSessionState)) +$sync.UIRunspace.ApartmentState = "STA" +$sync.UIRunspace.Open() + +$uiShell = [powershell]::Create() +$uiShell.Runspace = $sync.UIRunspace +[void]$uiShell.AddScript({ Start-WinUtilUserInterface }) +$uiHandle = $uiShell.BeginInvoke() +$uiHandle.AsyncWaitHandle.WaitOne() +``` + +**Why**: the window never blocks on work, and a failure on the interface thread is reported +instead of disappearing. -Winutil uses PowerShell runspaces for the GUI to remain responsive: +## Long-Running Work + +Every long action goes through `Start-WinUtilJob`, which owns the parts each one used to +repeat: refusing to start while another job runs, the busy flag (`$sync.ActiveJob`), the +progress bar and taskbar item, a start/finish/failure line in the log, and restoring the +interface in a `finally` whatever happens. ```powershell -# Create runspace -$sync.runspace = [runspacefactory]::CreateRunspace() -$sync.runspace.Open() -$sync.runspace.SessionStateProxy.SetVariable("sync", $sync) - -# Run code in background -$powershell = [powershell]::Create().AddScript($scriptblock) -$powershell.Runspace = $sync.runspace -$handle = $powershell.BeginInvoke() +Start-WinUtilJob -Name "Features" -Description "Installing Windows Features" -Parameters @{ + Features = @($sync.selectedFeatures) +} -ScriptBlock { + param($Features) + + $total = @($Features).Count + $completed = 0 + foreach ($feature in $Features) { + $completed++ + Write-WinUtilJobProgress -Status "Installing $feature ($completed/$total)" -Percent ([int](($completed / $total) * 100)) + Invoke-WinUtilFeatureInstall $feature + } +} ``` -**Why**: Prevents UI freezing during long-running operations. +The body only has to do the work and call `Write-WinUtilJobProgress`. Anything it throws is +caught, logged, and shown on the taskbar as a failure. + +**Values, not closures**: the body is rebuilt inside the worker from its text, so it receives +what it needs through `-Parameters` rather than capturing the caller's variables. ## WPF Event Handling @@ -454,23 +496,21 @@ if (!(Get-Command choco -ErrorAction SilentlyContinue)) { choco install $app.choco -y ``` -## Error Handling +## Error Handling And Logging -Winutil uses PowerShell error handling: +A job body does not need its own error handling. `Start-WinUtilJob` catches whatever the body +throws, logs it, and marks the run as failed on the taskbar, so a failure can never leave the +interface stuck busy. Only catch inside a body when you have something specific to do first, +such as cleaning up a mounted image, and then rethrow. ```powershell -try { - # Attempt operation - Invoke-SomeOperation -} -catch { - Write-Host "Error: $_" -ForegroundColor Red - # Log error - Add-Content -Path $logfile -Value "ERROR: $_" -} +Write-WinUtilLog -Level "ERROR" -Component "Install" -Message "winget install failed: $($_.Exception.Message)" ``` -**Logging**: Errors and operations are logged for debugging. +Entries go to `%LocalAppData%\winutil\logs\winutil_.log` from every thread, guarded +by a named mutex. The console transcript is a separate file in the same directory, because +`Start-Transcript` keeps its file open and only records the runspace it was started on - so +anything a worker logged would otherwise never reach disk. ## Configuration Loading @@ -488,15 +528,23 @@ $sync.configs.features = Get-Content "config/feature.json" | ConvertFrom-Json ## UI Update Pattern -UI updates must happen on the UI thread: +Controls may only be touched from the thread that owns the window, so background work reaches +them through `Invoke-WPFUIThread`: ```powershell -$sync.form.Dispatcher.Invoke([action]{ - $sync.WPFStatusLabel.Content = "Installing..." -}, "Normal") +Invoke-WPFUIThread -Parameters @{ Count = $installed.Count } -ScriptBlock { + param($Count) + $sync.WPFselectedAppsButton.Content = "Selected Apps: $Count" +} ``` -**Why**: WPF requires UI updates on the main thread. +Add `-Async` to post the update instead of waiting for it. `Write-WinUtilJobProgress` and the +Win11 Creator status log use that so a per-item update never stalls the worker. + +**Values, not closures**: the body is rebuilt inside the interface runspace, so it takes what it +needs through `-Parameters`. Handing over a scriptblock from a worker instead would keep that +worker's session state, which loses the caller's variables on an async post and costs roughly +twenty times as much per command - enough to turn a checkbox refresh into a visible freeze. ## Adding New Features From 2488adbf56d201a6855206100e74f5767067bf91 Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 08:02:08 +0200 Subject: [PATCH 07/70] Stop Invoke-WPFUIThread leaking the body's output to its caller The helper returned whatever the body produced, including a bare $null. Callers written against the old void signature then returned an array instead of their own value: Get-WinUtilSelectedPackages handed back @($null, $split), both package lists read as empty, and Install and Uninstall reported success without installing or removing anything. Output is now suppressed unless -PassThru is asked for, which only Show-WinUtilMessage needs. Covered by tests on both the helper and the package split. --- .../private/Get-WinUtilSelectedPackages.ps1 | 5 ++- functions/private/Show-WinUtilMessage.ps1 | 2 +- functions/public/Invoke-WPFUIThread.ps1 | 19 +++++++--- pester/job-layer.Tests.ps1 | 36 +++++++++++++++++-- pester/package.Tests.ps1 | 15 ++++++++ 5 files changed, 67 insertions(+), 10 deletions(-) diff --git a/functions/private/Get-WinUtilSelectedPackages.ps1 b/functions/private/Get-WinUtilSelectedPackages.ps1 index 8ca5ff4223..aee4595a53 100644 --- a/functions/private/Get-WinUtilSelectedPackages.ps1 +++ b/functions/private/Get-WinUtilSelectedPackages.ps1 @@ -8,10 +8,9 @@ function Get-WinUtilSelectedPackages { [string] $Preference ) + # A single package has no meaningful percentage to show if ($PackageList.count -eq 1) { - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Indeterminate" -value 0.01 -overlay "logo" } - } else { - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" } + Write-WinUtilJobProgress -State "Indeterminate" } $packagesWinget = [System.Collections.ArrayList]::new() diff --git a/functions/private/Show-WinUtilMessage.ps1 b/functions/private/Show-WinUtilMessage.ps1 index e42cb9c615..b617019583 100644 --- a/functions/private/Show-WinUtilMessage.ps1 +++ b/functions/private/Show-WinUtilMessage.ps1 @@ -21,7 +21,7 @@ function Show-WinUtilMessage { return [System.Windows.MessageBox]::Show($Message, $Title, $Button, $Icon) } - return Invoke-WPFUIThread -Parameters @{ + return Invoke-WPFUIThread -PassThru -Parameters @{ Message = $Message Title = $Title Button = $Button diff --git a/functions/public/Invoke-WPFUIThread.ps1 b/functions/public/Invoke-WPFUIThread.ps1 index e6cc1e180b..9c01c0645d 100644 --- a/functions/public/Invoke-WPFUIThread.ps1 +++ b/functions/public/Invoke-WPFUIThread.ps1 @@ -26,6 +26,10 @@ function Invoke-WPFUIThread { .PARAMETER Async Post the work and return immediately instead of waiting for it. Use for progress and log updates, which must never stall the caller. + + .PARAMETER PassThru + Return what the body produced. Off by default so that a caller who only wanted a + control updated does not get stray output mixed into its own return value. #> param( [Parameter(Mandatory, Position = 0)] @@ -33,7 +37,9 @@ function Invoke-WPFUIThread { [hashtable]$Parameters = @{}, - [switch]$Async + [switch]$Async, + + [switch]$PassThru ) $dispatcher = $sync.Form.Dispatcher @@ -42,7 +48,9 @@ function Invoke-WPFUIThread { } if (-not $Async -and $dispatcher.CheckAccess()) { - return (& $ScriptBlock @Parameters) + $inlineResult = & $ScriptBlock @Parameters + if ($PassThru) { return $inlineResult } + return } $executor = $sync.UIDispatchDelegate @@ -52,7 +60,9 @@ function Invoke-WPFUIThread { $null = $dispatcher.BeginInvoke([Windows.Threading.DispatcherPriority]::Background, [action]$ScriptBlock) return } - return $dispatcher.Invoke([action]$ScriptBlock) + $fallbackResult = $dispatcher.Invoke([action]$ScriptBlock) + if ($PassThru) { return $fallbackResult } + return } $work = @{ @@ -65,5 +75,6 @@ function Invoke-WPFUIThread { return } - return $dispatcher.Invoke($executor, @($work)) + $result = $dispatcher.Invoke($executor, @($work)) + if ($PassThru) { return $result } } diff --git a/pester/job-layer.Tests.ps1 b/pester/job-layer.Tests.ps1 index 615016cfe5..4aa692e477 100644 --- a/pester/job-layer.Tests.ps1 +++ b/pester/job-layer.Tests.ps1 @@ -5,12 +5,13 @@ BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path . (Join-Path $script:repoRoot "functions\private\Start-WinUtilJob.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFUIThread.ps1") function Invoke-WPFRunspace { param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) } - function Invoke-WPFUIThread { - param([scriptblock]$ScriptBlock, [hashtable]$Parameters, [switch]$Async) + function Write-WinUtilJobBanner { + param([string]$Message, [string]$Level) } function Write-WinUtilLog { param($Message, $Level, $Component) @@ -62,6 +63,37 @@ Describe "Interface thread dispatch" { } } +Describe "Invoke-WPFUIThread output" { + BeforeEach { + $dispatcher = [pscustomobject]@{ HasShutdownStarted = $false } + $dispatcher | Add-Member -MemberType ScriptMethod -Name CheckAccess -Value { $true } + $script:sync = [Hashtable]::Synchronized(@{ + Form = [pscustomobject]@{ Dispatcher = $dispatcher } + }) + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + } + + # Output from the body must not join the caller's own return value: a caller that only + # wanted a control updated would otherwise return an array and every index read wrong. + It "swallows the body's output by default" { + @(Invoke-WPFUIThread -ScriptBlock { "stray" }).Count | Should -Be 0 + } + + It "returns the body's output when asked" { + Invoke-WPFUIThread -PassThru -ScriptBlock { "wanted" } | Should -Be "wanted" + } + + It "passes values in rather than relying on the caller's scope" { + Invoke-WPFUIThread -PassThru -Parameters @{ Value = 7 } -ScriptBlock { + param($Value) + $Value * 2 + } | Should -Be 14 + } +} + Describe "Start-WinUtilJob" { BeforeEach { $script:sync = [Hashtable]::Synchronized(@{ diff --git a/pester/package.Tests.ps1 b/pester/package.Tests.ps1 index 64949c751d..71f5893a34 100644 --- a/pester/package.Tests.ps1 +++ b/pester/package.Tests.ps1 @@ -11,6 +11,12 @@ BeforeAll { . (Join-Path $script:repoRoot "functions\private\Install-WinUtilProgramChoco.ps1") function Invoke-WPFUIThread { } + function Write-WinUtilJobBanner { + param([string]$Message, [string]$Level) + } + function Write-WinUtilJobProgress { + param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay, [switch]$Hide) + } function Write-WinUtilLog { } } @@ -71,6 +77,15 @@ Describe "Get-WinUtilSelectedPackages" { @($result["Winget"]).Count | Should -Be 0 } + It "returns exactly one object so the caller can index the split" { + # A stray value on the output stream would make this an array, and every package list + # would then read back empty. + $result = @(Get-WinUtilSelectedPackages -PackageList @([pscustomobject]@{ winget = "Git.Git" }) -Preference "Winget") + + $result.Count | Should -Be 1 + (@($result[0]["Winget"]) -join "|") | Should -Be "Git.Git" + } + It "returns empty package lists for an empty selection" { $result = Get-WinUtilSelectedPackages -PackageList @() -Preference "Winget" From 074facc6b5b16d6fdb00da01e1acf6e7d59099f7 Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 08:02:22 +0200 Subject: [PATCH 08/70] Put every button that changes the system on the job layer Invoke-WPFButton now classifies the press instead of running it. Anything that changes the system gets a job; tab switches, selection helpers, window chrome and the WPFPanel* applet launchers stay on the interface thread. Updates, the Ultimate Performance plan, the Fixes buttons, OpenSSH Server, the system repair scan and the AppX query previously ran inline, which froze the window, produced no progress and interleaved their output with a running job. The job layer also owns the console banner now. Write-WinUtilJobBanner draws it once, so the eleven hand-drawn === boxes are gone and every operation announces its start, not only its end. - The job is named after what the button says, read from the config or the control itself, so there is no second list of labels to keep in step. - Show-WinUtilMessage replaces the last raw MessageBox calls, which could not have worked from a worker thread. - Write-WinUtilJobProgress replaces the last direct Set-WinUtilTaskbaritem calls. --- functions/private/Start-WinUtilJob.ps1 | 11 +- .../private/Test-WinUtilPackageManager.ps1 | 16 +-- functions/private/Write-WinUtilJobBanner.ps1 | 33 ++++++ functions/public/Invoke-WPFAppxInstall.ps1 | 6 +- functions/public/Invoke-WPFAppxRemoval.ps1 | 4 - functions/public/Invoke-WPFButton.ps1 | 100 ++++++++++++++++-- functions/public/Invoke-WPFFeatureInstall.ps1 | 7 +- functions/public/Invoke-WPFFixesNTPPool.ps1 | 3 - functions/public/Invoke-WPFFixesUpdate.ps1 | 16 +-- functions/public/Invoke-WPFFixesWinget.ps1 | 14 +-- functions/public/Invoke-WPFInstall.ps1 | 6 +- functions/public/Invoke-WPFInstallUpgrade.ps1 | 23 ++-- functions/public/Invoke-WPFSSHServer.ps1 | 11 +- functions/public/Invoke-WPFSystemRepair.ps1 | 17 ++- .../public/Invoke-WPFUltimatePerformance.ps1 | 21 +++- functions/public/Invoke-WPFUnInstall.ps1 | 6 +- functions/public/Invoke-WPFUpdatesdefault.ps1 | 3 - functions/public/Invoke-WPFUpdatesdisable.ps1 | 3 - .../public/Invoke-WPFUpdatessecurity.ps1 | 3 - functions/public/Invoke-WPFtweaksbutton.ps1 | 4 - functions/public/Invoke-WPFundoall.ps1 | 4 - pester/appx.Tests.ps1 | 9 ++ pester/runspace-lifecycle.Tests.ps1 | 4 +- pester/win11creator.Tests.ps1 | 1 - 24 files changed, 206 insertions(+), 119 deletions(-) create mode 100644 functions/private/Write-WinUtilJobBanner.ps1 diff --git a/functions/private/Start-WinUtilJob.ps1 b/functions/private/Start-WinUtilJob.ps1 index 3023c20d66..dcde1270cf 100644 --- a/functions/private/Start-WinUtilJob.ps1 +++ b/functions/private/Start-WinUtilJob.ps1 @@ -11,11 +11,13 @@ function Start-WinUtilJob { - refusing to start while another job is running, with one consistent message - the busy flag other code checks - the progress bar and taskbar item for the whole lifetime of the job + - the boxed start and finish banner in the console - a start, finish and failure line in the log under the job's own component - catching anything the body throws, so a failure cannot leave the UI stuck busy - restoring the interface in a finally block whatever happens - The body only has to do the work and call Write-WinUtilJobProgress. + The body only has to do the work and call Write-WinUtilJobProgress. It must not + print its own banner or set the busy flag. .PARAMETER Name Short job name. Used as the log component and in progress text, for example Install. @@ -61,6 +63,7 @@ function Start-WinUtilJob { $label = if ($Description) { $Description } else { $Name } Write-WinUtilLog -Component $Name -Message "$Name job started." + Write-WinUtilJobBanner -Message $label Write-WinUtilJobProgress -Status "$label..." -Percent 0 -State "Normal" -Overlay "logo" if ($DisableAppList -and $sync.Form -and $sync.Form.Dispatcher) { @@ -73,21 +76,23 @@ function Start-WinUtilJob { # state it was defined in, and recreating it there keeps it bound to the worker instead. Invoke-WPFRunspace -ParameterList @( ("JobName", $Name), + ("JobLabel", $label), ("JobBody", $ScriptBlock.ToString()), ("JobParameters", $Parameters), ("JobRestoresAppList", [bool]$DisableAppList) ) -ScriptBlock { - param($JobName, $JobBody, $JobParameters, $JobRestoresAppList) + param($JobName, $JobLabel, $JobBody, $JobParameters, $JobRestoresAppList) try { $body = [scriptblock]::Create($JobBody) & $body @JobParameters Write-WinUtilLog -Component $JobName -Message "$JobName job finished." + Write-WinUtilJobBanner -Message "$JobLabel finished" Write-WinUtilJobProgress -Status "$JobName finished" -Percent 100 -State "None" -Overlay "checkmark" } catch { Write-WinUtilLog -Level "ERROR" -Component $JobName -Message "$JobName job failed: $($_.Exception.Message)" - Write-Host "$JobName failed: $($_.Exception.Message)" + Write-WinUtilJobBanner -Message "$JobLabel failed: $($_.Exception.Message)" -Level "ERROR" Write-WinUtilJobProgress -Status "$JobName failed" -Percent 100 -State "Error" -Overlay "warning" } finally { if ($JobRestoresAppList -and $sync.Form -and $sync.Form.Dispatcher) { diff --git a/functions/private/Test-WinUtilPackageManager.ps1 b/functions/private/Test-WinUtilPackageManager.ps1 index ed7546d85b..1e0d0ce4fb 100644 --- a/functions/private/Test-WinUtilPackageManager.ps1 +++ b/functions/private/Test-WinUtilPackageManager.ps1 @@ -19,28 +19,20 @@ function Test-WinUtilPackageManager { if ($winget) { if (Get-Command winget -ErrorAction SilentlyContinue) { - Write-Host "===========================================" -ForegroundColor Green - Write-Host "--- WinGet is installed ---" -ForegroundColor Green - Write-Host "===========================================" -ForegroundColor Green + Write-WinUtilJobBanner -Message "WinGet is installed" $status = "installed" } else { - Write-Host "===========================================" -ForegroundColor Red - Write-Host "--- WinGet is not installed ---" -ForegroundColor Red - Write-Host "===========================================" -ForegroundColor Red + Write-WinUtilJobBanner -Message "WinGet is not installed" -Level "ERROR" $status = "not-installed" } } if ($choco) { if (Get-Command choco -ErrorAction SilentlyContinue) { - Write-Host "===========================================" -ForegroundColor Green - Write-Host "--- Chocolatey is installed ---" -ForegroundColor Green - Write-Host "===========================================" -ForegroundColor Green + Write-WinUtilJobBanner -Message "Chocolatey is installed" $status = "installed" } else { - Write-Host "===========================================" -ForegroundColor Red - Write-Host "--- Chocolatey is not installed ---" -ForegroundColor Red - Write-Host "===========================================" -ForegroundColor Red + Write-WinUtilJobBanner -Message "Chocolatey is not installed" -Level "ERROR" $status = "not-installed" } } diff --git a/functions/private/Write-WinUtilJobBanner.ps1 b/functions/private/Write-WinUtilJobBanner.ps1 new file mode 100644 index 0000000000..1615ad1580 --- /dev/null +++ b/functions/private/Write-WinUtilJobBanner.ps1 @@ -0,0 +1,33 @@ +function Write-WinUtilJobBanner { + <# + .SYNOPSIS + Writes the boxed start, finish or failure line a job prints to the console + + .DESCRIPTION + One place decides what a running operation looks like in the terminal, so every + workflow announces itself the same way instead of hand-drawing its own box. Called + by Start-WinUtilJob, not by job bodies. + + .PARAMETER Message + The line to box, for example "Installing apps". + + .PARAMETER Level + INFO for a normal banner, ERROR to colour it as a failure. + #> + param( + [Parameter(Mandatory)] + [string]$Message, + + [ValidateSet("INFO", "ERROR")] + [string]$Level = "INFO" + ) + + $line = "-- $Message --" + $border = "=" * $line.Length + $colour = if ($Level -eq "ERROR") { "Red" } else { "Cyan" } + + Write-Host "" + Write-Host $border -ForegroundColor $colour + Write-Host $line -ForegroundColor $colour + Write-Host $border -ForegroundColor $colour +} diff --git a/functions/public/Invoke-WPFAppxInstall.ps1 b/functions/public/Invoke-WPFAppxInstall.ps1 index b6006ca747..c5fc8b7358 100644 --- a/functions/public/Invoke-WPFAppxInstall.ps1 +++ b/functions/public/Invoke-WPFAppxInstall.ps1 @@ -4,7 +4,7 @@ function Invoke-WPFAppxInstall { return } - Start-WinUtilJob -Name "AppX install" -Description "Preparing AppX install" -Parameters @{ + Start-WinUtilJob -Name "AppX install" -Description "Installing AppX packages" -Parameters @{ Selected = @($sync.selectedAppx) Apps = $sync.configs.appxHashtable } -ScriptBlock { @@ -22,9 +22,5 @@ function Invoke-WPFAppxInstall { Install-WinUtilAPPX -Name $app.PackageId -StoreId $app.StoreId Write-WinUtilJobProgress -Status "Installed $($app.Content) ($position/$totalPackages)" -Percent ([int](($position / $totalPackages) * 100)) } - - Write-Host "=================================" - Write-Host "-- AppX Install Finished ---" - Write-Host "=================================" } } diff --git a/functions/public/Invoke-WPFAppxRemoval.ps1 b/functions/public/Invoke-WPFAppxRemoval.ps1 index c279c0ccc5..4464ea0511 100644 --- a/functions/public/Invoke-WPFAppxRemoval.ps1 +++ b/functions/public/Invoke-WPFAppxRemoval.ps1 @@ -60,9 +60,5 @@ function Invoke-WPFAppxRemoval { Write-WinUtilJobProgress -Status "Removing provisioned AppX packages" -Percent 90 Remove-WinUtilProvisionedAPPX -PackageList $packageList.ToArray() } - - Write-Host "=================================" - Write-Host "-- AppX Removal Finished ---" - Write-Host "=================================" } } diff --git a/functions/public/Invoke-WPFButton.ps1 b/functions/public/Invoke-WPFButton.ps1 index c778e1dbf0..45736efc8f 100644 --- a/functions/public/Invoke-WPFButton.ps1 +++ b/functions/public/Invoke-WPFButton.ps1 @@ -3,7 +3,14 @@ function Invoke-WPFButton { <# .SYNOPSIS - Invokes the function associated with the clicked button + Routes a button press, deciding whether it is interface work or a job + + .DESCRIPTION + This is the one place that classifies a button. Anything that changes the system runs + through Start-WinUtilJob, which means it gets the busy flag, the progress bar, the + taskbar item, the console banner and the log lines without each workflow arranging that + for itself. Anything that only changes what the interface is showing runs here and now, + because pushing it onto a worker would just make it slower. .PARAMETER Button The name of the button that was clicked @@ -12,13 +19,91 @@ function Invoke-WPFButton { Param ([string]$Button) - # Use this to get the name of the button - #[System.Windows.MessageBox]::Show("$Button","Chris Titus Tech's Windows Utility","OK","Info") # Clear the progress left behind by the previous job, but never while one is running if (-not $sync.ActiveJob) { Write-WinUtilJobProgress -Hide } + # Buttons that only change what the interface shows. Tab switches, selection helpers, + # window chrome, and the WPFPanel* entries that hand off to a Windows applet. + $interfaceOnly = @( + "WPFCloseButton", "WPFMinimizeButton", "WPFMaximizeButton", "WPFselectedAppsButton", + "WPFCollapseAllCategories", "WPFExpandAllCategories", + "WPFStandard", "WPFMinimal", "WPFAdvanced", + "WPFClearTweaksSelection", "WPFClearInstallSelection", + "WPFAppxRemoval", "WPFBackToTweaks", + "WPFDefaultAppxSelection", "WPFSelectAllAppx", "WPFClearAppxSelection" + ) + + # Workflow entrypoints that start their own job, because they read and validate the current + # selection on this thread first, and name the job after what the user actually chose. + $selfManaged = @( + "WPFInstall", "WPFUninstall", "WPFtweaksbutton", "WPFundoall", "WPFOOSUbutton", + "WPFGetInstalled", "WPFGetInstalledTweaks", + "WPFInstallSelectedAppx", "WPFRemoveSelectedAppx", "WPFFeatureInstall" + ) + + if ($Button -like "WPFTab?BT" -or $Button -like "WPFPanel*" -or + $interfaceOnly -contains $Button -or $selfManaged -contains $Button) { + Invoke-WPFButtonAction -Button $Button + return + } + + Start-WinUtilJob -Name (Get-WinUtilButtonLabel -Button $Button) -Parameters @{ + Button = $Button + } -ScriptBlock { + param($Button) + + Invoke-WPFButtonAction -Button $Button + } +} + +function Get-WinUtilButtonLabel { + <# + .SYNOPSIS + Returns the name a button's job should be reported under. + + .DESCRIPTION + Whatever the button says is what the progress bar, the banner and the log say, so the + wording never drifts from the interface and there is no second list to maintain. Falls + back to the button name when there is nothing to read. + #> + param([string]$Button) + + $content = $sync.configs.feature.$Button.Content + if (-not [string]::IsNullOrWhiteSpace($content)) { + return $content + } + + $control = $sync.$Button + if ($control -and $control.Content) { + $text = if ($control.Content -is [string]) { $control.Content } else { $control.Content.Text } + if (-not [string]::IsNullOrWhiteSpace($text)) { + return ([string]$text).Trim() + } + } + + return ($Button -replace '^WPF', '') +} + +function Invoke-WPFButtonAction { + + <# + + .SYNOPSIS + Invokes the function associated with the clicked button + + .DESCRIPTION + The work itself. Called by Invoke-WPFButton, either directly or from inside a job, so it + must not concern itself with progress, busy state or banners. + + .PARAMETER Button + The name of the button that was clicked + + #> + + Param ([string]$Button) + # Check if button is defined in feature config with function or InvokeScript if ($sync.configs.feature.$Button) { $buttonConfig = $sync.configs.feature.$Button @@ -79,9 +164,12 @@ function Invoke-WPFButton { } "WPFGetInstalledAppx" { $installedAppxPackages = Get-WinUtilInstalledAPPX - foreach ($appx in $sync.configs.appxHashtable.GetEnumerator()) { - if ($appx.Value.PackageId -in $installedAppxPackages) { - $sync.$($appx.Key).IsChecked = $true + Invoke-WPFUIThread -Parameters @{ Installed = $installedAppxPackages } -ScriptBlock { + param($Installed) + foreach ($appx in $sync.configs.appxHashtable.GetEnumerator()) { + if ($appx.Value.PackageId -in $Installed) { + $sync.$($appx.Key).IsChecked = $true + } } } } diff --git a/functions/public/Invoke-WPFFeatureInstall.ps1 b/functions/public/Invoke-WPFFeatureInstall.ps1 index c347e6d327..bbe9f632af 100644 --- a/functions/public/Invoke-WPFFeatureInstall.ps1 +++ b/functions/public/Invoke-WPFFeatureInstall.ps1 @@ -11,7 +11,7 @@ function Invoke-WPFFeatureInstall { return } - Start-WinUtilJob -Name "Features" -Description "Preparing Windows Features" -Parameters @{ + Start-WinUtilJob -Name "Features" -Description "Installing Windows features" -Parameters @{ Features = @($sync.selectedFeatures) } -ScriptBlock { param($Features) @@ -26,9 +26,6 @@ function Invoke-WPFFeatureInstall { Write-WinUtilJobProgress -Status "Installed $feature ($completed/$total)" -Percent ([int](($completed / $total) * 100)) } - Write-Host "===================================" - Write-Host "--- Features are Installed ---" - Write-Host "--- A Reboot may be required ---" - Write-Host "===================================" + Write-Host "A reboot may be required." } } diff --git a/functions/public/Invoke-WPFFixesNTPPool.ps1 b/functions/public/Invoke-WPFFixesNTPPool.ps1 index f858879f2d..610ff0741e 100644 --- a/functions/public/Invoke-WPFFixesNTPPool.ps1 +++ b/functions/public/Invoke-WPFFixesNTPPool.ps1 @@ -14,7 +14,4 @@ function Invoke-WPFFixesNTPPool { Restart-Service w32time w32tm /resync - Write-Host "=================================" - Write-Host "-- NTP Configuration Complete ---" - Write-Host "=================================" } diff --git a/functions/public/Invoke-WPFFixesUpdate.ps1 b/functions/public/Invoke-WPFFixesUpdate.ps1 index 4d7cfcefa4..be5077ac9d 100644 --- a/functions/public/Invoke-WPFFixesUpdate.ps1 +++ b/functions/public/Invoke-WPFFixesUpdate.ps1 @@ -30,7 +30,7 @@ function Invoke-WPFFixesUpdate { param($Aggressive = $false) Write-Progress -Id 0 -Activity "Repairing Windows Update" -PercentComplete 0 - Set-WinUtilTaskbaritem -state "Indeterminate" -overlay "logo" + Write-WinUtilJobProgress -State "Indeterminate" Write-Host "Starting Windows Update Repair..." # Wait for the first progress bar to show, otherwise the second one won't show Start-Sleep -Milliseconds 200 @@ -192,24 +192,14 @@ function Invoke-WPFFixesUpdate { try { (New-Object -ComObject Microsoft.Update.AutoUpdate).DetectNow() } catch { - Set-WinUtilTaskbaritem -state "Error" -overlay "warning" + Write-WinUtilLog -Level "ERROR" -Component "Updates" -Message "Failed to create Windows Update COM object: $_" Write-Warning "Failed to create Windows Update COM object: $_" } Start-Process -NoNewWindow -FilePath "wuauclt" -ArgumentList "/resetauthorization", "/detectnow" Write-Progress -Id 10 -ParentId 0 -Activity "Forcing discovery" -Status "Completed" -PercentComplete 100 Write-Progress -Id 0 -Activity "Repairing Windows Update" -Status "Completed" -PercentComplete 100 - Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" - - $ButtonType = [System.Windows.MessageBoxButton]::OK - $MessageboxTitle = "Reset Windows Update " - $Messageboxbody = ("Stock settings loaded.`n Please reboot your computer") - $MessageIcon = [System.Windows.MessageBoxImage]::Information - - [System.Windows.MessageBox]::Show($Messageboxbody, $MessageboxTitle, $ButtonType, $MessageIcon) - Write-Host "===============================================" - Write-Host "-- Reset All Windows Update Settings to Stock -" - Write-Host "===============================================" + Show-WinUtilMessage -Message "Stock settings loaded.`n Please reboot your computer" -Title "Reset Windows Update" -Button "OK" -Icon "Information" | Out-Null # Remove the progress bars Write-Progress -Id 0 -Activity "Repairing Windows Update" -Completed diff --git a/functions/public/Invoke-WPFFixesWinget.ps1 b/functions/public/Invoke-WPFFixesWinget.ps1 index f41f371081..cb8c3b8f7d 100644 --- a/functions/public/Invoke-WPFFixesWinget.ps1 +++ b/functions/public/Invoke-WPFFixesWinget.ps1 @@ -7,17 +7,7 @@ function Invoke-WPFFixesWinget { .DESCRIPTION BravoNorris for the fantastic idea of a button to reinstall WinGet #> - # Install Choco if not already present - try { - Set-WinUtilTaskbaritem -state "Indeterminate" -overlay "logo" - Write-Host "==> Starting WinGet Repair" - Install-WinUtilWinget - } catch { - Write-Error "Failed to install WinGet: $_" - Set-WinUtilTaskbaritem -state "Error" -overlay "warning" - } finally { - Write-Host "==> Finished WinGet Repair" - Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" - } + Write-WinUtilJobProgress -Status "Repairing WinGet" -State "Indeterminate" + Install-WinUtilWinget } diff --git a/functions/public/Invoke-WPFInstall.ps1 b/functions/public/Invoke-WPFInstall.ps1 index 2f7728036f..31a00e74d7 100644 --- a/functions/public/Invoke-WPFInstall.ps1 +++ b/functions/public/Invoke-WPFInstall.ps1 @@ -17,7 +17,7 @@ function Invoke-WPFInstall { $ManagerPreference = $sync.preferences.packagemanager Write-WinUtilLog -Component "Install" -Message "Install requested for $(@($PackagesToInstall).Count) selected package(s) using preference: $ManagerPreference" - Start-WinUtilJob -Name "Install" -Description "Preparing app install" -DisableAppList -Parameters @{ + Start-WinUtilJob -Name "Install" -Description "Installing apps" -DisableAppList -Parameters @{ PackagesToInstall = $PackagesToInstall ManagerPreference = $ManagerPreference } -ScriptBlock { @@ -55,9 +55,5 @@ function Invoke-WPFInstall { $completedPackages += @($packagesChoco).Count Write-WinUtilJobProgress -Status "Installed Chocolatey packages ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) } - - Write-Host "===========================================" - Write-Host "-- Installs have finished ---" - Write-Host "===========================================" } } diff --git a/functions/public/Invoke-WPFInstallUpgrade.ps1 b/functions/public/Invoke-WPFInstallUpgrade.ps1 index 8db0167414..b0c9111736 100644 --- a/functions/public/Invoke-WPFInstallUpgrade.ps1 +++ b/functions/public/Invoke-WPFInstallUpgrade.ps1 @@ -1,21 +1,24 @@ function Invoke-WPFInstallUpgrade { + <# + + .SYNOPSIS + Upgrades every installed package, in a window of its own so the user can close WinUtil + + #> + if ($sync.ChocoRadioButton.IsChecked) { + Write-WinUtilJobProgress -Status "Preparing Chocolatey" -State "Indeterminate" Install-WinUtilChoco # Ensure Chocolatey is installed before upgrading - Write-Host "===========================================" - Write-Host "-- Updates started ---" - Write-Host "-- You can close this window if desired ---" - Write-Host "===========================================" - + Write-WinUtilLog -Component "Install" -Message "Starting a Chocolatey upgrade of all packages in a separate window." Start-Process -FilePath powershell.exe -ArgumentList 'choco upgrade all -y' } else { + Write-WinUtilJobProgress -Status "Preparing WinGet" -State "Indeterminate" Install-WinUtilWinget # Ensure WinGet is installed before upgrading - Write-Host "===========================================" - Write-Host "-- Updates started ---" - Write-Host "-- You can close this window if desired ---" - Write-Host "===========================================" - + Write-WinUtilLog -Component "Install" -Message "Starting a WinGet upgrade of all packages in a separate window." Start-Process -FilePath powershell.exe -ArgumentList '-NoExit winget upgrade --all --include-unknown --silent --accept-source-agreements --accept-package-agreements' } + + Write-Host "The upgrade runs in its own window. You can close WinUtil while it works." } diff --git a/functions/public/Invoke-WPFSSHServer.ps1 b/functions/public/Invoke-WPFSSHServer.ps1 index 0ea6de59ad..3702248bd6 100644 --- a/functions/public/Invoke-WPFSSHServer.ps1 +++ b/functions/public/Invoke-WPFSSHServer.ps1 @@ -2,16 +2,9 @@ function Invoke-WPFSSHServer { <# .SYNOPSIS - Invokes the OpenSSH Server install in a runspace + Installs and starts the OpenSSH Server #> - Invoke-WPFRunspace -ScriptBlock { - - Invoke-WinUtilSSHServer - - Write-Host "=======================================" - Write-Host "-- OpenSSH Server installed! ---" - Write-Host "=======================================" - } + Invoke-WinUtilSSHServer } diff --git a/functions/public/Invoke-WPFSystemRepair.ps1 b/functions/public/Invoke-WPFSystemRepair.ps1 index a44a5c0c25..1299e31a80 100644 --- a/functions/public/Invoke-WPFSystemRepair.ps1 +++ b/functions/public/Invoke-WPFSystemRepair.ps1 @@ -10,10 +10,17 @@ function Invoke-WPFSystemRepair { 3. DISM - Repair a corrupted Windows operating system image #> - Start-Process cmd.exe -ArgumentList "/c chkdsk /scan /perf" -NoNewWindow -Wait - Start-Process cmd.exe -ArgumentList "/c sfc /scannow" -NoNewWindow -Wait - Start-Process cmd.exe -ArgumentList "/c dism /online /cleanup-image /restorehealth" -NoNewWindow -Wait + $steps = @( + @{ Label = "Checking the disk for errors"; Arguments = "/c chkdsk /scan /perf" }, + @{ Label = "Scanning protected system files"; Arguments = "/c sfc /scannow" }, + @{ Label = "Repairing the Windows image"; Arguments = "/c dism /online /cleanup-image /restorehealth" } + ) - Write-Host "==> Finished System Repair" - Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" + $completed = 0 + foreach ($step in $steps) { + Write-WinUtilJobProgress -Status "$($step.Label) ($($completed + 1)/$($steps.Count))" -Percent ([int](($completed / $steps.Count) * 100)) + Write-WinUtilLog -Component "SystemRepair" -Message $step.Label + Start-Process cmd.exe -ArgumentList $step.Arguments -NoNewWindow -Wait + $completed++ + } } diff --git a/functions/public/Invoke-WPFUltimatePerformance.ps1 b/functions/public/Invoke-WPFUltimatePerformance.ps1 index 20ffa02672..70cffbac70 100644 --- a/functions/public/Invoke-WPFUltimatePerformance.ps1 +++ b/functions/public/Invoke-WPFUltimatePerformance.ps1 @@ -1,9 +1,26 @@ function Invoke-WPFUltimatePerformance ([switch]$Enable) { + <# + + .SYNOPSIS + Adds or removes the Ultimate Performance power plan + + #> + if ($Enable) { + Write-WinUtilJobProgress -Status "Adding the Ultimate Performance power plan" -State "Indeterminate" + Write-WinUtilLog -Component "Power" -Message "Duplicating and activating the Ultimate Performance power plan." + powercfg /setactive (powercfg /duplicatescheme e9a42b02-d5df-448d-aa00-03f14749eb61 | Select-String -Pattern '[A-Fa-f0-9-]{36}').Matches.Value - [System.Windows.MessageBox]::Show("Ultimate Power Plan plan installed and activated.","Success","OK","Information") + + Write-Host "Ultimate Performance power plan installed and activated." + Show-WinUtilMessage -Message "Ultimate Power Plan plan installed and activated." -Title "Success" -Button "OK" -Icon "Information" | Out-Null } else { + Write-WinUtilJobProgress -Status "Restoring the default power plans" -State "Indeterminate" + Write-WinUtilLog -Component "Power" -Message "Restoring the default power schemes." + powercfg /restoredefaultschemes - [System.Windows.MessageBox]::Show("Power Plan was reset to defaults.","Success","OK","Information") + + Write-Host "Power plan was reset to defaults." + Show-WinUtilMessage -Message "Power Plan was reset to defaults." -Title "Success" -Button "OK" -Icon "Information" | Out-Null } } diff --git a/functions/public/Invoke-WPFUnInstall.ps1 b/functions/public/Invoke-WPFUnInstall.ps1 index bd6fd4d6be..3f9e24b248 100644 --- a/functions/public/Invoke-WPFUnInstall.ps1 +++ b/functions/public/Invoke-WPFUnInstall.ps1 @@ -27,7 +27,7 @@ function Invoke-WPFUnInstall { $ManagerPreference = $sync.preferences.packagemanager Write-WinUtilLog -Component "Uninstall" -Message "Uninstall requested for $(@($PackagesToUninstall).Count) selected package(s) using preference: $ManagerPreference" - Start-WinUtilJob -Name "Uninstall" -Description "Preparing app uninstall" -DisableAppList -Parameters @{ + Start-WinUtilJob -Name "Uninstall" -Description "Uninstalling apps" -DisableAppList -Parameters @{ PackagesToUninstall = $PackagesToUninstall ManagerPreference = $ManagerPreference } -ScriptBlock { @@ -66,9 +66,5 @@ function Invoke-WPFUnInstall { $completedPackages += @($packagesChoco).Count Write-WinUtilJobProgress -Status "Uninstalled Chocolatey packages ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) } - - Write-Host "===========================================" - Write-Host "-- Uninstalls have finished ---" - Write-Host "===========================================" } } diff --git a/functions/public/Invoke-WPFUpdatesdefault.ps1 b/functions/public/Invoke-WPFUpdatesdefault.ps1 index 8b250caa5f..0374ebcbc3 100644 --- a/functions/public/Invoke-WPFUpdatesdefault.ps1 +++ b/functions/public/Invoke-WPFUpdatesdefault.ps1 @@ -82,9 +82,6 @@ function Invoke-WPFUpdatesdefault { Get-ScheduledTask -TaskPath $Task -ErrorAction SilentlyContinue | Enable-ScheduledTask -ErrorAction SilentlyContinue } - Write-Host "===================================================" -ForegroundColor Green - Write-Host "--- Windows Update Settings Reset to Default ---" -ForegroundColor Green - Write-Host "===================================================" -ForegroundColor Green Write-Host "Note: You must restart your system in order for all changes to take effect." -ForegroundColor Yellow Write-WinUtilLog -Component "Updates" -Message "Windows Update default workflow completed. Restart required." diff --git a/functions/public/Invoke-WPFUpdatesdisable.ps1 b/functions/public/Invoke-WPFUpdatesdisable.ps1 index 547bb9f0ee..d7b9d9ad1c 100644 --- a/functions/public/Invoke-WPFUpdatesdisable.ps1 +++ b/functions/public/Invoke-WPFUpdatesdisable.ps1 @@ -57,9 +57,6 @@ function Invoke-WPFUpdatesdisable { Get-ScheduledTask -TaskPath $Task -ErrorAction SilentlyContinue | Disable-ScheduledTask -ErrorAction SilentlyContinue } - Write-Host "=================================" -ForegroundColor Green - Write-Host "--- Windows Update Is Disabled ---" -ForegroundColor Green - Write-Host "=================================" -ForegroundColor Green Write-Host "Note: You must restart your system in order for all changes to take effect." -ForegroundColor Yellow Write-WinUtilLog -Component "Updates" -Message "Windows Update disable workflow completed. Restart required." diff --git a/functions/public/Invoke-WPFUpdatessecurity.ps1 b/functions/public/Invoke-WPFUpdatessecurity.ps1 index 09eaf2028e..e5aed39c61 100644 --- a/functions/public/Invoke-WPFUpdatessecurity.ps1 +++ b/functions/public/Invoke-WPFUpdatessecurity.ps1 @@ -76,8 +76,5 @@ function Invoke-WPFUpdatessecurity { Set-ItemProperty -Path $automaticUpdatePolicyPath -Name "NoAutoRebootWithLoggedOnUsers" -Type DWord -Value 1 Set-ItemProperty -Path $automaticUpdatePolicyPath -Name "AUPowerManagement" -Type DWord -Value 0 - Write-Host "=================================" - Write-Host "-- Updates Set to Recommended ---" - Write-Host "=================================" Write-WinUtilLog -Component "Updates" -Message "Recommended Windows Update settings workflow completed." } diff --git a/functions/public/Invoke-WPFtweaksbutton.ps1 b/functions/public/Invoke-WPFtweaksbutton.ps1 index 0c4e1db3b1..c22e15e06b 100644 --- a/functions/public/Invoke-WPFtweaksbutton.ps1 +++ b/functions/public/Invoke-WPFtweaksbutton.ps1 @@ -48,9 +48,5 @@ function Invoke-WPFtweaksbutton { $completedSteps++ Write-WinUtilJobProgress -Percent ([int](($completedSteps / $totalSteps) * 100)) } - - Write-Host "=================================" - Write-Host "-- Tweaks are Finished ---" - Write-Host "=================================" } } diff --git a/functions/public/Invoke-WPFundoall.ps1 b/functions/public/Invoke-WPFundoall.ps1 index 516087dc59..e3f986f4b8 100644 --- a/functions/public/Invoke-WPFundoall.ps1 +++ b/functions/public/Invoke-WPFundoall.ps1 @@ -26,9 +26,5 @@ function Invoke-WPFundoall { Invoke-WinUtiltweaks $Tweaks[$i] -undo $true Write-WinUtilJobProgress -Percent ([int]((($i + 1) / $total) * 100)) } - - Write-Host "==================================" - Write-Host "--- Undo Tweaks are Finished ---" - Write-Host "==================================" } } diff --git a/pester/appx.Tests.ps1 b/pester/appx.Tests.ps1 index 814d5a3eab..49cb15a443 100644 --- a/pester/appx.Tests.ps1 +++ b/pester/appx.Tests.ps1 @@ -237,6 +237,12 @@ Describe "Get installed AppX selection" { Mock Get-WinUtilInstalledAPPX { @("Example.Package") } Mock Invoke-WPFAppxInstall { } + Mock Write-WinUtilJobProgress { } + Mock Invoke-WPFUIThread { $uiParameters = $Parameters; & $ScriptBlock @uiParameters } + Mock Start-WinUtilJob { + $jobParameters = $Parameters + & $ScriptBlock @jobParameters + } } AfterEach { @@ -246,6 +252,9 @@ Describe "Get installed AppX selection" { It "selects configured packages returned by the compatibility-safe query" { Invoke-WPFButton -Button "WPFGetInstalledAppx" + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "GetInstalledAppx" + } Should -Invoke -CommandName Get-WinUtilInstalledAPPX -Times 1 -Exactly $script:sync.WPFAppxExample.IsChecked | Should -BeTrue $script:sync.WPFAppxMissing.IsChecked | Should -BeFalse diff --git a/pester/runspace-lifecycle.Tests.ps1 b/pester/runspace-lifecycle.Tests.ps1 index 1b046a837e..e83513ea99 100644 --- a/pester/runspace-lifecycle.Tests.ps1 +++ b/pester/runspace-lifecycle.Tests.ps1 @@ -82,8 +82,8 @@ Describe "Runspace startup wiring" { foreach ($variableName in @("sync", "PARAM_OFFLINE", "inputXML", "WinUtilAutounattendXml")) { $sessionStateScript | Should -Match ([regex]::Escape("Name = `"$variableName`"")) } - # Every WinUtil function has to travel, not just the ones matching a name pattern: - # the interface runspace builds tabs and job bodies call arbitrary helpers. + # The interface runspace builds tabs and job bodies call arbitrary helpers, so every + # WinUtil function has to be carried over, not a name-matched subset. $sessionStateScript | Should -Match 'foreach \(\$function in \(Get-ChildItem function:\\\)\)' $sessionStateScript | Should -Match '\$builtInFunctions\.Contains\(\$function\.Name\)' $sessionStateScript | Should -Not -Match "imatch 'winutil\|WPF'" diff --git a/pester/win11creator.Tests.ps1 b/pester/win11creator.Tests.ps1 index 77780079aa..0034f3c0b0 100644 --- a/pester/win11creator.Tests.ps1 +++ b/pester/win11creator.Tests.ps1 @@ -199,7 +199,6 @@ Describe "Win11 Creator setup media" { foreach ($source in @($content, $usbContent)) { $source | Should -Match ([regex]::Escape('Write-WinUtilJobProgress -Status')) - # No hand-rolled Log/SetProgress helpers inside job bodies any more $source | Should -Not -Match '(?m)^\s*function (Log|SetProgress)\(' $source | Should -Not -Match ([regex]::Escape('$sync["WPFTweaksProgressLabel"]')) } From 5069853806a9f2eaf825383fbca9dc06a46ea5cd Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 08:02:30 +0200 Subject: [PATCH 09/70] Document button routing and the job banner --- .../src/content/docs/code-reference/architecture.mdx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/src/content/docs/code-reference/architecture.mdx b/docs/src/content/docs/code-reference/architecture.mdx index 270c0ee91e..f9e8fb48ed 100644 --- a/docs/src/content/docs/code-reference/architecture.mdx +++ b/docs/src/content/docs/code-reference/architecture.mdx @@ -425,10 +425,14 @@ instead of disappearing. ## Long-Running Work -Every long action goes through `Start-WinUtilJob`, which owns the parts each one used to -repeat: refusing to start while another job runs, the busy flag (`$sync.ActiveJob`), the -progress bar and taskbar item, a start/finish/failure line in the log, and restoring the -interface in a `finally` whatever happens. +Every long action goes through `Start-WinUtilJob`, which owns everything a running operation +needs: refusing to start while another job runs, the busy flag (`$sync.ActiveJob`), the progress +bar and taskbar item, the boxed console banner, a start/finish/failure line in the log, and +restoring the interface in a `finally` whatever happens. + +`Invoke-WPFButton` decides what counts as a long action. Anything that changes the system gets a +job; anything that only changes what the interface is showing runs on the interface thread. +That single classification is why no workflow arranges its own progress, banner or busy state. ```powershell Start-WinUtilJob -Name "Features" -Description "Installing Windows Features" -Parameters @{ From 5b75feeb9e7a811aeeb3c2a5d5fe17fba8ee208f Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 08:06:13 +0200 Subject: [PATCH 10/70] Read function bodies from the FunctionInfo instead of the function provider Building the session state is on the path to first paint, and going through function:\ for every function cost about as much as the whole interface runspace saved. Time to first window is back level with upstream. --- functions/private/New-WinUtilSessionState.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/private/New-WinUtilSessionState.ps1 b/functions/private/New-WinUtilSessionState.ps1 index 5297604bcc..a63daa84f1 100644 --- a/functions/private/New-WinUtilSessionState.ps1 +++ b/functions/private/New-WinUtilSessionState.ps1 @@ -41,7 +41,7 @@ function New-WinUtilSessionState { } $initialSessionState.Commands.Add( - (New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList $function.Name, (Get-Content function:\$($function.Name))) + (New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList $function.Name, $function.Definition) ) } From 997cdbf74d460494478ed2804176a3280406bd42 Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 08:16:37 +0200 Subject: [PATCH 11/70] Render the taskbar overlays after first paint The logo overlay render costs about 55ms and nothing can see it until the window is up, so it no longer sits between the interface being built and being shown. Both the logo and the status overlays are now rendered from the same deferred call once the window has painted. --- functions/private/Start-WinUtilUserInterface.ps1 | 8 ++++---- pester/assets.Tests.ps1 | 9 ++++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/functions/private/Start-WinUtilUserInterface.ps1 b/functions/private/Start-WinUtilUserInterface.ps1 index 9b6d61ed05..a82aa65ee1 100644 --- a/functions/private/Start-WinUtilUserInterface.ps1 +++ b/functions/private/Start-WinUtilUserInterface.ps1 @@ -267,7 +267,10 @@ function Start-WinUtilUserInterface { $sync["Form"].Focus() $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Initialize-WinUtilRunspacePool | Out-Null }) | Out-Null - $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $false -IncludeStatusAssets $true }) | Out-Null + $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ + Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $true -IncludeStatusAssets $true + Set-WinUtilTaskbaritem -overlay "logo" + }) | Out-Null }) # The SearchBarTimer is used to delay the search operation until the user has stopped typing for a short period @@ -338,9 +341,6 @@ function Start-WinUtilUserInterface { $NavLogoPanel = $sync["Form"].FindName("NavLogoPanel") $NavLogoPanel.Children.Add((Invoke-WinUtilAssets -Type "logo" -Size 25)) | Out-Null - Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $true -IncludeStatusAssets $false - - Set-WinUtilTaskbaritem -overlay "logo" $sync["Form"].Add_Activated({ Set-WinUtilTaskbaritem -overlay "logo" diff --git a/pester/assets.Tests.ps1 b/pester/assets.Tests.ps1 index 072adf1067..f9433a89aa 100644 --- a/pester/assets.Tests.ps1 +++ b/pester/assets.Tests.ps1 @@ -16,11 +16,14 @@ Describe "Rendered asset caching" { $assetScript | Should -Match '\$sync\.RenderedAssetCache\[\$cacheKey\] = \$bitmapImage' } - It "renders only the logo overlay before first paint and defers status overlays" { + It "renders no taskbar overlay before first paint" { $uiScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1") -Raw + $beforeFirstPaint = $uiScript.Substring(0, $uiScript.IndexOf('Add_ContentRendered')) - $uiScript | Should -Match 'Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$true -IncludeStatusAssets \$false' - $uiScript | Should -Match 'Dispatcher\.BeginInvoke\(\[System\.Windows\.Threading\.DispatcherPriority\]::Background, \[action\]\{ Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$false -IncludeStatusAssets \$true \}' + # Rendering an overlay costs tens of milliseconds and nothing can see it until the + # window is up, so it belongs behind first paint. + $beforeFirstPaint | Should -Not -Match 'Initialize-WinUtilTaskbarOverlayAssets' + $uiScript | Should -Match '(?s)DispatcherPriority\]::Background, \[action\]\{\s+Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$true -IncludeStatusAssets \$true\s+Set-WinUtilTaskbaritem -overlay "logo"' $uiScript | Should -Not -Match '\$sync\["checkmarkrender"\] = \(Invoke-WinUtilAssets -Type "checkmark"' $uiScript | Should -Not -Match '\$sync\["warningrender"\] = \(Invoke-WinUtilAssets -Type "warning"' } From 5b1ac05b68c1de647ed2721e7e300cae25d1588c Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 08:16:37 +0200 Subject: [PATCH 12/70] Make a failed package fail the job Both package helpers ran the manager and moved on regardless of its exit code, so a run in which nothing installed still reported success with a green checkmark. They now emit a result per package, classified from the exit code: succeeded, skipped for WinGet telling us there was nothing to do, or failed. The workflow collects them and Complete-WinUtilPackageRun prints the summary and throws when anything failed, which is what puts the job into its failed state. --- .../private/Complete-WinUtilPackageRun.ps1 | 43 +++++++ .../private/Install-WinUtilProgramChoco.ps1 | 31 ++++- .../private/Install-WinUtilProgramWinget.ps1 | 41 ++++++- functions/public/Invoke-WPFInstall.ps1 | 8 +- functions/public/Invoke-WPFUnInstall.ps1 | 8 +- pester/install-workflow.Tests.ps1 | 3 + pester/package-outcome.Tests.ps1 | 112 ++++++++++++++++++ 7 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 functions/private/Complete-WinUtilPackageRun.ps1 create mode 100644 pester/package-outcome.Tests.ps1 diff --git a/functions/private/Complete-WinUtilPackageRun.ps1 b/functions/private/Complete-WinUtilPackageRun.ps1 new file mode 100644 index 0000000000..cb6f54a6ee --- /dev/null +++ b/functions/private/Complete-WinUtilPackageRun.ps1 @@ -0,0 +1,43 @@ +function Complete-WinUtilPackageRun { + <# + .SYNOPSIS + Reports what a package run actually did and fails the job if anything did not work + + .DESCRIPTION + Package managers report failure through an exit code, which is easy to walk past. + Without this the job layer would show a green checkmark for a run in which nothing + installed. Throwing here is what turns a failed package into a failed job. + + .PARAMETER Action + Install or Uninstall, used in the summary text. + + .PARAMETER Results + The result objects produced by Install-WinUtilProgramWinget and + Install-WinUtilProgramChoco. + #> + param( + [Parameter(Mandatory)] + [string]$Action, + + [object[]]$Results = @() + ) + + $succeeded = @($Results | Where-Object { $_.Outcome -eq "Succeeded" }) + $skipped = @($Results | Where-Object { $_.Outcome -eq "Skipped" }) + $failed = @($Results | Where-Object { $_.Outcome -eq "Failed" }) + + $summary = "$($succeeded.Count) succeeded, $($skipped.Count) skipped, $($failed.Count) failed" + Write-WinUtilLog -Component "Package" -Message "$Action summary: $summary" + Write-Host "$Action summary: $summary" + + foreach ($result in $skipped) { + Write-Host " skipped $($result.Package) - $($result.Detail)" + } + foreach ($result in $failed) { + Write-Host " failed $($result.Package) - $($result.Detail)" -ForegroundColor Red + } + + if ($failed.Count -gt 0) { + throw "$($failed.Count) of $($Results.Count) package(s) failed: $(($failed | ForEach-Object { $_.Package }) -join ', ')" + } +} diff --git a/functions/private/Install-WinUtilProgramChoco.ps1 b/functions/private/Install-WinUtilProgramChoco.ps1 index 9d9853e5e9..ff1cda8499 100644 --- a/functions/private/Install-WinUtilProgramChoco.ps1 +++ b/functions/private/Install-WinUtilProgramChoco.ps1 @@ -1,4 +1,14 @@ function Install-WinUtilProgramChoco { + <# + + .SYNOPSIS + Installs or uninstalls packages with Chocolatey and reports the outcome + + .DESCRIPTION + Chocolatey takes the whole package list in one call, so the result covers the batch + rather than an entry per package. + + #> param ( [Parameter(Mandatory=$true)] [ValidateSet("Install", "Uninstall")] @@ -16,5 +26,24 @@ function Install-WinUtilProgramChoco { Write-WinUtilLog -Component "Package" -Message "$Action choco package(s): $($Programs -join ', ')" $process = Start-Process -FilePath choco -ArgumentList $arguments -NoNewWindow -Wait -PassThru - Write-WinUtilLog -Component "Package" -Message "$Action choco package(s) completed: $($Programs -join ', ') (exit code: $($process.ExitCode))" + $exitCode = $process.ExitCode + + # 1641 and 3010 mean the work succeeded and Windows wants a reboot + if ($exitCode -in @(0, 1641, 3010)) { + $outcome = "Succeeded" + } else { + $outcome = "Failed" + } + + $level = if ($outcome -eq "Failed") { "ERROR" } else { "INFO" } + Write-WinUtilLog -Level $level -Component "Package" -Message "$Action choco package(s) $($outcome.ToLowerInvariant()): $($Programs -join ', ') (exit code: $exitCode)" + + [pscustomobject]@{ + Package = ($Programs -join ', ') + Manager = "choco" + Action = $Action + ExitCode = $exitCode + Outcome = $outcome + Detail = "exit code $exitCode" + } } diff --git a/functions/private/Install-WinUtilProgramWinget.ps1 b/functions/private/Install-WinUtilProgramWinget.ps1 index d4b6f1f4a1..1a79426185 100644 --- a/functions/private/Install-WinUtilProgramWinget.ps1 +++ b/functions/private/Install-WinUtilProgramWinget.ps1 @@ -1,4 +1,14 @@ Function Install-WinUtilProgramWinget { + <# + + .SYNOPSIS + Installs or uninstalls packages with WinGet and reports the outcome of each one + + .DESCRIPTION + Emits one result object per package so the caller can tell what actually happened + rather than assuming the run succeeded. + + #> param ( [Parameter(Mandatory=$true)] [ValidateSet("Install", "Uninstall")] @@ -8,6 +18,12 @@ Function Install-WinUtilProgramWinget { [string[]]$Programs ) + # WinGet reports "there was nothing to do" through the exit code rather than as success + $nothingToDo = @{ + -1978335135 = "already installed" + -1978335189 = "no applicable update" + } + foreach ($program in $Programs) { if ([string]::IsNullOrWhiteSpace($program) -or $program -eq "na") { continue @@ -27,6 +43,29 @@ Function Install-WinUtilProgramWinget { Write-WinUtilLog -Component "Package" -Message "$Action winget package: $program (source: $source)" $process = Start-Process -FilePath winget -ArgumentList $arguments -NoNewWindow -Wait -PassThru - Write-WinUtilLog -Component "Package" -Message "$Action winget package completed: $program (exit code: $($process.ExitCode))" + $exitCode = $process.ExitCode + + if ($exitCode -eq 0) { + $outcome = "Succeeded" + $detail = "exit code 0" + } elseif ($nothingToDo.ContainsKey($exitCode)) { + $outcome = "Skipped" + $detail = $nothingToDo[$exitCode] + } else { + $outcome = "Failed" + $detail = "exit code $exitCode" + } + + $level = if ($outcome -eq "Failed") { "ERROR" } else { "INFO" } + Write-WinUtilLog -Level $level -Component "Package" -Message "$Action winget package $($outcome.ToLowerInvariant()): $program ($detail)" + + [pscustomobject]@{ + Package = $program + Manager = "winget" + Action = $Action + ExitCode = $exitCode + Outcome = $outcome + Detail = $detail + } } } diff --git a/functions/public/Invoke-WPFInstall.ps1 b/functions/public/Invoke-WPFInstall.ps1 index 31a00e74d7..9fe6a53470 100644 --- a/functions/public/Invoke-WPFInstall.ps1 +++ b/functions/public/Invoke-WPFInstall.ps1 @@ -34,13 +34,15 @@ function Invoke-WPFInstall { $completedPackages = 0 Write-WinUtilLog -Component "Install" -Message "Install package manager split: winget=$(@($packagesWinget).Count), choco=$(@($packagesChoco).Count)" + $results = @() + if ($packagesWinget.Count -gt 0 -and $packagesWinget -ne "0") { Install-WinUtilWinget foreach ($program in $packagesWinget) { $position = $completedPackages + 1 Write-WinUtilJobProgress -Status "Installing $program ($position/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) - Install-WinUtilProgramWinget -Action Install -Programs @($program) + $results += Install-WinUtilProgramWinget -Action Install -Programs @($program) $completedPackages++ Write-WinUtilJobProgress -Status "Installed $program ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) } @@ -51,9 +53,11 @@ function Invoke-WPFInstall { Write-WinUtilJobProgress -Status "Installing Chocolatey packages ($position/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) Install-WinUtilChoco - Install-WinUtilProgramChoco -Action Install -Programs $packagesChoco + $results += Install-WinUtilProgramChoco -Action Install -Programs $packagesChoco $completedPackages += @($packagesChoco).Count Write-WinUtilJobProgress -Status "Installed Chocolatey packages ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) } + + Complete-WinUtilPackageRun -Action "Install" -Results $results } } diff --git a/functions/public/Invoke-WPFUnInstall.ps1 b/functions/public/Invoke-WPFUnInstall.ps1 index 3f9e24b248..cc469ef205 100644 --- a/functions/public/Invoke-WPFUnInstall.ps1 +++ b/functions/public/Invoke-WPFUnInstall.ps1 @@ -47,12 +47,14 @@ function Invoke-WPFUnInstall { New-Item -Path "$Env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe\MicrosoftEdge.exe" -Force } + $results = @() + if ($packagesWinget.Count -gt 0) { foreach ($program in $packagesWinget) { $position = $completedPackages + 1 Write-WinUtilJobProgress -Status "Uninstalling $program ($position/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) - Install-WinUtilProgramWinget -Action Uninstall -Programs @($program) + $results += Install-WinUtilProgramWinget -Action Uninstall -Programs @($program) $completedPackages++ Write-WinUtilJobProgress -Status "Uninstalled $program ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) } @@ -62,9 +64,11 @@ function Invoke-WPFUnInstall { $position = $completedPackages + 1 Write-WinUtilJobProgress -Status "Uninstalling Chocolatey packages ($position/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) - Install-WinUtilProgramChoco -Action Uninstall -Programs $packagesChoco + $results += Install-WinUtilProgramChoco -Action Uninstall -Programs $packagesChoco $completedPackages += @($packagesChoco).Count Write-WinUtilJobProgress -Status "Uninstalled Chocolatey packages ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) } + + Complete-WinUtilPackageRun -Action "Uninstall" -Results $results } } diff --git a/pester/install-workflow.Tests.ps1 b/pester/install-workflow.Tests.ps1 index d7726e037c..6f156fdd37 100644 --- a/pester/install-workflow.Tests.ps1 +++ b/pester/install-workflow.Tests.ps1 @@ -38,6 +38,9 @@ BeforeAll { function Install-WinUtilProgramChoco { param($Action, $Programs) } + function Complete-WinUtilPackageRun { + param([string]$Action, [object[]]$Results) + } function Invoke-WPFUIThread { param([scriptblock]$ScriptBlock, [hashtable]$Parameters, [switch]$Async) } diff --git a/pester/package-outcome.Tests.ps1 b/pester/package-outcome.Tests.ps1 new file mode 100644 index 0000000000..cbb93eefe6 --- /dev/null +++ b/pester/package-outcome.Tests.ps1 @@ -0,0 +1,112 @@ +#=========================================================================== +# Tests - Package run outcomes +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + + . (Join-Path $script:repoRoot "functions\private\Install-WinUtilProgramWinget.ps1") + . (Join-Path $script:repoRoot "functions\private\Install-WinUtilProgramChoco.ps1") + . (Join-Path $script:repoRoot "functions\private\Complete-WinUtilPackageRun.ps1") + + function Write-WinUtilLog { + param($Message, $Level, $Component) + } +} + +Describe "Install-WinUtilProgramWinget outcomes" { + BeforeEach { + Mock Write-WinUtilLog { } + } + + It "reports success for exit code 0" { + Mock Start-Process { [pscustomobject]@{ ExitCode = 0 } } + + $result = Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git") + + $result.Outcome | Should -Be "Succeeded" + $result.Package | Should -Be "Git.Git" + $result.Manager | Should -Be "winget" + } + + It "reports an already installed package as skipped rather than failed" { + Mock Start-Process { [pscustomobject]@{ ExitCode = -1978335135 } } + + $result = Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git") + + $result.Outcome | Should -Be "Skipped" + $result.Detail | Should -Be "already installed" + } + + It "reports any other exit code as a failure" { + Mock Start-Process { [pscustomobject]@{ ExitCode = -1978335212 } } + + $result = Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git") + + $result.Outcome | Should -Be "Failed" + $result.ExitCode | Should -Be -1978335212 + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Level -eq "ERROR" -and $Message -like "*failed: Git.Git*" + } + } + + It "returns one result per package" { + Mock Start-Process { [pscustomobject]@{ ExitCode = 0 } } + + $results = @(Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git", "VideoLAN.VLC")) + + $results.Count | Should -Be 2 + } +} + +Describe "Install-WinUtilProgramChoco outcomes" { + BeforeEach { + Mock Write-WinUtilLog { } + } + + It "treats a reboot-required exit code as success" { + Mock Start-Process { [pscustomobject]@{ ExitCode = 3010 } } + + (Install-WinUtilProgramChoco -Action Install -Programs @("git")).Outcome | Should -Be "Succeeded" + } + + It "reports a non-zero exit code as a failure" { + Mock Start-Process { [pscustomobject]@{ ExitCode = 1 } } + + (Install-WinUtilProgramChoco -Action Install -Programs @("git")).Outcome | Should -Be "Failed" + } +} + +Describe "Complete-WinUtilPackageRun" { + BeforeEach { + Mock Write-WinUtilLog { } + Mock Write-Host { } + } + + It "reports the counts of each outcome" { + $results = @( + [pscustomobject]@{ Package = "a"; Outcome = "Succeeded"; Detail = "exit code 0" }, + [pscustomobject]@{ Package = "b"; Outcome = "Skipped"; Detail = "already installed" } + ) + + Complete-WinUtilPackageRun -Action "Install" -Results $results + + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Message -eq "Install summary: 1 succeeded, 1 skipped, 0 failed" + } + } + + It "fails the job when a package failed" { + $results = @( + [pscustomobject]@{ Package = "a"; Outcome = "Succeeded"; Detail = "exit code 0" }, + [pscustomobject]@{ Package = "b"; Outcome = "Failed"; Detail = "exit code 5" } + ) + + { Complete-WinUtilPackageRun -Action "Install" -Results $results } | + Should -Throw "1 of 2 package(s) failed: b" + } + + It "accepts an empty run" { + { Complete-WinUtilPackageRun -Action "Install" -Results @() } | Should -Not -Throw + } +} From ccf89865446b18d3c85e93e9d323b4ce497d2cc8 Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 08:47:27 +0200 Subject: [PATCH 13/70] Time every pipeline step and report the slowest ones Measure-WinUtilStep wraps a step, passes its output through untouched, logs how long it took and keeps the record. Every job and the interface build end with a summary ranking the slowest steps and their share of the total, so "which tweak is taking forever" and "what is holding up startup" are answerable from the log instead of by guessing. Wired into the interface build, each tweak, each undo, each feature, and each package. Jobs also log their own wall-clock duration, and the interface logs the moment it can first service input. --- functions/private/Measure-WinUtilStep.ps1 | 88 +++++++++++++++++++ functions/private/Start-WinUtilJob.ps1 | 9 +- .../private/Start-WinUtilUserInterface.ps1 | 62 +++++++++---- functions/public/Invoke-WPFFeatureInstall.ps1 | 4 +- functions/public/Invoke-WPFInstall.ps1 | 8 +- functions/public/Invoke-WPFUnInstall.ps1 | 8 +- functions/public/Invoke-WPFtweaksbutton.ps1 | 12 ++- functions/public/Invoke-WPFundoall.ps1 | 4 +- pester/install-workflow.Tests.ps1 | 1 + pester/job-layer.Tests.ps1 | 5 +- pester/tweaks.Tests.ps1 | 1 + pester/xaml.Tests.ps1 | 3 + scripts/start.ps1 | 3 + 13 files changed, 177 insertions(+), 31 deletions(-) create mode 100644 functions/private/Measure-WinUtilStep.ps1 diff --git a/functions/private/Measure-WinUtilStep.ps1 b/functions/private/Measure-WinUtilStep.ps1 new file mode 100644 index 0000000000..2b5d24bbfb --- /dev/null +++ b/functions/private/Measure-WinUtilStep.ps1 @@ -0,0 +1,88 @@ +function Measure-WinUtilStep { + <# + .SYNOPSIS + Times one step of a pipeline and records it for the timing summary + + .DESCRIPTION + Wrap any step whose cost is worth knowing. The step's own output passes through + untouched, so this can be dropped around an existing expression without changing + what the caller receives. + + Every recorded step reaches the session log as a "timing:" line and is kept in + $sync.StepTimings so the summary can rank them afterwards. + + .PARAMETER Name + What the step is, as it should read in the log. + + .PARAMETER ScriptBlock + The work to time. + + .PARAMETER Scope + Groups steps that belong to the same run, normally a job name or "UI". + #> + param( + [Parameter(Mandatory, Position = 0)] + [string]$Name, + + [Parameter(Mandatory, Position = 1)] + [scriptblock]$ScriptBlock, + + [string]$Scope = "WinUtil" + ) + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + try { + & $ScriptBlock + } finally { + $stopwatch.Stop() + + if ($null -ne $sync.StepTimings) { + $null = $sync.StepTimings.Add([pscustomobject]@{ + Scope = $Scope + Step = $Name + Milliseconds = $stopwatch.ElapsedMilliseconds + }) + } + + Write-WinUtilLog -Component $Scope -Message "timing: $Name took $($stopwatch.ElapsedMilliseconds) ms" + } +} + +function Write-WinUtilTimingSummary { + <# + .SYNOPSIS + Logs the slowest steps of a scope, so the log answers "what took so long" + + .PARAMETER Scope + Which group of steps to report on. + + .PARAMETER Top + How many of the slowest steps to list. + + .PARAMETER TotalMilliseconds + The measured wall-clock total. Without it the summary adds the steps up, which + misses whatever happened between them. + #> + param( + [Parameter(Mandatory)] + [string]$Scope, + + [int]$Top = 5, + + [long]$TotalMilliseconds = -1 + ) + + $steps = @($sync.StepTimings | Where-Object { $_.Scope -eq $Scope }) + if ($steps.Count -eq 0) { + return + } + + $measured = ($steps | Measure-Object -Property Milliseconds -Sum).Sum + $total = if ($TotalMilliseconds -ge 0) { $TotalMilliseconds } else { $measured } + + Write-WinUtilLog -Component $Scope -Message "timing summary: $($steps.Count) step(s), $measured ms measured of $total ms total" + foreach ($step in ($steps | Sort-Object Milliseconds -Descending | Select-Object -First $Top)) { + $share = if ($total -gt 0) { [int](($step.Milliseconds / $total) * 100) } else { 0 } + Write-WinUtilLog -Component $Scope -Message "timing summary: $($step.Milliseconds) ms ($share%) $($step.Step)" + } +} diff --git a/functions/private/Start-WinUtilJob.ps1 b/functions/private/Start-WinUtilJob.ps1 index dcde1270cf..1f00074c6a 100644 --- a/functions/private/Start-WinUtilJob.ps1 +++ b/functions/private/Start-WinUtilJob.ps1 @@ -83,18 +83,23 @@ function Start-WinUtilJob { ) -ScriptBlock { param($JobName, $JobLabel, $JobBody, $JobParameters, $JobRestoresAppList) + $jobClock = [System.Diagnostics.Stopwatch]::StartNew() try { $body = [scriptblock]::Create($JobBody) & $body @JobParameters - Write-WinUtilLog -Component $JobName -Message "$JobName job finished." + $jobClock.Stop() + Write-WinUtilLog -Component $JobName -Message "$JobName job finished in $($jobClock.ElapsedMilliseconds) ms." Write-WinUtilJobBanner -Message "$JobLabel finished" Write-WinUtilJobProgress -Status "$JobName finished" -Percent 100 -State "None" -Overlay "checkmark" } catch { - Write-WinUtilLog -Level "ERROR" -Component $JobName -Message "$JobName job failed: $($_.Exception.Message)" + $jobClock.Stop() + Write-WinUtilLog -Level "ERROR" -Component $JobName -Message "$JobName job failed after $($jobClock.ElapsedMilliseconds) ms: $($_.Exception.Message)" Write-WinUtilJobBanner -Message "$JobLabel failed: $($_.Exception.Message)" -Level "ERROR" Write-WinUtilJobProgress -Status "$JobName failed" -Percent 100 -State "Error" -Overlay "warning" } finally { + Write-WinUtilTimingSummary -Scope $JobName -TotalMilliseconds $jobClock.ElapsedMilliseconds + if ($JobRestoresAppList -and $sync.Form -and $sync.Form.Dispatcher) { Invoke-WPFUIThread -ScriptBlock { if ($null -ne $sync.ItemsControl) { $sync.ItemsControl.IsEnabled = $true } diff --git a/functions/private/Start-WinUtilUserInterface.ps1 b/functions/private/Start-WinUtilUserInterface.ps1 index a82aa65ee1..5e5806483a 100644 --- a/functions/private/Start-WinUtilUserInterface.ps1 +++ b/functions/private/Start-WinUtilUserInterface.ps1 @@ -12,14 +12,21 @@ function Start-WinUtilUserInterface { place that is allowed to touch controls directly. #> - [void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework') + $buildClock = [System.Diagnostics.Stopwatch]::StartNew() + + Measure-WinUtilStep -Scope "UI" -Name "load WPF assemblies" -ScriptBlock { + [void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework') + } + [xml]$XAML = $inputXML # Read the XAML file $readerOperationSuccessful = $false # There's more cases of failure then success. $reader = (New-Object System.Xml.XmlNodeReader $xaml) try { - $sync["Form"] = [Windows.Markup.XamlReader]::Load( $reader ) + Measure-WinUtilStep -Scope "UI" -Name "parse XAML" -ScriptBlock { + $sync["Form"] = [Windows.Markup.XamlReader]::Load( $reader ) + } $readerOperationSuccessful = $true } catch [System.Management.Automation.MethodInvocationException] { Write-Host "We ran into a problem with the XAML code. Check the syntax for this control..." -ForegroundColor Red @@ -68,17 +75,23 @@ function Start-WinUtilUserInterface { }) }) - Invoke-WinutilThemeChange -theme $sync.preferences.theme + Measure-WinUtilStep -Scope "UI" -Name "apply theme" -ScriptBlock { + Invoke-WinutilThemeChange -theme $sync.preferences.theme + } # Build only the default tab before first paint; other tabs initialize on first activation. $sync.InitializedTabs = @{} - Initialize-WinUtilTabContent -TabName "Install" + Measure-WinUtilStep -Scope "UI" -Name "build Install tab" -ScriptBlock { + Initialize-WinUtilTabContent -TabName "Install" + } #=========================================================================== # Store Form Objects In PowerShell #=========================================================================== - $xaml.SelectNodes("//*[@Name]") | ForEach-Object {$sync["$("$($psitem.Name)")"] = $sync["Form"].FindName($psitem.Name)} + Measure-WinUtilStep -Scope "UI" -Name "map named controls" -ScriptBlock { + $xaml.SelectNodes("//*[@Name]") | ForEach-Object {$sync["$("$($psitem.Name)")"] = $sync["Form"].FindName($psitem.Name)} + } # How background work reaches the controls. Built here so it carries this runspace's # session state: posted work then runs as ordinary interface code instead of as a @@ -108,15 +121,17 @@ function Start-WinUtilUserInterface { "Winget" {$sync.WingetRadioButton.IsChecked = $true; break} } - $sync.keys | ForEach-Object { - if($sync.$psitem) { - if($($sync["$psitem"].GetType() | Select-Object -ExpandProperty Name) -in @("ToggleButton", "Button")) { - if ($sync.Buttons -notcontains $psitem) { - $sync["$psitem"].Add_Click({ - [System.Object]$Sender = $args[0] - Invoke-WPFButton $Sender.name - }) - $sync.Buttons.Add($psitem) | Out-Null + Measure-WinUtilStep -Scope "UI" -Name "wire static button clicks" -ScriptBlock { + $sync.keys | ForEach-Object { + if($sync.$psitem) { + if($($sync["$psitem"].GetType() | Select-Object -ExpandProperty Name) -in @("ToggleButton", "Button")) { + if ($sync.Buttons -notcontains $psitem) { + $sync["$psitem"].Add_Click({ + [System.Object]$Sender = $args[0] + Invoke-WPFButton $Sender.name + }) + $sync.Buttons.Add($psitem) | Out-Null + } } } } @@ -268,7 +283,6 @@ function Start-WinUtilUserInterface { $sync["Form"].Focus() $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Initialize-WinUtilRunspacePool | Out-Null }) | Out-Null $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ - Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $true -IncludeStatusAssets $true Set-WinUtilTaskbaritem -overlay "logo" }) | Out-Null }) @@ -339,8 +353,10 @@ function Start-WinUtilUserInterface { $sync["Form"].MaxHeight = [Double]::PositiveInfinity }) - $NavLogoPanel = $sync["Form"].FindName("NavLogoPanel") - $NavLogoPanel.Children.Add((Invoke-WinUtilAssets -Type "logo" -Size 25)) | Out-Null + Measure-WinUtilStep -Scope "UI" -Name "build nav logo" -ScriptBlock { + $NavLogoPanel = $sync["Form"].FindName("NavLogoPanel") + $NavLogoPanel.Children.Add((Invoke-WinUtilAssets -Type "logo" -Size 25)) | Out-Null + } $sync["Form"].Add_Activated({ Set-WinUtilTaskbaritem -overlay "logo" @@ -468,7 +484,17 @@ Version : Date: Thu, 6 Aug 2026 08:47:27 +0200 Subject: [PATCH 14/70] Render the taskbar overlays on the main thread's runspace The overlays need an STA thread, which the worker pool is not, so they get one of their own. Starting it from the interface thread cost more than it saved: opening the runspace took 154-221ms there against 88ms of rendering. Starting it from the main thread instead is free, because that thread does nothing but wait for the window, and the render then overlaps the interface build. Measured over three runs each, time from start to the interface accepting input: 2071/2105ms before, 2105/2131/2193ms started from the interface thread, 2026/2044/2050ms started from the main thread. Also caches the session state, which two runspaces now share, and moves the runspace cleanup registration into its own function for the second caller. --- functions/private/New-WinUtilSessionState.ps1 | 8 +++ .../Register-WinUtilRunspaceCleanup.ps1 | 67 +++++++++++++++++++ .../private/Start-WinUtilAssetRendering.ps1 | 36 ++++++++++ functions/public/Invoke-WPFRunspace.ps1 | 44 +----------- pester/assets.Tests.ps1 | 20 +++--- pester/runspace.Tests.ps1 | 3 +- pester/sanity.Tests.ps1 | 1 + scripts/main.ps1 | 5 ++ 8 files changed, 131 insertions(+), 53 deletions(-) create mode 100644 functions/private/Register-WinUtilRunspaceCleanup.ps1 create mode 100644 functions/private/Start-WinUtilAssetRendering.ps1 diff --git a/functions/private/New-WinUtilSessionState.ps1 b/functions/private/New-WinUtilSessionState.ps1 index a63daa84f1..8c487f178f 100644 --- a/functions/private/New-WinUtilSessionState.ps1 +++ b/functions/private/New-WinUtilSessionState.ps1 @@ -11,8 +11,15 @@ function New-WinUtilSessionState { Only the functions PowerShell itself provides are skipped, since the default session state already carries those. + + The result is cached. An InitialSessionState is a template that any number of + runspaces can be created from, and building it is not free. #> + if ($sync.SessionState) { + return $sync.SessionState + } + $initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() $variables = @( @@ -45,5 +52,6 @@ function New-WinUtilSessionState { ) } + $sync.SessionState = $initialSessionState return $initialSessionState } diff --git a/functions/private/Register-WinUtilRunspaceCleanup.ps1 b/functions/private/Register-WinUtilRunspaceCleanup.ps1 new file mode 100644 index 0000000000..0f1462162b --- /dev/null +++ b/functions/private/Register-WinUtilRunspaceCleanup.ps1 @@ -0,0 +1,67 @@ +function Register-WinUtilRunspaceCleanup { + <# + .SYNOPSIS + Disposes a PowerShell instance once its work has finished + + .DESCRIPTION + Ends the invocation and disposes the instance from a thread pool callback, so + nothing has to wait for a fire-and-forget runspace to complete just to clean it up. + + .PARAMETER PowerShell + The instance to dispose. + + .PARAMETER Handle + The handle returned by its BeginInvoke. + #> + param( + [Parameter(Mandatory)] + $PowerShell, + + [Parameter(Mandatory)] + $Handle + ) + + if (-not ("WinUtilRunspaceCleanup" -as [type])) { + Add-Type @" +using System; +using System.Management.Automation; + +public sealed class WinUtilRunspaceCleanupState +{ + public PowerShell PowerShell { get; set; } + public IAsyncResult Handle { get; set; } +} + +public static class WinUtilRunspaceCleanup +{ + public static readonly System.Threading.WaitOrTimerCallback Callback = Cleanup; + + public static void Cleanup(object state, bool timedOut) + { + var cleanupState = state as WinUtilRunspaceCleanupState; + if (cleanupState == null || cleanupState.PowerShell == null || cleanupState.Handle == null) + { + return; + } + + try + { + cleanupState.PowerShell.EndInvoke(cleanupState.Handle); + } + catch + { + } + finally + { + cleanupState.PowerShell.Dispose(); + } + } +} +"@ + } + + $cleanupState = [WinUtilRunspaceCleanupState]::new() + $cleanupState.PowerShell = $PowerShell + $cleanupState.Handle = $Handle + [System.Threading.ThreadPool]::RegisterWaitForSingleObject($Handle.AsyncWaitHandle, [WinUtilRunspaceCleanup]::Callback, $cleanupState, -1, $true) | Out-Null +} diff --git a/functions/private/Start-WinUtilAssetRendering.ps1 b/functions/private/Start-WinUtilAssetRendering.ps1 new file mode 100644 index 0000000000..587db8dabd --- /dev/null +++ b/functions/private/Start-WinUtilAssetRendering.ps1 @@ -0,0 +1,36 @@ +function Start-WinUtilAssetRendering { + <# + .SYNOPSIS + Renders the taskbar overlay bitmaps on a thread of their own + + .DESCRIPTION + Rasterising the overlays costs the interface thread time it could spend getting the + window up. The bitmaps are frozen before they are published, which is what makes it + safe to build them anywhere. + + Started early so the render overlaps the rest of the interface build. If the render + has not finished by the time an overlay is asked for, Set-WinUtilTaskbaritem falls + back to rendering it in place, so nothing waits on this. + + The runspace needs STA because RenderTargetBitmap does; the shared worker pool is + not, which is why this does not use it. + #> + + $runspace = [runspacefactory]::CreateRunspace((New-WinUtilSessionState)) + $runspace.ApartmentState = "STA" + $runspace.ThreadOptions = "ReuseThread" + $runspace.Open() + + $shell = [powershell]::Create() + $shell.Runspace = $runspace + [void]$shell.AddScript({ + Measure-WinUtilStep -Scope "UI" -Name "render taskbar overlays (off thread)" -ScriptBlock { + Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $true -IncludeStatusAssets $true + } + }) + + $handle = $shell.BeginInvoke() + Register-WinUtilRunspaceCleanup -PowerShell $shell -Handle $handle + + return $handle +} diff --git a/functions/public/Invoke-WPFRunspace.ps1 b/functions/public/Invoke-WPFRunspace.ps1 index d57a76c244..3cec9d847c 100644 --- a/functions/public/Invoke-WPFRunspace.ps1 +++ b/functions/public/Invoke-WPFRunspace.ps1 @@ -31,45 +31,6 @@ function Invoke-WPFRunspace { $ParameterList ) - if (-not ("WinUtilRunspaceCleanup" -as [type])) { - Add-Type @" -using System; -using System.Management.Automation; - -public sealed class WinUtilRunspaceCleanupState -{ - public PowerShell PowerShell { get; set; } - public IAsyncResult Handle { get; set; } -} - -public static class WinUtilRunspaceCleanup -{ - public static readonly System.Threading.WaitOrTimerCallback Callback = Cleanup; - - public static void Cleanup(object state, bool timedOut) - { - var cleanupState = state as WinUtilRunspaceCleanupState; - if (cleanupState == null || cleanupState.PowerShell == null || cleanupState.Handle == null) - { - return; - } - - try - { - cleanupState.PowerShell.EndInvoke(cleanupState.Handle); - } - catch - { - } - finally - { - cleanupState.PowerShell.Dispose(); - } - } -} -"@ - } - Initialize-WinUtilRunspacePool | Out-Null # Create a PowerShell instance @@ -88,10 +49,7 @@ public static class WinUtilRunspaceCleanup # Execute the RunspacePool $handle = $powershell.BeginInvoke() - $cleanupState = [WinUtilRunspaceCleanupState]::new() - $cleanupState.PowerShell = $powershell - $cleanupState.Handle = $handle - [System.Threading.ThreadPool]::RegisterWaitForSingleObject($handle.AsyncWaitHandle, [WinUtilRunspaceCleanup]::Callback, $cleanupState, -1, $true) | Out-Null + Register-WinUtilRunspaceCleanup -PowerShell $powershell -Handle $handle # Return the handle return $handle diff --git a/pester/assets.Tests.ps1 b/pester/assets.Tests.ps1 index f9433a89aa..bdb33a9543 100644 --- a/pester/assets.Tests.ps1 +++ b/pester/assets.Tests.ps1 @@ -16,16 +16,18 @@ Describe "Rendered asset caching" { $assetScript | Should -Match '\$sync\.RenderedAssetCache\[\$cacheKey\] = \$bitmapImage' } - It "renders no taskbar overlay before first paint" { + It "renders the taskbar overlays away from the interface thread" { $uiScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1") -Raw - $beforeFirstPaint = $uiScript.Substring(0, $uiScript.IndexOf('Add_ContentRendered')) - - # Rendering an overlay costs tens of milliseconds and nothing can see it until the - # window is up, so it belongs behind first paint. - $beforeFirstPaint | Should -Not -Match 'Initialize-WinUtilTaskbarOverlayAssets' - $uiScript | Should -Match '(?s)DispatcherPriority\]::Background, \[action\]\{\s+Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$true -IncludeStatusAssets \$true\s+Set-WinUtilTaskbaritem -overlay "logo"' - $uiScript | Should -Not -Match '\$sync\["checkmarkrender"\] = \(Invoke-WinUtilAssets -Type "checkmark"' - $uiScript | Should -Not -Match '\$sync\["warningrender"\] = \(Invoke-WinUtilAssets -Type "warning"' + $mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw + $assetScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilAssetRendering.ps1") -Raw + + # The interface thread never rasterises an overlay; the main thread does it while + # waiting for the window, and Set-WinUtilTaskbaritem covers the case where the render + # has not landed yet. + $uiScript | Should -Not -Match 'Initialize-WinUtilTaskbarOverlayAssets' + $mainScript | Should -Match '(?s)\$uiHandle = \$uiShell\.BeginInvoke\(\).*Start-WinUtilAssetRendering.*\$uiHandle\.AsyncWaitHandle\.WaitOne\(\)' + $assetScript | Should -Match '\$runspace\.ApartmentState = "STA"' + $assetScript | Should -Match 'Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$true -IncludeStatusAssets \$true' } It "lazily creates taskbar overlays before assigning them" { diff --git a/pester/runspace.Tests.ps1 b/pester/runspace.Tests.ps1 index 719cb97961..6bef290ffe 100644 --- a/pester/runspace.Tests.ps1 +++ b/pester/runspace.Tests.ps1 @@ -6,7 +6,8 @@ BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path . (Join-Path $script:repoRoot "functions\private\Close-WinUtilRunspacePool.ps1") . (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilRunspacePool.ps1") - . (Join-Path $script:repoRoot "functions\public\Invoke-WPFRunspace.ps1") + . (Join-Path $script:repoRoot "functions\private\Register-WinUtilRunspaceCleanup.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFRunspace.ps1") function Start-WinUtilJob { param([string]$Name, [scriptblock]$ScriptBlock, [hashtable]$Parameters, [string]$Description, [switch]$DisableAppList) } diff --git a/pester/sanity.Tests.ps1 b/pester/sanity.Tests.ps1 index 8bf845fa7d..3799f2dbb6 100644 --- a/pester/sanity.Tests.ps1 +++ b/pester/sanity.Tests.ps1 @@ -208,6 +208,7 @@ Describe "Compiled WinUtil sanity" { Describe "Runspace sanity" { BeforeAll { + . (Join-Path $script:repoRoot "functions\private\Register-WinUtilRunspaceCleanup.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFRunspace.ps1") . (Join-Path $script:repoRoot "functions\private\Close-WinUtilRunspacePool.ps1") . (Join-Path $script:repoRoot "functions\private\New-WinUtilSessionState.ps1") diff --git a/scripts/main.ps1 b/scripts/main.ps1 index f68cdae6d1..24ecda215f 100644 --- a/scripts/main.ps1 +++ b/scripts/main.ps1 @@ -111,6 +111,11 @@ $uiShell.Runspace = $sync.UIRunspace Write-WinUtilLog -Component "UI" -Message "Starting the interface thread." $uiHandle = $uiShell.BeginInvoke() + +# This thread has nothing to do but wait, so it pays for the overlay render rather than +# leaving it to the thread that is building the window +Start-WinUtilAssetRendering | Out-Null + $uiHandle.AsyncWaitHandle.WaitOne() | Out-Null try { From 6bbc2b321635bc71040f55453191791f460c8bc2 Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 11:57:51 +0200 Subject: [PATCH 15/70] Cut startup to first interaction roughly in half - wire button clicks by type name against a HashSet, not a pipeline per $sync key: 335ms to 81ms - build no tab content before first paint; Invoke-WPFTab already builds the tab it activates - group apps by category into Lists, not by appending to arrays - interface built ~1460ms to ~465ms, ready for input ~2090ms to ~858ms --- .../Initialize-InstallCategoryAppList.ps1 | 7 ++- .../private/Initialize-WinUtilTabContent.ps1 | 13 +++-- .../private/Start-WinUtilUserInterface.ps1 | 56 ++++++++++++------- pester/lazy-tabs.Tests.ps1 | 19 ++++--- 4 files changed, 60 insertions(+), 35 deletions(-) diff --git a/functions/private/Initialize-InstallCategoryAppList.ps1 b/functions/private/Initialize-InstallCategoryAppList.ps1 index c2e198afa0..fda3549814 100644 --- a/functions/private/Initialize-InstallCategoryAppList.ps1 +++ b/functions/private/Initialize-InstallCategoryAppList.ps1 @@ -16,14 +16,15 @@ function Initialize-InstallCategoryAppList { $Apps ) - # Pre-group apps by category before creating WPF controls. + # Pre-group apps by category before creating WPF controls. Lists, because appending to + # an array copies it and there are several hundred apps. $appsByCategory = @{} foreach ($appKey in $Apps.Keys) { $category = $Apps.$appKey.Category if (-not $appsByCategory.ContainsKey($category)) { - $appsByCategory[$category] = @() + $appsByCategory[$category] = [System.Collections.Generic.List[string]]::new() } - $appsByCategory[$category] += $appKey + $appsByCategory[$category].Add($appKey) } $sync.InstallAppRenderQueue = [System.Collections.Queue]::new() diff --git a/functions/private/Initialize-WinUtilTabContent.ps1 b/functions/private/Initialize-WinUtilTabContent.ps1 index 3394a8f2f2..305da3ca64 100644 --- a/functions/private/Initialize-WinUtilTabContent.ps1 +++ b/functions/private/Initialize-WinUtilTabContent.ps1 @@ -14,10 +14,15 @@ function Initialize-WinUtilTabContent { switch ($TabName) { "Install" { - Invoke-WPFUIElements -configVariable $sync.configs.appnavigation -targetGridName "appscategory" -columncount 1 - Initialize-WPFUI -targetGridName "appscategory" - - Initialize-WPFUI -targetGridName "appspanel" + Measure-WinUtilStep -Scope "UI" -Name "Install tab: app navigation" -ScriptBlock { + Invoke-WPFUIElements -configVariable $sync.configs.appnavigation -targetGridName "appscategory" -columncount 1 + } + Measure-WinUtilStep -Scope "UI" -Name "Install tab: category area" -ScriptBlock { + Initialize-WPFUI -targetGridName "appscategory" + } + Measure-WinUtilStep -Scope "UI" -Name "Install tab: app area" -ScriptBlock { + Initialize-WPFUI -targetGridName "appspanel" + } } "Tweaks" { Invoke-WPFUIElements -configVariable $sync.configs.tweaks -targetGridName "tweakspanel" -columncount 2 diff --git a/functions/private/Start-WinUtilUserInterface.ps1 b/functions/private/Start-WinUtilUserInterface.ps1 index 5e5806483a..1b51c15609 100644 --- a/functions/private/Start-WinUtilUserInterface.ps1 +++ b/functions/private/Start-WinUtilUserInterface.ps1 @@ -79,11 +79,9 @@ function Start-WinUtilUserInterface { Invoke-WinutilThemeChange -theme $sync.preferences.theme } - # Build only the default tab before first paint; other tabs initialize on first activation. + # No tab content is built before first paint. Invoke-WPFTab builds whichever tab it + # activates, and ContentRendered activates the default one. $sync.InitializedTabs = @{} - Measure-WinUtilStep -Scope "UI" -Name "build Install tab" -ScriptBlock { - Initialize-WinUtilTabContent -TabName "Install" - } #=========================================================================== # Store Form Objects In PowerShell @@ -100,12 +98,18 @@ function Start-WinUtilUserInterface { $sync.UIDispatchDelegate = [System.Func[object, object]]{ param($Work) - $body = [scriptblock]::Create($Work.Body) - $parameters = $Work.Parameters - if ($parameters -and $parameters.Count -gt 0) { - & $body @parameters - } else { - & $body + try { + $body = [scriptblock]::Create($Work.Body) + $parameters = $Work.Parameters + if ($parameters -and $parameters.Count -gt 0) { + & $body @parameters + } else { + & $body + } + } catch { + # An unhandled failure here would surface as a dispatcher exception with no trace + # back to the work that caused it + Write-WinUtilErrorRecord -ErrorRecord $_ -Component "UI" -Context "Interface work" } } @@ -122,18 +126,27 @@ function Start-WinUtilUserInterface { } Measure-WinUtilStep -Scope "UI" -Name "wire static button clicks" -ScriptBlock { - $sync.keys | ForEach-Object { - if($sync.$psitem) { - if($($sync["$psitem"].GetType() | Select-Object -ExpandProperty Name) -in @("ToggleButton", "Button")) { - if ($sync.Buttons -notcontains $psitem) { - $sync["$psitem"].Add_Click({ - [System.Object]$Sender = $args[0] - Invoke-WPFButton $Sender.name - }) - $sync.Buttons.Add($psitem) | Out-Null - } - } + # CheckBox and RadioButton also derive from ButtonBase, so the exact type name is what + # decides, not -is + $clickableTypes = [System.Collections.Generic.HashSet[string]]::new([string[]]@("Button", "ToggleButton"), [StringComparer]::OrdinalIgnoreCase) + $alreadyWired = [System.Collections.Generic.HashSet[string]]::new([string[]]@($sync.Buttons), [StringComparer]::OrdinalIgnoreCase) + + $clickHandler = { + [System.Object]$Sender = $args[0] + Invoke-WPFButton $Sender.name + } + + foreach ($entry in @($sync.GetEnumerator())) { + $control = $entry.Value + if ($null -eq $control -or -not $clickableTypes.Contains($control.GetType().Name)) { + continue } + if (-not $alreadyWired.Add([string]$entry.Key)) { + continue + } + + $control.Add_Click($clickHandler) + $sync.Buttons.Add($entry.Key) | Out-Null } } @@ -285,6 +298,7 @@ function Start-WinUtilUserInterface { $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Set-WinUtilTaskbaritem -overlay "logo" }) | Out-Null + $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Start-WinUtilTabWarmup }) | Out-Null }) # The SearchBarTimer is used to delay the search operation until the user has stopped typing for a short period diff --git a/pester/lazy-tabs.Tests.ps1 b/pester/lazy-tabs.Tests.ps1 index b1fbf4a034..f90d4697c6 100644 --- a/pester/lazy-tabs.Tests.ps1 +++ b/pester/lazy-tabs.Tests.ps1 @@ -4,7 +4,11 @@ BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\private\Measure-WinUtilStep.ps1") + function Write-WinUtilLog { + param($Message, $Level, $Component) + } function Invoke-WPFUIElements { param($configVariable, [string]$targetGridName, [int]$columncount) } @@ -89,14 +93,15 @@ Describe "Initialize-WinUtilTabContent" { } Describe "Startup lazy tab wiring" { - It "builds only install tab content before first paint" { + It "builds no tab content before first paint" { $uiScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1") -Raw - $startupRegion = $uiScript.Substring(0, $uiScript.IndexOf("# Store Form Objects In PowerShell")) + $tabScript = Get-Content -Path (Join-Path $script:repoRoot "functions\public\Invoke-WPFTab.ps1") -Raw - $startupRegion | Should -Match 'Initialize-WinUtilTabContent -TabName "Install"' - $startupRegion | Should -Not -Match 'targetGridName "tweakspanel"' - $startupRegion | Should -Not -Match 'targetGridName "featurespanel"' - $startupRegion | Should -Not -Match 'targetGridName "appxpanel"' + # Building a tab costs several hundred milliseconds. ContentRendered activates the + # default tab, and activating a tab is what builds it. + $uiScript | Should -Not -Match 'Initialize-WinUtilTabContent' + $uiScript | Should -Match '(?s)Add_ContentRendered.*Invoke-WPFTab "WPFTab1BT"' + $tabScript | Should -Match 'Initialize-WinUtilTabContent -TabName \$sync\.currentTab' } It "initializes tab content when a tab is selected" { @@ -111,7 +116,7 @@ Describe "Startup lazy tab wiring" { $rendererScript | Should -Match '(?s)"Button"\s*\{.*\$button\.Add_Click\(\{.*Invoke-WPFButton \$Sender\.name' $rendererScript | Should -Match '\$sync\.Buttons\.Add\(\$button\.Name\)' - $uiScript | Should -Match '\$sync\.Buttons -notcontains \$psitem' + $uiScript | Should -Match '\$sync\.Buttons\.Add\(\$entry\.Key\)' } It "binds generated documentation links when lazy panels are rendered" { From 276487b920b1230d64ff2bdfff1882febdcfc52f Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 11:57:52 +0200 Subject: [PATCH 16/70] Warm the unopened tabs while the interface is idle - queue each remaining tab at ApplicationIdle priority after first paint - one tab per queued operation so input is serviced in between - first click on a tab no longer pays for its build --- functions/private/Start-WinUtilTabWarmup.ps1 | 61 ++++++++++++++++++++ pester/xaml.Tests.ps1 | 1 + 2 files changed, 62 insertions(+) create mode 100644 functions/private/Start-WinUtilTabWarmup.ps1 diff --git a/functions/private/Start-WinUtilTabWarmup.ps1 b/functions/private/Start-WinUtilTabWarmup.ps1 new file mode 100644 index 0000000000..972bef2fc0 --- /dev/null +++ b/functions/private/Start-WinUtilTabWarmup.ps1 @@ -0,0 +1,61 @@ +function Start-WinUtilTabWarmup { + <# + .SYNOPSIS + Builds the tabs the user has not opened yet, while the interface is idle + + .DESCRIPTION + Tab content has to be built on the interface thread, so a tab that is still empty + when it is first clicked makes that click pay for the build. Queueing the builds at + idle priority moves that cost to where nothing is waiting on it. + + One tab per queued operation, so the interface can service input between them + rather than being held for the length of every remaining tab. + #> + + $dispatcher = $sync.Form.Dispatcher + if ($null -eq $dispatcher -or $dispatcher.HasShutdownStarted) { + return + } + + $pending = [System.Collections.Queue]::new() + foreach ($tab in @("Tweaks", "Config", "AppX", "Win11ISO")) { + if (-not $sync.InitializedTabs[$tab]) { + $pending.Enqueue($tab) + } + } + + if ($pending.Count -eq 0) { + return + } + + $sync.TabWarmupQueue = $pending + $null = $dispatcher.BeginInvoke([Windows.Threading.DispatcherPriority]::ApplicationIdle, [action]{ + Invoke-WinUtilTabWarmupStep + }) +} + +function Invoke-WinUtilTabWarmupStep { + <# + .SYNOPSIS + Builds the next queued tab and re-queues itself while any remain + #> + + if ($null -eq $sync.TabWarmupQueue -or $sync.TabWarmupQueue.Count -eq 0) { + return + } + + $tab = $sync.TabWarmupQueue.Dequeue() + try { + Measure-WinUtilStep -Scope "UI" -Name "warm $tab tab" -ScriptBlock { + Initialize-WinUtilTabContent -TabName $tab + } + } catch { + Write-WinUtilErrorRecord -ErrorRecord $_ -Component "UI" -Context "Warming the $tab tab" + } + + if ($sync.TabWarmupQueue.Count -gt 0 -and $sync.Form.Dispatcher -and -not $sync.Form.Dispatcher.HasShutdownStarted) { + $null = $sync.Form.Dispatcher.BeginInvoke([Windows.Threading.DispatcherPriority]::ApplicationIdle, [action]{ + Invoke-WinUtilTabWarmupStep + }) + } +} diff --git a/pester/xaml.Tests.ps1 b/pester/xaml.Tests.ps1 index 4eb4868fe9..741de1a4b5 100644 --- a/pester/xaml.Tests.ps1 +++ b/pester/xaml.Tests.ps1 @@ -436,6 +436,7 @@ Describe "XAML and sync wiring" { "StepTimings", "StartedAt", "SessionState", + "TabWarmupQueue", "selected", "selectedAppx", "selectedApps", From 1464925b94b82a65f3caa44e7b4afd0c21640f35 Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 11:57:52 +0200 Subject: [PATCH 17/70] Report failures with the context needed to act on them - Write-WinUtilErrorRecord logs message, exception type, command, line and script stack - used by the job layer, the button funnel, the interface dispatcher and the main thread - route buttons to the job layer by whitelist, so chrome and popup toggles stop starting empty jobs --- functions/private/Start-WinUtilJob.ps1 | 2 +- .../private/Write-WinUtilErrorRecord.ps1 | 52 ++++++++++++++++++ functions/public/Invoke-WPFButton.ps1 | 53 ++++++++++--------- pester/job-layer.Tests.ps1 | 3 +- scripts/main.ps1 | 6 +-- 5 files changed, 85 insertions(+), 31 deletions(-) create mode 100644 functions/private/Write-WinUtilErrorRecord.ps1 diff --git a/functions/private/Start-WinUtilJob.ps1 b/functions/private/Start-WinUtilJob.ps1 index 1f00074c6a..479b6c81ca 100644 --- a/functions/private/Start-WinUtilJob.ps1 +++ b/functions/private/Start-WinUtilJob.ps1 @@ -94,7 +94,7 @@ function Start-WinUtilJob { Write-WinUtilJobProgress -Status "$JobName finished" -Percent 100 -State "None" -Overlay "checkmark" } catch { $jobClock.Stop() - Write-WinUtilLog -Level "ERROR" -Component $JobName -Message "$JobName job failed after $($jobClock.ElapsedMilliseconds) ms: $($_.Exception.Message)" + Write-WinUtilErrorRecord -ErrorRecord $_ -Component $JobName -Context "$JobName failed after $($jobClock.ElapsedMilliseconds) ms" Write-WinUtilJobBanner -Message "$JobLabel failed: $($_.Exception.Message)" -Level "ERROR" Write-WinUtilJobProgress -Status "$JobName failed" -Percent 100 -State "Error" -Overlay "warning" } finally { diff --git a/functions/private/Write-WinUtilErrorRecord.ps1 b/functions/private/Write-WinUtilErrorRecord.ps1 new file mode 100644 index 0000000000..384e17c419 --- /dev/null +++ b/functions/private/Write-WinUtilErrorRecord.ps1 @@ -0,0 +1,52 @@ +function Write-WinUtilErrorRecord { + <# + .SYNOPSIS + Logs a failure with enough context to act on it + + .DESCRIPTION + A bare "You cannot call a method on a null-valued expression." says nothing about + where it came from. This records the message together with the exception type, the + command and line that raised it, and the script stack, under a component name. + + .PARAMETER ErrorRecord + The error to report, normally $_ from a catch block. + + .PARAMETER Component + Which part of WinUtil was running, for example Install or UI. + + .PARAMETER Context + What was being attempted, for example the button name or the package. + #> + param( + [Parameter(Mandatory)] + $ErrorRecord, + + [string]$Component = "WinUtil", + + [string]$Context + ) + + $headline = if ($Context) { "$Context : $($ErrorRecord.Exception.Message)" } else { $ErrorRecord.Exception.Message } + Write-WinUtilLog -Level "ERROR" -Component $Component -Message $headline + + $invocation = $ErrorRecord.InvocationInfo + if ($invocation) { + $where = "$($invocation.ScriptName):$($invocation.ScriptLineNumber)" + if ([string]::IsNullOrWhiteSpace($invocation.ScriptName)) { + $where = "line $($invocation.ScriptLineNumber)" + } + Write-WinUtilLog -Level "ERROR" -Component $Component -Message " at $where in $($invocation.MyCommand): $($invocation.Line.Trim())" + } + + Write-WinUtilLog -Level "ERROR" -Component $Component -Message " type $($ErrorRecord.Exception.GetType().FullName), category $($ErrorRecord.CategoryInfo.Category)" + + if ($ErrorRecord.ScriptStackTrace) { + foreach ($frame in ($ErrorRecord.ScriptStackTrace -split "`r?`n")) { + if (-not [string]::IsNullOrWhiteSpace($frame)) { + Write-WinUtilLog -Level "ERROR" -Component $Component -Message " $($frame.Trim())" + } + } + } + + Write-Host "$Component : $headline" -ForegroundColor Red +} diff --git a/functions/public/Invoke-WPFButton.ps1 b/functions/public/Invoke-WPFButton.ps1 index 45736efc8f..a10777ffcf 100644 --- a/functions/public/Invoke-WPFButton.ps1 +++ b/functions/public/Invoke-WPFButton.ps1 @@ -24,37 +24,36 @@ function Invoke-WPFButton { Write-WinUtilJobProgress -Hide } - # Buttons that only change what the interface shows. Tab switches, selection helpers, - # window chrome, and the WPFPanel* entries that hand off to a Windows applet. - $interfaceOnly = @( - "WPFCloseButton", "WPFMinimizeButton", "WPFMaximizeButton", "WPFselectedAppsButton", - "WPFCollapseAllCategories", "WPFExpandAllCategories", - "WPFStandard", "WPFMinimal", "WPFAdvanced", - "WPFClearTweaksSelection", "WPFClearInstallSelection", - "WPFAppxRemoval", "WPFBackToTweaks", - "WPFDefaultAppxSelection", "WPFSelectAllAppx", "WPFClearAppxSelection" + # Switch-driven buttons that change the system. Anything in feature.json counts too, apart + # from the WPFPanel* entries, which only hand off to a Windows applet. + # + # This is a whitelist on purpose: window chrome and popup toggles also reach here, because + # every Button in $sync gets wired to this function, and they must not become jobs. + $workButtons = @( + "WPFInstallUpgrade", "WPFAddUltPerf", "WPFRemoveUltPerf", + "WPFUpdatesdefault", "WPFUpdatesdisable", "WPFUpdatessecurity", + "WPFGetInstalledAppx" ) - # Workflow entrypoints that start their own job, because they read and validate the current - # selection on this thread first, and name the job after what the user actually chose. - $selfManaged = @( - "WPFInstall", "WPFUninstall", "WPFtweaksbutton", "WPFundoall", "WPFOOSUbutton", - "WPFGetInstalled", "WPFGetInstalledTweaks", - "WPFInstallSelectedAppx", "WPFRemoveSelectedAppx", "WPFFeatureInstall" - ) + $isConfigWork = $sync.configs.feature.$Button -and $Button -notlike "WPFPanel*" - if ($Button -like "WPFTab?BT" -or $Button -like "WPFPanel*" -or - $interfaceOnly -contains $Button -or $selfManaged -contains $Button) { - Invoke-WPFButtonAction -Button $Button + if ($isConfigWork -or $workButtons -contains $Button) { + Start-WinUtilJob -Name (Get-WinUtilButtonLabel -Button $Button) -Parameters @{ + Button = $Button + } -ScriptBlock { + param($Button) + + Invoke-WPFButtonAction -Button $Button + } return } - Start-WinUtilJob -Name (Get-WinUtilButtonLabel -Button $Button) -Parameters @{ - Button = $Button - } -ScriptBlock { - param($Button) - + # A handler that throws on the interface thread would otherwise reach the user as a bare + # message with no indication of which control produced it + try { Invoke-WPFButtonAction -Button $Button + } catch { + Write-WinUtilErrorRecord -ErrorRecord $_ -Component "UI" -Context "Button '$Button'" } } @@ -83,7 +82,11 @@ function Get-WinUtilButtonLabel { } } - return ($Button -replace '^WPF', '') + $fallback = ($Button -replace '^WPF', '') + if ([string]::IsNullOrWhiteSpace($fallback)) { + return "WinUtil" + } + return $fallback } function Invoke-WPFButtonAction { diff --git a/pester/job-layer.Tests.ps1 b/pester/job-layer.Tests.ps1 index 8742a2e747..9b07590a6b 100644 --- a/pester/job-layer.Tests.ps1 +++ b/pester/job-layer.Tests.ps1 @@ -5,6 +5,7 @@ BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path . (Join-Path $script:repoRoot "functions\private\Measure-WinUtilStep.ps1") + . (Join-Path $script:repoRoot "functions\private\Write-WinUtilErrorRecord.ps1") . (Join-Path $script:repoRoot "functions\private\Start-WinUtilJob.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFUIThread.ps1") @@ -211,7 +212,7 @@ Describe "Start-WinUtilJob" { } | Should -Not -Throw Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { - $Level -eq "ERROR" -and $Component -eq "Example" -and $Message -like "Example job failed after * ms: boom" + $Level -eq "ERROR" -and $Component -eq "Example" -and $Message -like "*failed after * ms : boom" } Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { $Status -eq "Example failed" -and $State -eq "Error" -and $Overlay -eq "warning" diff --git a/scripts/main.ps1 b/scripts/main.ps1 index 24ecda215f..15149e376d 100644 --- a/scripts/main.ps1 +++ b/scripts/main.ps1 @@ -121,13 +121,11 @@ $uiHandle.AsyncWaitHandle.WaitOne() | Out-Null try { $uiShell.EndInvoke($uiHandle) | Out-Null } catch { - Write-Host "The WinUtil interface stopped with an error: $($_.Exception.Message)" -ForegroundColor Red - Write-WinUtilLog -Level "ERROR" -Component "UI" -Message "Interface thread failed: $($_.Exception.Message)" + Write-WinUtilErrorRecord -ErrorRecord $_ -Component "UI" -Context "Interface thread stopped" } foreach ($uiError in $uiShell.Streams.Error) { - Write-Host $uiError -ForegroundColor Red - Write-WinUtilLog -Level "ERROR" -Component "UI" -Message $uiError + Write-WinUtilErrorRecord -ErrorRecord $uiError -Component "UI" -Context "Interface thread" } $uiShell.Dispose() From 60f73fe8337f487b0d2ed47ac0f7e61ee8251212 Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 14:13:06 +0200 Subject: [PATCH 18/70] Wire the Install tab controls where they are created - ChocoRadioButton, WingetRadioButton and the install action buttons come from appnavigation.json, so they do not exist until the Install tab is built - the interface build wired them anyway, which is the three null-reference errors reported on close since tab content moved behind first paint - Initialize-WinUtilInstallTabControls now does it from the tab build, guarded - offline mode disables the install buttons from there too, for the same reason - new test fails if the interface build touches any config-generated control --- .../Initialize-WinUtilInstallTabControls.ps1 | 35 ++++++++++++ .../private/Initialize-WinUtilTabContent.ps1 | 1 + .../private/Start-WinUtilUserInterface.ps1 | 19 +------ pester/generated-controls.Tests.ps1 | 55 +++++++++++++++++++ pester/lazy-tabs.Tests.ps1 | 1 + 5 files changed, 94 insertions(+), 17 deletions(-) create mode 100644 functions/private/Initialize-WinUtilInstallTabControls.ps1 create mode 100644 pester/generated-controls.Tests.ps1 diff --git a/functions/private/Initialize-WinUtilInstallTabControls.ps1 b/functions/private/Initialize-WinUtilInstallTabControls.ps1 new file mode 100644 index 0000000000..2acc68c666 --- /dev/null +++ b/functions/private/Initialize-WinUtilInstallTabControls.ps1 @@ -0,0 +1,35 @@ +function Initialize-WinUtilInstallTabControls { + <# + .SYNOPSIS + Wires the Install tab controls that are generated from config rather than declared + in XAML + + .DESCRIPTION + The package manager radio buttons and the install action buttons are created by + Invoke-WPFUIElements, so they do not exist until the Install tab is built. Setting + them up anywhere other than immediately after that build makes the code depend on + when the tab happens to be created. + #> + + if ($sync.ChocoRadioButton) { + $sync.ChocoRadioButton.Add_Checked({ + $sync.preferences.packagemanager = "Choco" + }) + } + if ($sync.WingetRadioButton) { + $sync.WingetRadioButton.Add_Checked({ + $sync.preferences.packagemanager = "Winget" + }) + } + + switch ($sync.preferences.packagemanager) { + "Choco" { if ($sync.ChocoRadioButton) { $sync.ChocoRadioButton.IsChecked = $true }; break } + "Winget" { if ($sync.WingetRadioButton) { $sync.WingetRadioButton.IsChecked = $true }; break } + } + + if ($PARAM_OFFLINE) { + foreach ($name in "WPFInstall", "WPFUninstall", "WPFInstallUpgrade", "WPFGetInstalled") { + if ($sync.$name) { $sync.$name.IsEnabled = $false } + } + } +} diff --git a/functions/private/Initialize-WinUtilTabContent.ps1 b/functions/private/Initialize-WinUtilTabContent.ps1 index 305da3ca64..eedad6b4d6 100644 --- a/functions/private/Initialize-WinUtilTabContent.ps1 +++ b/functions/private/Initialize-WinUtilTabContent.ps1 @@ -23,6 +23,7 @@ function Initialize-WinUtilTabContent { Measure-WinUtilStep -Scope "UI" -Name "Install tab: app area" -ScriptBlock { Initialize-WPFUI -targetGridName "appspanel" } + Initialize-WinUtilInstallTabControls } "Tweaks" { Invoke-WPFUIElements -configVariable $sync.configs.tweaks -targetGridName "tweakspanel" -columncount 2 diff --git a/functions/private/Start-WinUtilUserInterface.ps1 b/functions/private/Start-WinUtilUserInterface.ps1 index 1b51c15609..a035073638 100644 --- a/functions/private/Start-WinUtilUserInterface.ps1 +++ b/functions/private/Start-WinUtilUserInterface.ps1 @@ -113,18 +113,6 @@ function Start-WinUtilUserInterface { } } - $sync.ChocoRadioButton.Add_Checked({ - $sync.preferences.packagemanager = "Choco" - }) - $sync.WingetRadioButton.Add_Checked({ - $sync.preferences.packagemanager = "Winget" - }) - - switch ($sync.preferences.packagemanager) { - "Choco" {$sync.ChocoRadioButton.IsChecked = $true; break} - "Winget" {$sync.WingetRadioButton.IsChecked = $true; break} - } - Measure-WinUtilStep -Scope "UI" -Name "wire static button clicks" -ScriptBlock { # CheckBox and RadioButton also derive from ButtonBase, so the exact type name is what # decides, not -is @@ -273,11 +261,8 @@ function Start-WinUtilUserInterface { $sync.WPFTab1BT.Opacity = 0.5 $sync.WPFTab1BT.ToolTip = "Internet connection required for installing applications." - # Disable install-related buttons - $sync.WPFInstall.IsEnabled = $false - $sync.WPFUninstall.IsEnabled = $false - $sync.WPFInstallUpgrade.IsEnabled = $false - $sync.WPFGetInstalled.IsEnabled = $false + # The install action buttons are generated with the Install tab, so + # Initialize-WinUtilInstallTabControls disables them when that tab is built # Show offline indicator Write-Host "Offline mode detected - Install tab disabled." -ForegroundColor Yellow diff --git a/pester/generated-controls.Tests.ps1 b/pester/generated-controls.Tests.ps1 new file mode 100644 index 0000000000..a30f9d194b --- /dev/null +++ b/pester/generated-controls.Tests.ps1 @@ -0,0 +1,55 @@ +#=========================================================================== +# Tests - Generated control lifetime +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + $script:xamlText = Get-Content -Path (Join-Path $script:repoRoot "xaml\inputXML.xaml") -Raw + + # Names that exist only once a tab has been built from config + $script:generatedNames = @( + Get-ChildItem -Path (Join-Path $script:repoRoot "config") -Filter *.json | ForEach-Object { + $config = Get-Content -Path $_.FullName -Raw | ConvertFrom-Json + $config.PSObject.Properties.Name + } + ) | Sort-Object -Unique | Where-Object { $script:xamlText -notmatch "Name=`"$([regex]::Escape($_))`"" } +} + +Describe "Generated controls" { + # Tab content is built after first paint, so anything referencing a generated control from + # the interface build runs while that control is still $null. That produced three silent + # "You cannot call a method on a null-valued expression" errors. + It "are not touched while the interface is being built" { + $uiScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1") -Raw + + $referenced = @( + [regex]::Matches($uiScript, '\$sync(?:\["([A-Za-z_][A-Za-z0-9_]*)"\]|\.([A-Za-z_][A-Za-z0-9_]*))') | + ForEach-Object { if ($_.Groups[1].Success) { $_.Groups[1].Value } else { $_.Groups[2].Value } } + ) | Sort-Object -Unique + + $tooEarly = @($referenced | Where-Object { $script:generatedNames -contains $_ }) + + if ($tooEarly.Count -gt 0) { + throw "Start-WinUtilUserInterface touches generated control(s) that do not exist yet: $($tooEarly -join ', '). Wire them from Initialize-WinUtilInstallTabControls or the tab that creates them." + } + } + + It "are wired from the tab that creates them" { + $tabScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilTabContent.ps1") -Raw + $installScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilInstallTabControls.ps1") -Raw + + $tabScript | Should -Match 'Initialize-WinUtilInstallTabControls' + $installScript | Should -Match '\$sync\.ChocoRadioButton' + $installScript | Should -Match '\$sync\.WingetRadioButton' + $installScript | Should -Match '\$PARAM_OFFLINE' + } + + It "are guarded against still being missing" { + $installScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilInstallTabControls.ps1") -Raw + + # Every use is behind an existence check, so a config change cannot reintroduce the crash + $installScript | Should -Match 'if \(\$sync\.ChocoRadioButton\)' + $installScript | Should -Match 'if \(\$sync\.WingetRadioButton\)' + $installScript | Should -Match 'if \(\$sync\.\$name\)' + } +} diff --git a/pester/lazy-tabs.Tests.ps1 b/pester/lazy-tabs.Tests.ps1 index f90d4697c6..805d1af341 100644 --- a/pester/lazy-tabs.Tests.ps1 +++ b/pester/lazy-tabs.Tests.ps1 @@ -16,6 +16,7 @@ BeforeAll { param([string]$TargetGridName) } function Invoke-WinUtilISOCheckExistingWork { } + function Initialize-WinUtilInstallTabControls { } . (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilTabContent.ps1") } From 4c1c10a664cfe9d11845ef4b64fd6fc97f689095 Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 15:52:50 +0200 Subject: [PATCH 19/70] Stop losing warnings and non-terminating errors from job bodies - a worker buffers its warning and error streams on an object nobody reads: Write-Warning never reached the log, Write-Error reached nothing at all - the job layer merges both into the log, so all 30+ Write-Warning and 4 Write-Error sites in the helpers are visible without touching each one - the interface runspace warning stream is drained on exit too - $sync.LoggedErrors counts error events, detail lines excluded - a job that logged errors without throwing now finishes as "N error(s)" with a warning overlay instead of a green checkmark --- functions/private/Start-WinUtilJob.ps1 | 29 +++++++++++++--- .../private/Write-WinUtilErrorRecord.ps1 | 6 ++-- functions/private/Write-WinUtilLog.ps1 | 9 ++++- pester/job-layer.Tests.ps1 | 33 +++++++++++++++++++ pester/xaml.Tests.ps1 | 1 + scripts/main.ps1 | 4 +++ scripts/start.ps1 | 2 ++ 7 files changed, 76 insertions(+), 8 deletions(-) diff --git a/functions/private/Start-WinUtilJob.ps1 b/functions/private/Start-WinUtilJob.ps1 index 479b6c81ca..faabaa9a19 100644 --- a/functions/private/Start-WinUtilJob.ps1 +++ b/functions/private/Start-WinUtilJob.ps1 @@ -84,14 +84,35 @@ function Start-WinUtilJob { param($JobName, $JobLabel, $JobBody, $JobParameters, $JobRestoresAppList) $jobClock = [System.Diagnostics.Stopwatch]::StartNew() + $errorsBefore = if ($sync.LoggedErrors) { $sync.LoggedErrors.Count } else { 0 } try { $body = [scriptblock]::Create($JobBody) - & $body @JobParameters + + # A worker's warning and error streams are buffered on a PowerShell object nobody + # reads, so Write-Warning never reaches the log and Write-Error reaches nothing at + # all. Merging them into the output stream is what puts them in front of a reader. + & $body @JobParameters 2>&1 3>&1 | ForEach-Object { + if ($_ -is [System.Management.Automation.WarningRecord]) { + Write-WinUtilLog -Level "WARN" -Component $JobName -Message $_.Message + } elseif ($_ -is [System.Management.Automation.ErrorRecord]) { + Write-WinUtilErrorRecord -ErrorRecord $_ -Component $JobName -Context "Non-terminating error" + } + } $jobClock.Stop() - Write-WinUtilLog -Component $JobName -Message "$JobName job finished in $($jobClock.ElapsedMilliseconds) ms." - Write-WinUtilJobBanner -Message "$JobLabel finished" - Write-WinUtilJobProgress -Status "$JobName finished" -Percent 100 -State "None" -Overlay "checkmark" + + # A step can fail without throwing, for example a registry write refused by policy. + # The job still finished, but saying so without qualification would be a lie. + $newErrors = if ($sync.LoggedErrors) { $sync.LoggedErrors.Count - $errorsBefore } else { 0 } + if ($newErrors -gt 0) { + Write-WinUtilLog -Level "WARN" -Component $JobName -Message "$JobName job finished in $($jobClock.ElapsedMilliseconds) ms with $newErrors error(s)." + Write-WinUtilJobBanner -Message "$JobLabel finished with $newErrors error(s), see the log" -Level "ERROR" + Write-WinUtilJobProgress -Status "$JobName finished with $newErrors error(s)" -Percent 100 -State "Paused" -Overlay "warning" + } else { + Write-WinUtilLog -Component $JobName -Message "$JobName job finished in $($jobClock.ElapsedMilliseconds) ms." + Write-WinUtilJobBanner -Message "$JobLabel finished" + Write-WinUtilJobProgress -Status "$JobName finished" -Percent 100 -State "None" -Overlay "checkmark" + } } catch { $jobClock.Stop() Write-WinUtilErrorRecord -ErrorRecord $_ -Component $JobName -Context "$JobName failed after $($jobClock.ElapsedMilliseconds) ms" diff --git a/functions/private/Write-WinUtilErrorRecord.ps1 b/functions/private/Write-WinUtilErrorRecord.ps1 index 384e17c419..d3306363d9 100644 --- a/functions/private/Write-WinUtilErrorRecord.ps1 +++ b/functions/private/Write-WinUtilErrorRecord.ps1 @@ -35,15 +35,15 @@ function Write-WinUtilErrorRecord { if ([string]::IsNullOrWhiteSpace($invocation.ScriptName)) { $where = "line $($invocation.ScriptLineNumber)" } - Write-WinUtilLog -Level "ERROR" -Component $Component -Message " at $where in $($invocation.MyCommand): $($invocation.Line.Trim())" + Write-WinUtilLog -Level "ERROR" -Detail -Component $Component -Message " at $where in $($invocation.MyCommand): $($invocation.Line.Trim())" } - Write-WinUtilLog -Level "ERROR" -Component $Component -Message " type $($ErrorRecord.Exception.GetType().FullName), category $($ErrorRecord.CategoryInfo.Category)" + Write-WinUtilLog -Level "ERROR" -Detail -Component $Component -Message " type $($ErrorRecord.Exception.GetType().FullName), category $($ErrorRecord.CategoryInfo.Category)" if ($ErrorRecord.ScriptStackTrace) { foreach ($frame in ($ErrorRecord.ScriptStackTrace -split "`r?`n")) { if (-not [string]::IsNullOrWhiteSpace($frame)) { - Write-WinUtilLog -Level "ERROR" -Component $Component -Message " $($frame.Trim())" + Write-WinUtilLog -Level "ERROR" -Detail -Component $Component -Message " $($frame.Trim())" } } } diff --git a/functions/private/Write-WinUtilLog.ps1 b/functions/private/Write-WinUtilLog.ps1 index 939feebe97..2f5dace286 100644 --- a/functions/private/Write-WinUtilLog.ps1 +++ b/functions/private/Write-WinUtilLog.ps1 @@ -27,9 +27,16 @@ function Write-WinUtilLog { [ValidateSet("INFO", "WARN", "ERROR", "DEBUG")] [string]$Level = "INFO", - [string]$Component = "WinUtil" + [string]$Component = "WinUtil", + + # Continuation of an error already counted, such as a stack frame + [switch]$Detail ) + if ($Level -eq "ERROR" -and -not $Detail -and $null -ne $sync.LoggedErrors) { + $null = $sync.LoggedErrors.Add("[$Component] $Message") + } + try { $logPath = $null if ($null -ne $sync -and $sync.ContainsKey("logPath")) { diff --git a/pester/job-layer.Tests.ps1 b/pester/job-layer.Tests.ps1 index 9b07590a6b..adcfeddd00 100644 --- a/pester/job-layer.Tests.ps1 +++ b/pester/job-layer.Tests.ps1 @@ -220,6 +220,39 @@ Describe "Start-WinUtilJob" { $script:sync.ActiveJob | Should -BeNullOrEmpty } + It "reports a job that logged errors without throwing" { + $script:sync.LoggedErrors = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new()) + Start-WinUtilJob -Name "Example" -ScriptBlock { } | Out-Null + + & $script:capturedRunspaceBody ` + -JobName "Example" ` + -JobLabel "Example" ` + -JobBody '$null = $sync.LoggedErrors.Add("[Registry] refused by policy")' ` + -JobParameters @{} ` + -JobRestoresAppList $false + + Should -Invoke -CommandName Write-WinUtilJobProgress -Times 1 -Exactly -ParameterFilter { + $Status -eq "Example finished with 1 error(s)" -and $State -eq "Paused" -and $Overlay -eq "warning" + } + $script:sync.ActiveJob | Should -BeNullOrEmpty + } + + It "surfaces warnings and non-terminating errors raised by the body" { + $script:sync.LoggedErrors = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new()) + Start-WinUtilJob -Name "Example" -ScriptBlock { } | Out-Null + + & $script:capturedRunspaceBody ` + -JobName "Example" ` + -JobLabel "Example" ` + -JobBody 'Write-Warning "a warning"' ` + -JobParameters @{} ` + -JobRestoresAppList $false + + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Level -eq "WARN" -and $Message -eq "a warning" + } + } + It "restores the app list after a failing job that disabled it" { Start-WinUtilJob -Name "Install" -DisableAppList -ScriptBlock { } | Out-Null $script:sync.ActiveJob = "Install" diff --git a/pester/xaml.Tests.ps1 b/pester/xaml.Tests.ps1 index 741de1a4b5..2760252bb3 100644 --- a/pester/xaml.Tests.ps1 +++ b/pester/xaml.Tests.ps1 @@ -434,6 +434,7 @@ Describe "XAML and sync wiring" { "UIRunspace", "UIDispatchDelegate", "StepTimings", + "LoggedErrors", "StartedAt", "SessionState", "TabWarmupQueue", diff --git a/scripts/main.ps1 b/scripts/main.ps1 index 15149e376d..e67db501e0 100644 --- a/scripts/main.ps1 +++ b/scripts/main.ps1 @@ -124,6 +124,10 @@ try { Write-WinUtilErrorRecord -ErrorRecord $_ -Component "UI" -Context "Interface thread stopped" } +foreach ($uiWarning in $uiShell.Streams.Warning) { + Write-WinUtilLog -Level "WARN" -Component "UI" -Message $uiWarning.Message +} + foreach ($uiError in $uiShell.Streams.Error) { Write-WinUtilErrorRecord -ErrorRecord $uiError -Component "UI" -Context "Interface thread" } diff --git a/scripts/start.ps1 b/scripts/start.ps1 index 8c690e9282..b460fe9872 100644 --- a/scripts/start.ps1 +++ b/scripts/start.ps1 @@ -65,6 +65,8 @@ $sync.preferences = @{} $sync.ActiveJob = $null # Every step recorded by Measure-WinUtilStep, from any thread $sync.StepTimings = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new()) +# Every error logged, so a job can report that something went wrong even when it did not throw +$sync.LoggedErrors = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new()) $sync.StartedAt = Get-Date $sync.selectedAppx = [System.Collections.Generic.List[string]]::new() $sync.selectedApps = [System.Collections.Generic.List[string]]::new() From 2e83b32692939b91ff91670a680421170422bd65 Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 16:29:26 +0200 Subject: [PATCH 20/70] Drive winget through Microsoft.WinGet.Client for real progress The winget CLI hides its progress bar as soon as its output is redirected, so a package could only ever be reported as started and finished. The module reports progress and returns a structured result. - Install-WinUtilWinGetClient installs and imports the module, cached per session - Invoke-WinUtilWinGetCommand runs a cmdlet on a nested PowerShell and polls its progress stream, which cannot be redirected like output or errors - percentages map into the package's slice of the job bar: "7zip.7zip - 1.9 MB / 1.9 MB" - outcome comes from Status and InstallerErrorCode, not an exit code - a package already present is upgraded, not reinstalled: Install-WinGetPackage re-downloads and re-runs the installer even without -Force - detection uses Get-WinGetPackage and matches on name as well as id, so apps installed outside winget are recognised (Brave, and every other ARP entry) - falls back to the command line unchanged when the module cannot be installed Verified against real winget in the eval VM, 12 checks; 545 unit tests pass. --- .../private/Install-WinUtilProgramWinget.ps1 | 114 ++++++++++++++--- .../private/Install-WinUtilWinGetClient.ps1 | 46 +++++++ functions/private/Install-WinUtilWinget.ps1 | 3 + .../private/Invoke-WinUtilCurrentSystem.ps1 | 22 ++++ .../private/Invoke-WinUtilWinGetCommand.ps1 | 80 ++++++++++++ functions/public/Invoke-WPFInstall.ps1 | 4 +- functions/public/Invoke-WPFUnInstall.ps1 | 4 +- pester/package-outcome.Tests.ps1 | 120 ++++++++++++++++++ pester/package.Tests.ps1 | 7 +- pester/system-helpers.Tests.ps1 | 4 + pester/xaml.Tests.ps1 | 1 + 11 files changed, 382 insertions(+), 23 deletions(-) create mode 100644 functions/private/Install-WinUtilWinGetClient.ps1 create mode 100644 functions/private/Invoke-WinUtilWinGetCommand.ps1 diff --git a/functions/private/Install-WinUtilProgramWinget.ps1 b/functions/private/Install-WinUtilProgramWinget.ps1 index 1a79426185..539367b83c 100644 --- a/functions/private/Install-WinUtilProgramWinget.ps1 +++ b/functions/private/Install-WinUtilProgramWinget.ps1 @@ -8,6 +8,17 @@ Function Install-WinUtilProgramWinget { Emits one result object per package so the caller can tell what actually happened rather than assuming the run succeeded. + Prefers the Microsoft.WinGet.Client module, which reports download and install + progress and returns a structured result. Falls back to the winget command line when + the module is not available; that path can only report a package as started and + finished, because winget hides its progress bar once its output is redirected. + + .PARAMETER ProgressBase + Where this package starts within the job's overall progress bar. + + .PARAMETER ProgressSpan + How much of the overall bar this package accounts for. + #> param ( [Parameter(Mandatory=$true)] @@ -15,7 +26,11 @@ Function Install-WinUtilProgramWinget { [string]$Action, [Parameter(Mandatory=$true)] - [string[]]$Programs + [string[]]$Programs, + + [int]$ProgressBase = 0, + + [int]$ProgressSpan = 0 ) # WinGet reports "there was nothing to do" through the exit code rather than as success @@ -23,6 +38,10 @@ Function Install-WinUtilProgramWinget { -1978335135 = "already installed" -1978335189 = "no applicable update" } + # The module says the same thing through a status name + $nothingToDoStatus = @("NoApplicableUpgrade", "PackageAlreadyInstalled", "NoApplicableInstallers") + + $useModule = Install-WinUtilWinGetClient foreach ($program in $Programs) { if ([string]::IsNullOrWhiteSpace($program) -or $program -eq "na") { @@ -35,25 +54,84 @@ Function Install-WinUtilProgramWinget { $program = $program.Substring("msstore:".Length) } - if ($Action -eq 'Install') { - $arguments = @("install", "--id", $program, "--accept-package-agreements", "--accept-source-agreements", "--source", $source, "--silent") - } else { - $arguments = @("uninstall", "--id", $program, "--source", $source, "--silent") - } - Write-WinUtilLog -Component "Package" -Message "$Action winget package: $program (source: $source)" - $process = Start-Process -FilePath winget -ArgumentList $arguments -NoNewWindow -Wait -PassThru - $exitCode = $process.ExitCode - - if ($exitCode -eq 0) { - $outcome = "Succeeded" - $detail = "exit code 0" - } elseif ($nothingToDo.ContainsKey($exitCode)) { - $outcome = "Skipped" - $detail = $nothingToDo[$exitCode] + + $outcome = "Failed" + $detail = "no result" + $exitCode = -1 + + if ($useModule) { + $parameters = @{ + Id = $program + Source = $source + Mode = "Silent" + MatchOption = "EqualsCaseInsensitive" + } + + if ($Action -eq "Uninstall") { + $command = "Uninstall-WinGetPackage" + } else { + # Install-WinGetPackage re-downloads and re-runs the installer for a package + # that is already present, so an install pass would reinstall everything the + # machine already has. Update-WinGetPackage upgrades it or reports + # NoApplicableUpgrade, which is what "install or upgrade" should mean. + $existing = Invoke-WinUtilWinGetCommand -Command "Get-WinGetPackage" -Parameters @{ + Id = $program + MatchOption = "EqualsCaseInsensitive" + ErrorAction = "SilentlyContinue" + } + $command = if (@($existing).Count -gt 0 -and $null -ne @($existing)[0]) { + "Update-WinGetPackage" + } else { + "Install-WinGetPackage" + } + } + + $results = Invoke-WinUtilWinGetCommand -Command $command -Parameters $parameters ` + -ProgressBase $ProgressBase -ProgressSpan $ProgressSpan -Label $program + $result = @($results)[0] + + if ($null -eq $result) { + $outcome = "Failed" + $detail = "the WinGet client returned nothing" + } else { + $status = [string]$result.Status + $exitCode = [int]$result.InstallerErrorCode + if ($status -eq "Ok" -and $exitCode -eq 0) { + $outcome = "Succeeded" + $detail = "status Ok" + } elseif ($nothingToDoStatus -contains $status) { + $outcome = "Skipped" + $detail = $status + } else { + $outcome = "Failed" + $detail = "status $status$(if ($exitCode -ne 0) { ", installer error $exitCode" })" + } + + if ($result.RebootRequired) { + Write-WinUtilLog -Level "WARN" -Component "Package" -Message "$program needs a reboot to finish." + } + } } else { - $outcome = "Failed" - $detail = "exit code $exitCode" + if ($Action -eq 'Install') { + $arguments = @("install", "--id", $program, "--accept-package-agreements", "--accept-source-agreements", "--source", $source, "--silent") + } else { + $arguments = @("uninstall", "--id", $program, "--source", $source, "--silent") + } + + $process = Start-Process -FilePath winget -ArgumentList $arguments -NoNewWindow -Wait -PassThru + $exitCode = $process.ExitCode + + if ($exitCode -eq 0) { + $outcome = "Succeeded" + $detail = "exit code 0" + } elseif ($nothingToDo.ContainsKey($exitCode)) { + $outcome = "Skipped" + $detail = $nothingToDo[$exitCode] + } else { + $outcome = "Failed" + $detail = "exit code $exitCode" + } } $level = if ($outcome -eq "Failed") { "ERROR" } else { "INFO" } diff --git a/functions/private/Install-WinUtilWinGetClient.ps1 b/functions/private/Install-WinUtilWinGetClient.ps1 new file mode 100644 index 0000000000..c3dc4d11aa --- /dev/null +++ b/functions/private/Install-WinUtilWinGetClient.ps1 @@ -0,0 +1,46 @@ +function Install-WinUtilWinGetClient { + <# + .SYNOPSIS + Makes the Microsoft.WinGet.Client module available, and reports whether it is + + .DESCRIPTION + The winget command line hides its progress bar as soon as its output is redirected, + so a caller can never report how far along an install is. The module reports + progress and a structured result instead, which is what lets WinUtil show real + percentages rather than a bar that jumps from nothing to done. + + The answer is cached for the session. Installing the module reaches the PowerShell + Gallery and takes some seconds, so a machine that cannot get it falls back to the + command line rather than paying that cost on every call. + #> + + if ($null -ne $sync.WinGetClientReady) { + return $sync.WinGetClientReady + } + + if (Get-Module -Name Microsoft.WinGet.Client) { + $sync.WinGetClientReady = $true + return $true + } + + try { + if (-not (Get-Module -ListAvailable -Name Microsoft.WinGet.Client)) { + Write-WinUtilLog -Component "Package" -Message "Installing the Microsoft.WinGet.Client module." + Write-WinUtilJobProgress -Status "Preparing the WinGet client" -State "Indeterminate" + + if (-not (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue)) { + Install-PackageProvider -Name NuGet -Force -Scope CurrentUser -ErrorAction Stop | Out-Null + } + Install-Module -Name Microsoft.WinGet.Client -Force -Scope CurrentUser -Repository PSGallery -ErrorAction Stop + } + + Import-Module Microsoft.WinGet.Client -ErrorAction Stop + Write-WinUtilLog -Component "Package" -Message "WinGet client module ready: $((Get-Module Microsoft.WinGet.Client).Version)" + $sync.WinGetClientReady = $true + } catch { + Write-WinUtilLog -Level "WARN" -Component "Package" -Message "WinGet client module unavailable, falling back to the winget command line: $($_.Exception.Message)" + $sync.WinGetClientReady = $false + } + + return $sync.WinGetClientReady +} diff --git a/functions/private/Install-WinUtilWinget.ps1 b/functions/private/Install-WinUtilWinget.ps1 index f3fc4627f6..cbacddd904 100644 --- a/functions/private/Install-WinUtilWinget.ps1 +++ b/functions/private/Install-WinUtilWinget.ps1 @@ -8,6 +8,9 @@ function Install-WinUtilWinget { installs winGet if needed #> if ((Test-WinUtilPackageManager -winget) -eq "installed") { + # The client module is what reports install progress, so make it available while the + # user is already waiting rather than on the first package + Install-WinUtilWinGetClient | Out-Null return } diff --git a/functions/private/Invoke-WinUtilCurrentSystem.ps1 b/functions/private/Invoke-WinUtilCurrentSystem.ps1 index ed0f4a3df2..140c05ca05 100644 --- a/functions/private/Invoke-WinUtilCurrentSystem.ps1 +++ b/functions/private/Invoke-WinUtilCurrentSystem.ps1 @@ -24,6 +24,28 @@ Function Invoke-WinUtilCurrentSystem { } if ($checkbox -eq "winget") { + # Get-WinGetPackage returns the installed set as objects, including the entries winget + # only knows from Add/Remove Programs. Those carry a real name but an ARP id, so an app + # installed outside winget is only recognisable by name. + if (Install-WinUtilWinGetClient) { + $installed = @(Get-WinGetPackage -ErrorAction Stop) + $installedIds = [System.Collections.Generic.HashSet[string]]::new([string[]]@($installed | ForEach-Object { $_.Id }), [StringComparer]::OrdinalIgnoreCase) + $installedNames = [System.Collections.Generic.HashSet[string]]::new([string[]]@($installed | ForEach-Object { $_.Name }), [StringComparer]::OrdinalIgnoreCase) + Write-WinUtilLog -Component "Install" -Message "WinGet reports $($installed.Count) installed package(s)." + + $sync.configs.applicationsHashtable.GetEnumerator() | ForEach-Object { + $packageId = (($_.Value.winget -split ";")[-1] -replace "^msstore:", "").Trim() + if ([string]::IsNullOrWhiteSpace($packageId) -or $packageId -eq "na") { + return + } + + if ($installedIds.Contains($packageId) -or $installedNames.Contains([string]$_.Value.Content)) { + Write-Output $_.Key + } + } + return + } + $originalEncoding = [Console]::OutputEncoding try { [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() diff --git a/functions/private/Invoke-WinUtilWinGetCommand.ps1 b/functions/private/Invoke-WinUtilWinGetCommand.ps1 new file mode 100644 index 0000000000..8379498afa --- /dev/null +++ b/functions/private/Invoke-WinUtilWinGetCommand.ps1 @@ -0,0 +1,80 @@ +function Invoke-WinUtilWinGetCommand { + <# + .SYNOPSIS + Runs a Microsoft.WinGet.Client command and reports its progress as it goes + + .DESCRIPTION + The module reports progress through the PowerShell progress stream, which cannot be + redirected like output or errors. Running the command on a nested PowerShell + instance makes that stream readable, so download and install percentages can be + polled while the command is still running. + + .PARAMETER Command + The cmdlet to run, for example Install-WinGetPackage. + + .PARAMETER Parameters + Arguments for the cmdlet. + + .PARAMETER ProgressBase + Where this command starts within the job's overall progress bar. + + .PARAMETER ProgressSpan + How much of the overall bar this command accounts for. Zero reports nothing. + + .PARAMETER Label + Text shown before the module's own status, normally the package name. + #> + param( + [Parameter(Mandatory)] + [string]$Command, + + [hashtable]$Parameters = @{}, + + [int]$ProgressBase = 0, + + [int]$ProgressSpan = 0, + + [string]$Label + ) + + $shell = [powershell]::Create() + try { + [void]$shell.AddCommand("Import-Module").AddParameter("Name", "Microsoft.WinGet.Client") + [void]$shell.AddStatement().AddCommand($Command) + foreach ($entry in $Parameters.GetEnumerator()) { + [void]$shell.AddParameter($entry.Key, $entry.Value) + } + + $handle = $shell.BeginInvoke() + + $reported = "" + while (-not $handle.IsCompleted) { + $latest = @($shell.Streams.Progress)[-1] + if ($latest) { + $status = if ($Label) { "$Label - $($latest.StatusDescription)" } else { $latest.StatusDescription } + # The module uses -1 for phases it cannot measure, such as post-install + if ($latest.PercentComplete -ge 0 -and $ProgressSpan -gt 0) { + $percent = $ProgressBase + [int](($latest.PercentComplete / 100) * $ProgressSpan) + Write-WinUtilJobProgress -Status $status -Percent $percent + } elseif ($status -ne $reported) { + Write-WinUtilJobProgress -Status $status + } + $reported = $status + } + Start-Sleep -Milliseconds 150 + } + + $output = $shell.EndInvoke($handle) + + foreach ($record in $shell.Streams.Error) { + Write-WinUtilErrorRecord -ErrorRecord $record -Component "Package" -Context $Command + } + foreach ($record in $shell.Streams.Warning) { + Write-WinUtilLog -Level "WARN" -Component "Package" -Message $record.Message + } + + return @($output) + } finally { + $shell.Dispose() + } +} diff --git a/functions/public/Invoke-WPFInstall.ps1 b/functions/public/Invoke-WPFInstall.ps1 index 65b5dcf8f8..a7976ddf28 100644 --- a/functions/public/Invoke-WPFInstall.ps1 +++ b/functions/public/Invoke-WPFInstall.ps1 @@ -42,8 +42,10 @@ function Invoke-WPFInstall { $position = $completedPackages + 1 Write-WinUtilJobProgress -Status "Installing $program ($position/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) + $slice = [int](100 / $totalPackages) $results += Measure-WinUtilStep -Scope "Install" -Name "winget $program" -ScriptBlock { - Install-WinUtilProgramWinget -Action Install -Programs @($program) + Install-WinUtilProgramWinget -Action Install -Programs @($program) ` + -ProgressBase ([int](($completedPackages / $totalPackages) * 100)) -ProgressSpan $slice } $completedPackages++ Write-WinUtilJobProgress -Status "Installed $program ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) diff --git a/functions/public/Invoke-WPFUnInstall.ps1 b/functions/public/Invoke-WPFUnInstall.ps1 index e52aa1eeec..9dd15155fe 100644 --- a/functions/public/Invoke-WPFUnInstall.ps1 +++ b/functions/public/Invoke-WPFUnInstall.ps1 @@ -54,8 +54,10 @@ function Invoke-WPFUnInstall { $position = $completedPackages + 1 Write-WinUtilJobProgress -Status "Uninstalling $program ($position/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) + $slice = [int](100 / $totalPackages) $results += Measure-WinUtilStep -Scope "Uninstall" -Name "winget $program" -ScriptBlock { - Install-WinUtilProgramWinget -Action Uninstall -Programs @($program) + Install-WinUtilProgramWinget -Action Uninstall -Programs @($program) ` + -ProgressBase ([int](($completedPackages / $totalPackages) * 100)) -ProgressSpan $slice } $completedPackages++ Write-WinUtilJobProgress -Status "Uninstalled $program ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) diff --git a/pester/package-outcome.Tests.ps1 b/pester/package-outcome.Tests.ps1 index cbb93eefe6..9c64f6bd72 100644 --- a/pester/package-outcome.Tests.ps1 +++ b/pester/package-outcome.Tests.ps1 @@ -9,6 +9,10 @@ BeforeAll { . (Join-Path $script:repoRoot "functions\private\Install-WinUtilProgramChoco.ps1") . (Join-Path $script:repoRoot "functions\private\Complete-WinUtilPackageRun.ps1") + # The CLI path is what these tests cover; the module path is verified against real winget + function Install-WinUtilWinGetClient { $false } + function Invoke-WinUtilWinGetCommand { param([string]$Command, [hashtable]$Parameters, [int]$ProgressBase, [int]$ProgressSpan, [string]$Label) } + function Write-WinUtilJobProgress { param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay, [switch]$Hide) } function Write-WinUtilLog { param($Message, $Level, $Component) } @@ -59,6 +63,122 @@ Describe "Install-WinUtilProgramWinget outcomes" { } } +Describe "Install-WinUtilProgramWinget through the WinGet client module" { + BeforeAll { + function New-WinGetResult { + param([string]$Status = "Ok", [int]$InstallerErrorCode = 0, [bool]$RebootRequired = $false) + [pscustomobject]@{ + Id = "Git.Git" + Name = "Git" + Status = $Status + InstallerErrorCode = $InstallerErrorCode + RebootRequired = $RebootRequired + } + } + } + + BeforeEach { + Mock Write-WinUtilLog { } + Mock Install-WinUtilWinGetClient { $true } + Mock Start-Process { throw "the command line must not be used when the module is available" } + # Not installed unless a test says otherwise + Mock Invoke-WinUtilWinGetCommand { } -ParameterFilter { $Command -eq "Get-WinGetPackage" } + } + + It "prefers the module over the command line" { + Mock Invoke-WinUtilWinGetCommand { New-WinGetResult } -ParameterFilter { $Command -ne "Get-WinGetPackage" } + + $result = Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git") + + $result.Outcome | Should -Be "Succeeded" + Should -Invoke -CommandName Invoke-WinUtilWinGetCommand -Times 1 -Exactly -ParameterFilter { + $Command -eq "Install-WinGetPackage" -and + $Parameters.Id -eq "Git.Git" -and + $Parameters.Mode -eq "Silent" -and + $Label -eq "Git.Git" + } + Should -Invoke -CommandName Start-Process -Times 0 -Exactly + } + + It "passes the progress slice through so the bar moves within a package" { + Mock Invoke-WinUtilWinGetCommand { New-WinGetResult } + + Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git") -ProgressBase 40 -ProgressSpan 20 | Out-Null + + Should -Invoke -CommandName Invoke-WinUtilWinGetCommand -Times 1 -Exactly -ParameterFilter { + $ProgressBase -eq 40 -and $ProgressSpan -eq 20 + } + } + + It "uses the uninstall cmdlet for an uninstall" { + Mock Invoke-WinUtilWinGetCommand { New-WinGetResult } + + Install-WinUtilProgramWinget -Action Uninstall -Programs @("Git.Git") | Out-Null + + Should -Invoke -CommandName Invoke-WinUtilWinGetCommand -Times 1 -Exactly -ParameterFilter { + $Command -eq "Uninstall-WinGetPackage" + } + } + + # Install-WinGetPackage re-downloads and re-runs the installer for a package that is + # already present, so an install pass would reinstall the whole machine. + It "upgrades a package that is already installed instead of reinstalling it" { + Mock Invoke-WinUtilWinGetCommand { New-WinGetResult } -ParameterFilter { $Command -eq "Get-WinGetPackage" } + Mock Invoke-WinUtilWinGetCommand { New-WinGetResult -Status "NoApplicableUpgrade" } -ParameterFilter { $Command -eq "Update-WinGetPackage" } + + $result = Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git") + + $result.Outcome | Should -Be "Skipped" + Should -Invoke -CommandName Invoke-WinUtilWinGetCommand -Times 1 -Exactly -ParameterFilter { + $Command -eq "Update-WinGetPackage" + } + Should -Invoke -CommandName Invoke-WinUtilWinGetCommand -Times 0 -Exactly -ParameterFilter { + $Command -eq "Install-WinGetPackage" + } + } + + It "installs a package that is not present" { + Mock Invoke-WinUtilWinGetCommand { New-WinGetResult } -ParameterFilter { $Command -ne "Get-WinGetPackage" } + + Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git") | Out-Null + + Should -Invoke -CommandName Invoke-WinUtilWinGetCommand -Times 1 -Exactly -ParameterFilter { + $Command -eq "Install-WinGetPackage" + } + } + + It "treats a nothing-to-do status as skipped" { + Mock Invoke-WinUtilWinGetCommand { New-WinGetResult -Status "NoApplicableUpgrade" } + + (Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git")).Outcome | Should -Be "Skipped" + } + + It "treats an installer error as a failure" { + Mock Invoke-WinUtilWinGetCommand { New-WinGetResult -Status "InstallError" -InstallerErrorCode 1603 } + + $result = Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git") + + $result.Outcome | Should -Be "Failed" + $result.ExitCode | Should -Be 1603 + } + + It "treats no result at all as a failure" { + Mock Invoke-WinUtilWinGetCommand { } + + (Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git")).Outcome | Should -Be "Failed" + } + + It "notes when a package wants a reboot" { + Mock Invoke-WinUtilWinGetCommand { New-WinGetResult -RebootRequired $true } + + Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git") | Out-Null + + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Level -eq "WARN" -and $Message -like "*needs a reboot*" + } + } +} + Describe "Install-WinUtilProgramChoco outcomes" { BeforeEach { Mock Write-WinUtilLog { } diff --git a/pester/package.Tests.ps1 b/pester/package.Tests.ps1 index 71f5893a34..a4feb8dafe 100644 --- a/pester/package.Tests.ps1 +++ b/pester/package.Tests.ps1 @@ -14,9 +14,10 @@ BeforeAll { function Write-WinUtilJobBanner { param([string]$Message, [string]$Level) } - function Write-WinUtilJobProgress { - param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay, [switch]$Hide) - } + # The CLI path is what these tests cover; the module path is verified against real winget + function Install-WinUtilWinGetClient { $false } + function Invoke-WinUtilWinGetCommand { param([string]$Command, [hashtable]$Parameters, [int]$ProgressBase, [int]$ProgressSpan, [string]$Label) } + function Write-WinUtilJobProgress { param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay, [switch]$Hide) } function Write-WinUtilLog { } } diff --git a/pester/system-helpers.Tests.ps1 b/pester/system-helpers.Tests.ps1 index 84b05d749e..bfdda29974 100644 --- a/pester/system-helpers.Tests.ps1 +++ b/pester/system-helpers.Tests.ps1 @@ -16,6 +16,10 @@ BeforeAll { function choco { param([Parameter(ValueFromRemainingArguments = $true)]$Arguments) } + # The CLI path is what these tests cover; the module path is verified against real winget + function Install-WinUtilWinGetClient { $false } + function Invoke-WinUtilWinGetCommand { param([string]$Command, [hashtable]$Parameters, [int]$ProgressBase, [int]$ProgressSpan, [string]$Label) } + function Write-WinUtilJobProgress { param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay, [switch]$Hide) } function Write-WinUtilLog { } } diff --git a/pester/xaml.Tests.ps1 b/pester/xaml.Tests.ps1 index 2760252bb3..964531370d 100644 --- a/pester/xaml.Tests.ps1 +++ b/pester/xaml.Tests.ps1 @@ -435,6 +435,7 @@ Describe "XAML and sync wiring" { "UIDispatchDelegate", "StepTimings", "LoggedErrors", + "WinGetClientReady", "StartedAt", "SessionState", "TabWarmupQueue", From 8717d6c550a6fed432b69269332f65a4e0a3c73e Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 18:37:30 +0200 Subject: [PATCH 21/70] Show the install phase as running instead of finished Measured what the module actually emits: 7 progress records whether the package is 1.9 MB or 57.8 MB, only two of them download samples, and the install phase reports 0 then 100 with nothing between. On VLC the install is 4.3s of the 9.7s. - the download gets the first half of the package's slice, so reaching 100% download no longer fills the bar - the install phase pulses the bar and counts elapsed seconds in the label, because neither winget nor the module exposes installer progress - scan the whole progress collection, not just its last record: a byte sample can be superseded within milliseconds - RoundedProgressBarStyle gained an indeterminate trigger; it had none Fixes uninstall reporting a package that is not installed as a failure, which is what UninstallError after 354ms was, and adds ExtendedErrorCode to the detail. --- .../private/Install-WinUtilProgramWinget.ps1 | 37 ++++++++---- .../private/Invoke-WinUtilWinGetCommand.ps1 | 56 +++++++++++++++---- .../private/Write-WinUtilJobProgress.ps1 | 3 + pester/package-outcome.Tests.ps1 | 32 ++++++++++- xaml/inputXML.xaml | 20 +++++++ 5 files changed, 126 insertions(+), 22 deletions(-) diff --git a/functions/private/Install-WinUtilProgramWinget.ps1 b/functions/private/Install-WinUtilProgramWinget.ps1 index 539367b83c..f399ce453f 100644 --- a/functions/private/Install-WinUtilProgramWinget.ps1 +++ b/functions/private/Install-WinUtilProgramWinget.ps1 @@ -68,23 +68,36 @@ Function Install-WinUtilProgramWinget { MatchOption = "EqualsCaseInsensitive" } + # Both actions need to know whether the package is there before acting on it + $existing = Invoke-WinUtilWinGetCommand -Command "Get-WinGetPackage" -Parameters @{ + Id = $program + MatchOption = "EqualsCaseInsensitive" + ErrorAction = "SilentlyContinue" + } + $isInstalled = @($existing).Count -gt 0 -and $null -ne @($existing)[0] + if ($Action -eq "Uninstall") { + if (-not $isInstalled) { + # Asking winget to remove something it cannot see is an error, but for the + # user the package is already gone + Write-WinUtilLog -Component "Package" -Message "Uninstall winget package skipped: $program (not installed)" + [pscustomobject]@{ + Package = $program + Manager = "winget" + Action = $Action + ExitCode = 0 + Outcome = "Skipped" + Detail = "not installed" + } + continue + } $command = "Uninstall-WinGetPackage" } else { # Install-WinGetPackage re-downloads and re-runs the installer for a package # that is already present, so an install pass would reinstall everything the # machine already has. Update-WinGetPackage upgrades it or reports # NoApplicableUpgrade, which is what "install or upgrade" should mean. - $existing = Invoke-WinUtilWinGetCommand -Command "Get-WinGetPackage" -Parameters @{ - Id = $program - MatchOption = "EqualsCaseInsensitive" - ErrorAction = "SilentlyContinue" - } - $command = if (@($existing).Count -gt 0 -and $null -ne @($existing)[0]) { - "Update-WinGetPackage" - } else { - "Install-WinGetPackage" - } + $command = if ($isInstalled) { "Update-WinGetPackage" } else { "Install-WinGetPackage" } } $results = Invoke-WinUtilWinGetCommand -Command $command -Parameters $parameters ` @@ -105,7 +118,9 @@ Function Install-WinUtilProgramWinget { $detail = $status } else { $outcome = "Failed" - $detail = "status $status$(if ($exitCode -ne 0) { ", installer error $exitCode" })" + $detail = "status $status" + if ($exitCode -ne 0) { $detail += ", installer error $exitCode" } + if ($result.ExtendedErrorCode) { $detail += ", $($result.ExtendedErrorCode)" } } if ($result.RebootRequired) { diff --git a/functions/private/Invoke-WinUtilWinGetCommand.ps1 b/functions/private/Invoke-WinUtilWinGetCommand.ps1 index 8379498afa..c8de13bd2a 100644 --- a/functions/private/Invoke-WinUtilWinGetCommand.ps1 +++ b/functions/private/Invoke-WinUtilWinGetCommand.ps1 @@ -47,21 +47,57 @@ function Invoke-WinUtilWinGetCommand { $handle = $shell.BeginInvoke() + # The module reports a download as a percentage but an install only as 0 then 100, and + # the install is often half the wall-clock time. Giving the download the first half of + # the slice keeps the bar from looking finished while the installer is still running. + $downloadShare = 0.5 + $started = [System.Diagnostics.Stopwatch]::StartNew() $reported = "" + $indeterminate = $false + + # A byte sample can be superseded within milliseconds, so the whole collection is + # scanned rather than only its last entry. The records accumulate, so nothing is lost + # to a slow poll. + $downloadPattern = '[\d.]+\s*[KMGT]?B\s*/' + $lastPercent = -1 + while (-not $handle.IsCompleted) { - $latest = @($shell.Streams.Progress)[-1] + $records = @($shell.Streams.Progress) + $latest = $records[-1] if ($latest) { - $status = if ($Label) { "$Label - $($latest.StatusDescription)" } else { $latest.StatusDescription } - # The module uses -1 for phases it cannot measure, such as post-install - if ($latest.PercentComplete -ge 0 -and $ProgressSpan -gt 0) { - $percent = $ProgressBase + [int](($latest.PercentComplete / 100) * $ProgressSpan) - Write-WinUtilJobProgress -Status $status -Percent $percent - } elseif ($status -ne $reported) { - Write-WinUtilJobProgress -Status $status + $measured = @($records | Where-Object { $_.StatusDescription -match $downloadPattern -and $_.PercentComplete -ge 0 }) + $downloading = $latest.StatusDescription -match "^\s*Downloading" -or $latest.StatusDescription -match $downloadPattern + + if ($downloading -and $ProgressSpan -gt 0) { + if ($indeterminate) { + Write-WinUtilJobProgress -State "Normal" + $indeterminate = $false + } + $best = if ($measured.Count -gt 0) { ($measured | Measure-Object -Property PercentComplete -Maximum).Maximum } else { 0 } + $percent = $ProgressBase + [int](($best / 100) * $ProgressSpan * $downloadShare) + if ($percent -ne $lastPercent -or $latest.StatusDescription -ne $reported) { + Write-WinUtilJobProgress -Status "$Label - $($latest.StatusDescription)" -Percent $percent + $lastPercent = $percent + $reported = $latest.StatusDescription + } + } else { + # The installer reports nothing until it exits, so show that it is running + if (-not $indeterminate -and $ProgressSpan -gt 0) { + Write-WinUtilJobProgress -Percent ($ProgressBase + [int]($ProgressSpan * $downloadShare)) -State "Indeterminate" + $indeterminate = $true + } + $status = "$Label - $($latest.StatusDescription) ($([int]$started.Elapsed.TotalSeconds)s)" + if ($status -ne $reported) { + Write-WinUtilJobProgress -Status $status + $reported = $status + } } - $reported = $status } - Start-Sleep -Milliseconds 150 + Start-Sleep -Milliseconds 100 + } + + if ($indeterminate) { + Write-WinUtilJobProgress -State "Normal" } $output = $shell.EndInvoke($handle) diff --git a/functions/private/Write-WinUtilJobProgress.ps1 b/functions/private/Write-WinUtilJobProgress.ps1 index f8c59bd604..f0b5f84ea0 100644 --- a/functions/private/Write-WinUtilJobProgress.ps1 +++ b/functions/private/Write-WinUtilJobProgress.ps1 @@ -70,6 +70,9 @@ function Write-WinUtilJobProgress { $sync.Form.TaskbarItemInfo.ProgressValue = $Percent / 100 } if ($HasState) { + # Indeterminate means the length is unknown, so the bar pulses rather than sitting + # at a value that would read as a completed step + $sync.WPFTweaksProgressValue.IsIndeterminate = ($State -eq "Indeterminate") Set-WinUtilTaskbaritem -state $State } if ($HasOverlay) { diff --git a/pester/package-outcome.Tests.ps1 b/pester/package-outcome.Tests.ps1 index 9c64f6bd72..efa208597c 100644 --- a/pester/package-outcome.Tests.ps1 +++ b/pester/package-outcome.Tests.ps1 @@ -111,7 +111,8 @@ Describe "Install-WinUtilProgramWinget through the WinGet client module" { } It "uses the uninstall cmdlet for an uninstall" { - Mock Invoke-WinUtilWinGetCommand { New-WinGetResult } + Mock Invoke-WinUtilWinGetCommand { New-WinGetResult } -ParameterFilter { $Command -eq "Get-WinGetPackage" } + Mock Invoke-WinUtilWinGetCommand { New-WinGetResult } -ParameterFilter { $Command -eq "Uninstall-WinGetPackage" } Install-WinUtilProgramWinget -Action Uninstall -Programs @("Git.Git") | Out-Null @@ -120,6 +121,35 @@ Describe "Install-WinUtilProgramWinget through the WinGet client module" { } } + # Asking winget to remove something it cannot see returns UninstallError, which reads as a + # failed run for a package the user does not have. + It "skips uninstalling a package that is not installed" { + $result = Install-WinUtilProgramWinget -Action Uninstall -Programs @("Git.Git") + + $result.Outcome | Should -Be "Skipped" + $result.Detail | Should -Be "not installed" + Should -Invoke -CommandName Invoke-WinUtilWinGetCommand -Times 0 -Exactly -ParameterFilter { + $Command -eq "Uninstall-WinGetPackage" + } + } + + It "reports the extended error code when a package fails" { + Mock Invoke-WinUtilWinGetCommand { New-WinGetResult } -ParameterFilter { $Command -eq "Get-WinGetPackage" } + Mock Invoke-WinUtilWinGetCommand { + [pscustomobject]@{ + Status = "UninstallError" + InstallerErrorCode = 0 + ExtendedErrorCode = "Exception from HRESULT: 0x8A15004F" + RebootRequired = $false + } + } -ParameterFilter { $Command -eq "Uninstall-WinGetPackage" } + + $result = Install-WinUtilProgramWinget -Action Uninstall -Programs @("Git.Git") + + $result.Outcome | Should -Be "Failed" + $result.Detail | Should -Match "0x8A15004F" + } + # Install-WinGetPackage re-downloads and re-runs the installer for a package that is # already present, so an install pass would reinstall the whole machine. It "upgrades a package that is already installed instead of reinstalling it" { diff --git a/xaml/inputXML.xaml b/xaml/inputXML.xaml index a3667c5619..ea52939dd5 100644 --- a/xaml/inputXML.xaml +++ b/xaml/inputXML.xaml @@ -960,6 +960,26 @@ + + + + + + + + + + + + + + + From 8f5268983a61ec279aedecc92601abd07534258a Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 18:54:50 +0200 Subject: [PATCH 22/70] Say what a winget failure actually was The command line prints a sentence for a failure; the client module returns only an HRESULT, so the same failure read as "COMException (0x8A15007D)". Both report the same number, so one table serves both paths. - Get-WinUtilWinGetErrorMessage explains the codes WinUtil hits, and gives the hex plus the return-code reference for anything else - 0x8A15007D now reads: installed for a single user, cannot be removed while running as administrator, remove it from Settings > Apps - unsigned HRESULTs are wrapped rather than cast, which overflowed Int32 - a shared failure reason is repeated in the thrown message - the banner wraps at 76 columns instead of drawing a box wider than the console --- .../private/Complete-WinUtilPackageRun.ps1 | 9 ++- .../private/Get-WinUtilWinGetErrorMessage.ps1 | 43 +++++++++++ .../private/Install-WinUtilProgramWinget.ps1 | 25 ++++++- functions/private/Write-WinUtilJobBanner.ps1 | 24 +++++- pester/package-outcome.Tests.ps1 | 3 +- pester/package.Tests.ps1 | 1 + pester/winget-errors.Tests.ps1 | 73 +++++++++++++++++++ 7 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 functions/private/Get-WinUtilWinGetErrorMessage.ps1 create mode 100644 pester/winget-errors.Tests.ps1 diff --git a/functions/private/Complete-WinUtilPackageRun.ps1 b/functions/private/Complete-WinUtilPackageRun.ps1 index cb6f54a6ee..3dfe7b9171 100644 --- a/functions/private/Complete-WinUtilPackageRun.ps1 +++ b/functions/private/Complete-WinUtilPackageRun.ps1 @@ -38,6 +38,13 @@ function Complete-WinUtilPackageRun { } if ($failed.Count -gt 0) { - throw "$($failed.Count) of $($Results.Count) package(s) failed: $(($failed | ForEach-Object { $_.Package }) -join ', ')" + $names = ($failed | ForEach-Object { $_.Package }) -join ', ' + $reasons = @($failed | ForEach-Object { $_.Detail } | Sort-Object -Unique) + + # One shared reason is worth repeating; several would bury it, and they are listed above + if ($reasons.Count -eq 1) { + throw "$($failed.Count) of $($Results.Count) package(s) failed: $names. $($reasons[0])" + } + throw "$($failed.Count) of $($Results.Count) package(s) failed: $names. See the lines above for each reason." } } diff --git a/functions/private/Get-WinUtilWinGetErrorMessage.ps1 b/functions/private/Get-WinUtilWinGetErrorMessage.ps1 new file mode 100644 index 0000000000..3cb5d5e962 --- /dev/null +++ b/functions/private/Get-WinUtilWinGetErrorMessage.ps1 @@ -0,0 +1,43 @@ +function Get-WinUtilWinGetErrorMessage { + <# + .SYNOPSIS + Turns a WinGet result code into something a person can act on + + .DESCRIPTION + The winget command line prints a sentence explaining a failure; the client module + returns only an HRESULT, so the same failure reads as + "System.Runtime.InteropServices.COMException (0x8A15007D)". Both report the same + number, so one table serves both paths. + + Unknown codes still get their hex form back, which is what a search needs. + + .PARAMETER Code + The exit code or HRESULT, as a signed 32 bit integer. + #> + param( + [Parameter(Mandatory)] + [int]$Code + ) + + if ($Code -eq 0) { + return $null + } + + $messages = @{ + -1978335226 = "The installer ran but reported a failure." + -1978335224 = "The installer could not be downloaded." + -1978335216 = "No installer in this package applies to this machine." + -1978335215 = "The downloaded installer did not match the expected hash, so it was rejected." + -1978335212 = "No package matched that id." + -1978335189 = "There is no newer version to upgrade to." + -1978335135 = "The package is already installed." + -1978335107 = "The package was installed for a single user and cannot be removed while WinUtil is running as administrator. Remove it from Settings > Apps, or run winget from a normal, unelevated terminal." + } + + $hex = "0x{0:X8}" -f $Code + if ($messages.ContainsKey($Code)) { + return "$($messages[$Code]) ($hex)" + } + + return "WinGet reported $hex. See https://learn.microsoft.com/windows/package-manager/winget/returnCodes" +} diff --git a/functions/private/Install-WinUtilProgramWinget.ps1 b/functions/private/Install-WinUtilProgramWinget.ps1 index f399ce453f..9c047791dd 100644 --- a/functions/private/Install-WinUtilProgramWinget.ps1 +++ b/functions/private/Install-WinUtilProgramWinget.ps1 @@ -118,9 +118,26 @@ Function Install-WinUtilProgramWinget { $detail = $status } else { $outcome = "Failed" - $detail = "status $status" - if ($exitCode -ne 0) { $detail += ", installer error $exitCode" } - if ($result.ExtendedErrorCode) { $detail += ", $($result.ExtendedErrorCode)" } + + # The module returns an HRESULT where the command line prints a sentence. + # It is the same number, so the same table explains it. + $wingetCode = 0 + $extended = $result.ExtendedErrorCode + if ($extended -is [System.Exception]) { + $wingetCode = $extended.HResult + } elseif ($extended -and "$extended" -match '0x([0-9A-Fa-f]{8})') { + # These codes have the high bit set, so the unsigned value has to be + # wrapped rather than cast, which would overflow + $unsigned = [uint32]("0x$($Matches[1])") + $wingetCode = if ($unsigned -gt [int]::MaxValue) { [int]($unsigned - 4294967296) } else { [int]$unsigned } + } + + $explanation = Get-WinUtilWinGetErrorMessage -Code $wingetCode + if (-not $explanation -and $exitCode -ne 0) { + $explanation = "The installer returned $exitCode." + } + + $detail = if ($explanation) { "$status - $explanation" } else { "status $status" } } if ($result.RebootRequired) { @@ -145,7 +162,7 @@ Function Install-WinUtilProgramWinget { $detail = $nothingToDo[$exitCode] } else { $outcome = "Failed" - $detail = "exit code $exitCode" + $detail = Get-WinUtilWinGetErrorMessage -Code $exitCode } } diff --git a/functions/private/Write-WinUtilJobBanner.ps1 b/functions/private/Write-WinUtilJobBanner.ps1 index 1615ad1580..612640f429 100644 --- a/functions/private/Write-WinUtilJobBanner.ps1 +++ b/functions/private/Write-WinUtilJobBanner.ps1 @@ -22,12 +22,30 @@ function Write-WinUtilJobBanner { [string]$Level = "INFO" ) - $line = "-- $Message --" - $border = "=" * $line.Length + # Wrapped, because a failure listing several packages would otherwise draw a box wider + # than the console + $width = 76 + $words = $Message -split '\s+' + $lines = [System.Collections.Generic.List[string]]::new() + $current = "" + foreach ($word in $words) { + if ($current.Length -gt 0 -and ($current.Length + 1 + $word.Length) -gt $width) { + $lines.Add($current) + $current = $word + } else { + $current = if ($current.Length -eq 0) { $word } else { "$current $word" } + } + } + if ($current.Length -gt 0) { $lines.Add($current) } + + $longest = ($lines | Measure-Object -Property Length -Maximum).Maximum + $border = "=" * ($longest + 6) $colour = if ($Level -eq "ERROR") { "Red" } else { "Cyan" } Write-Host "" Write-Host $border -ForegroundColor $colour - Write-Host $line -ForegroundColor $colour + foreach ($line in $lines) { + Write-Host ("-- {0}$(' ' * ($longest - $line.Length)) --" -f $line) -ForegroundColor $colour + } Write-Host $border -ForegroundColor $colour } diff --git a/pester/package-outcome.Tests.ps1 b/pester/package-outcome.Tests.ps1 index efa208597c..fbb41c9d3f 100644 --- a/pester/package-outcome.Tests.ps1 +++ b/pester/package-outcome.Tests.ps1 @@ -5,6 +5,7 @@ BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\private\Get-WinUtilWinGetErrorMessage.ps1") . (Join-Path $script:repoRoot "functions\private\Install-WinUtilProgramWinget.ps1") . (Join-Path $script:repoRoot "functions\private\Install-WinUtilProgramChoco.ps1") . (Join-Path $script:repoRoot "functions\private\Complete-WinUtilPackageRun.ps1") @@ -253,7 +254,7 @@ Describe "Complete-WinUtilPackageRun" { ) { Complete-WinUtilPackageRun -Action "Install" -Results $results } | - Should -Throw "1 of 2 package(s) failed: b" + Should -Throw "1 of 2 package(s) failed: b. *" } It "accepts an empty run" { diff --git a/pester/package.Tests.ps1 b/pester/package.Tests.ps1 index a4feb8dafe..fe27ab2a7f 100644 --- a/pester/package.Tests.ps1 +++ b/pester/package.Tests.ps1 @@ -7,6 +7,7 @@ BeforeAll { . (Join-Path $script:repoRoot "functions\private\Get-WinUtilSelectedPackages.ps1") . (Join-Path $script:repoRoot "functions\private\Test-WinUtilPackageManager.ps1") + . (Join-Path $script:repoRoot "functions\private\Get-WinUtilWinGetErrorMessage.ps1") . (Join-Path $script:repoRoot "functions\private\Install-WinUtilProgramWinget.ps1") . (Join-Path $script:repoRoot "functions\private\Install-WinUtilProgramChoco.ps1") diff --git a/pester/winget-errors.Tests.ps1 b/pester/winget-errors.Tests.ps1 new file mode 100644 index 0000000000..2609beaced --- /dev/null +++ b/pester/winget-errors.Tests.ps1 @@ -0,0 +1,73 @@ +#=========================================================================== +# Tests - WinGet result codes +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\private\Get-WinUtilWinGetErrorMessage.ps1") +} + +Describe "Get-WinUtilWinGetErrorMessage" { + It "returns nothing for success" { + Get-WinUtilWinGetErrorMessage -Code 0 | Should -BeNullOrEmpty + } + + # The command line prints this sentence; the module returns only the HRESULT. Both report + # the same number, which is what makes one table enough. + It "explains the user scope uninstall failure that winget words itself" { + $message = Get-WinUtilWinGetErrorMessage -Code -1978335107 + + $message | Should -Match "single user" + $message | Should -Match "administrator" + $message | Should -Match "0x8A15007D" + } + + It "explains the codes that mean there was nothing to do" { + (Get-WinUtilWinGetErrorMessage -Code -1978335135) | Should -Match "already installed" + (Get-WinUtilWinGetErrorMessage -Code -1978335189) | Should -Match "no newer version" + } + + It "explains a missing package and a hash mismatch" { + (Get-WinUtilWinGetErrorMessage -Code -1978335212) | Should -Match "No package matched" + (Get-WinUtilWinGetErrorMessage -Code -1978335215) | Should -Match "hash" + } + + It "still gives the hex code and a reference for anything unknown" { + $message = Get-WinUtilWinGetErrorMessage -Code -1978335000 + + $message | Should -Match "0x8A1500" + $message | Should -Match "returnCodes" + } +} + +Describe "Package failure reporting" { + BeforeAll { + . (Join-Path $script:repoRoot "functions\private\Complete-WinUtilPackageRun.ps1") + function Write-WinUtilLog { param($Message, $Level, $Component) } + } + + BeforeEach { + Mock Write-WinUtilLog { } + Mock Write-Host { } + } + + It "repeats a single shared reason so it is not lost" { + $results = @( + [pscustomobject]@{ Package = "a"; Outcome = "Failed"; Detail = "UninstallError - user scope" }, + [pscustomobject]@{ Package = "b"; Outcome = "Failed"; Detail = "UninstallError - user scope" } + ) + + { Complete-WinUtilPackageRun -Action "Uninstall" -Results $results } | + Should -Throw "*a, b. UninstallError - user scope*" + } + + It "points at the per-package lines when the reasons differ" { + $results = @( + [pscustomobject]@{ Package = "a"; Outcome = "Failed"; Detail = "one reason" }, + [pscustomobject]@{ Package = "b"; Outcome = "Failed"; Detail = "another reason" } + ) + + { Complete-WinUtilPackageRun -Action "Uninstall" -Results $results } | + Should -Throw "*See the lines above for each reason.*" + } +} From 06e75cfc01d6a49072c1265fa9fa0f150faece9e Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 19:14:56 +0200 Subject: [PATCH 23/70] Keep the run position in the progress text during a package - the bar itself was already whole-workflow: 0-12, 25-37, 50-62, 75-87, 100 - but the status read "A.A - 50% downloaded", dropping the (n/total) the old per-package messages carried - callers pass a label, so it now reads "A.A (1/4) - 50% downloaded" --- functions/private/Install-WinUtilProgramWinget.ps1 | 11 +++++++++-- functions/public/Invoke-WPFInstall.ps1 | 3 ++- functions/public/Invoke-WPFUnInstall.ps1 | 3 ++- pester/package-outcome.Tests.ps1 | 11 +++++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/functions/private/Install-WinUtilProgramWinget.ps1 b/functions/private/Install-WinUtilProgramWinget.ps1 index 9c047791dd..05882cdc2f 100644 --- a/functions/private/Install-WinUtilProgramWinget.ps1 +++ b/functions/private/Install-WinUtilProgramWinget.ps1 @@ -19,6 +19,10 @@ Function Install-WinUtilProgramWinget { .PARAMETER ProgressSpan How much of the overall bar this package accounts for. + .PARAMETER Label + How the package should be named in the progress text. Callers working through a list + pass the position in it, so the status keeps saying where the run is overall. + #> param ( [Parameter(Mandatory=$true)] @@ -30,7 +34,9 @@ Function Install-WinUtilProgramWinget { [int]$ProgressBase = 0, - [int]$ProgressSpan = 0 + [int]$ProgressSpan = 0, + + [string]$Label ) # WinGet reports "there was nothing to do" through the exit code rather than as success @@ -100,8 +106,9 @@ Function Install-WinUtilProgramWinget { $command = if ($isInstalled) { "Update-WinGetPackage" } else { "Install-WinGetPackage" } } + $progressLabel = if ($Label) { $Label } else { $program } $results = Invoke-WinUtilWinGetCommand -Command $command -Parameters $parameters ` - -ProgressBase $ProgressBase -ProgressSpan $ProgressSpan -Label $program + -ProgressBase $ProgressBase -ProgressSpan $ProgressSpan -Label $progressLabel $result = @($results)[0] if ($null -eq $result) { diff --git a/functions/public/Invoke-WPFInstall.ps1 b/functions/public/Invoke-WPFInstall.ps1 index a7976ddf28..bb69641d3e 100644 --- a/functions/public/Invoke-WPFInstall.ps1 +++ b/functions/public/Invoke-WPFInstall.ps1 @@ -45,7 +45,8 @@ function Invoke-WPFInstall { $slice = [int](100 / $totalPackages) $results += Measure-WinUtilStep -Scope "Install" -Name "winget $program" -ScriptBlock { Install-WinUtilProgramWinget -Action Install -Programs @($program) ` - -ProgressBase ([int](($completedPackages / $totalPackages) * 100)) -ProgressSpan $slice + -ProgressBase ([int](($completedPackages / $totalPackages) * 100)) -ProgressSpan $slice ` + -Label "$program ($position/$totalPackages)" } $completedPackages++ Write-WinUtilJobProgress -Status "Installed $program ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) diff --git a/functions/public/Invoke-WPFUnInstall.ps1 b/functions/public/Invoke-WPFUnInstall.ps1 index 9dd15155fe..9781a96210 100644 --- a/functions/public/Invoke-WPFUnInstall.ps1 +++ b/functions/public/Invoke-WPFUnInstall.ps1 @@ -57,7 +57,8 @@ function Invoke-WPFUnInstall { $slice = [int](100 / $totalPackages) $results += Measure-WinUtilStep -Scope "Uninstall" -Name "winget $program" -ScriptBlock { Install-WinUtilProgramWinget -Action Uninstall -Programs @($program) ` - -ProgressBase ([int](($completedPackages / $totalPackages) * 100)) -ProgressSpan $slice + -ProgressBase ([int](($completedPackages / $totalPackages) * 100)) -ProgressSpan $slice ` + -Label "$program ($position/$totalPackages)" } $completedPackages++ Write-WinUtilJobProgress -Status "Uninstalled $program ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) diff --git a/pester/package-outcome.Tests.ps1 b/pester/package-outcome.Tests.ps1 index fbb41c9d3f..18f22d546f 100644 --- a/pester/package-outcome.Tests.ps1 +++ b/pester/package-outcome.Tests.ps1 @@ -111,6 +111,17 @@ Describe "Install-WinUtilProgramWinget through the WinGet client module" { } } + # Without it the status reads as a package on its own, losing where the run is overall + It "keeps the caller's label so the position in the run stays visible" { + Mock Invoke-WinUtilWinGetCommand { New-WinGetResult } -ParameterFilter { $Command -ne "Get-WinGetPackage" } + + Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git") -Label "Git.Git (2/7)" | Out-Null + + Should -Invoke -CommandName Invoke-WinUtilWinGetCommand -Times 1 -Exactly -ParameterFilter { + $Command -ne "Get-WinGetPackage" -and $Label -eq "Git.Git (2/7)" + } + } + It "uses the uninstall cmdlet for an uninstall" { Mock Invoke-WinUtilWinGetCommand { New-WinGetResult } -ParameterFilter { $Command -eq "Get-WinGetPackage" } Mock Invoke-WinUtilWinGetCommand { New-WinGetResult } -ParameterFilter { $Command -eq "Uninstall-WinGetPackage" } From 299508d412a004da2a11f76b4f22243b39f7d827 Mon Sep 17 00:00:00 2001 From: MyDrift Date: Thu, 6 Aug 2026 20:44:47 +0200 Subject: [PATCH 24/70] Pulse the progress bar in place instead of filling it - IsIndeterminate makes WPF discard Value and stretch the indicator across the whole track: measured 398px of a 400px track at value 40, against 159px correct - the pulse is driven by Tag instead, so the bar keeps the progress it reached - RemoveStoryboard on exit, because Stop left the indicator at whatever opacity the pulse happened to be on --- functions/private/Write-WinUtilJobProgress.ps1 | 6 +++--- xaml/inputXML.xaml | 12 +++++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/functions/private/Write-WinUtilJobProgress.ps1 b/functions/private/Write-WinUtilJobProgress.ps1 index f0b5f84ea0..c87fedddd7 100644 --- a/functions/private/Write-WinUtilJobProgress.ps1 +++ b/functions/private/Write-WinUtilJobProgress.ps1 @@ -70,9 +70,9 @@ function Write-WinUtilJobProgress { $sync.Form.TaskbarItemInfo.ProgressValue = $Percent / 100 } if ($HasState) { - # Indeterminate means the length is unknown, so the bar pulses rather than sitting - # at a value that would read as a completed step - $sync.WPFTweaksProgressValue.IsIndeterminate = ($State -eq "Indeterminate") + # Pulse in place at whatever progress has been reached. IsIndeterminate would make + # WPF discard Value and fill the whole bar, which reads as finished. + $sync.WPFTweaksProgressValue.Tag = if ($State -eq "Indeterminate") { "Pulse" } else { $null } Set-WinUtilTaskbaritem -state $State } if ($HasOverlay) { diff --git a/xaml/inputXML.xaml b/xaml/inputXML.xaml index ea52939dd5..4ba4931c83 100644 --- a/xaml/inputXML.xaml +++ b/xaml/inputXML.xaml @@ -963,8 +963,12 @@ - + where a static bar would read as finished. + + Driven by Tag rather than IsIndeterminate: that property makes + WPF ignore Value and stretch the indicator across the whole + track, which would throw away the progress reached so far. --> + @@ -976,7 +980,9 @@ - + + From 924ab6f712d4f3a6bbe16510e45550b81db4b838 Mon Sep 17 00:00:00 2001 From: MyDrift Date: Fri, 7 Aug 2026 19:34:30 +0200 Subject: [PATCH 25/70] perf: cut Install tab build and keep the interface answering during warmup - look apps up by hashtable index, not dynamic member: Install tab app area 361ms -> 91ms - cap a render pass at 25 apps so a large category cannot stall the interface - yield between batches when building speculative tab content - claim a tab as initialized before building it, so a click during a yield cannot double build - time each step of a tab switch --- .../private/Initialize-InstallAppEntry.ps1 | 2 +- .../Initialize-InstallCategoryAppList.ps1 | 4 +- .../private/Initialize-WinUtilTabContent.ps1 | 62 +++++++++++-------- .../Start-WinUtilInstallAppRendering.ps1 | 20 +++++- functions/private/Start-WinUtilTabWarmup.ps1 | 2 +- functions/public/Invoke-WPFTab.ps1 | 62 +++++++++---------- functions/public/Invoke-WPFUIElements.ps1 | 18 +++++- pester/install-rendering.Tests.ps1 | 18 +++++- pester/xaml.Tests.ps1 | 2 +- 9 files changed, 125 insertions(+), 65 deletions(-) diff --git a/functions/private/Initialize-InstallAppEntry.ps1 b/functions/private/Initialize-InstallAppEntry.ps1 index 7fa501039d..a7b99465bb 100644 --- a/functions/private/Initialize-InstallAppEntry.ps1 +++ b/functions/private/Initialize-InstallAppEntry.ps1 @@ -13,7 +13,7 @@ function Initialize-InstallAppEntry { $appKey ) - $app = $sync.configs.applicationsHashtable.$appKey + $app = $sync.configs.applicationsHashtable[$appKey] # Create the outer Border for the application type $border = New-Object Windows.Controls.Border diff --git a/functions/private/Initialize-InstallCategoryAppList.ps1 b/functions/private/Initialize-InstallCategoryAppList.ps1 index fda3549814..0ab222e3e6 100644 --- a/functions/private/Initialize-InstallCategoryAppList.ps1 +++ b/functions/private/Initialize-InstallCategoryAppList.ps1 @@ -19,8 +19,10 @@ function Initialize-InstallCategoryAppList { # Pre-group apps by category before creating WPF controls. Lists, because appending to # an array copies it and there are several hundred apps. $appsByCategory = @{} + # Indexed, not dynamic member, lookup: the latter goes through the PSObject adapter and + # costs about seventy times as much per app. foreach ($appKey in $Apps.Keys) { - $category = $Apps.$appKey.Category + $category = $Apps[$appKey].Category if (-not $appsByCategory.ContainsKey($category)) { $appsByCategory[$category] = [System.Collections.Generic.List[string]]::new() } diff --git a/functions/private/Initialize-WinUtilTabContent.ps1 b/functions/private/Initialize-WinUtilTabContent.ps1 index eedad6b4d6..27e8a7ee46 100644 --- a/functions/private/Initialize-WinUtilTabContent.ps1 +++ b/functions/private/Initialize-WinUtilTabContent.ps1 @@ -1,7 +1,11 @@ function Initialize-WinUtilTabContent { param( [Parameter(Mandatory = $true)] - [string]$TabName + [string]$TabName, + + # Build in batches, letting the interface answer in between. Used by the warmup, which + # nobody is waiting on. A tab the user just clicked is built in one go. + [switch]$Yield ) if ($null -eq $sync.InitializedTabs) { @@ -12,34 +16,42 @@ function Initialize-WinUtilTabContent { return } - switch ($TabName) { - "Install" { - Measure-WinUtilStep -Scope "UI" -Name "Install tab: app navigation" -ScriptBlock { - Invoke-WPFUIElements -configVariable $sync.configs.appnavigation -targetGridName "appscategory" -columncount 1 + # Claimed before building, not after: a yielding build lets a click through, and that click + # would otherwise start building the same tab a second time. + $sync.InitializedTabs[$TabName] = $true + + try { + switch ($TabName) { + "Install" { + Measure-WinUtilStep -Scope "UI" -Name "Install tab: app navigation" -ScriptBlock { + Invoke-WPFUIElements -configVariable $sync.configs.appnavigation -targetGridName "appscategory" -columncount 1 -Yield:$Yield + } + Measure-WinUtilStep -Scope "UI" -Name "Install tab: category area" -ScriptBlock { + Initialize-WPFUI -targetGridName "appscategory" + } + Measure-WinUtilStep -Scope "UI" -Name "Install tab: app area" -ScriptBlock { + Initialize-WPFUI -targetGridName "appspanel" + } + Initialize-WinUtilInstallTabControls } - Measure-WinUtilStep -Scope "UI" -Name "Install tab: category area" -ScriptBlock { - Initialize-WPFUI -targetGridName "appscategory" + "Tweaks" { + Invoke-WPFUIElements -configVariable $sync.configs.tweaks -targetGridName "tweakspanel" -columncount 2 -Yield:$Yield } - Measure-WinUtilStep -Scope "UI" -Name "Install tab: app area" -ScriptBlock { - Initialize-WPFUI -targetGridName "appspanel" + "Config" { + Invoke-WPFUIElements -configVariable $sync.configs.feature -targetGridName "featurespanel" -columncount 2 -Yield:$Yield } - Initialize-WinUtilInstallTabControls - } - "Tweaks" { - Invoke-WPFUIElements -configVariable $sync.configs.tweaks -targetGridName "tweakspanel" -columncount 2 - } - "Config" { - Invoke-WPFUIElements -configVariable $sync.configs.feature -targetGridName "featurespanel" -columncount 2 - } - "AppX" { - Invoke-WPFUIElements -configVariable $sync.configs.appx -targetGridName "appxpanel" -columncount 2 - } - "Win11ISO" { - if ($sync.Form -and $sync.Form.Dispatcher) { - $sync.Form.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Invoke-WinUtilISOCheckExistingWork }) | Out-Null + "AppX" { + Invoke-WPFUIElements -configVariable $sync.configs.appx -targetGridName "appxpanel" -columncount 2 -Yield:$Yield + } + "Win11ISO" { + if ($sync.Form -and $sync.Form.Dispatcher) { + $sync.Form.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Invoke-WinUtilISOCheckExistingWork }) | Out-Null + } } } + } catch { + # A half built tab must be allowed to rebuild rather than staying empty forever + $sync.InitializedTabs[$TabName] = $false + throw } - - $sync.InitializedTabs[$TabName] = $true } diff --git a/functions/private/Start-WinUtilInstallAppRendering.ps1 b/functions/private/Start-WinUtilInstallAppRendering.ps1 index f3bf9da103..1ecdc455df 100644 --- a/functions/private/Start-WinUtilInstallAppRendering.ps1 +++ b/functions/private/Start-WinUtilInstallAppRendering.ps1 @@ -4,10 +4,28 @@ function Invoke-WinUtilInstallAppRenderBatch { $CategoryBatch ) - foreach ($appKey in $CategoryBatch.AppKeys) { + # A category is not a unit of work: the largest holds several times as many apps as the + # smallest, so rendering one per pass hands the interface a stall of unpredictable length. + $batchLimit = 25 + $remaining = $null + $keys = $CategoryBatch.AppKeys + if ($keys.Count -gt $batchLimit) { + $remaining = $keys[$batchLimit..($keys.Count - 1)] + $keys = $keys[0..($batchLimit - 1)] + } + + foreach ($appKey in $keys) { $sync.$appKey = Initialize-InstallAppEntry -TargetElement $CategoryBatch.TargetElement -AppKey $appKey } + if ($remaining) { + $sync.InstallAppRenderQueue.Enqueue([pscustomobject]@{ + Category = $CategoryBatch.Category + TargetElement = $CategoryBatch.TargetElement + AppKeys = @($remaining) + }) + } + if ($sync.currentTab -eq "Install" -and $sync.SearchBar -and -not [string]::IsNullOrWhiteSpace($sync.SearchBar.Text)) { Find-AppsByNameOrDescription -SearchString $sync.SearchBar.Text -Category $sync.SearchBar.Tag } diff --git a/functions/private/Start-WinUtilTabWarmup.ps1 b/functions/private/Start-WinUtilTabWarmup.ps1 index 972bef2fc0..eeec6fc13c 100644 --- a/functions/private/Start-WinUtilTabWarmup.ps1 +++ b/functions/private/Start-WinUtilTabWarmup.ps1 @@ -47,7 +47,7 @@ function Invoke-WinUtilTabWarmupStep { $tab = $sync.TabWarmupQueue.Dequeue() try { Measure-WinUtilStep -Scope "UI" -Name "warm $tab tab" -ScriptBlock { - Initialize-WinUtilTabContent -TabName $tab + Initialize-WinUtilTabContent -TabName $tab -Yield } } catch { Write-WinUtilErrorRecord -ErrorRecord $_ -Component "UI" -Context "Warming the $tab tab" diff --git a/functions/public/Invoke-WPFTab.ps1 b/functions/public/Invoke-WPFTab.ps1 index 43c7a06abb..37055244c4 100644 --- a/functions/public/Invoke-WPFTab.ps1 +++ b/functions/public/Invoke-WPFTab.ps1 @@ -15,47 +15,43 @@ function Invoke-WPFTab { [string]$ClickedTab ) - $tabNav = Get-WinUtilVariables | Where-Object {$psitem -like "WPFTabNav"} $tabNumber = [int]($ClickedTab -replace "WPFTab","" -replace "BT","") - 1 - $filter = Get-WinUtilVariables -Type ToggleButton | Where-Object {$psitem -like "WPFTab?BT"} - $sync.$tabNav.Items[$tabNumber].IsSelected = $true - ($sync.GetEnumerator()).where{$psitem.Key -in $filter} | ForEach-Object { - if ($ClickedTab -ne $PSItem.name) { - $sync[$PSItem.Name].IsChecked = $false - } else { - $sync["$ClickedTab"].IsChecked = $true + Measure-WinUtilStep -Scope "Tab" -Name "$ClickedTab select" -ScriptBlock { + $filter = Get-WinUtilVariables -Type ToggleButton | Where-Object {$psitem -like "WPFTab?BT"} + $sync.WPFTabNav.Items[$tabNumber].IsSelected = $true + ($sync.GetEnumerator()).where{$psitem.Key -in $filter} | ForEach-Object { + if ($ClickedTab -ne $PSItem.name) { + $sync[$PSItem.Name].IsChecked = $false + } else { + $sync["$ClickedTab"].IsChecked = $true + } } + $sync.currentTab = $sync.WPFTabNav.Items[$tabNumber].Header } - $sync.currentTab = $sync.$tabNav.Items[$tabNumber].Header - Initialize-WinUtilTabContent -TabName $sync.currentTab - - # Always reset the filter for the current tab - if ($sync.currentTab -eq "Install") { - # Reset Install tab filter - Find-AppsByNameOrDescription -SearchString "" - } elseif ($sync.currentTab -eq "Tweaks") { - # Reset Tweaks tab filter - Find-TweaksByNameOrDescription -SearchString "" - } elseif ($sync.currentTab -eq "AppX") { - # Reset AppX tab filter - Find-TweaksByNameOrDescription -SearchString "" + + Measure-WinUtilStep -Scope "Tab" -Name "$ClickedTab content" -ScriptBlock { + Initialize-WinUtilTabContent -TabName $sync.currentTab } - # Show search bar in Install, Tweaks, and AppX tabs - if ($tabNumber -eq 0 -or $tabNumber -eq 1 -or $tabNumber -eq 5) { - $sync.SearchBar.Visibility = "Visible" - $searchIcon = ($sync.Form.FindName("SearchBar").Parent.Children | Where-Object { $_ -is [System.Windows.Controls.TextBlock] -and $_.Text -eq [char]0xE721 })[0] - if ($searchIcon) { - $searchIcon.Visibility = "Visible" + Measure-WinUtilStep -Scope "Tab" -Name "$ClickedTab filter reset" -ScriptBlock { + if ($sync.currentTab -eq "Install") { + Find-AppsByNameOrDescription -SearchString "" + } elseif ($sync.currentTab -eq "Tweaks" -or $sync.currentTab -eq "AppX") { + Find-TweaksByNameOrDescription -SearchString "" } - } else { - $sync.SearchBar.Visibility = "Collapsed" + } + + Measure-WinUtilStep -Scope "Tab" -Name "$ClickedTab search bar" -ScriptBlock { + # Show search bar in Install, Tweaks, and AppX tabs $searchIcon = ($sync.Form.FindName("SearchBar").Parent.Children | Where-Object { $_ -is [System.Windows.Controls.TextBlock] -and $_.Text -eq [char]0xE721 })[0] - if ($searchIcon) { - $searchIcon.Visibility = "Collapsed" + if ($tabNumber -eq 0 -or $tabNumber -eq 1 -or $tabNumber -eq 5) { + $sync.SearchBar.Visibility = "Visible" + if ($searchIcon) { $searchIcon.Visibility = "Visible" } + } else { + $sync.SearchBar.Visibility = "Collapsed" + if ($searchIcon) { $searchIcon.Visibility = "Collapsed" } + $sync.SearchBarClearButton.Visibility = "Collapsed" } - # Hide the clear button if it's visible - $sync.SearchBarClearButton.Visibility = "Collapsed" } } diff --git a/functions/public/Invoke-WPFUIElements.ps1 b/functions/public/Invoke-WPFUIElements.ps1 index 85d0c5161d..54d3a0973e 100644 --- a/functions/public/Invoke-WPFUIElements.ps1 +++ b/functions/public/Invoke-WPFUIElements.ps1 @@ -22,7 +22,11 @@ function Invoke-WPFUIElements { [string]$targetGridName, [Parameter(Mandatory, Position = 2)] - [int]$columncount + [int]$columncount, + + # Let the interface answer between batches of entries. Only for content nobody is + # waiting on: a user who just clicked the tab is better served by finishing at once. + [switch]$Yield ) $window = $sync.form @@ -153,6 +157,18 @@ function Invoke-WPFUIElements { }}, Content foreach ($entryInfo in $entries) { $count++ + + # Constructing a panel's worth of controls in one go holds the interface for + # hundreds of milliseconds. Draining the queue every so often keeps a click + # responsive while speculative content is still being built. + if ($Yield -and ($count % 20) -eq 0 -and $sync.Form -and -not $sync.Form.Dispatcher.HasShutdownStarted) { + $frame = New-Object Windows.Threading.DispatcherFrame + $null = $sync.Form.Dispatcher.BeginInvoke( + [Windows.Threading.DispatcherPriority]::Background, + [action]{ $frame.Continue = $false }) + [Windows.Threading.Dispatcher]::PushFrame($frame) + } + # Create the UI elements based on the entry type switch ($entryInfo.Type) { "Toggle" { diff --git a/pester/install-rendering.Tests.ps1 b/pester/install-rendering.Tests.ps1 index 5c77f6a9b4..7ce0d05f97 100644 --- a/pester/install-rendering.Tests.ps1 +++ b/pester/install-rendering.Tests.ps1 @@ -113,10 +113,26 @@ Describe "Install app rendering startup contract" { It "keeps app-entry metadata lookup independent from the old caller scope" { $entryScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-InstallAppEntry.ps1") -Raw - $entryScript | Should -Match '\$app = \$sync\.configs\.applicationsHashtable\.\$appKey' + $entryScript | Should -Match '\$app = \$sync\.configs\.applicationsHashtable\[\$appKey\]' $entryScript | Should -Not -Match '\$Apps\.\$appKey' } + It "groups apps by category through the hashtable indexer" { + # Dynamic member lookup goes through the PSObject adapter and costs about seventy times + # as much per app, which is most of the Install tab build + $listScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-InstallCategoryAppList.ps1") -Raw + + $listScript | Should -Match '\$Apps\[\$appKey\]\.Category' + $listScript | Should -Not -Match '\$Apps\.\$appKey' + } + + It "bounds a render pass so one large category cannot stall the interface" { + $renderScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilInstallAppRendering.ps1") -Raw + + $renderScript | Should -Match '\$batchLimit\s*=\s*\d+' + $renderScript | Should -Match '\$sync\.InstallAppRenderQueue\.Enqueue' + } + It "restores delayed app checkbox state from selected apps" { $entryScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-InstallAppEntry.ps1") -Raw diff --git a/pester/xaml.Tests.ps1 b/pester/xaml.Tests.ps1 index 964531370d..a432e2bb1b 100644 --- a/pester/xaml.Tests.ps1 +++ b/pester/xaml.Tests.ps1 @@ -284,7 +284,7 @@ Describe "XAML document" { $buttonSource | Should -Match '"WPFAppxRemoval"\s*\{Invoke-WPFTab "WPFTab6BT"\}' $buttonSource | Should -Match '"WPFBackToTweaks"\s*\{Invoke-WPFTab "WPFTab2BT"\}' $buttonSource | Should -Match '"WPFInstallSelectedAppx"\s*\{Invoke-WPFAppxInstall\}' - $tabSource | Should -Match '\$sync\.\$tabNav\.Items\[\$tabNumber\]\.IsSelected = \$true' + $tabSource | Should -Match '\$sync\.WPFTabNav\.Items\[\$tabNumber\]\.IsSelected = \$true' } It "centers top bar controls vertically" { From 70dc7a792ec706619ff51d37aa0f5d8c0889bb1a Mon Sep 17 00:00:00 2001 From: MyDrift Date: Fri, 7 Aug 2026 20:16:23 +0200 Subject: [PATCH 26/70] xaml: drop layout elements and styles that do nothing - remove 8 single child wrappers from control templates, one per instance of every button, toggle and tweak switch - delete unreferenced labelfortweaks and ScrollVisibilityRectangle styles - verified pixel identical across all five tabs --- xaml/inputXML.xaml | 200 ++++++++++++++++++--------------------------- 1 file changed, 81 insertions(+), 119 deletions(-) diff --git a/xaml/inputXML.xaml b/xaml/inputXML.xaml index 4ba4931c83..c0e5c2c1fb 100644 --- a/xaml/inputXML.xaml +++ b/xaml/inputXML.xaml @@ -136,15 +136,13 @@ - - - - - + + + @@ -361,26 +359,22 @@ - - - - - - - + + + - + @@ -422,15 +416,13 @@ - - - - - + + + @@ -459,40 +451,38 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + @@ -666,28 +656,26 @@ - - - - - - - + + + + + @@ -826,16 +814,6 @@ - -