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
This commit is contained in:
Chris Titus
2026-07-01 23:34:59 -05:00
committed by GitHub
parent 916dc761ba
commit 93dc23dd66
31 changed files with 1114 additions and 133 deletions
+1
View File
@@ -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`.
+40 -3
View File
@@ -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
@@ -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")
}
}
+15 -5
View File
@@ -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
}
@@ -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
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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"
}
}
@@ -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 {
@@ -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' {
@@ -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
}
+51 -14
View File
@@ -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
}
+1
View File
@@ -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") {
+13
View File
@@ -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.
+42
View File
@@ -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'
}
}
+3 -2
View File
@@ -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
+134
View File
@@ -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'
}
}
+94
View File
@@ -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'
}
}
+116
View File
@@ -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))
}
}
}
+70
View File
@@ -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'
}
}
+54 -14
View File
@@ -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'
}
}
+7 -21
View File
@@ -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
}
}
}
+84
View File
@@ -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"
}
}
}
+6
View File
@@ -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",
+20 -60
View File
@@ -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
})
+66
View File
@@ -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`.
@@ -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"
}
@@ -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()
}
}
@@ -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
}
@@ -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)
}