From 93dc23dd664539b7a8ae27c88086915b62656a30 Mon Sep 17 00:00:00 2001 From: Chris Titus Date: Wed, 1 Jul 2026 23:34:59 -0500 Subject: [PATCH] Speed up runspace and tab initialization (#4793) * Add startup performance tracing * Lazy initialize non-default tabs * Render install app entries incrementally * Defer and cache toggle status checks * Clean up runspace invocation ownership * Defer GUI runspace pool startup * Defer status taskbar asset rendering * Record final speed verification * Fix deferred install render timer callback * Add dispatcher smoke coverage for install rendering * Replace install render timer with dispatcher callbacks * Gate performance tracing behind compile switch * Clean up analyzer warnings in speed changes * Speed up runspace and tab initialization --- AGENTS.md | 1 + Compile.ps1 | 43 +++++- .../private/Close-WinUtilRunspacePool.ps1 | 18 +++ functions/private/Get-WinUtilToggleStatus.ps1 | 20 ++- .../private/Initialize-InstallAppEntry.ps1 | 15 +- .../Initialize-InstallCategoryAppList.ps1 | 23 +-- .../Initialize-WinUtilRunspacePool.ps1 | 39 +++++ .../private/Initialize-WinUtilTabContent.ps1 | 45 ++++++ ...Initialize-WinUtilTaskbarOverlayAssets.ps1 | 21 +++ functions/private/Invoke-WinUtilAssets.ps1 | 18 +++ functions/private/Set-WinUtilTaskbarItem.ps1 | 9 ++ .../Start-WinUtilInstallAppRendering.ps1 | 59 ++++++++ functions/public/Invoke-WPFRunspace.ps1 | 65 +++++++-- functions/public/Invoke-WPFTab.ps1 | 1 + multiplecompileerror.txt | 13 ++ pester/assets.Tests.ps1 | 42 ++++++ pester/configs.Tests.ps1 | 5 +- pester/install-rendering.Tests.ps1 | 134 ++++++++++++++++++ pester/lazy-tabs.Tests.ps1 | 94 ++++++++++++ pester/performance.Tests.ps1 | 116 +++++++++++++++ pester/runspace-lifecycle.Tests.ps1 | 70 +++++++++ pester/runspace.Tests.ps1 | 68 +++++++-- pester/sanity.Tests.ps1 | 28 +--- pester/toggle-status.Tests.ps1 | 84 +++++++++++ pester/xaml.Tests.ps1 | 6 + scripts/main.ps1 | 80 +++-------- speed-todo.md | 66 +++++++++ tools/perf/Start-WinUtilPerformanceTrace.ps1 | 14 ++ tools/perf/Stop-WinUtilPerformanceTrace.ps1 | 15 ++ tools/perf/Test-WinUtilPerformanceTrace.ps1 | 12 ++ .../Write-WinUtilPerformanceCheckpoint.ps1 | 23 +++ 31 files changed, 1114 insertions(+), 133 deletions(-) create mode 100644 functions/private/Close-WinUtilRunspacePool.ps1 create mode 100644 functions/private/Initialize-WinUtilRunspacePool.ps1 create mode 100644 functions/private/Initialize-WinUtilTabContent.ps1 create mode 100644 functions/private/Initialize-WinUtilTaskbarOverlayAssets.ps1 create mode 100644 functions/private/Start-WinUtilInstallAppRendering.ps1 create mode 100644 multiplecompileerror.txt create mode 100644 pester/assets.Tests.ps1 create mode 100644 pester/install-rendering.Tests.ps1 create mode 100644 pester/lazy-tabs.Tests.ps1 create mode 100644 pester/performance.Tests.ps1 create mode 100644 pester/runspace-lifecycle.Tests.ps1 create mode 100644 pester/toggle-status.Tests.ps1 create mode 100644 speed-todo.md create mode 100644 tools/perf/Start-WinUtilPerformanceTrace.ps1 create mode 100644 tools/perf/Stop-WinUtilPerformanceTrace.ps1 create mode 100644 tools/perf/Test-WinUtilPerformanceTrace.ps1 create mode 100644 tools/perf/Write-WinUtilPerformanceCheckpoint.ps1 diff --git a/AGENTS.md b/AGENTS.md index e28a4498..9518ac1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,3 +188,4 @@ When the user corrects an agent approach, add or tighten one concrete rule here - Keep package install/uninstall process launches simple unless explicitly requested; do not add a separate stdout/stderr process logging helper for winget or Chocolatey. - When the active log file is owned by `Start-Transcript`, do not call `Add-Content` against that file; write to host output so the transcript captures the line in the same log file without recording a terminating-error diagnostic. - Log install/uninstall package names and package-manager IDs before queuing background runspace work; do not rely on runspace host output for the package identity. +- Keep performance tracing helpers under `tools/perf`; include them in generated output only through `Compile.ps1 -Trace`. diff --git a/Compile.ps1 b/Compile.ps1 index 22f29bb6..430e396d 100644 --- a/Compile.ps1 +++ b/Compile.ps1 @@ -1,16 +1,46 @@ param ( - [switch]$Run + [switch]$Run, + [switch]$Trace ) $OFS = "`r`n" +function Get-WinUtilTraceStrippedContent { + param( + [Parameter(Mandatory = $true)] + [string]$Content + ) + + if ($Trace) { + return $Content + } + + $filteredLines = $Content -split "`r?`n" | Where-Object { + $_ -notmatch '^\s*(Start-WinUtilPerformanceTrace|Stop-WinUtilPerformanceTrace|Write-WinUtilPerformanceCheckpoint)\b' + } + + $filteredLines -join "`r`n" +} + # Variable to sync between runspaces $sync = [Hashtable]::Synchronized(@{}) $sync.configs = @{} $script = (Get-Content -Path scripts\start.ps1) -replace '#{replaceme}', (Get-Date -Format 'yy.MM.dd') -$script += Get-ChildItem -Path functions -Recurse -File | Get-Content -Raw +$script += Get-ChildItem -Path functions -Recurse -File | ForEach-Object { + Get-WinUtilTraceStrippedContent -Content (Get-Content -Path $_.FullName -Raw) +} + +if ($Trace) { + $script += Get-ChildItem -Path tools\perf -Filter *.ps1 -File | Get-Content -Raw + + $script += @' +$sync.PerformanceTraceEnabled = $true +Start-WinUtilPerformanceTrace +Write-WinUtilPerformanceCheckpoint -Name "Config load start" +'@ +} Get-ChildItem config | ForEach-Object { $obj = Get-Content -Path $_.FullName -Raw | ConvertFrom-Json @@ -27,6 +57,13 @@ Get-ChildItem config | ForEach-Object { $sync.configs[$_.BaseName] = $obj $script += "`$sync.configs.$($_.BaseName) = @'`r`n$json`r`n'@ | ConvertFrom-Json" + if ($Trace) { + $script += "`r`nWrite-WinUtilPerformanceCheckpoint -Name `"Config $($_.BaseName) loaded`"" + } +} + +if ($Trace) { + $script += "`r`nWrite-WinUtilPerformanceCheckpoint -Name `"Config load complete`"" } $xaml = Get-Content -Path xaml\inputXML.xaml -Raw @@ -35,7 +72,7 @@ $script += "`$inputXML = @'`r`n$xaml`r`n'@" $autounattendXml = Get-Content -Path tools\autounattend.xml -Raw $script += "`$WinUtilAutounattendXml = @'`r`n$autounattendXml`r`n'@" -$script += Get-Content -Path scripts\main.ps1 -Raw +$script += Get-WinUtilTraceStrippedContent -Content (Get-Content -Path scripts\main.ps1 -Raw) Set-Content -Path winutil.ps1 -Value $script diff --git a/functions/private/Close-WinUtilRunspacePool.ps1 b/functions/private/Close-WinUtilRunspacePool.ps1 new file mode 100644 index 00000000..22361dc2 --- /dev/null +++ b/functions/private/Close-WinUtilRunspacePool.ps1 @@ -0,0 +1,18 @@ +function Close-WinUtilRunspacePool { + if ($null -eq $sync -or -not $sync.ContainsKey("runspace") -or $null -eq $sync.runspace) { + return + } + + try { + if ($sync.runspace.RunspacePoolStateInfo.State -notin @( + [System.Management.Automation.Runspaces.RunspacePoolState]::Closed, + [System.Management.Automation.Runspaces.RunspacePoolState]::Closing, + [System.Management.Automation.Runspaces.RunspacePoolState]::Broken + )) { + $sync.runspace.Close() + } + } finally { + $sync.runspace.Dispose() + $sync.Remove("runspace") + } +} diff --git a/functions/private/Get-WinUtilToggleStatus.ps1 b/functions/private/Get-WinUtilToggleStatus.ps1 index 0d19e7d9..e0b03e5f 100644 --- a/functions/private/Get-WinUtilToggleStatus.ps1 +++ b/functions/private/Get-WinUtilToggleStatus.ps1 @@ -2,29 +2,39 @@ Function Get-WinUtilToggleStatus ($ToggleSwitch) { $ToggleSwitchReg = $sync.configs.tweaks.$ToggleSwitch.registry + if ($null -eq $sync.ToggleStatusCache) { + $sync.ToggleStatusCache = @{} + } + + if ($sync.ToggleStatusCache.ContainsKey($ToggleSwitch)) { + return [bool]$sync.ToggleStatusCache[$ToggleSwitch] + } + if (-not (Get-PSDrive -Name HKU -ErrorAction SilentlyContinue)) { New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS | Out-Null } foreach ($regentry in $ToggleSwitchReg) { - if (-not (Test-Path $regentry.Path)) { - New-Item -Path $regentry.Path -Force | Out-Null + if (Test-Path $regentry.Path) { + $regstate = (Get-ItemProperty -Path $regentry.Path).$($regentry.Name) + } else { + $regstate = $null } - $regstate = (Get-ItemProperty -Path $regentry.Path).$($regentry.Name) - if ($null -eq $regstate) { - switch ($regentry.DefaultState) { + switch ([string]$regentry.DefaultState) { "true" { $regstate = $regentry.Value } "false" { $regstate = $regentry.OriginalValue } } } if ($regstate -ne $regentry.Value) { + $sync.ToggleStatusCache[$ToggleSwitch] = $false return $false } } + $sync.ToggleStatusCache[$ToggleSwitch] = $true return $true } diff --git a/functions/private/Initialize-InstallAppEntry.ps1 b/functions/private/Initialize-InstallAppEntry.ps1 index e17b2e7c..fa6e0685 100644 --- a/functions/private/Initialize-InstallAppEntry.ps1 +++ b/functions/private/Initialize-InstallAppEntry.ps1 @@ -13,11 +13,13 @@ function Initialize-InstallAppEntry { $appKey ) + $app = $sync.configs.applicationsHashtable.$appKey + # Create the outer Border for the application type $border = New-Object Windows.Controls.Border $border.Style = $sync.Form.Resources.AppEntryBorderStyle $border.Tag = $appKey - $border.ToolTip = $Apps.$appKey.description + $border.ToolTip = $app.description $border.Add_MouseLeftButtonUp({ $childCheckbox = ($this.Child | Where-Object {$_.Template.TargetType -eq [System.Windows.Controls.Checkbox]})[0] $childCheckBox.isChecked = -not $childCheckbox.IsChecked @@ -61,10 +63,10 @@ function Initialize-InstallAppEntry { # Create the TextBlock for the application name $appName = New-Object Windows.Controls.TextBlock $appName.Style = $sync.Form.Resources.AppEntryNameStyle - $appName.Text = $Apps.$appKey.content + $appName.Text = $app.content # Add FOSS label after the name if FOSS - if ($Apps.$appKey.foss -eq $true) { + if ($app.foss -eq $true) { $fossRun = [System.Windows.Documents.Run]::new(" $([char]0x25CF)") $fossRun.Foreground = [Windows.Media.SolidColorBrush]::new([Windows.Media.Color]::FromRgb(110, 255, 114)) $fossRun.FontSize = 11.5 @@ -74,10 +76,13 @@ function Initialize-InstallAppEntry { $checkBox.Content = $appName # Add accessibility properties to make the elements screen reader friendly - $checkBox.SetValue([Windows.Automation.AutomationProperties]::NameProperty, $Apps.$appKey.content) - $border.SetValue([Windows.Automation.AutomationProperties]::NameProperty, $Apps.$appKey.content) + $checkBox.SetValue([Windows.Automation.AutomationProperties]::NameProperty, $app.content) + $border.SetValue([Windows.Automation.AutomationProperties]::NameProperty, $app.content) $border.Child = $checkBox + if ($sync.selectedApps -contains $appKey) { + $checkBox.IsChecked = $true + } # Add the border to the corresponding Category $TargetElement.Children.Add($border) | Out-Null return $checkbox diff --git a/functions/private/Initialize-InstallCategoryAppList.ps1 b/functions/private/Initialize-InstallCategoryAppList.ps1 index f736342b..c2e198af 100644 --- a/functions/private/Initialize-InstallCategoryAppList.ps1 +++ b/functions/private/Initialize-InstallCategoryAppList.ps1 @@ -16,7 +16,7 @@ function Initialize-InstallCategoryAppList { $Apps ) - # Pre-group apps by category + # Pre-group apps by category before creating WPF controls. $appsByCategory = @{} foreach ($appKey in $Apps.Keys) { $category = $Apps.$appKey.Category @@ -25,6 +25,8 @@ function Initialize-InstallCategoryAppList { } $appsByCategory[$category] += $appKey } + $sync.InstallAppRenderQueue = [System.Collections.Queue]::new() + foreach ($category in $($appsByCategory.Keys | Sort-Object)) { # Create a container for category label + apps $categoryContainer = New-Object Windows.Controls.StackPanel @@ -52,10 +54,10 @@ function Initialize-InstallCategoryAppList { # Add click handler to toggle category visibility $toggleButton.Add_MouseLeftButtonUp({ - param($sender, $e) + param($categoryToggle) # Find the parent StackPanel (categoryContainer) - $categoryContainer = $sender.Parent + $categoryContainer = $categoryToggle.Parent if ($categoryContainer -and $categoryContainer.Children.Count -ge 2) { # The WrapPanel is the second child $wrapPanel = $categoryContainer.Children[1] @@ -64,11 +66,11 @@ function Initialize-InstallCategoryAppList { if ($wrapPanel.Visibility -eq [Windows.Visibility]::Visible) { $wrapPanel.Visibility = [Windows.Visibility]::Collapsed # Change - to + - $sender.Content = $sender.Content -replace "^- ", "+ " + $categoryToggle.Content = $categoryToggle.Content -replace "^- ", "+ " } else { $wrapPanel.Visibility = [Windows.Visibility]::Visible # Change + to - - $sender.Content = $sender.Content -replace "^\+ ", "- " + $categoryToggle.Content = $categoryToggle.Content -replace "^\+ ", "- " } } }) @@ -89,9 +91,12 @@ function Initialize-InstallCategoryAppList { # Add the entire category container to the target element $null = $TargetElement.Items.Add($categoryContainer) - # Add apps to the wrap panel - $appsByCategory[$category] | Sort-Object | ForEach-Object { - $sync.$_ = $(Initialize-InstallAppEntry -TargetElement $wrapPanel -AppKey $_) - } + $sync.InstallAppRenderQueue.Enqueue([pscustomobject]@{ + Category = $category + TargetElement = $wrapPanel + AppKeys = @($appsByCategory[$category] | Sort-Object) + }) } + + Start-WinUtilInstallAppRendering } diff --git a/functions/private/Initialize-WinUtilRunspacePool.ps1 b/functions/private/Initialize-WinUtilRunspacePool.ps1 new file mode 100644 index 00000000..8cf56c90 --- /dev/null +++ b/functions/private/Initialize-WinUtilRunspacePool.ps1 @@ -0,0 +1,39 @@ +function Initialize-WinUtilRunspacePool { + if ($sync.runspace -and $sync.runspace.RunspacePoolStateInfo.State -eq [System.Management.Automation.Runspaces.RunspacePoolState]::Opened) { + return $sync.runspace + } + + if ($sync.runspace) { + Close-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 + ) + + $sync.runspace.Open() + Write-WinUtilPerformanceCheckpoint -Name "Runspace pool initialized" + return $sync.runspace +} diff --git a/functions/private/Initialize-WinUtilTabContent.ps1 b/functions/private/Initialize-WinUtilTabContent.ps1 new file mode 100644 index 00000000..0ac4093f --- /dev/null +++ b/functions/private/Initialize-WinUtilTabContent.ps1 @@ -0,0 +1,45 @@ +function Initialize-WinUtilTabContent { + param( + [Parameter(Mandatory = $true)] + [string]$TabName + ) + + if ($null -eq $sync.InitializedTabs) { + $sync.InitializedTabs = @{} + } + + if ($sync.InitializedTabs[$TabName]) { + return + } + + switch ($TabName) { + "Install" { + Invoke-WPFUIElements -configVariable $sync.configs.appnavigation -targetGridName "appscategory" -columncount 1 + Initialize-WPFUI -targetGridName "appscategory" + Write-WinUtilPerformanceCheckpoint -Name "App navigation UI created" + + Initialize-WPFUI -targetGridName "appspanel" + Write-WinUtilPerformanceCheckpoint -Name "Install UI created" + } + "Tweaks" { + Invoke-WPFUIElements -configVariable $sync.configs.tweaks -targetGridName "tweakspanel" -columncount 2 + Write-WinUtilPerformanceCheckpoint -Name "Tweaks UI created" + } + "Config" { + Invoke-WPFUIElements -configVariable $sync.configs.feature -targetGridName "featurespanel" -columncount 2 + Write-WinUtilPerformanceCheckpoint -Name "Features UI created" + } + "AppX" { + Invoke-WPFUIElements -configVariable $sync.configs.appx -targetGridName "appxpanel" -columncount 2 + Write-WinUtilPerformanceCheckpoint -Name "AppX UI created" + } + "Win11 Creator" { + if ($sync.Form -and $sync.Form.Dispatcher) { + $sync.Form.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Invoke-WinUtilISOCheckExistingWork }) | Out-Null + } + Write-WinUtilPerformanceCheckpoint -Name "Win11 ISO tab initialized" + } + } + + $sync.InitializedTabs[$TabName] = $true +} diff --git a/functions/private/Initialize-WinUtilTaskbarOverlayAssets.ps1 b/functions/private/Initialize-WinUtilTaskbarOverlayAssets.ps1 new file mode 100644 index 00000000..6c17dd6c --- /dev/null +++ b/functions/private/Initialize-WinUtilTaskbarOverlayAssets.ps1 @@ -0,0 +1,21 @@ +function Initialize-WinUtilTaskbarOverlayAssets { + param( + [bool]$IncludeLogo = $true, + [bool]$IncludeStatusAssets = $true + ) + + if ($IncludeLogo -and -not $sync["logorender"]) { + $sync["logorender"] = (Invoke-WinUtilAssets -Type "Logo" -Size 90 -Render) + Write-WinUtilPerformanceCheckpoint -Name "Taskbar logo asset rendered" + } + + if ($IncludeStatusAssets -and -not $sync["checkmarkrender"]) { + $sync["checkmarkrender"] = (Invoke-WinUtilAssets -Type "checkmark" -Size 512 -Render) + Write-WinUtilPerformanceCheckpoint -Name "Taskbar checkmark asset rendered" + } + + if ($IncludeStatusAssets -and -not $sync["warningrender"]) { + $sync["warningrender"] = (Invoke-WinUtilAssets -Type "warning" -Size 512 -Render) + Write-WinUtilPerformanceCheckpoint -Name "Taskbar warning asset rendered" + } +} diff --git a/functions/private/Invoke-WinUtilAssets.ps1 b/functions/private/Invoke-WinUtilAssets.ps1 index 6907909e..2239e8db 100644 --- a/functions/private/Invoke-WinUtilAssets.ps1 +++ b/functions/private/Invoke-WinUtilAssets.ps1 @@ -5,6 +5,17 @@ function Invoke-WinUtilAssets { [switch]$render ) + if ($render -and $null -ne $sync) { + if ($null -eq $sync.RenderedAssetCache) { + $sync.RenderedAssetCache = @{} + } + + $cacheKey = "$(([string]$type).ToLowerInvariant())|$Size" + if ($sync.RenderedAssetCache.ContainsKey($cacheKey)) { + return $sync.RenderedAssetCache[$cacheKey] + } + } + # Create the Viewbox and set its size $LogoViewbox = New-Object Windows.Controls.Viewbox $LogoViewbox.Width = $Size @@ -191,6 +202,13 @@ C 21.36,47.14 28.67,50.71 30.01,52.63 $bitmapImage.StreamSource = $imageStream $bitmapImage.CacheOption = [Windows.Media.Imaging.BitmapCacheOption]::OnLoad $bitmapImage.EndInit() + if ($bitmapImage.CanFreeze) { + $bitmapImage.Freeze() + } + + if ($null -ne $sync -and $sync.ContainsKey("RenderedAssetCache")) { + $sync.RenderedAssetCache[$cacheKey] = $bitmapImage + } return $bitmapImage } else { diff --git a/functions/private/Set-WinUtilTaskbarItem.ps1 b/functions/private/Set-WinUtilTaskbarItem.ps1 index 6eeb7674..ea2b6d43 100644 --- a/functions/private/Set-WinUtilTaskbarItem.ps1 +++ b/functions/private/Set-WinUtilTaskbarItem.ps1 @@ -61,12 +61,21 @@ function Set-WinUtilTaskbaritem { if ($overlay) { switch ($overlay) { 'logo' { + if (-not $sync["logorender"]) { + Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $true -IncludeStatusAssets $false + } $sync["Form"].taskbarItemInfo.Overlay = $sync["logorender"] } 'checkmark' { + if (-not $sync["checkmarkrender"]) { + Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $false -IncludeStatusAssets $true + } $sync["Form"].taskbarItemInfo.Overlay = $sync["checkmarkrender"] } 'warning' { + if (-not $sync["warningrender"]) { + Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $false -IncludeStatusAssets $true + } $sync["Form"].taskbarItemInfo.Overlay = $sync["warningrender"] } 'None' { diff --git a/functions/private/Start-WinUtilInstallAppRendering.ps1 b/functions/private/Start-WinUtilInstallAppRendering.ps1 new file mode 100644 index 00000000..afface44 --- /dev/null +++ b/functions/private/Start-WinUtilInstallAppRendering.ps1 @@ -0,0 +1,59 @@ +function Invoke-WinUtilInstallAppRenderBatch { + param( + [Parameter(Mandatory = $true)] + $CategoryBatch + ) + + foreach ($appKey in $CategoryBatch.AppKeys) { + $sync.$appKey = Initialize-InstallAppEntry -TargetElement $CategoryBatch.TargetElement -AppKey $appKey + } + + if ($sync.currentTab -eq "Install" -and $sync.SearchBar -and -not [string]::IsNullOrWhiteSpace($sync.SearchBar.Text)) { + Find-AppsByNameOrDescription -SearchString $sync.SearchBar.Text + } +} + +function Complete-WinUtilInstallAppRendering { + $sync.InstallAppEntriesRendered = $true + Write-WinUtilPerformanceCheckpoint -Name "Install app entries rendered" +} + +function Invoke-WinUtilInstallAppRenderNextBatch { + if ($sync.InstallAppRenderQueue.Count -gt 0) { + $categoryBatch = $sync.InstallAppRenderQueue.Dequeue() + Invoke-WinUtilInstallAppRenderBatch -CategoryBatch $categoryBatch + } + + if ($sync.InstallAppRenderQueue.Count -gt 0) { + $sync.Form.Dispatcher.BeginInvoke( + [System.Windows.Threading.DispatcherPriority]::Background, + [action]{ Invoke-WinUtilInstallAppRenderNextBatch } + ) | Out-Null + return + } + + Complete-WinUtilInstallAppRendering +} + +function Start-WinUtilInstallAppRendering { + if ($null -eq $sync.InstallAppRenderQueue) { + return + } + + $sync.InstallAppEntriesRendered = $false + + if ($sync.Form -and $sync.Form.Dispatcher) { + $sync.Form.Dispatcher.BeginInvoke( + [System.Windows.Threading.DispatcherPriority]::Background, + [action]{ Invoke-WinUtilInstallAppRenderNextBatch } + ) | Out-Null + return + } + + while ($sync.InstallAppRenderQueue.Count -gt 0) { + $categoryBatch = $sync.InstallAppRenderQueue.Dequeue() + Invoke-WinUtilInstallAppRenderBatch -CategoryBatch $categoryBatch + } + + Complete-WinUtilInstallAppRendering +} diff --git a/functions/public/Invoke-WPFRunspace.ps1 b/functions/public/Invoke-WPFRunspace.ps1 index 03e05038..39c25d40 100644 --- a/functions/public/Invoke-WPFRunspace.ps1 +++ b/functions/public/Invoke-WPFRunspace.ps1 @@ -30,30 +30,67 @@ 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 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 - $script:powershell = [powershell]::Create() + $powershell = [powershell]::Create() # Add Scriptblock and Arguments to runspace - [void]$script:powershell.AddScript($ScriptBlock) - [void]$script:powershell.AddArgument($ArgumentList) + [void]$powershell.AddScript($ScriptBlock) + [void]$powershell.AddArgument($ArgumentList) foreach ($parameter in $ParameterList) { - [void]$script:powershell.AddParameter($parameter[0], $parameter[1]) + [void]$powershell.AddParameter($parameter[0], $parameter[1]) } - $script:powershell.RunspacePool = $sync.runspace + $powershell.RunspacePool = $sync.runspace # Execute the RunspacePool - $script:handle = $script:powershell.BeginInvoke() + $handle = $powershell.BeginInvoke() + + $cleanupState = [WinUtilRunspaceCleanupState]::new() + $cleanupState.PowerShell = $powershell + $cleanupState.Handle = $handle + $cleanupCallback = [System.Threading.WaitOrTimerCallback][WinUtilRunspaceCleanup]::Cleanup + [System.Threading.ThreadPool]::RegisterWaitForSingleObject($handle.AsyncWaitHandle, $cleanupCallback, $cleanupState, -1, $true) | Out-Null - # Clean up the RunspacePool threads when they are complete, and invoke the garbage collector to clean up the memory - if ($script:handle.IsCompleted) { - $script:powershell.EndInvoke($script:handle) - $script:powershell.Dispose() - $sync.runspace.Dispose() - $sync.runspace.Close() - [System.GC]::Collect() - } # Return the handle return $handle } diff --git a/functions/public/Invoke-WPFTab.ps1 b/functions/public/Invoke-WPFTab.ps1 index 735b38a7..8640318f 100644 --- a/functions/public/Invoke-WPFTab.ps1 +++ b/functions/public/Invoke-WPFTab.ps1 @@ -29,6 +29,7 @@ function Invoke-WPFTab { } } $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") { diff --git a/multiplecompileerror.txt b/multiplecompileerror.txt new file mode 100644 index 00000000..01307119 --- /dev/null +++ b/multiplecompileerror.txt @@ -0,0 +1,13 @@ +An error has occurred that was not properly handled. Additional information is shown below. The PowerShell process will exit. +Unhandled exception. System.ComponentModel.Win32Exception (1816): Not enough quota is available to process this command. + at System.Windows.Interop.HwndTarget.UpdateWindowSettings(Boolean enableRenderTarget, Nullable`1 channelSet) + at System.Windows.Interop.HwndTarget.UpdateWindowPos(IntPtr lParam) + at System.Windows.Interop.HwndTarget.HandleMessage(WindowMessage msg, IntPtr wparam, IntPtr lparam) + at System.Windows.Interop.HwndSource.HwndTargetFilterMessage(IntPtr hwnd, Int32 msg, IntPtr wParam, IntPtr lParam, Boolean& handled) + at MS.Win32.HwndWrapper.WndProc(IntPtr hwnd, Int32 msg, IntPtr wParam, IntPtr lParam, Boolean& handled) + at System.Windows.Threading.ExceptionWrapper.InternalRealCall(Delegate callback, Object args, Int32 numArgs) + at System.Windows.Threading.ExceptionWrapper.TryCatchWhen(Object source, Delegate callback, Object args, Int32 numArgs, Delegate catchHandler) + at MS.Win32.HwndSubclass.SubclassWndProc(IntPtr hwnd, Int32 msg, IntPtr wParam, IntPtr lParam) + +[process exited with code 3221226525 (0xc000041d)] +You can now close this terminal with Ctrl+D, or press Enter to restart. diff --git a/pester/assets.Tests.ps1 b/pester/assets.Tests.ps1 new file mode 100644 index 00000000..028654f2 --- /dev/null +++ b/pester/assets.Tests.ps1 @@ -0,0 +1,42 @@ +#=========================================================================== +# Tests - Asset rendering +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +} + +Describe "Rendered asset caching" { + It "caches rendered bitmap assets by type and size" { + $assetScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilAssets.ps1") -Raw + + $assetScript | Should -Match 'RenderedAssetCache' + $assetScript | Should -Match '\$cacheKey = "\$\(\(\[string\]\$type\)\.ToLowerInvariant\(\)\)\|\$Size"' + $assetScript | Should -Match 'return \$sync\.RenderedAssetCache\[\$cacheKey\]' + $assetScript | Should -Match '\$sync\.RenderedAssetCache\[\$cacheKey\] = \$bitmapImage' + } + + 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 + + $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"' + } + + It "lazily creates taskbar overlays before assigning them" { + $taskbarScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Set-WinUtilTaskbarItem.ps1") -Raw + + $taskbarScript | Should -Match 'Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$true -IncludeStatusAssets \$false' + $taskbarScript | Should -Match 'Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$false -IncludeStatusAssets \$true' + } + + It "records individual taskbar overlay render checkpoints" { + $overlayScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilTaskbarOverlayAssets.ps1") -Raw + + $overlayScript | Should -Match 'Taskbar logo asset rendered' + $overlayScript | Should -Match 'Taskbar checkmark asset rendered' + $overlayScript | Should -Match 'Taskbar warning asset rendered' + } +} diff --git a/pester/configs.Tests.ps1 b/pester/configs.Tests.ps1 index ee321360..d0fff569 100644 --- a/pester/configs.Tests.ps1 +++ b/pester/configs.Tests.ps1 @@ -245,13 +245,14 @@ Describe "Preset config" { Describe "App navigation config" { It "is wired to an existing XAML target grid" { $mainScript = Get-Content -Path $script:mainScriptPath -Raw + $tabInitializerScript = Get-Content -Path (Join-Path $script:repoRoot "functions/private/Initialize-WinUtilTabContent.ps1") -Raw $targetGridMatch = [regex]::Match( - $mainScript, + "$mainScript`n$tabInitializerScript", 'Invoke-WPFUIElements\s+-configVariable\s+\$sync\.configs\.appnavigation\s+-targetGridName\s+"([^"]+)"' ) if (-not $targetGridMatch.Success) { - throw "scripts/main.ps1 does not wire appnavigation through Invoke-WPFUIElements." + throw "Startup tab initialization does not wire appnavigation through Invoke-WPFUIElements." } $xamlText = Get-Content -Path $script:xamlPath -Raw diff --git a/pester/install-rendering.Tests.ps1 b/pester/install-rendering.Tests.ps1 new file mode 100644 index 00000000..d680c1c2 --- /dev/null +++ b/pester/install-rendering.Tests.ps1 @@ -0,0 +1,134 @@ +#=========================================================================== +# Tests - Install tab rendering +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +} + +Describe "Install app rendering startup contract" { + It "queues app entries after creating category containers" { + $categoryScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-InstallCategoryAppList.ps1") -Raw + + $categoryScript | Should -Match '\$sync\.InstallAppRenderQueue = \[System\.Collections\.Queue\]::new\(\)' + $categoryScript | Should -Match 'Start-WinUtilInstallAppRendering' + $categoryScript | Should -Match 'Pre-group apps by category before creating WPF controls' + } + + It "renders queued apps through dispatcher callbacks when a form dispatcher exists" { + $renderScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilInstallAppRendering.ps1") -Raw + + $renderScript | Should -Match 'Dispatcher\.BeginInvoke' + $renderScript | Should -Match 'Invoke-WinUtilInstallAppRenderNextBatch' + $renderScript | Should -Match 'Initialize-InstallAppEntry' + $renderScript | Should -Match 'Install app entries rendered' + } + + It "does not use dispatcher timers for deferred install rendering" { + $renderScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilInstallAppRendering.ps1") -Raw + + $renderScript | Should -Not -Match 'DispatcherTimer' + $renderScript | Should -Not -Match '\$timer' + $renderScript | Should -Not -Match '\$dispatcherTimer' + $renderScript | Should -Not -Match '\$timer\.Stop\(\)' + $renderScript | Should -Not -Match '& \$renderCategory' + } + + It "drains queued app batches on the WPF dispatcher without timer scope errors" { + Add-Type -AssemblyName WindowsBase + . (Join-Path $script:repoRoot "functions\private\Start-WinUtilInstallAppRendering.ps1") + + $previousSync = Get-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + $previousInitializeAppEntry = Get-Item -Path Function:\Initialize-InstallAppEntry -ErrorAction SilentlyContinue + $previousSearch = Get-Item -Path Function:\Find-AppsByNameOrDescription -ErrorAction SilentlyContinue + $previousCheckpoint = Get-Item -Path Function:\Write-WinUtilPerformanceCheckpoint -ErrorAction SilentlyContinue + $errorCountBefore = $global:Error.Count + + try { + $global:sync = [Hashtable]::Synchronized(@{}) + $global:sync.currentTab = "Install" + $global:sync.SearchBar = [pscustomobject]@{ Text = "" } + $global:sync.Form = [pscustomobject]@{ Dispatcher = [System.Windows.Threading.Dispatcher]::CurrentDispatcher } + $global:sync.InstallAppRenderQueue = [System.Collections.Queue]::new() + + $renderedApps = [System.Collections.Generic.List[string]]::new() + $checkpoints = [System.Collections.Generic.List[string]]::new() + + function global:Initialize-InstallAppEntry { + param($TargetElement, $AppKey) + $renderedApps.Add($AppKey) + return "entry:$AppKey" + } + + function global:Find-AppsByNameOrDescription { + param($SearchString) + throw "Search should not run for an empty search box in this test." + } + + function global:Write-WinUtilPerformanceCheckpoint { + param([string]$Name) + $checkpoints.Add($Name) + } + + $global:sync.InstallAppRenderQueue.Enqueue([pscustomobject]@{ TargetElement = [pscustomobject]@{}; AppKeys = @("AppA", "AppB") }) + $global:sync.InstallAppRenderQueue.Enqueue([pscustomobject]@{ TargetElement = [pscustomobject]@{}; AppKeys = @("AppC") }) + + $frame = New-Object System.Windows.Threading.DispatcherFrame + $timeout = [System.Diagnostics.Stopwatch]::StartNew() + Start-WinUtilInstallAppRendering + + $closeTimer = New-Object System.Windows.Threading.DispatcherTimer + $closeTimer.Interval = [TimeSpan]::FromMilliseconds(25) + $closeTimer.Add_Tick({ + param($sender) + $timer = [System.Windows.Threading.DispatcherTimer]$sender + + if ($global:sync.InstallAppEntriesRendered -or $timeout.Elapsed.TotalSeconds -gt 5) { + $timer.Stop() + $frame.Continue = $false + } + }) + $closeTimer.Start() + + [System.Windows.Threading.Dispatcher]::PushFrame($frame) + + $global:sync.InstallAppEntriesRendered | Should -BeTrue + $global:sync.InstallAppRenderQueue.Count | Should -Be 0 + @($renderedApps) | Should -Be @("AppA", "AppB", "AppC") + @($checkpoints) | Should -Contain "Install app entries rendered" + $global:Error.Count | Should -Be $errorCountBefore + } finally { + if ($previousSync) { + Set-Variable -Name sync -Value $previousSync.Value -Scope Global + } else { + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + } + + foreach ($functionBackup in @( + @{ Name = "Initialize-InstallAppEntry"; Backup = $previousInitializeAppEntry }, + @{ Name = "Find-AppsByNameOrDescription"; Backup = $previousSearch }, + @{ Name = "Write-WinUtilPerformanceCheckpoint"; Backup = $previousCheckpoint } + )) { + if ($functionBackup.Backup) { + Set-Item -Path "Function:\$($functionBackup.Name)" -Value $functionBackup.Backup.ScriptBlock + } else { + Remove-Item -Path "Function:\$($functionBackup.Name)" -ErrorAction SilentlyContinue + } + } + } + } + + 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 -Not -Match '\$Apps\.\$appKey' + } + + It "restores delayed app checkbox state from selected apps" { + $entryScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-InstallAppEntry.ps1") -Raw + + $entryScript | Should -Match '\$sync\.selectedApps -contains \$appKey' + $entryScript | Should -Match '\$checkBox\.IsChecked = \$true' + } +} diff --git a/pester/lazy-tabs.Tests.ps1 b/pester/lazy-tabs.Tests.ps1 new file mode 100644 index 00000000..7df27972 --- /dev/null +++ b/pester/lazy-tabs.Tests.ps1 @@ -0,0 +1,94 @@ +#=========================================================================== +# Tests - Lazy tab initialization +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + + function Invoke-WPFUIElements { + param($configVariable, [string]$targetGridName, [int]$columncount) + } + function Initialize-WPFUI { + param([string]$TargetGridName) + } + function Write-WinUtilPerformanceCheckpoint { + param([string]$Name) + } + function Invoke-WinUtilISOCheckExistingWork { } + + . (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilTabContent.ps1") +} + +Describe "Initialize-WinUtilTabContent" { + BeforeEach { + $script:sync = [Hashtable]::Synchronized(@{ + configs = @{ + appnavigation = [pscustomobject]@{} + tweaks = [pscustomobject]@{} + feature = [pscustomobject]@{} + appx = [pscustomobject]@{} + } + }) + + Mock Invoke-WPFUIElements { } + Mock Initialize-WPFUI { } + Mock Write-WinUtilPerformanceCheckpoint { } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + } + + It "initializes the install tab once" { + Initialize-WinUtilTabContent -TabName "Install" + Initialize-WinUtilTabContent -TabName "Install" + + Should -Invoke -CommandName Invoke-WPFUIElements -Times 1 -Exactly -ParameterFilter { + $targetGridName -eq "appscategory" -and $columncount -eq 1 + } + Should -Invoke -CommandName Initialize-WPFUI -Times 1 -Exactly -ParameterFilter { + $TargetGridName -eq "appscategory" + } + Should -Invoke -CommandName Initialize-WPFUI -Times 1 -Exactly -ParameterFilter { + $TargetGridName -eq "appspanel" + } + $script:sync.InitializedTabs["Install"] | Should -BeTrue + } + + It "initializes deferred config-backed tabs once" { + Initialize-WinUtilTabContent -TabName "Tweaks" + Initialize-WinUtilTabContent -TabName "Config" + Initialize-WinUtilTabContent -TabName "AppX" + Initialize-WinUtilTabContent -TabName "Tweaks" + Initialize-WinUtilTabContent -TabName "Config" + Initialize-WinUtilTabContent -TabName "AppX" + + Should -Invoke -CommandName Invoke-WPFUIElements -Times 1 -Exactly -ParameterFilter { + $targetGridName -eq "tweakspanel" -and $columncount -eq 2 + } + Should -Invoke -CommandName Invoke-WPFUIElements -Times 1 -Exactly -ParameterFilter { + $targetGridName -eq "featurespanel" -and $columncount -eq 2 + } + Should -Invoke -CommandName Invoke-WPFUIElements -Times 1 -Exactly -ParameterFilter { + $targetGridName -eq "appxpanel" -and $columncount -eq 2 + } + } +} + +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")) + + $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"' + } + + It "initializes tab content when a tab is selected" { + $tabScript = Get-Content -Path (Join-Path $script:repoRoot "functions\public\Invoke-WPFTab.ps1") -Raw + + $tabScript | Should -Match 'Initialize-WinUtilTabContent -TabName \$sync\.currentTab' + } +} diff --git a/pester/performance.Tests.ps1 b/pester/performance.Tests.ps1 new file mode 100644 index 00000000..50c1b56c --- /dev/null +++ b/pester/performance.Tests.ps1 @@ -0,0 +1,116 @@ +#=========================================================================== +# Tests - Performance tracing +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + + . (Join-Path $script:repoRoot "functions\private\Write-WinUtilLog.ps1") + . (Join-Path $script:repoRoot "tools\perf\Test-WinUtilPerformanceTrace.ps1") + . (Join-Path $script:repoRoot "tools\perf\Write-WinUtilPerformanceCheckpoint.ps1") + . (Join-Path $script:repoRoot "tools\perf\Start-WinUtilPerformanceTrace.ps1") + . (Join-Path $script:repoRoot "tools\perf\Stop-WinUtilPerformanceTrace.ps1") +} + +AfterAll { + & (Join-Path $script:repoRoot "Compile.ps1") +} + +Describe "WinUtil performance tracing helpers" { + BeforeEach { + $script:originalPerfEnv = $env:WINUTIL_PERF_LOG + Remove-Item Env:\WINUTIL_PERF_LOG -ErrorAction SilentlyContinue + $script:sync = [Hashtable]::Synchronized(@{}) + } + + AfterEach { + if ($null -eq $script:originalPerfEnv) { + Remove-Item Env:\WINUTIL_PERF_LOG -ErrorAction SilentlyContinue + } else { + $env:WINUTIL_PERF_LOG = $script:originalPerfEnv + } + + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name originalPerfEnv -Scope Script -ErrorAction SilentlyContinue + } + + It "is disabled by default" { + Test-WinUtilPerformanceTrace | Should -BeFalse + } + + It "can be enabled by environment variable" { + $env:WINUTIL_PERF_LOG = "1" + + Test-WinUtilPerformanceTrace | Should -BeTrue + } + + It "writes startup checkpoints through the normal WinUtil log helper" { + $env:WINUTIL_PERF_LOG = "1" + Mock Write-WinUtilLog { } + + Start-WinUtilPerformanceTrace + Write-WinUtilPerformanceCheckpoint -Name "XAML loaded" + Stop-WinUtilPerformanceTrace + + Should -Invoke -CommandName Write-WinUtilLog -Times 3 -Exactly -ParameterFilter { + $Component -eq "StartupPerf" -and $Level -eq "DEBUG" + } + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Message -like "XAML loaded:*" + } + } +} + +Describe "Startup performance checkpoints" { + It "keeps performance tracing out of normal compiled output" { + & (Join-Path $script:repoRoot "Compile.ps1") + + $compiledScript = Get-Content -Path (Join-Path $script:repoRoot "winutil.ps1") -Raw + + $compiledScript | Should -Not -Match "Test-WinUtilPerformanceTrace" + $compiledScript | Should -Not -Match "Start-WinUtilPerformanceTrace" + $compiledScript | Should -Not -Match "Write-WinUtilPerformanceCheckpoint" + $compiledScript | Should -Not -Match "Stop-WinUtilPerformanceTrace" + $compiledScript | Should -Not -Match "PerformanceTraceEnabled" + } + + It "adds config-load checkpoints only to trace compiled output" { + $compileScript = Get-Content -Path (Join-Path $script:repoRoot "Compile.ps1") -Raw + + $compileScript | Should -Match '\[switch\]\$Trace' + $compileScript | Should -Match "Start-WinUtilPerformanceTrace" + $compileScript | Should -Match "Config load start" + $compileScript | Should -Match "Config load complete" + $compileScript | Should -Match "Config .* loaded" + + & (Join-Path $script:repoRoot "Compile.ps1") -Trace + $compiledScript = Get-Content -Path (Join-Path $script:repoRoot "winutil.ps1") -Raw + + $compiledScript | Should -Match "function Test-WinUtilPerformanceTrace" + $compiledScript | Should -Match '\$sync\.PerformanceTraceEnabled = \$true' + $compiledScript | Should -Match "Config load start" + $compiledScript | Should -Match "Config load complete" + } + + It "adds runtime checkpoints for startup hotspots" { + $mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw + $lazyTabScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilTabContent.ps1") -Raw + $runspaceScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilRunspacePool.ps1") -Raw + $overlayScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilTaskbarOverlayAssets.ps1") -Raw + $startupText = "$mainScript`n$lazyTabScript`n$runspaceScript`n$overlayScript" + + foreach ($checkpoint in @( + "Runspace pool initialized", + "XAML loaded", + "Theme applied", + "Install UI created", + "Tweaks UI created", + "Features UI created", + "AppX UI created", + "Taskbar logo asset rendered", + "First content rendered" + )) { + $startupText | Should -Match ([regex]::Escape($checkpoint)) + } + } +} diff --git a/pester/runspace-lifecycle.Tests.ps1 b/pester/runspace-lifecycle.Tests.ps1 new file mode 100644 index 00000000..00b79851 --- /dev/null +++ b/pester/runspace-lifecycle.Tests.ps1 @@ -0,0 +1,70 @@ +#=========================================================================== +# Tests - Runspace lifecycle +#=========================================================================== + +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") +} + +Describe "Initialize-WinUtilRunspacePool" { + BeforeEach { + $script:sync = [Hashtable]::Synchronized(@{}) + $script:PARAM_OFFLINE = $false + + function Write-WinUtilPerformanceCheckpoint { param($Name) } + Mock Write-WinUtilPerformanceCheckpoint { } + } + + AfterEach { + Close-WinUtilRunspacePool + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name PARAM_OFFLINE -Scope Script -ErrorAction SilentlyContinue + } + + It "creates and reuses one open runspace pool" { + $firstPool = Initialize-WinUtilRunspacePool + $secondPool = Initialize-WinUtilRunspacePool + + $firstPool.RunspacePoolStateInfo.State | Should -Be ([System.Management.Automation.Runspaces.RunspacePoolState]::Opened) + [object]::ReferenceEquals($firstPool, $secondPool) | Should -BeTrue + Should -Invoke -CommandName Write-WinUtilPerformanceCheckpoint -Times 1 -Exactly -ParameterFilter { + $Name -eq "Runspace pool initialized" + } + } + + It "closes and removes the active runspace pool" { + $pool = Initialize-WinUtilRunspacePool + + Close-WinUtilRunspacePool + + $pool.RunspacePoolStateInfo.State | Should -Be ([System.Management.Automation.Runspaces.RunspacePoolState]::Closed) + $script:sync.ContainsKey("runspace") | Should -BeFalse + } +} + +Describe "Runspace startup wiring" { + It "does not create the GUI runspace pool before automation checks" { + $mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw + $beforePreset = $mainScript.Substring(0, $mainScript.IndexOf('if ($Preset)')) + + $beforePreset | Should -Not -Match '\[runspacefactory\]::CreateRunspacePool' + $beforePreset | Should -Not -Match '\$sync\.runspace\.Open\(\)' + } + + 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 + + $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' + $mainScript | Should -Match 'Close-WinUtilRunspacePool' + } + + It "creates runspaces on demand before queueing background work" { + $runspaceScript = Get-Content -Path (Join-Path $script:repoRoot "functions\public\Invoke-WPFRunspace.ps1") -Raw + + $runspaceScript | Should -Match 'Initialize-WinUtilRunspacePool \| Out-Null' + } +} diff --git a/pester/runspace.Tests.ps1 b/pester/runspace.Tests.ps1 index 9cb42e85..2fec90a5 100644 --- a/pester/runspace.Tests.ps1 +++ b/pester/runspace.Tests.ps1 @@ -4,6 +4,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\public\Invoke-WPFFeatureInstall.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFAppxRemoval.ps1") @@ -18,21 +20,17 @@ BeforeAll { $initialSessionState.Variables.Add($syncVariable) $script:sync.runspace = [runspacefactory]::CreateRunspacePool(1, 2, $initialSessionState, $Host) $script:sync.runspace.Open() + + function Write-WinUtilPerformanceCheckpoint { param($Name) } } function script:Clear-WinUtilRunspaceTestContext { - if ($script:powershell) { - $script:powershell.Dispose() - } - if ($script:sync -and $script:sync.runspace) { $script:sync.runspace.Close() $script:sync.runspace.Dispose() } Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name powershell -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name handle -Scope Script -ErrorAction SilentlyContinue } function script:Assert-WinUtilAsyncHandle { @@ -54,28 +52,34 @@ Describe "Invoke-WPFRunspace behavior" { } It "returns a single async handle with no argument list" { + $script:sync.Result = $null + $handle = Invoke-WPFRunspace -ScriptBlock { Start-Sleep -Milliseconds 100 - "no-args|$($sync.Marker)" + $sync.Result = "no-args|$($sync.Marker)" } Assert-WinUtilAsyncHandle -Handle $handle - @($script:powershell.EndInvoke($handle))[0] | Should -Be "no-args|shared" + $script:sync.Result | Should -Be "no-args|shared" } It "passes one named parameter" { + $script:sync.Result = $null + $handle = Invoke-WPFRunspace -ParameterList @(,("Name", "value")) -ScriptBlock { param([string]$Name) Start-Sleep -Milliseconds 100 - "Name=$Name" + $sync.Result = "Name=$Name" } Assert-WinUtilAsyncHandle -Handle $handle - @($script:powershell.EndInvoke($handle))[0] | Should -Be "Name=value" + $script:sync.Result | Should -Be "Name=value" } It "passes multiple named parameters" { + $script:sync.Result = $null + $handle = Invoke-WPFRunspace -ParameterList @( ("First", "alpha"), ("Second", "beta") @@ -86,21 +90,57 @@ Describe "Invoke-WPFRunspace behavior" { ) Start-Sleep -Milliseconds 100 - "$First|$Second|$($sync.Marker)" + $sync.Result = "$First|$Second|$($sync.Marker)" } Assert-WinUtilAsyncHandle -Handle $handle - @($script:powershell.EndInvoke($handle))[0] | Should -Be "alpha|beta|shared" + $script:sync.Result | Should -Be "alpha|beta|shared" } - It "surfaces scriptblock failures through the owning PowerShell instance" { + It "keeps the shared runspace pool usable after scriptblock failures" { $handle = Invoke-WPFRunspace -ScriptBlock { Start-Sleep -Milliseconds 100 throw "runspace failure" } Assert-WinUtilAsyncHandle -Handle $handle - { $script:powershell.EndInvoke($handle) } | Should -Throw -ExpectedMessage "*runspace failure*" + + $script:sync.Result = $null + $secondHandle = Invoke-WPFRunspace -ScriptBlock { + $sync.Result = "after-failure" + } + + Assert-WinUtilAsyncHandle -Handle $secondHandle + $script:sync.Result | Should -Be "after-failure" + } + + It "runs multiple queued invocations without shared PowerShell state" { + $script:sync.FirstResult = $null + $script:sync.SecondResult = $null + + $firstHandle = Invoke-WPFRunspace -ParameterList @(,("Value", "first")) -ScriptBlock { + param([string]$Value) + + Start-Sleep -Milliseconds 150 + $sync.FirstResult = $Value + } + $secondHandle = Invoke-WPFRunspace -ParameterList @(,("Value", "second")) -ScriptBlock { + param([string]$Value) + + $sync.SecondResult = $Value + } + + Assert-WinUtilAsyncHandle -Handle $firstHandle + Assert-WinUtilAsyncHandle -Handle $secondHandle + $script:sync.FirstResult | Should -Be "first" + $script:sync.SecondResult | Should -Be "second" + } + + It "does not use script-scoped PowerShell or handle state" { + $runspaceScript = Get-Content -Path (Join-Path $script:repoRoot "functions\public\Invoke-WPFRunspace.ps1") -Raw + + $runspaceScript | Should -Not -Match '\$script:powershell' + $runspaceScript | Should -Not -Match '\$script:handle' } } diff --git a/pester/sanity.Tests.ps1 b/pester/sanity.Tests.ps1 index 91c62aa8..ee9725c2 100644 --- a/pester/sanity.Tests.ps1 +++ b/pester/sanity.Tests.ps1 @@ -206,6 +206,10 @@ Describe "Compiled WinUtil sanity" { 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\Initialize-WinUtilRunspacePool.ps1") + + function Write-WinUtilPerformanceCheckpoint { param($Name) } } It "returns a single async handle and runs a scriptblock with arguments in the shared runspace pool" { @@ -216,44 +220,26 @@ Describe "Runspace sanity" { $script:sync.runspace = [runspacefactory]::CreateRunspacePool(1, 2, $initialSessionState, $Host) $script:sync.runspace.Open() - $ended = $false try { + $script:sync.Result = $null $handle = Invoke-WPFRunspace -ArgumentList "argument" -ParameterList @(,("NamedValue", "parameter")) -ScriptBlock { param($ArgumentValue, [string]$NamedValue) Start-Sleep -Milliseconds 200 - "$ArgumentValue|$NamedValue|$($sync.SmokeValue)" + $sync.Result = "$ArgumentValue|$NamedValue|$($sync.SmokeValue)" } ($handle -is [System.IAsyncResult]) | Should -BeTrue ($handle -is [array]) | Should -BeFalse $handle.AsyncWaitHandle.WaitOne(5000) | Should -BeTrue - - $result = $script:powershell.EndInvoke($handle) - $ended = $true - - @($result)[0] | Should -Be "argument|parameter|shared" + $script:sync.Result | Should -Be "argument|parameter|shared" } finally { - if (-not $ended -and $handle -and $handle.IsCompleted -and $script:powershell) { - try { - $script:powershell.EndInvoke($handle) | Out-Null - } catch { - # The assertion failure is more useful than cleanup errors here. - } - } - - if ($script:powershell) { - $script:powershell.Dispose() - } - if ($script:sync -and $script:sync.runspace) { $script:sync.runspace.Close() $script:sync.runspace.Dispose() } Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name powershell -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name handle -Scope Script -ErrorAction SilentlyContinue } } } diff --git a/pester/toggle-status.Tests.ps1 b/pester/toggle-status.Tests.ps1 new file mode 100644 index 00000000..1777c72b --- /dev/null +++ b/pester/toggle-status.Tests.ps1 @@ -0,0 +1,84 @@ +#=========================================================================== +# Tests - Toggle status checks +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\private\Get-WinUtilToggleStatus.ps1") +} + +Describe "Get-WinUtilToggleStatus" { + BeforeEach { + $script:sync = [Hashtable]::Synchronized(@{ + configs = @{ + tweaks = [pscustomobject]@{ + WPFToggleExample = [pscustomobject]@{ + registry = @( + [pscustomobject]@{ + Path = "HKCU:\Software\WinUtilToggle" + Name = "Enabled" + Value = "1" + OriginalValue = "0" + DefaultState = "true" + } + ) + } + WPFToggleDisabledByDefault = [pscustomobject]@{ + registry = @( + [pscustomobject]@{ + Path = "HKCU:\Software\WinUtilToggle" + Name = "Enabled" + Value = "1" + OriginalValue = "0" + DefaultState = "false" + } + ) + } + } + } + }) + + Mock Get-PSDrive { [pscustomobject]@{ Name = "HKU" } } -ParameterFilter { $Name -eq "HKU" } + Mock New-PSDrive { } + Mock New-Item { } + Mock Test-Path { $false } + Mock Get-ItemProperty { [pscustomobject]@{ Enabled = "1" } } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + } + + It "does not create missing registry paths while reading toggle state" { + Get-WinUtilToggleStatus "WPFToggleExample" | Should -BeTrue + + Should -Invoke -CommandName Test-Path -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKCU:\Software\WinUtilToggle" + } + Should -Invoke -CommandName New-Item -Times 0 -Exactly + Should -Invoke -CommandName Get-ItemProperty -Times 0 -Exactly + } + + It "uses configured false default when the registry path is missing" { + Get-WinUtilToggleStatus "WPFToggleDisabledByDefault" | Should -BeFalse + + Should -Invoke -CommandName New-Item -Times 0 -Exactly + } + + It "caches toggle results for repeated checks" { + Mock Test-Path { $true } -ParameterFilter { $Path -eq "HKCU:\Software\WinUtilToggle" } + Mock Get-ItemProperty { [pscustomobject]@{ Enabled = "1" } } -ParameterFilter { + $Path -eq "HKCU:\Software\WinUtilToggle" + } + + Get-WinUtilToggleStatus "WPFToggleExample" | Should -BeTrue + Get-WinUtilToggleStatus "WPFToggleExample" | Should -BeTrue + + Should -Invoke -CommandName Test-Path -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKCU:\Software\WinUtilToggle" + } + Should -Invoke -CommandName Get-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKCU:\Software\WinUtilToggle" + } + } +} diff --git a/pester/xaml.Tests.ps1 b/pester/xaml.Tests.ps1 index 05e35bf2..1aa7100e 100644 --- a/pester/xaml.Tests.ps1 +++ b/pester/xaml.Tests.ps1 @@ -295,13 +295,19 @@ Describe "XAML and sync wiring" { "keys", "ContainsKey", "GetEnumerator", + "Remove", "logorender", "checkmarkrender", "warningrender", + "InitializedTabs", + "RenderedAssetCache", + "ToggleStatusCache", "InstallAppAreaBorder", "InstallAppAreaScrollViewer", "InstallAppAreaOverlay", "InstallAppAreaOverlayText", + "InstallAppRenderQueue", + "InstallAppEntriesRendered", "ProgressBar", "progressBarTextBlock", "Win11ISOImageInfo", diff --git a/scripts/main.ps1 b/scripts/main.ps1 index faf9af54..5bd2987a 100644 --- a/scripts/main.ps1 +++ b/scripts/main.ps1 @@ -29,39 +29,6 @@ public enum PackageManagers } "@ -# SPDX-License-Identifier: MIT -# Set the maximum number of threads for the RunspacePool to the number of threads on the machine -$maxthreads = [int]$env:NUMBER_OF_PROCESSORS - -# 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() - -# Add the variable to the session state -$InitialSessionState.Variables.Add($hashVars) -$InitialSessionState.Variables.Add($offlineVar) - -# Get every private function and add them 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) -} - -# Create the runspace pool -$sync.runspace = [runspacefactory]::CreateRunspacePool( - 1, # Minimum thread count - $maxthreads, # Maximum thread count - $InitialSessionState, # Initial session state - $Host # Machine to create runspaces on -) - -# Open the RunspacePool instance -$sync.runspace.Open() - # Create classes for different exceptions class WingetFailedInstall : Exception { @@ -90,10 +57,14 @@ $sync.configs.appxHashtable = @{} $sync.configs.appx.PSObject.Properties | ForEach-Object { $sync.configs.appxHashtable[$_.Name] = $_.Value } +Write-WinUtilPerformanceCheckpoint -Name "Config hashtables initialized" Set-Preferences +Write-WinUtilPerformanceCheckpoint -Name "Preferences loaded" if ($Preset) { + Initialize-WinUtilRunspacePool | Out-Null + # Selects the tweaks from $Preset varible Update-WinUtilSelections -flatJson $sync.configs.preset.$Preset @@ -101,21 +72,21 @@ if ($Preset) { Invoke-WinUtilAutoRun # Cleanup and exit - $sync.runspace.Dispose() - $sync.runspace.Close() + Close-WinUtilRunspacePool [System.GC]::Collect() Stop-Transcript return } if ($Config) { + Initialize-WinUtilRunspacePool | Out-Null + Invoke-WPFImpex -type "import" -Config $Config Invoke-WinUtilAutoRun # Cleanup and exit - $sync.runspace.Dispose() - $sync.runspace.Close() + Close-WinUtilRunspacePool [System.GC]::Collect() Stop-Transcript return @@ -130,6 +101,7 @@ $reader = (New-Object System.Xml.XmlNodeReader $xaml) try { $sync["Form"] = [Windows.Markup.XamlReader]::Load( $reader ) $readerOperationSuccessful = $true + Write-WinUtilPerformanceCheckpoint -Name "XAML loaded" } 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 @@ -144,8 +116,7 @@ try { 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 - $sync.runspace.Dispose() - $sync.runspace.Close() + Close-WinUtilRunspacePool [System.GC]::Collect() exit 1 } @@ -179,19 +150,12 @@ $sync.Form.Add_Loaded({ }) Invoke-WinutilThemeChange -theme $sync.preferences.theme +Write-WinUtilPerformanceCheckpoint -Name "Theme applied" -# Now call the function with the final merged config -Invoke-WPFUIElements -configVariable $sync.configs.appnavigation -targetGridName "appscategory" -columncount 1 -Initialize-WPFUI -targetGridName "appscategory" - -Initialize-WPFUI -targetGridName "appspanel" - -Invoke-WPFUIElements -configVariable $sync.configs.tweaks -targetGridName "tweakspanel" -columncount 2 - -Invoke-WPFUIElements -configVariable $sync.configs.feature -targetGridName "featurespanel" -columncount 2 - -Invoke-WPFUIElements -configVariable $sync.configs.appx -targetGridName "appxpanel" -columncount 2 +# Build only the default tab before first paint; other tabs initialize on first activation. +$sync.InitializedTabs = @{} +Initialize-WinUtilTabContent -TabName "Install" # Future implementation: Add Windows Version to updates panel #Invoke-WPFUIElements -configVariable $sync.configs.updates -targetGridName "updatespanel" -columncount 1 @@ -257,8 +221,8 @@ Set-WinUtilTaskbaritem -state "None" $sync["Form"].title = $sync["Form"].title + " " + $sync.version # Set the commands that will run when the form is closed $sync["Form"].Add_Closing({ - $sync.runspace.Dispose() - $sync.runspace.Close() + Stop-WinUtilPerformanceTrace -Name "Window closing" + Close-WinUtilRunspacePool [System.GC]::Collect() }) @@ -326,6 +290,7 @@ $sync["Form"].Add_Deactivated({ }) $sync["Form"].Add_ContentRendered({ + Write-WinUtilPerformanceCheckpoint -Name "First content rendered" # Load the Windows Forms assembly Add-Type -AssemblyName System.Windows.Forms $primaryScreen = [System.Windows.Forms.Screen]::PrimaryScreen @@ -374,6 +339,8 @@ $sync["Form"].Add_ContentRendered({ } $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 @@ -418,10 +385,7 @@ $sync["Form"].Add_Loaded({ $NavLogoPanel = $sync["Form"].FindName("NavLogoPanel") $NavLogoPanel.Children.Add((Invoke-WinUtilAssets -Type "logo" -Size 25)) | Out-Null - -$sync["logorender"] = (Invoke-WinUtilAssets -Type "Logo" -Size 90 -Render) -$sync["checkmarkrender"] = (Invoke-WinUtilAssets -Type "checkmark" -Size 512 -Render) -$sync["warningrender"] = (Invoke-WinUtilAssets -Type "warning" -Size 512 -Render) +Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $true -IncludeStatusAssets $false Set-WinUtilTaskbaritem -overlay "logo" @@ -514,10 +478,6 @@ $sync["FontScalingApplyButton"].Add_Click({ # ── Win11ISO Tab button handlers ────────────────────────────────────────────── -$sync["WPFTab5BT"].Add_Click({ - $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Invoke-WinUtilISOCheckExistingWork }) | Out-Null -}) - $sync["WPFWin11ISOBrowseButton"].Add_Click({ Invoke-WinUtilISOBrowse }) diff --git a/speed-todo.md b/speed-todo.md new file mode 100644 index 00000000..73033031 --- /dev/null +++ b/speed-todo.md @@ -0,0 +1,66 @@ +# Speed TODO + +WinUtil currently does too much work before the first GUI paint. Work from the top down: measure first, improve perceived launch time next, then clean up deeper execution/runspace paths. + +## P0 - Measure First + +- [x] Add temporary startup timing checkpoints with `System.Diagnostics.Stopwatch`. +- [x] Measure config embedding/load, runspace pool initialization, XAML load, theme application, install UI creation, tweaks UI creation, feature UI creation, AppX UI creation, and asset rendering. +- [x] Record baseline launch timing on a clean run and a warm run. +- [x] Keep timing output in the existing WinUtil log/transcript path. +- [x] Remove or gate temporary timing noise after the optimization work is validated. + +## P1 - Faster First Paint + +- [x] Show the main window before building every non-visible panel. +- [x] Build only the default visible tab during startup. +- [x] Move Tweaks, Features, AppX, and Win11 ISO tab initialization to first tab activation. +- [x] Add one-time guards so each lazy tab initializes only once. +- [x] Preserve current behavior for `-Preset` and `-Config` automation paths. + +## P2 - Install Tab Rendering + +- [x] Reduce up-front creation of all install app entries. +- [x] Keep category/app grouping as pure data before touching WPF controls. +- [x] Create WPF controls only on the UI thread. +- [x] Investigate replacing eager app-entry creation with item templates or incremental category rendering. +- [x] Verify search, selected-app counts, right-click popup, install, uninstall, and import/export selection state still work. + +## P3 - Toggle And Registry Startup Cost + +- [x] Stop doing expensive registry-backed toggle status checks while constructing invisible UI. +- [x] Defer toggle state reads until the Tweaks tab is first opened. +- [x] Review `Get-WinUtilToggleStatus` so a status read does not create missing registry paths unless that behavior is explicitly required. +- [x] Batch or cache repeated registry checks where possible. +- [x] Verify toggle import, undo, preset, and manual click behavior still match current expectations. + +## P4 - Runspace Cleanup + +- [x] Refactor `Invoke-WPFRunspace` to avoid shared `$script:powershell` and `$script:handle` state for concurrent callers. +- [x] Keep the shared runspace pool alive for the app lifetime instead of disposing it from individual queued calls. +- [x] Add explicit completion cleanup for each PowerShell instance after `EndInvoke`. +- [x] Add focused Pester coverage for multiple queued runspace calls, failures, and cleanup. +- [x] Do not parallelize winget/choco package installs by default; package manager locking and prompts make that unsafe. + +## P5 - Defer Runspace Pool Startup + +- [x] Move GUI runspace pool creation after first render when no automation mode is active. +- [x] Keep synchronous runspace pool creation for `-Preset` and `-Config`. +- [x] Prewarm the pool shortly after `ContentRendered` so later actions do not feel delayed. +- [x] Ensure closing the form disposes the pool if it was created. +- [x] Verify buttons that queue work create or reuse the pool safely. + +## P6 - Asset And Theme Cost + +- [x] Measure `Invoke-WinUtilAssets -Render` calls during startup. +- [x] Cache rendered logo, checkmark, and warning images once per process. +- [x] Defer non-critical taskbar overlay assets until after first render if measurable. +- [x] Verify taskbar overlay success/error states still display correctly. + +## P7 - Verification + +- [x] Run `.\Compile.ps1`. +- [x] Run focused Pester tests for runspace, config, XAML wiring, install workflow, tweaks, and preferences/theme. +- [x] Run the full Pester gate before marking speed work complete. +- [ ] Manually launch with `.\Compile.ps1 -Run` and verify first paint, default tab, lazy tabs, search, install selection, tweak toggles, AppX tab, and Win11 ISO tab. +- [x] Check `git status --short` and do not stage or commit generated `winutil.ps1`. diff --git a/tools/perf/Start-WinUtilPerformanceTrace.ps1 b/tools/perf/Start-WinUtilPerformanceTrace.ps1 new file mode 100644 index 00000000..0d936232 --- /dev/null +++ b/tools/perf/Start-WinUtilPerformanceTrace.ps1 @@ -0,0 +1,14 @@ +function Start-WinUtilPerformanceTrace { + if (-not (Test-WinUtilPerformanceTrace) -or $null -eq $sync) { + return + } + + if (-not $sync.ContainsKey("PerformanceTrace")) { + $sync.PerformanceTrace = @{ + Stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + LastElapsedMs = 0.0 + } + } + + Write-WinUtilPerformanceCheckpoint -Name "Startup trace started" +} diff --git a/tools/perf/Stop-WinUtilPerformanceTrace.ps1 b/tools/perf/Stop-WinUtilPerformanceTrace.ps1 new file mode 100644 index 00000000..aa507fe5 --- /dev/null +++ b/tools/perf/Stop-WinUtilPerformanceTrace.ps1 @@ -0,0 +1,15 @@ +function Stop-WinUtilPerformanceTrace { + param( + [string]$Name = "Startup trace complete" + ) + + if (-not (Test-WinUtilPerformanceTrace) -or $null -eq $sync -or -not $sync.ContainsKey("PerformanceTrace")) { + return + } + + Write-WinUtilPerformanceCheckpoint -Name $Name + + if ($null -ne $sync.PerformanceTrace.Stopwatch) { + $sync.PerformanceTrace.Stopwatch.Stop() + } +} diff --git a/tools/perf/Test-WinUtilPerformanceTrace.ps1 b/tools/perf/Test-WinUtilPerformanceTrace.ps1 new file mode 100644 index 00000000..1b899590 --- /dev/null +++ b/tools/perf/Test-WinUtilPerformanceTrace.ps1 @@ -0,0 +1,12 @@ +function Test-WinUtilPerformanceTrace { + $enabledValue = ([string]$env:WINUTIL_PERF_LOG).ToLowerInvariant() + if ($enabledValue -in @("1", "true", "yes", "on")) { + return $true + } + + if ($null -ne $sync -and $sync.ContainsKey("PerformanceTraceEnabled")) { + return [bool]$sync.PerformanceTraceEnabled + } + + return $false +} diff --git a/tools/perf/Write-WinUtilPerformanceCheckpoint.ps1 b/tools/perf/Write-WinUtilPerformanceCheckpoint.ps1 new file mode 100644 index 00000000..9f244c16 --- /dev/null +++ b/tools/perf/Write-WinUtilPerformanceCheckpoint.ps1 @@ -0,0 +1,23 @@ +function Write-WinUtilPerformanceCheckpoint { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if (-not (Test-WinUtilPerformanceTrace) -or $null -eq $sync) { + return + } + + if (-not $sync.ContainsKey("PerformanceTrace") -or $null -eq $sync.PerformanceTrace.Stopwatch) { + $sync.PerformanceTrace = @{ + Stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + LastElapsedMs = 0.0 + } + } + + $elapsedMs = [math]::Round($sync.PerformanceTrace.Stopwatch.Elapsed.TotalMilliseconds, 2) + $deltaMs = [math]::Round($elapsedMs - [double]$sync.PerformanceTrace.LastElapsedMs, 2) + $sync.PerformanceTrace.LastElapsedMs = $elapsedMs + + Write-WinUtilLog -Level "DEBUG" -Component "StartupPerf" -Message ("{0}: {1:N2} ms (+{2:N2} ms)" -f $Name, $elapsedMs, $deltaMs) +}