diff --git a/AGENTS.md b/AGENTS.md index 43aada50e8..87fb1df40e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,7 +174,12 @@ When the user corrects an agent approach, add or tighten one concrete rule here - Import Pester 5.8.0 before running tests so `Invoke-Pester -Output Detailed -CI` does not resolve to Windows' inbox Pester 3.4.0. - Keep package install/uninstall process launches simple unless explicitly requested; do not add a separate stdout/stderr process logging helper for winget or Chocolatey. - When the active log file is owned by `Start-Transcript`, do not call `Add-Content` against that file; write to host output so the transcript captures the line in the same log file without recording a terminating-error diagnostic. -- Keep UI helpers such as `Invoke-WPFUIThread` and `Set-WinUtilTweaksProgressIndicator` safe to call without a window; the `-Preset` and `-Config` paths run the workflows before the form is created and before PresentationCore is loaded. +- Keep UI helpers such as `Invoke-WPFUIThread` and `Write-WinUtilJobProgress` safe to call without a window; the `-Preset` and `-Config` paths run the workflows before the form is created and before PresentationCore is loaded. Ask `Test-WinUtilUIAlive` rather than writing the `$sync.Form` / dispatcher / `HasShutdownStarted` check out by hand. +- Put long operations on the job layer with `Start-WinUtilJob` and report from them with `Write-WinUtilJobProgress`; a job body must not set the busy flag, print its own banner, or carry its own try/catch/finally around the interface. `Invoke-WPFRunspace` directly is for fire-and-forget work that is not a job. +- Drain interface work that nobody is waiting for through `Start-WinUtilBackgroundQueue`; do not hand-roll another dequeue-and-re-post pump. +- Values a posted scriptblock needs travel as the dispatcher's argument or through `-Parameters`, never captured from the caller: a plain block resolves them when the dispatcher gets to it, and `GetNewClosure` binds command lookup to a copied scope. For the same reason, prefer a compiled `[action]` over `Invoke-WPFUIThread -Async` on hot re-posting paths, which marshals its body as text and recompiles it per post. +- Diagnostic scaffolding does not ship. Measure with it, then delete it. +- Have each Pester file load the assemblies and dot-source the functions it needs; several passed only because an earlier file in alphabetical order happened to load them. - Log install/uninstall package names and package-manager IDs before queuing background runspace work; do not rely on runspace host output for the package identity. - For Win11 Creator, start each new ISO modification in a fresh `WinUtil_Win11ISO_*` temp directory; existing-work detection is only for resuming/exporting already modified media. - For Win11 Creator driver injection, keep offline WIM servicing to one mount, one `/Add-Driver`, and one commit; do not export editions or run unrelated WIM cleanup, and reject damaged metadata before ISO export. diff --git a/config/themes.json b/config/themes.json index 3d0cd9cf12..9b2a383fae 100644 --- a/config/themes.json +++ b/config/themes.json @@ -58,6 +58,8 @@ "ScrollBarHoverColor": "#5A5D62", "ScrollBarDraggingColor": "#6A6D72", "ProgressBarForegroundColor": "#2E77FF", + "ProgressBarErrorColor": "#D13438", + "ProgressBarWarningColor": "#B36A00", "ProgressBarBackgroundColor": "Transparent", "ButtonInstallBackgroundColor": "#F7F7F7", "ButtonTweaksBackgroundColor": "#F7F7F7", @@ -98,6 +100,8 @@ "ScrollBarHoverColor": "#3B4252", "ScrollBarDraggingColor": "#5E81AC", "ProgressBarForegroundColor": "#6EFF72", + "ProgressBarErrorColor": "#FF6B6B", + "ProgressBarWarningColor": "#FFC83D", "ProgressBarBackgroundColor": "Transparent", "ButtonInstallBackgroundColor": "#222222", "ButtonTweaksBackgroundColor": "#333333", diff --git a/config/tweaks.json b/config/tweaks.json index a67e19b9b6..c9484e6541 100644 --- a/config/tweaks.json +++ b/config/tweaks.json @@ -1086,8 +1086,10 @@ "panel": "1", "InvokeScript": [ " - Remove-Item -Path \"$Env:Temp\\*\" -Recurse -Force - Remove-Item -Path \"$Env:SystemRoot\\Temp\\*\" -Recurse -Force + # A temp folder always holds files something has open, including this run's own, and + # the job layer counts a logged error as a failed step + Remove-Item -Path \"$Env:Temp\\*\" -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path \"$Env:SystemRoot\\Temp\\*\" -Recurse -Force -ErrorAction SilentlyContinue " ], "link": "https://winutil.christitus.com/code-reference/tweaks/essential-tweaks/deletetempfiles" diff --git a/docs/src/content/docs/code-reference/architecture.mdx b/docs/src/content/docs/code-reference/architecture.mdx index 000e621111..91d02f9485 100644 --- a/docs/src/content/docs/code-reference/architecture.mdx +++ b/docs/src/content/docs/code-reference/architecture.mdx @@ -79,14 +79,15 @@ winutil/ **Why**: Makes distribution easier (single file) and improves load time. #### 2. scripts/main.ps1 -**Purpose**: Entry point that initializes the GUI and event system. +**Purpose**: Entry point that manages the run. **Responsibilities**: -- Load XAML and create WPF window -- Initialize form elements -- Set up event handlers - Load configurations -- Display the GUI +- Run the headless `-Preset` and `-Config` paths +- Start the interface on a dedicated STA runspace and wait for it +- Report anything the interface thread failed with, then clean up + +The interface itself lives in `Start-WinUtilUserInterface`, not here. See [Threading Model](#threading-model). #### 3. functions/public/ **Purpose**: User-facing functions that implement main features. @@ -390,23 +391,68 @@ Update UI - `service`: Services to change - `OriginalValue/State`: For undo functionality -## PowerShell Runspace +## Threading Model + +WinUtil runs on three kinds of thread, and each has one job: + +| Thread | Runspace | Responsibility | +| --- | --- | --- | +| Main | The one the script started in | Start the interface, wait for it, surface its errors, clean up | +| Interface | `$sync.UIRunspace`, a dedicated STA runspace | Own the window. Paint and dispatch, nothing else | +| Workers | `$sync.runspace`, a shared pool | Run everything long: installs, tweaks, features, AppX, Win11 Creator | -Winutil uses PowerShell runspaces for the GUI to remain responsive: +All three are created from the same starting point, `New-WinUtilSessionState`, which carries +`$sync`, the compiled script's globals, and every WinUtil function. That is what lets any +thread call any helper without the caller injecting function definitions. ```powershell -# Create runspace -$sync.runspace = [runspacefactory]::CreateRunspace() -$sync.runspace.Open() -$sync.runspace.SessionStateProxy.SetVariable("sync", $sync) - -# Run code in background -$powershell = [powershell]::Create().AddScript($scriptblock) -$powershell.Runspace = $sync.runspace -$handle = $powershell.BeginInvoke() +# main.ps1 - the interface gets its own thread +$sync.UIRunspace = [runspacefactory]::CreateRunspace($Host, (New-WinUtilSessionState)) +$sync.UIRunspace.ApartmentState = "STA" +$sync.UIRunspace.Open() + +$uiShell = [powershell]::Create() +$uiShell.Runspace = $sync.UIRunspace +[void]$uiShell.AddScript({ Start-WinUtilUserInterface }) +$uiHandle = $uiShell.BeginInvoke() +$uiHandle.AsyncWaitHandle.WaitOne() ``` -**Why**: Prevents UI freezing during long-running operations. +**Why**: the window never blocks on work, and a failure on the interface thread is reported +instead of disappearing. + +## Long-Running Work + +Every long action goes through `Start-WinUtilJob`, which owns everything a running operation +needs: refusing to start while another job runs, the busy flag (`$sync.ActiveJob`), the progress +bar and taskbar item, the boxed console banner, a start/finish/failure line in the log, and +restoring the interface in a `finally` whatever happens. + +`Invoke-WPFButton` decides what counts as a long action. Anything that changes the system gets a +job; anything that only changes what the interface is showing runs on the interface thread. +That single classification is why no workflow arranges its own progress, banner or busy state. + +```powershell +Start-WinUtilJob -Name "Features" -Description "Installing Windows Features" -Parameters @{ + Features = @($sync.selectedFeatures) +} -ScriptBlock { + param($Features) + + $total = @($Features).Count + $completed = 0 + foreach ($feature in $Features) { + $completed++ + Step-WinUtilJob -Status "Installing $feature ($completed/$total)" -Percent ([int](($completed / $total) * 100)) + Invoke-WinUtilFeatureInstall $feature + } +} +``` + +The body only has to do the work and call `Step-WinUtilJob`. Anything it throws is +caught, logged, and shown on the taskbar as a failure. + +**Values, not closures**: the body is rebuilt inside the worker from its text, so it receives +what it needs through `-Parameters` rather than capturing the caller's variables. ## WPF Event Handling @@ -452,23 +498,21 @@ if (!(Get-Command choco -ErrorAction SilentlyContinue)) { choco install $app.choco -y ``` -## Error Handling +## Error Handling And Logging -Winutil uses PowerShell error handling: +A job body does not need its own error handling. `Start-WinUtilJob` catches whatever the body +throws, logs it, and marks the run as failed on the taskbar, so a failure can never leave the +interface stuck busy. Only catch inside a body when you have something specific to do first, +such as cleaning up a mounted image, and then rethrow. ```powershell -try { - # Attempt operation - Invoke-SomeOperation -} -catch { - Write-Host "Error: $_" -ForegroundColor Red - # Log error - Add-Content -Path $logfile -Value "ERROR: $_" -} +Write-WinUtilLog -Level "ERROR" -Component "Install" -Message "winget install failed: $($_.Exception.Message)" ``` -**Logging**: Errors and operations are logged for debugging. +Entries go to `%LocalAppData%\winutil\logs\winutil_.log` from every thread, guarded +by a named mutex. The console transcript is a separate file in the same directory, because +`Start-Transcript` keeps its file open and only records the runspace it was started on - so +anything a worker logged would otherwise never reach disk. ## Configuration Loading @@ -486,15 +530,23 @@ $sync.configs.features = Get-Content "config/feature.json" | ConvertFrom-Json ## UI Update Pattern -UI updates must happen on the UI thread: +Controls may only be touched from the thread that owns the window, so background work reaches +them through `Invoke-WPFUIThread`: ```powershell -$sync.form.Dispatcher.Invoke([action]{ - $sync.WPFStatusLabel.Content = "Installing..." -}, "Normal") +Invoke-WPFUIThread -Parameters @{ Count = $installed.Count } -ScriptBlock { + param($Count) + $sync.WPFselectedAppsButton.Content = "Selected Apps: $Count" +} ``` -**Why**: WPF requires UI updates on the main thread. +Add `-Async` to post the update instead of waiting for it. `Step-WinUtilJob` and the +Win11 Creator status log use that so a per-item update never stalls the worker. + +**Values, not closures**: the body is rebuilt inside the interface runspace, so it takes what it +needs through `-Parameters`. Handing over a scriptblock from a worker instead would keep that +worker's session state, which loses the caller's variables on an async post and costs roughly +twenty times as much per command - enough to turn a checkbox refresh into a visible freeze. ## Adding New Features diff --git a/functions/private/Close-WinUtilRunspacePool.ps1 b/functions/private/Close-WinUtilRunspacePool.ps1 index 22361dc2d9..fa0df79ab5 100644 --- a/functions/private/Close-WinUtilRunspacePool.ps1 +++ b/functions/private/Close-WinUtilRunspacePool.ps1 @@ -1,8 +1,37 @@ function Close-WinUtilRunspacePool { + <# + .SYNOPSIS + Stops anything still running and closes the worker pool + + .DESCRIPTION + Closing the pool with work still in it is what produced an unhandled + InvalidRunspaceStateException: a queued instance starts on a runspace that is already + closing, throws on a thread pool thread, and takes the process down. Whatever is in + flight is therefore asked to stop, and waited for, before the pool is closed. + #> + param( + [int]$StopTimeoutSeconds = 15, + + # Leaves ShuttingDown clear: nothing resets it, so setting it here would refuse every + # later action for the rest of the session + [switch]$Recycle + ) + if ($null -eq $sync -or -not $sync.ContainsKey("runspace") -or $null -eq $sync.runspace) { return } + # Set before stopping, so nothing that is winding down queues fresh work behind us + if (-not $Recycle) { + $sync.ShuttingDown = $true + } + + try { + Stop-WinUtilActiveWork -TimeoutSeconds $StopTimeoutSeconds | Out-Null + } catch { + Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Could not stop running work cleanly: $($_.Exception.Message)" + } + try { if ($sync.runspace.RunspacePoolStateInfo.State -notin @( [System.Management.Automation.Runspaces.RunspacePoolState]::Closed, @@ -11,8 +40,12 @@ function Close-WinUtilRunspacePool { )) { $sync.runspace.Close() } + } catch { + # A pool that will not close cleanly must not stop the window from closing + Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Worker pool did not close cleanly: $($_.Exception.Message)" } finally { - $sync.runspace.Dispose() + try { $sync.runspace.Dispose() } catch { } $sync.Remove("runspace") + if ($sync.ActiveShells) { $sync.ActiveShells.Clear() } } } diff --git a/functions/private/Complete-WinUtilPackageRun.ps1 b/functions/private/Complete-WinUtilPackageRun.ps1 new file mode 100644 index 0000000000..41d6cf15d4 --- /dev/null +++ b/functions/private/Complete-WinUtilPackageRun.ps1 @@ -0,0 +1,46 @@ +function Complete-WinUtilPackageRun { + <# + .SYNOPSIS + Reports what a package run actually did and fails the job if anything did not work + + .DESCRIPTION + Package managers report failure through an exit code, which is easy to walk past. + Without this the job layer would show a green checkmark for a run in which nothing + installed. Throwing here is what turns a failed package into a failed job. + + .PARAMETER Action + Install or Uninstall, used in the summary text. + + #> + param( + [Parameter(Mandatory)] + [string]$Action, + + [object[]]$Results = @() + ) + + $succeeded = @($Results | Where-Object { $_.Outcome -eq "Succeeded" }) + $skipped = @($Results | Where-Object { $_.Outcome -eq "Skipped" }) + $failed = @($Results | Where-Object { $_.Outcome -eq "Failed" }) + + $summary = "$($succeeded.Count) succeeded, $($skipped.Count) skipped, $($failed.Count) failed" + Write-WinUtilLog -Component "Package" -Message "$Action summary: $summary" + Write-Host "$Action summary: $summary" + + foreach ($result in $skipped) { + Write-Host " skipped $($result.Package) - $($result.Detail)" + } + foreach ($result in $failed) { + Write-Host " failed $($result.Package) - $($result.Detail)" -ForegroundColor Red + } + + if ($failed.Count -gt 0) { + $names = ($failed | ForEach-Object { $_.Package }) -join ', ' + $reasons = @($failed | ForEach-Object { $_.Detail } | Sort-Object -Unique) + + if ($reasons.Count -eq 1) { + throw "$($failed.Count) of $($Results.Count) package(s) failed: $names. $($reasons[0])" + } + throw "$($failed.Count) of $($Results.Count) package(s) failed: $names. See the lines above for each reason." + } +} diff --git a/functions/private/Get-WinUtilAppEntryHandlers.ps1 b/functions/private/Get-WinUtilAppEntryHandlers.ps1 new file mode 100644 index 0000000000..999a2a38cd --- /dev/null +++ b/functions/private/Get-WinUtilAppEntryHandlers.ps1 @@ -0,0 +1,60 @@ +function Get-WinUtilAppEntryHandlers { + <# + .SYNOPSIS + The event handlers shared by every app entry on the Install tab + + .DESCRIPTION + A scriptblock literal inside a loop is a new scriptblock every time round, and + building six of them per app is the single largest cost of drawing the app list: + measured at 2.13 ms per entry against 0.62 ms when they are made once and reused. + + None of them close over anything per entry. They read the sender through $this, so + one instance serves every app. + #> + + if ($null -ne $script:WinUtilAppEntryHandlers) { + return $script:WinUtilAppEntryHandlers + } + + $script:WinUtilAppEntryHandlers = @{ + BorderClick = { + # Resolve through $sync because the border's child is a layout Grid for FOSS entries + $childCheckbox = $sync.$($this.Tag) + $childCheckbox.IsChecked = -not $childCheckbox.IsChecked + } + MouseEnter = { + if (($sync.$($this.Tag).IsChecked) -eq $false) { + $this.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallHighlightedColor") + } + } + MouseLeave = { + if (($sync.$($this.Tag).IsChecked) -eq $false) { + $this.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallUnselectedColor") + } + } + RightClick = { + # Store the selected app in a global variable so it can be used in the popup + $sync.appPopupSelectedApp = $this.Tag + # Set the popup position to the current mouse position + $sync.appPopup.PlacementTarget = $this + $sync.appPopup.IsOpen = $true + } + # The checkbox sits inside the entry layout Grid, so the border is one level further up + Checked = { + Invoke-WPFSelectedCheckboxesUpdate -type "Add" -checkboxName $this.Tag + $borderElement = $this.Parent.Parent + $borderElement.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallSelectedColor") + } + Unchecked = { + Invoke-WPFSelectedCheckboxesUpdate -type "Remove" -checkboxName $this.Tag + $borderElement = $this.Parent.Parent + $borderElement.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallUnselectedColor") + } + ImageFailed = { + $this.Visibility = "Collapsed" + $this.Parent.Children[0].Visibility = "Visible" + } + } + + return $script:WinUtilAppEntryHandlers +} diff --git a/functions/private/Get-WinUtilSelectedPackages.ps1 b/functions/private/Get-WinUtilSelectedPackages.ps1 index 8ca5ff4223..36f17e24d9 100644 --- a/functions/private/Get-WinUtilSelectedPackages.ps1 +++ b/functions/private/Get-WinUtilSelectedPackages.ps1 @@ -8,10 +8,9 @@ function Get-WinUtilSelectedPackages { [string] $Preference ) + # A single package has no meaningful percentage to show if ($PackageList.count -eq 1) { - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Indeterminate" -value 0.01 -overlay "logo" } - } else { - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" } + Step-WinUtilJob -State "Indeterminate" } $packagesWinget = [System.Collections.ArrayList]::new() diff --git a/functions/private/Initialize-InstallAppEntry.ps1 b/functions/private/Initialize-InstallAppEntry.ps1 index 8edb039c04..f397a03a17 100644 --- a/functions/private/Initialize-InstallAppEntry.ps1 +++ b/functions/private/Initialize-InstallAppEntry.ps1 @@ -13,35 +13,18 @@ function Initialize-InstallAppEntry { $appKey ) - $app = $sync.configs.applicationsHashtable.$appKey + $app = $sync.configs.applicationsHashtable[$appKey] + $handlers = Get-WinUtilAppEntryHandlers # 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 = Get-WinUtilEntryToolTip -Description $app.description -Key $appKey - $border.Add_MouseLeftButtonUp({ - # Resolve through $sync because the border's child is a layout Grid for FOSS entries - $childCheckbox = $sync.$($this.Tag) - $childCheckbox.IsChecked = -not $childCheckbox.IsChecked - }) - $border.Add_MouseEnter({ - if (($sync.$($this.Tag).IsChecked) -eq $false) { - $this.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallHighlightedColor") - } - }) - $border.Add_MouseLeave({ - if (($sync.$($this.Tag).IsChecked) -eq $false) { - $this.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallUnselectedColor") - } - }) - $border.Add_MouseRightButtonUp({ - # Store the selected app in a global variable so it can be used in the popup - $sync.appPopupSelectedApp = $this.Tag - # Set the popup position to the current mouse position - $sync.appPopup.PlacementTarget = $this - $sync.appPopup.IsOpen = $true - }) + $border.Add_MouseLeftButtonUp($handlers.BorderClick) + $border.Add_MouseEnter($handlers.MouseEnter) + $border.Add_MouseLeave($handlers.MouseLeave) + $border.Add_MouseRightButtonUp($handlers.RightClick) $checkBox = New-Object Windows.Controls.CheckBox # Sanitize the name for WPF @@ -49,18 +32,8 @@ function Initialize-InstallAppEntry { # Store the original appKey in Tag $checkBox.Tag = $appKey $checkbox.Style = $sync.Form.Resources.AppEntryCheckboxStyle - # The checkbox sits inside the entry layout Grid, so the border is one level further up - $checkbox.Add_Checked({ - Invoke-WPFSelectedCheckboxesUpdate -type "Add" -checkboxName $this.Tag - $borderElement = $this.Parent.Parent - $borderElement.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallSelectedColor") - }) - - $checkbox.Add_Unchecked({ - Invoke-WPFSelectedCheckboxesUpdate -type "Remove" -checkboxName $this.Tag - $borderElement = $this.Parent.Parent - $borderElement.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallUnselectedColor") - }) + $checkbox.Add_Checked($handlers.Checked) + $checkbox.Add_Unchecked($handlers.Unchecked) $contentPanel = New-Object Windows.Controls.StackPanel $contentPanel.Orientation = "Horizontal" @@ -73,7 +46,6 @@ function Initialize-InstallAppEntry { $fallback = New-Object Windows.Controls.TextBlock $fallback.Text = $app.content.TrimStart(".").Substring(0, 1).ToUpper() $fallback.FontWeight = "Bold"; $fallback.HorizontalAlignment = "Center"; $fallback.VerticalAlignment = "Center" - if ($app.link) { $fallback.Visibility = "Collapsed" } $fallback.SetResourceReference([Windows.Controls.TextBlock]::FontSizeProperty, "AppEntryFontSize") $fallback.SetResourceReference([Windows.Controls.TextBlock]::ForegroundProperty, "ToggleButtonOnColor") [void]$icon.Children.Add($fallback) @@ -81,7 +53,8 @@ function Initialize-InstallAppEntry { $logo = New-Object Windows.Controls.Image $logo.Stretch = [Windows.Media.Stretch]::Uniform $logo.Source = "https://www.google.com/s2/favicons?sz=64&domain_url=$([uri]::EscapeDataString($app.link))" - $logo.Add_ImageFailed({ $this.Visibility = "Collapsed"; $this.Parent.Children[0].Visibility = "Visible" }) + $logo.Add_ImageFailed($handlers.ImageFailed) + [void]$icon.Children.Add($logo) } [void]$contentPanel.Children.Add($icon) @@ -90,6 +63,8 @@ function Initialize-InstallAppEntry { $appName = New-Object Windows.Controls.TextBlock $appName.Style = $sync.Form.Resources.AppEntryNameStyle $appName.Text = $app.content + + # Add FOSS label after the name if FOSS [void]$contentPanel.Children.Add($appName) $checkBox.Content = $contentPanel @@ -110,6 +85,7 @@ function Initialize-InstallAppEntry { [void]$entryLayout.Children.Add($fossBadge) } + $border.Child = $entryLayout if ($sync.selectedApps -contains $appKey) { $checkBox.IsChecked = $true diff --git a/functions/private/Initialize-InstallCategoryAppList.ps1 b/functions/private/Initialize-InstallCategoryAppList.ps1 index 66f45222ba..272c031fa3 100644 --- a/functions/private/Initialize-InstallCategoryAppList.ps1 +++ b/functions/private/Initialize-InstallCategoryAppList.ps1 @@ -16,14 +16,17 @@ function Initialize-InstallCategoryAppList { $Apps ) - # Pre-group apps by category before creating WPF controls. + # Pre-group apps by category before creating WPF controls. Lists, because appending to + # an array copies it and there are several hundred apps. $appsByCategory = @{} + # Indexed, not dynamic member, lookup: the latter goes through the PSObject adapter and + # costs about seventy times as much per app. foreach ($appKey in $Apps.Keys) { - $category = $Apps.$appKey.Category + $category = $Apps[$appKey].Category if (-not $appsByCategory.ContainsKey($category)) { - $appsByCategory[$category] = @() + $appsByCategory[$category] = [System.Collections.Generic.List[string]]::new() } - $appsByCategory[$category] += $appKey + $appsByCategory[$category].Add($appKey) } $sync.InstallAppRenderQueue = [System.Collections.Queue]::new() diff --git a/functions/private/Initialize-WinUtilInstallTabControls.ps1 b/functions/private/Initialize-WinUtilInstallTabControls.ps1 new file mode 100644 index 0000000000..2acc68c666 --- /dev/null +++ b/functions/private/Initialize-WinUtilInstallTabControls.ps1 @@ -0,0 +1,35 @@ +function Initialize-WinUtilInstallTabControls { + <# + .SYNOPSIS + Wires the Install tab controls that are generated from config rather than declared + in XAML + + .DESCRIPTION + The package manager radio buttons and the install action buttons are created by + Invoke-WPFUIElements, so they do not exist until the Install tab is built. Setting + them up anywhere other than immediately after that build makes the code depend on + when the tab happens to be created. + #> + + if ($sync.ChocoRadioButton) { + $sync.ChocoRadioButton.Add_Checked({ + $sync.preferences.packagemanager = "Choco" + }) + } + if ($sync.WingetRadioButton) { + $sync.WingetRadioButton.Add_Checked({ + $sync.preferences.packagemanager = "Winget" + }) + } + + switch ($sync.preferences.packagemanager) { + "Choco" { if ($sync.ChocoRadioButton) { $sync.ChocoRadioButton.IsChecked = $true }; break } + "Winget" { if ($sync.WingetRadioButton) { $sync.WingetRadioButton.IsChecked = $true }; break } + } + + if ($PARAM_OFFLINE) { + foreach ($name in "WPFInstall", "WPFUninstall", "WPFInstallUpgrade", "WPFGetInstalled") { + if ($sync.$name) { $sync.$name.IsEnabled = $false } + } + } +} diff --git a/functions/private/Initialize-WinUtilRunspacePool.ps1 b/functions/private/Initialize-WinUtilRunspacePool.ps1 index d9f5b248ec..9e51ee5a6e 100644 --- a/functions/private/Initialize-WinUtilRunspacePool.ps1 +++ b/functions/private/Initialize-WinUtilRunspacePool.ps1 @@ -1,36 +1,26 @@ function Initialize-WinUtilRunspacePool { + <# + .SYNOPSIS + Opens the shared worker pool that Start-WinUtilJob runs job bodies in + #> + if ($sync.runspace -and $sync.runspace.RunspacePoolStateInfo.State -eq [System.Management.Automation.Runspaces.RunspacePoolState]::Opened) { return $sync.runspace } if ($sync.runspace) { - Close-WinUtilRunspacePool + # A replacement, not a shutdown + Close-WinUtilRunspacePool -Recycle } # Set the maximum number of threads for the RunspacePool to the number of threads on the machine. $maxthreads = [Math]::Max([int]$env:NUMBER_OF_PROCESSORS, 1) - # Create a new session state for parsing variables into our runspace. - $hashVars = New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList 'sync', $sync, $null - $offlineVar = New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList 'PARAM_OFFLINE', $PARAM_OFFLINE, $null - $initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() - - $initialSessionState.Variables.Add($hashVars) - $initialSessionState.Variables.Add($offlineVar) - - # Get every WinUtil/WPF function and add it to the session state. - $functions = Get-ChildItem function:\ | Where-Object { $_.Name -imatch 'winutil|WPF' } - foreach ($function in $functions) { - $functionDefinition = Get-Content function:\$($function.Name) - $functionEntry = New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList $function.Name, $functionDefinition - $initialSessionState.Commands.Add($functionEntry) - } - $sync.runspace = [runspacefactory]::CreateRunspacePool( - 1, # Minimum thread count - $maxthreads, # Maximum thread count - $initialSessionState, # Initial session state - $Host # Machine to create runspaces on + 1, # Minimum thread count + $maxthreads, # Maximum thread count + (New-WinUtilSessionState), # Initial session state + $Host # Machine to create runspaces on ) $sync.runspace.Open() diff --git a/functions/private/Initialize-WinUtilTabContent.ps1 b/functions/private/Initialize-WinUtilTabContent.ps1 index db57efe432..51cabcf5d2 100644 --- a/functions/private/Initialize-WinUtilTabContent.ps1 +++ b/functions/private/Initialize-WinUtilTabContent.ps1 @@ -1,7 +1,11 @@ function Initialize-WinUtilTabContent { param( [Parameter(Mandatory = $true)] - [string]$TabName + [string]$TabName, + + # Build in batches, letting the interface answer in between. Used by the warmup, which + # nobody is waiting on. A tab the user just clicked is built in one go. + [switch]$Yield ) if ($null -eq $sync.InitializedTabs) { @@ -12,30 +16,42 @@ function Initialize-WinUtilTabContent { return } - switch ($TabName) { - "Install" { - Initialize-WPFUI -targetGridName "appscategory" + # Claimed before building, not after: a yielding build lets a click through, and that click + # would otherwise start building the same tab a second time. + $sync.InitializedTabs[$TabName] = $true - Initialize-WPFUI -targetGridName "appspanel" - } - "Tweaks" { - Invoke-WPFUIElements -configVariable $sync.configs.tweaks -targetGridName "tweakspanel" -columncount 2 - } - "Config" { - Invoke-WPFUIElements -configVariable $sync.configs.feature -targetGridName "featurespanel" -columncount 2 - } - "AppX" { - Invoke-WPFUIElements -configVariable $sync.configs.appx -targetGridName "appxpanel" -columncount 2 - } - "Win11ISO" { - if ($sync.Form -and $sync.Form.Dispatcher) { - $sync.Form.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Invoke-WinUtilISOCheckExistingWork }) | Out-Null + try { + switch ($TabName) { + "Install" { + Measure-WinUtilStep -Scope "UI" -Name "Install tab: category area" -ScriptBlock { + Initialize-WPFUI -targetGridName "appscategory" + } + Measure-WinUtilStep -Scope "UI" -Name "Install tab: app area" -ScriptBlock { + Initialize-WPFUI -targetGridName "appspanel" + } + Initialize-WinUtilInstallTabControls + } + "Tweaks" { + Invoke-WPFUIElements -configVariable $sync.configs.tweaks -targetGridName "tweakspanel" -columncount 2 -Yield:$Yield + } + "Config" { + Invoke-WPFUIElements -configVariable $sync.configs.feature -targetGridName "featurespanel" -columncount 2 -Yield:$Yield + } + "AppX" { + Invoke-WPFUIElements -configVariable $sync.configs.appx -targetGridName "appxpanel" -columncount 2 -Yield:$Yield + } + "Win11ISO" { + if (Test-WinUtilUIAlive) { + $sync.Form.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Invoke-WinUtilISOCheckExistingWork }) | Out-Null + } } } + # Controls built just now start unchecked, so anything already chosen by an import or a + # preset has to be applied to them once they exist + Reset-WPFCheckBoxes -doToggles $true + } catch { + # A half built tab must be allowed to rebuild rather than staying empty forever + $sync.InitializedTabs[$TabName] = $false + throw } - - $sync.InitializedTabs[$TabName] = $true - - # Sync freshly built controls to any selections already in $sync.selected* (import/preset). - Reset-WPFCheckBoxes -doToggles $true } diff --git a/functions/private/Install-WinUtilChoco.ps1 b/functions/private/Install-WinUtilChoco.ps1 index 9c9929f152..6c9d603db8 100644 --- a/functions/private/Install-WinUtilChoco.ps1 +++ b/functions/private/Install-WinUtilChoco.ps1 @@ -1,7 +1,39 @@ function Install-WinUtilChoco { - if (-not (Get-Command -Name choco)) { - Write-Host "Chocolatey is not installed. Installing now..." - $installScript = Invoke-WebRequest -Uri https://community.chocolatey.org/install.ps1 -UseBasicParsing - Invoke-Command -ScriptBlock ([scriptblock]::Create($installScript.Content)) + <# + .SYNOPSIS + Installs Chocolatey if it is not already present + #> + + if (Get-Command -Name choco -ErrorAction SilentlyContinue) { + return } + + Write-WinUtilLog -Component "Package" -Message "Chocolatey is not installed, installing it now." + Step-WinUtilJob -Status "Installing Chocolatey" -State "Indeterminate" + + # Windows PowerShell 5.1 can negotiate a protocol the site refuses, which the official + # bootstrap sets explicitly for the same reason + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12 + $installScript = Invoke-WebRequest -Uri https://community.chocolatey.org/install.ps1 -UseBasicParsing -TimeoutSec 60 + Invoke-Command -ScriptBlock ([scriptblock]::Create($installScript.Content)) + + # The installer extends PATH for new processes, which this one is not. Appended rather than + # replaced: overwriting drops whatever this process added earlier in the session, and a + # later step looking for that tool would no longer find it. + $existing = $env:PATH -split ';' | Where-Object { $_ } + $persisted = @( + [System.Environment]::GetEnvironmentVariable("Path", "Machine") + [System.Environment]::GetEnvironmentVariable("Path", "User") + ) -join ';' -split ';' | Where-Object { $_ } + + $missing = $persisted | Where-Object { $existing -notcontains $_ } + if ($missing) { + $env:PATH = (@($existing) + @($missing)) -join ';' + } + + if (-not (Get-Command -Name choco -ErrorAction SilentlyContinue)) { + throw "Chocolatey was installed but choco is still not on PATH." + } + + Write-WinUtilLog -Component "Package" -Message "Chocolatey installed." } diff --git a/functions/private/Install-WinUtilProgramChoco.ps1 b/functions/private/Install-WinUtilProgramChoco.ps1 index 9d9853e5e9..38564387f2 100644 --- a/functions/private/Install-WinUtilProgramChoco.ps1 +++ b/functions/private/Install-WinUtilProgramChoco.ps1 @@ -1,20 +1,107 @@ function Install-WinUtilProgramChoco { + <# + + .SYNOPSIS + Installs, upgrades or uninstalls packages with Chocolatey and reports each outcome + + .DESCRIPTION + One package per call to choco, so the progress bar moves through the list and a failure + names the package that failed rather than the whole batch. Choco's own output goes to the + log instead of the console, the way the WinGet path reports. + + .PARAMETER Action + Install, Upgrade or Uninstall. + + .PARAMETER Programs + The package names. For Upgrade, the single entry "all" upgrades everything. + + .PARAMETER ProgressBase + Where this call starts within the job's overall progress bar. + + .PARAMETER ProgressSpan + How much of the overall bar these packages account for. Zero reports nothing. + + #> param ( [Parameter(Mandatory=$true)] - [ValidateSet("Install", "Uninstall")] + [ValidateSet("Install", "Uninstall", "Upgrade")] [string]$Action, [Parameter(Mandatory=$true)] - [string[]]$Programs + [string[]]$Programs, + + [int]$ProgressBase = 0, + + [int]$ProgressSpan = 0 ) - if ($Action -eq 'Install') { - $arguments = "install $Programs -y" - } else { - $arguments = "uninstall $Programs -y" + # Chocolatey reports "nothing needed doing" and "it worked, now reboot" through exit codes + # rather than as failures + $rebootCodes = @{ + 1641 = "installed, the installer started a restart" + 3010 = "installed, a restart is needed to finish" + } + $nothingToDo = @{ + 2 = "nothing to do" } + $verb = $Action.ToLowerInvariant() - Write-WinUtilLog -Component "Package" -Message "$Action choco package(s): $($Programs -join ', ')" - $process = Start-Process -FilePath choco -ArgumentList $arguments -NoNewWindow -Wait -PassThru - Write-WinUtilLog -Component "Package" -Message "$Action choco package(s) completed: $($Programs -join ', ') (exit code: $($process.ExitCode))" + $packages = @($Programs | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $total = $packages.Count + $index = 0 + + foreach ($program in $packages) { + $index++ + if ($ProgressSpan -gt 0 -and $total -gt 0) { + $percent = $ProgressBase + [int]((($index - 1) / $total) * $ProgressSpan) + Step-WinUtilJob -Status "$Action $program ($index/$total)" -Percent $percent + } + + Write-WinUtilLog -Component "Package" -Message "$Action choco package: $program" + + # --no-progress stops choco redrawing a percentage line that only makes sense on a + # console nobody is watching + $arguments = @($verb, $program, "-y", "--no-progress") + $output = & choco @arguments 2>&1 + $exitCode = $LASTEXITCODE + + if ($exitCode -eq 0) { + $outcome = "Succeeded" + $detail = "exit code 0" + } elseif ($rebootCodes.ContainsKey($exitCode)) { + $outcome = "Succeeded" + $detail = $rebootCodes[$exitCode] + } elseif ($nothingToDo.ContainsKey($exitCode)) { + $outcome = "Skipped" + $detail = $nothingToDo[$exitCode] + } else { + $outcome = "Failed" + $detail = "exit code $exitCode" + } + + $level = if ($outcome -eq "Failed") { "ERROR" } else { "INFO" } + Write-WinUtilLog -Level $level -Component "Package" -Message "$Action choco package $($outcome.ToLowerInvariant()): $program ($detail)" + + if ($outcome -eq "Failed") { + # The reason is somewhere in choco's output, and without it the log says only that + # a number came back + foreach ($line in @($output | Select-Object -Last 15)) { + $text = ([string]$line).Trim() + if ($text) { Write-WinUtilLog -Level "WARN" -Component "Package" -Detail -Message $text } + } + } + + if ($ProgressSpan -gt 0 -and $total -gt 0) { + Step-WinUtilJob -Status "$Action $program ($index/$total)" -Percent ($ProgressBase + [int](($index / $total) * $ProgressSpan)) + } + + [pscustomobject]@{ + Package = $program + Manager = "choco" + Action = $Action + ExitCode = $exitCode + Outcome = $outcome + Detail = $detail + } + } } diff --git a/functions/private/Install-WinUtilProgramWinget.ps1 b/functions/private/Install-WinUtilProgramWinget.ps1 index d4b6f1f4a1..caa53d0a33 100644 --- a/functions/private/Install-WinUtilProgramWinget.ps1 +++ b/functions/private/Install-WinUtilProgramWinget.ps1 @@ -1,13 +1,47 @@ Function Install-WinUtilProgramWinget { + <# + + .SYNOPSIS + Installs or uninstalls packages with WinGet and reports the outcome of each one + + .DESCRIPTION + Emits one result object per package so the caller can tell what actually happened + rather than assuming the run succeeded. + + Runs one winget command per package so a failure names the package that failed rather + than the whole batch. Progress moves per package: winget hides its own progress bar once + its output is redirected, so there is nothing to report from inside a single install. + + #> param ( [Parameter(Mandatory=$true)] - [ValidateSet("Install", "Uninstall")] + [ValidateSet("Install", "Uninstall", "Upgrade")] [string]$Action, [Parameter(Mandatory=$true)] [string[]]$Programs ) + # APPINSTALLER_CLI_ERROR_ADMIN_CONTEXT_ACTION_PROHIBITED. WinGet refuses to act on a package + # that was installed in user scope while it is running elevated, and WinUtil is always + # elevated, so every per-user app answers this and nothing happens. + + # WinGet reports "there was nothing to do" through the exit code rather than as success + $nothingToDo = @{ + -1978335135 = "already installed" + -1978335189 = "no applicable update" + } + # The installer worked and wants a restart to finish. Windows reports that as its own exit + # code rather than as zero, and treating it as a failure marks working installs as broken. + $rebootExitCodes = @{ + 3010 = "installed, a restart is needed to finish" + 1641 = "installed, the installer started a restart" + # WinGet's own equivalents. -1978334966 is deliberately absent: it means a reboot is + # required before the install can proceed, which is not a completed install. + -1978334967 = "installed, a restart is needed to finish" + -1978334965 = "installed, the installer started a restart" + } + foreach ($program in $Programs) { if ([string]::IsNullOrWhiteSpace($program) -or $program -eq "na") { continue @@ -19,14 +53,49 @@ Function Install-WinUtilProgramWinget { $program = $program.Substring("msstore:".Length) } - if ($Action -eq 'Install') { - $arguments = @("install", "--id", $program, "--accept-package-agreements", "--accept-source-agreements", "--source", $source, "--silent") - } else { - $arguments = @("uninstall", "--id", $program, "--source", $source, "--silent") + Write-WinUtilLog -Component "Package" -Message "$Action winget package: $program (source: $source)" + + $outcome = "Failed" + $detail = "no result" + $exitCode = -1 + + $arguments = switch ($Action) { + "Uninstall" { @("uninstall", "--id", $program, "--source", $source, "--silent") } + # --include-unknown because the scan that found these ran with it: without it winget + # refuses every package whose installed version it could not read + "Upgrade" { @("upgrade", "--id", $program, "--accept-package-agreements", "--accept-source-agreements", "--source", $source, "--include-unknown", "--silent") } + default { @("install", "--id", $program, "--accept-package-agreements", "--accept-source-agreements", "--source", $source, "--silent") } } - Write-WinUtilLog -Component "Package" -Message "$Action winget package: $program (source: $source)" $process = Start-Process -FilePath winget -ArgumentList $arguments -NoNewWindow -Wait -PassThru - Write-WinUtilLog -Component "Package" -Message "$Action winget package completed: $program (exit code: $($process.ExitCode))" + $exitCode = $process.ExitCode + + if ($exitCode -eq 0) { + $outcome = "Succeeded" + $detail = "exit code 0" + } elseif ($rebootExitCodes.ContainsKey($exitCode)) { + $outcome = "Succeeded" + $detail = $rebootExitCodes[$exitCode] + } elseif ($nothingToDo.ContainsKey($exitCode)) { + $outcome = "Skipped" + $detail = $nothingToDo[$exitCode] + } else { + $outcome = "Failed" + # The client module reports the same failure as a bare HRESULT, so the hex form and + # Microsoft's own list serve both paths + $detail = "WinGet reported 0x{0:X8}. See https://learn.microsoft.com/windows/package-manager/winget/returnCodes" -f $exitCode + } + + $level = if ($outcome -eq "Failed") { "ERROR" } else { "INFO" } + Write-WinUtilLog -Level $level -Component "Package" -Message "$Action winget package $($outcome.ToLowerInvariant()): $program ($detail)" + + [pscustomobject]@{ + Package = $program + Manager = "winget" + Action = $Action + ExitCode = $exitCode + Outcome = $outcome + Detail = $detail + } } } diff --git a/functions/private/Install-WinUtilWinget.ps1 b/functions/private/Install-WinUtilWinget.ps1 index f3fc4627f6..8f23a05ed0 100644 --- a/functions/private/Install-WinUtilWinget.ps1 +++ b/functions/private/Install-WinUtilWinget.ps1 @@ -7,11 +7,21 @@ function Install-WinUtilWinget { .DESCRIPTION installs winGet if needed #> - if ((Test-WinUtilPackageManager -winget) -eq "installed") { + param( + [switch]$Force + ) + + # The repair action needs Repair-WinGetPackageManager to run even when winget is detected, + # which is the case a broken installation presents + if (-not $Force -and (Test-WinUtilPackageManager -winget) -eq "installed") { return } - Write-Host "WinGet is not installed. Installing now..." -ForegroundColor Red + if ($Force) { + Write-Host "Repairing the WinGet installation..." -ForegroundColor Yellow + } else { + Write-Host "WinGet is not installed. Installing now..." -ForegroundColor Red + } Install-PackageProvider -Name NuGet -Force Install-Module -Name Microsoft.WinGet.Client -Force diff --git a/functions/private/Invoke-WinUtilCloseRequest.ps1 b/functions/private/Invoke-WinUtilCloseRequest.ps1 new file mode 100644 index 0000000000..ce971b1b8f --- /dev/null +++ b/functions/private/Invoke-WinUtilCloseRequest.ps1 @@ -0,0 +1,128 @@ +function Invoke-WinUtilCloseRequest { + <# + .SYNOPSIS + Asks what to do about work that is still running when the window is closed + + .DESCRIPTION + A half finished install or tweak run is not ended without asking. Either it finishes + without the window, reporting to the console and then exiting, or it is stopped and + everything closes now. + + .PARAMETER RunningJob + The name of the job in flight, so the question names what is at stake. + #> + param( + [Parameter(Mandatory)] + [string]$RunningJob + ) + + # The question carries the meaning rather than naming buttons: Windows labels them in its own + # language, so "Yes" in the text would not match a button reading "Ja". + $answer = Show-WinUtilMessage -Button "YesNoCancel" -Icon "Warning" -Title "$RunningJob is still running" -Message @" +$RunningJob has not finished yet. + +Close the window and let it finish in the console? + +WinUtil will exit on its own once it is done. If you do not, it will be +stopped and everything closes now. Cancel keeps WinUtil open. +"@ + + switch ("$answer") { + "Yes" { + Write-WinUtilLog -Component "UI" -Message "Close requested: closing the window, $RunningJob continues in the console." + $sync.FinishInConsole = $true + $sync.ForceClose = $true + + Write-Host "" + Write-Host "WinUtil's window is closed. $RunningJob is still running here, and this window will close when it finishes." -ForegroundColor Cyan + Write-Host "" + + # Posted rather than closed from inside the handler that is already unwinding + Request-WinUtilWindowClose + } + "No" { + Write-WinUtilLog -Component "UI" -Message "Close requested: stopping $RunningJob." + Step-WinUtilJob -Status "Stopping $RunningJob" -State "Indeterminate" + $sync.ForceClose = $true + + # Stopping can take a moment that would otherwise look like the window had frozen + Request-WinUtilWindowClose -Before { + Close-WinUtilRunspacePool + $null = Clear-WinUtilActiveJob + } + } + default { + Write-WinUtilLog -Component "UI" -Message "Close cancelled, $RunningJob is still running." + } + } +} + +function Request-WinUtilWindowClose { + <# + .SYNOPSIS + Closes the window from outside the handler that is currently cancelling the close + + .PARAMETER Before + Work to do on the interface thread first, before the window goes. + #> + param( + [scriptblock]$Before + ) + + if (-not (Test-WinUtilUIAlive)) { + return + } + + $sync.PendingCloseWork = $Before + $sync.Form.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ + if ($sync.PendingCloseWork) { + & $sync.PendingCloseWork + $sync.PendingCloseWork = $null + } + $sync.Form.Close() + }) | Out-Null +} + +function Wait-WinUtilRemainingWork { + <# + .SYNOPSIS + Waits for work that outlived the window, reporting to the console + + .DESCRIPTION + Runs on the main thread once the interface has gone. The job is still on the worker + pool and keeps logging, so this only waits and keeps the wait visible. + + .PARAMETER TimeoutMinutes + Upper bound, so a worker that never returns cannot keep the process alive. + #> + param( + # Double rather than int: an int silently truncates a fractional value to zero, which + # turns the bound into "do not wait at all" + [double]$TimeoutMinutes = 120 + ) + + if (-not $sync.FinishInConsole -or -not $sync.ActiveJob) { + return + } + + $job = $sync.ActiveJob + Write-WinUtilLog -Component "UI" -Message "Window closed, waiting for $job to finish." + Write-Host "Waiting for $job to finish..." -ForegroundColor Cyan + + $clock = [System.Diagnostics.Stopwatch]::StartNew() + while ($sync.ActiveJob -and $clock.Elapsed.TotalMinutes -lt $TimeoutMinutes) { + Start-Sleep -Milliseconds 250 + } + + # The job's last progress line is still open, so anything after it needs a fresh line + Complete-WinUtilConsoleProgress + + if ($sync.ActiveJob) { + Write-WinUtilLog -Level "WARN" -Component "UI" -Message "$job did not finish within $TimeoutMinutes minutes, exiting anyway." + Write-Host "$job is taking longer than $TimeoutMinutes minutes. Exiting." -ForegroundColor Yellow + return + } + + Write-WinUtilLog -Component "UI" -Message "$job finished after the window closed, in $([int]$clock.Elapsed.TotalSeconds)s." + Write-Host "$job finished. Closing." -ForegroundColor Green +} diff --git a/functions/private/Invoke-WinUtilExplorerUpdate.ps1 b/functions/private/Invoke-WinUtilExplorerUpdate.ps1 index d51a8133f3..7a4b17041b 100644 --- a/functions/private/Invoke-WinUtilExplorerUpdate.ps1 +++ b/functions/private/Invoke-WinUtilExplorerUpdate.ps1 @@ -8,7 +8,9 @@ function Invoke-WinUtilExplorerUpdate { ) if ($action -eq "refresh") { - Invoke-WPFRunspace -ScriptBlock { + # The handle is of no use to the caller, and leaving it in the pipeline puts it into + # whatever result the calling workflow returns + $null = Invoke-WPFRunspace -ScriptBlock { # Define the Win32 type only if it doesn't exist if (-not ([System.Management.Automation.PSTypeName]'Win32').Type) { Add-Type -TypeDefinition @" diff --git a/functions/private/Invoke-WinUtilISO.ps1 b/functions/private/Invoke-WinUtilISO.ps1 index 4f3b2595fc..7e8b983565 100644 --- a/functions/private/Invoke-WinUtilISO.ps1 +++ b/functions/private/Invoke-WinUtilISO.ps1 @@ -1,17 +1,92 @@ +function Invoke-WinUtilRobocopy { + <# + .SYNOPSIS + Runs robocopy and fails the job when files were not copied + + .DESCRIPTION + robocopy reports through its exit code rather than by throwing, and codes below 8 + are success: 1 means files were copied, 3 means copied plus extras. 8 and above mean + at least one file did not make it, which produces media that looks complete and does + not boot. + #> + param( + [Parameter(Mandatory)][string]$Source, + [Parameter(Mandatory)][string]$Destination, + [string[]]$Arguments = @() + ) + + & robocopy $Source $Destination @Arguments + $code = $LASTEXITCODE + + if ($code -ge 8) { + throw "robocopy could not copy every file from $Source to $Destination (exit code $code)." + } + + Write-WinUtilISOLog "robocopy finished with exit code $code." +} + function Write-WinUtilISOLog { - param([string]$Message) - $ts = (Get-Date).ToString("HH:mm:ss") - $logLine = "[$ts] $Message" - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $current = $sync["WPFWin11ISOStatusLog"].Text - if ($current -eq "Ready. Please select a Windows 11 ISO to begin.") { - $sync["WPFWin11ISOStatusLog"].Text = $logLine + <# + .SYNOPSIS + Appends a line to the Win11 Creator status log and to the session log. + + .DESCRIPTION + The status log is a UI control, so the append is posted to the UI thread rather than + waited on. Without a window it degrades to the session log alone, which keeps job + bodies free of "is there a UI" checks. + #> + param( + [Parameter(Mandatory)][string]$Message, + [ValidateSet("INFO", "WARN", "ERROR")] + [string]$Level = "INFO" + ) + + Write-WinUtilLog -Level $Level -Component "Win11Creator" -Message $Message + + Invoke-WPFUIThread -Async -Parameters @{ + LogLine = "[$((Get-Date).ToString('HH:mm:ss'))] $Message" + } -ScriptBlock { + param($LogLine) + + $box = $sync["WPFWin11ISOStatusLog"] + if ($null -eq $box) { return } + + if ($box.Text -eq "Ready. Please select a Windows 11 ISO to begin.") { + $box.Text = $LogLine } else { - $sync["WPFWin11ISOStatusLog"].Text += "`n$logLine" + $box.Text += "`n$LogLine" } - $sync["WPFWin11ISOStatusLog"].CaretIndex = $sync["WPFWin11ISOStatusLog"].Text.Length - $sync["WPFWin11ISOStatusLog"].ScrollToEnd() - }) + $box.CaretIndex = $box.Text.Length + $box.ScrollToEnd() + } +} + +function Get-WinUtilEditionIdFromName { + <# + .SYNOPSIS + Maps a Windows 11 edition display name to the edition id used by unattended setup. + #> + param([string]$EditionName) + + $normalizedName = ($EditionName -replace '^Windows\s+11\s+', '').Trim() + switch -Regex ($normalizedName) { + '^Home Single Language$' { return 'CoreSingleLanguage' } + '^Home N$' { return 'CoreN' } + '^Home$' { return 'Core' } + '^Pro for Workstations N$' { return 'ProfessionalWorkstationN' } + '^Pro for Workstations$' { return 'ProfessionalWorkstation' } + '^Pro Education N$' { return 'ProfessionalEducationN' } + '^Pro Education$' { return 'ProfessionalEducation' } + '^Pro N$' { return 'ProfessionalN' } + '^Pro$' { return 'Professional' } + '^Education N$' { return 'EducationN' } + '^Education$' { return 'Education' } + '^Enterprise LTSC N$' { return 'EnterpriseSN' } + '^Enterprise LTSC$' { return 'EnterpriseS' } + '^Enterprise N$' { return 'EnterpriseN' } + '^Enterprise$' { return 'Enterprise' } + default { return '' } + } } function Invoke-WinUtilISOBrowse { @@ -42,60 +117,67 @@ function Invoke-WinUtilISOMountAndVerify { $isoPath = $sync["WPFWin11ISOPath"].Text if ([string]::IsNullOrWhiteSpace($isoPath) -or $isoPath -eq "No ISO selected...") { - [System.Windows.MessageBox]::Show("Please select an ISO file first.", "No ISO Selected", "OK", "Warning") + Show-WinUtilMessage -Message "Please select an ISO file first." -Title "No ISO Selected" -Button "OK" -Icon "Warning" | Out-Null return } - Write-WinUtilISOLog "Mounting ISO: $isoPath" - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Mounting ISO..." -Percent 10 - $sync["WPFWin11ISOBrowseButton"].IsEnabled = $false - $sync["WPFWin11ISOMountButton"].IsEnabled = $false - $sync["WPFWin11ISOModifyButton"].IsEnabled = $false - $sync["Win11ISOProcessRunning"] = $true - - Invoke-WPFRunspace -ParameterList @(,('isoPath', $isoPath)) -ScriptBlock { + Start-WinUtilJob -Name "ISO mount" -Description "Mounting ISO" -Parameters @{ + IsoPath = $isoPath + } -ScriptBlock { param($isoPath) + Invoke-WPFUIThread -ScriptBlock { + $sync["WPFWin11ISOBrowseButton"].IsEnabled = $false + $sync["WPFWin11ISOMountButton"].IsEnabled = $false + $sync["WPFWin11ISOModifyButton"].IsEnabled = $false + } + try { - Mount-DiskImage -ImagePath $isoPath + Write-WinUtilISOLog "Mounting ISO: $isoPath" + Step-WinUtilJob -Status "Mounting ISO..." -Percent 10 - do { + Mount-DiskImage -ImagePath $isoPath -ErrorAction Stop + + # Bounded, because a damaged or already-mounted image may never present a drive + # letter. The job layer runs one job at a time, so waiting here forever would block + # every other action and the shutdown wait for the rest of the session. + $letter = $null + $mountClock = [System.Diagnostics.Stopwatch]::StartNew() + while (-not $letter -and $mountClock.Elapsed.TotalSeconds -lt 60) { Start-Sleep -Milliseconds 500 - } until ((Get-DiskImage -ImagePath $isoPath | Get-Volume).DriveLetter) + $letter = (Get-DiskImage -ImagePath $isoPath | Get-Volume).DriveLetter + } - $driveLetter = (Get-DiskImage -ImagePath $isoPath | Get-Volume).DriveLetter + ":" + if (-not $letter) { + throw "The ISO mounted but no drive letter appeared within 60 seconds: $isoPath" + } + + $driveLetter = "${letter}:" Write-WinUtilISOLog "Mounted at drive $driveLetter" - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Verifying ISO contents..." -Percent 30 + Step-WinUtilJob -Status "Verifying ISO contents..." -Percent 30 $wimPath = Join-Path $driveLetter "sources\install.wim" $esdPath = Join-Path $driveLetter "sources\install.esd" if (-not (Test-Path $wimPath) -and -not (Test-Path $esdPath)) { Dismount-DiskImage -ImagePath $isoPath - Write-WinUtilISOLog "ERROR: install.wim/install.esd not found - not a valid Windows ISO." - Invoke-WPFUIThread { - [System.Windows.MessageBox]::Show( - "This does not appear to be a valid Windows ISO.`n`ninstall.wim / install.esd was not found.", - "Invalid ISO", "OK", "Error") - } - return + Write-WinUtilISOLog -Level "ERROR" -Message "install.wim/install.esd not found - not a valid Windows ISO." + Show-WinUtilMessage -Message "This does not appear to be a valid Windows ISO.`n`ninstall.wim / install.esd was not found." -Title "Invalid ISO" -Button "OK" -Icon "Error" | Out-Null + # Returning here would let the job layer report the run as finished + throw "install.wim / install.esd was not found in $isoPath." } $activeWim = if (Test-Path $wimPath) { $wimPath } else { $esdPath } - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Reading image metadata..." -Percent 55 + Step-WinUtilJob -Status "Reading image metadata..." -Percent 55 $imageInfo = Get-WindowsImage -ImagePath $activeWim | Select-Object ImageIndex, ImageName if (-not ($imageInfo | Where-Object { $_.ImageName -match "Windows 11" })) { Dismount-DiskImage -ImagePath $isoPath - Write-WinUtilISOLog "ERROR: No 'Windows 11' edition found in the image." - Invoke-WPFUIThread { - [System.Windows.MessageBox]::Show( - "No Windows 11 edition was found in this ISO.`n`nOnly official Windows 11 ISOs are supported.", - "Not a Windows 11 ISO", "OK", "Error") - } - return + Write-WinUtilISOLog -Level "ERROR" -Message "No 'Windows 11' edition found in the image." + Show-WinUtilMessage -Message "No Windows 11 edition was found in this ISO.`n`nOnly official Windows 11 ISOs are supported." -Title "Not a Windows 11 ISO" -Button "OK" -Icon "Error" | Out-Null + throw "No Windows 11 edition was found in $isoPath." } $sync["Win11ISOImageInfo"] = $imageInfo @@ -103,8 +185,14 @@ function Invoke-WinUtilISOMountAndVerify { $sync["Win11ISOWimPath"] = $activeWim $sync["Win11ISOImagePath"] = $isoPath - Invoke-WPFUIThread { - $sync["WPFWin11ISOMountDriveLetter"].Text = "Mounted at: $driveLetter | Image file: $(Split-Path $activeWim -Leaf)" + Invoke-WPFUIThread -Parameters @{ + DriveLetter = $driveLetter + ImageFileName = Split-Path $activeWim -Leaf + ImageInfo = $imageInfo + } -ScriptBlock { + param($DriveLetter, $ImageFileName, $imageInfo) + + $sync["WPFWin11ISOMountDriveLetter"].Text = "Mounted at: $DriveLetter | Image file: $ImageFileName" $sync["WPFWin11ISOEditionComboBox"].Items.Clear() foreach ($img in $imageInfo) { [void]$sync["WPFWin11ISOEditionComboBox"].Items.Add("$($img.ImageIndex): $($img.ImageName)") @@ -120,26 +208,14 @@ function Invoke-WinUtilISOMountAndVerify { } $sync["WPFWin11ISOVerifyResultPanel"].Visibility = "Visible" $sync["WPFWin11ISOModifySection"].Visibility = "Visible" - $sync["WPFWin11ISOModifyButton"].IsEnabled = $true } - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "ISO verified" -Percent 100 Write-WinUtilISOLog "ISO verified OK. Editions found: $($imageInfo.Count)" - } catch { - $errorMessage = $_ - Write-WinUtilISOLog "ERROR during mount/verify: $errorMessage" - Invoke-WPFUIThread { - [System.Windows.MessageBox]::Show( - "An error occurred while mounting or verifying the ISO:`n`n$errorMessage", - "Error", "OK", "Error") - } } finally { - Start-Sleep -Milliseconds 800 - Set-WinUtilTweaksProgressIndicator -Visible $false - Invoke-WPFUIThread { + Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOBrowseButton"].IsEnabled = $true $sync["WPFWin11ISOMountButton"].IsEnabled = $true - $sync["Win11ISOProcessRunning"] = $false + $sync["WPFWin11ISOModifyButton"].IsEnabled = $true } } } @@ -151,9 +227,7 @@ function Invoke-WinUtilISOModify { $wimPath = $sync["Win11ISOWimPath"] if (-not $isoPath) { - [System.Windows.MessageBox]::Show( - "No verified ISO found. Please complete Steps 1 and 2 first.", - "Not Ready", "OK", "Warning") + Show-WinUtilMessage -Message "No verified ISO found. Please complete Steps 1 and 2 first." -Title "Not Ready" -Button "OK" -Icon "Warning" | Out-Null return } @@ -165,15 +239,11 @@ function Invoke-WinUtilISOModify { $selectedWimIndex = $sync["Win11ISOImageInfo"][0].ImageIndex } $selectedEditionName = if ($selectedItem) { ($selectedItem -replace '^\d+:\s*', '') } else { "Unknown" } - Write-WinUtilISOLog "Selected edition: $selectedEditionName (Index $selectedWimIndex)" - - $sync["WPFWin11ISOModifyButton"].IsEnabled = $false - $sync["Win11ISOModifying"] = $true - $sync["Win11ISOProcessRunning"] = $true + # A fresh working directory per run; existing-work detection is only for resuming an export $workDir = Join-Path $env:TEMP "WinUtil_Win11ISO_$(Get-Date -Format 'yyyyMMdd_HHmmss')" if (Test-Path $workDir) { - $workDir = Join-Path $env:TEMP "WinUtil_Win11ISO_$(Get-Date -Format 'yyyyMMdd_HHmmss')_$(([guid]::NewGuid()).ToString('N').Substring(0, 8))" + $workDir = "$($workDir)_$(([guid]::NewGuid()).ToString('N').Substring(0, 8))" } $autounattendContent = if ($WinUtilAutounattendXml) { @@ -183,174 +253,112 @@ function Invoke-WinUtilISOModify { if (Test-Path $toolsXml) { Get-Content $toolsXml -Raw } else { "" } } - $runspace = [Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() - $runspace.ApartmentState = "STA" - $runspace.ThreadOptions = "ReuseThread" - $runspace.Open() - $injectDrivers = $sync["WPFWin11ISOInjectDrivers"].IsChecked -eq $true - $runspace.SessionStateProxy.SetVariable("sync", $sync) - $runspace.SessionStateProxy.SetVariable("isoPath", $isoPath) - $runspace.SessionStateProxy.SetVariable("driveLetter", $driveLetter) - $runspace.SessionStateProxy.SetVariable("wimPath", $wimPath) - $runspace.SessionStateProxy.SetVariable("workDir", $workDir) - $runspace.SessionStateProxy.SetVariable("selectedWimIndex", $selectedWimIndex) - $runspace.SessionStateProxy.SetVariable("selectedEditionName", $selectedEditionName) - $runspace.SessionStateProxy.SetVariable("autounattendContent", $autounattendContent) - $runspace.SessionStateProxy.SetVariable("injectDrivers", $injectDrivers) - - $isoScriptFuncDef = "function Invoke-WinUtilISOScript {`n" + ${function:Invoke-WinUtilISOScript}.ToString() + "`n}" - $win11ISOLogFuncDef = "function Write-WinUtilISOLog {`n" + ${function:Write-WinUtilISOLog}.ToString() + "`n}" - $runspace.SessionStateProxy.SetVariable("isoScriptFuncDef", $isoScriptFuncDef) - $runspace.SessionStateProxy.SetVariable("win11ISOLogFuncDef", $win11ISOLogFuncDef) - - $script = [Management.Automation.PowerShell]::Create() - $script.Runspace = $runspace - $script.AddScript({ - . ([scriptblock]::Create($isoScriptFuncDef)) - . ([scriptblock]::Create($win11ISOLogFuncDef)) - - function Log($msg) { - $ts = (Get-Date).ToString("HH:mm:ss") - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFWin11ISOStatusLog"].Text += "`n[$ts] $msg" - $sync["WPFWin11ISOStatusLog"].CaretIndex = $sync["WPFWin11ISOStatusLog"].Text.Length - $sync["WPFWin11ISOStatusLog"].ScrollToEnd() - }) - Add-Content -Path (Join-Path $workDir "WinUtil_Win11ISO.log") -Value "[$ts] $msg" - } - - function SetProgress($label, $pct) { - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFTweaksProgressBar"].Visibility = "Visible" - $sync["WPFTweaksProgressLabel"].Text = $label - $sync["WPFTweaksProgressLabel"].ToolTip = $label - $sync["WPFTweaksProgressValue"].Value = [Math]::Max($pct, 5) - }) - } - - function Get-WinUtilEditionIdFromName { - param([string]$EditionName) - - $normalizedName = ($EditionName -replace '^Windows\s+11\s+', '').Trim() - switch -Regex ($normalizedName) { - '^Home Single Language$' { return 'CoreSingleLanguage' } - '^Home N$' { return 'CoreN' } - '^Home$' { return 'Core' } - '^Pro for Workstations N$' { return 'ProfessionalWorkstationN' } - '^Pro for Workstations$' { return 'ProfessionalWorkstation' } - '^Pro Education N$' { return 'ProfessionalEducationN' } - '^Pro Education$' { return 'ProfessionalEducation' } - '^Pro N$' { return 'ProfessionalN' } - '^Pro$' { return 'Professional' } - '^Education N$' { return 'EducationN' } - '^Education$' { return 'Education' } - '^Enterprise LTSC N$' { return 'EnterpriseSN' } - '^Enterprise LTSC$' { return 'EnterpriseS' } - '^Enterprise N$' { return 'EnterpriseN' } - '^Enterprise$' { return 'Enterprise' } - default { return '' } - } + Start-WinUtilJob -Name "ISO modify" -Description "Modifying ISO" -Parameters @{ + IsoPath = $isoPath + DriveLetter = $driveLetter + WimPath = $wimPath + WorkDir = $workDir + SelectedWimIndex = $selectedWimIndex + SelectedEditionName = $selectedEditionName + AutounattendContent = $autounattendContent + InjectDrivers = $sync["WPFWin11ISOInjectDrivers"].IsChecked -eq $true + } -ScriptBlock { + param($isoPath, $DriveLetter, $WimPath, $workDir, $SelectedWimIndex, $SelectedEditionName, $AutounattendContent, $InjectDrivers) + + Invoke-WPFUIThread -ScriptBlock { + $sync["WPFWin11ISOModifyButton"].IsEnabled = $false + $sync["WPFWin11ISOSelectSection"].Visibility = "Collapsed" + $sync["WPFWin11ISOMountSection"].Visibility = "Collapsed" + $sync["WPFWin11ISOModifySection"].Visibility = "Collapsed" } try { - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFWin11ISOSelectSection"].Visibility = "Collapsed" - $sync["WPFWin11ISOMountSection"].Visibility = "Collapsed" - $sync["WPFWin11ISOModifySection"].Visibility = "Collapsed" - }) + Write-WinUtilISOLog "Selected edition: $SelectedEditionName (Index $SelectedWimIndex)" + Write-WinUtilISOLog "Creating working directory: $workDir" - Log "Creating working directory: $workDir" $isoContents = Join-Path $workDir "iso_contents" - New-Item -ItemType Directory -Path $isoContents -Force - SetProgress "Copying ISO contents..." 10 + New-Item -ItemType Directory -Path $isoContents -Force | Out-Null + Step-WinUtilJob -Status "Copying ISO contents..." -Percent 10 - Log "Copying ISO contents from $driveLetter to $isoContents..." - & robocopy $driveLetter $isoContents /E /NFL /NDL /NJH /NJS - Log "ISO contents copied." - SetProgress "Preparing setup media..." 25 + Write-WinUtilISOLog "Copying ISO contents from $DriveLetter to $isoContents..." + Invoke-WinUtilRobocopy -Source $DriveLetter -Destination $isoContents -Arguments @("/E","/NFL","/NDL","/NJH","/NJS") + Write-WinUtilISOLog "ISO contents copied." + Step-WinUtilJob -Status "Preparing setup media..." -Percent 25 - $sourceImageFileName = Split-Path $wimPath -Leaf + $sourceImageFileName = Split-Path $WimPath -Leaf $localWim = Join-Path $isoContents "sources\$sourceImageFileName" if (-not (Test-Path $localWim)) { throw "Copied ISO image file not found: sources\$sourceImageFileName" } - $selectedEditionId = Get-WinUtilEditionIdFromName -EditionName $selectedEditionName - - Log "Writing autounattend.xml and edition selection..." - Invoke-WinUtilISOScript -ISOContentsDir $isoContents -AutoUnattendXml $autounattendContent -InjectCurrentSystemDrivers $injectDrivers -InstallImagePath $localWim -InstallImageIndex $selectedWimIndex -InstallEditionId $selectedEditionId -Log { param($m) Log $m } - SetProgress "Preserving install image..." 70 - if ($injectDrivers) { - Log "Added current-system drivers to $sourceImageFileName index $selectedWimIndex with one mount and commit." + Write-WinUtilISOLog "Writing autounattend.xml and edition selection..." + Invoke-WinUtilISOScript -ISOContentsDir $isoContents ` + -AutoUnattendXml $AutounattendContent ` + -InjectCurrentSystemDrivers $InjectDrivers ` + -InstallImagePath $localWim ` + -InstallImageIndex $SelectedWimIndex ` + -InstallEditionId (Get-WinUtilEditionIdFromName -EditionName $SelectedEditionName) ` + -Log { param($m) Write-WinUtilISOLog $m } + + Step-WinUtilJob -Status "Preserving install image..." -Percent 70 + if ($InjectDrivers) { + Write-WinUtilISOLog "Added current-system drivers to $sourceImageFileName index $SelectedWimIndex with one mount and commit." } else { - Log "Preserved the original $sourceImageFileName without mounting, exporting, or modifying it." + Write-WinUtilISOLog "Preserved the original $sourceImageFileName without mounting, exporting, or modifying it." } - SetProgress "Dismounting source ISO..." 80 - Log "Dismounting original ISO..." + Step-WinUtilJob -Status "Dismounting source ISO..." -Percent 80 + Write-WinUtilISOLog "Dismounting original ISO..." Dismount-DiskImage -ImagePath $isoPath $sync["Win11ISOWorkDir"] = $workDir $sync["Win11ISOContentsDir"] = $isoContents - SetProgress "Modification complete" 100 - Log "install.wim modification complete. Choose an output option in Step 4." + Step-WinUtilJob -Status "Modification complete" -Percent 100 + Write-WinUtilISOLog "install.wim modification complete. Choose an output option in Step 4." - $sync["WPFWin11ISOOutputSection"].Dispatcher.Invoke([action]{ + Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOOutputSection"].Visibility = "Visible" - }) + } } catch { - Log "ERROR during modification: $_" + Write-WinUtilISOLog -Level "ERROR" -Message "Modification failed: $_" try { $mountedISO = Get-DiskImage -ImagePath $isoPath if ($mountedISO -and $mountedISO.Attached) { - Log "Cleaning up: dismounting source ISO..." + Write-WinUtilISOLog "Cleaning up: dismounting source ISO..." Dismount-DiskImage -ImagePath $isoPath } - } catch { Log "Warning: could not dismount ISO during cleanup: $_" } + } catch { Write-WinUtilISOLog -Level "WARN" -Message "Could not dismount ISO during cleanup: $_" } try { if (Test-Path $workDir) { - Log "Cleaning up: removing temp directory $workDir..." + Write-WinUtilISOLog "Cleaning up: removing temp directory $workDir..." Remove-Item -Path $workDir -Recurse -Force } - } catch { Log "Warning: could not remove temp directory during cleanup: $_" } + } catch { Write-WinUtilISOLog -Level "WARN" -Message "Could not remove temp directory during cleanup: $_" } + + Show-WinUtilMessage -Message "An error occurred during install.wim modification:`n`n$_" -Title "Modification Error" -Button "OK" -Icon "Error" | Out-Null - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - [System.Windows.MessageBox]::Show( - "An error occurred during install.wim modification:`n`n$_", - "Modification Error", "OK", "Error") - }) + throw } finally { - Start-Sleep -Milliseconds 800 - $sync["Win11ISOModifying"] = $false - $sync["Win11ISOProcessRunning"] = $false - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFTweaksProgressBar"].Visibility = "Collapsed" - $sync["WPFTweaksProgressLabel"].Text = "" - $sync["WPFTweaksProgressLabel"].ToolTip = "" - $sync["WPFTweaksProgressValue"].Value = 0 + Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOModifyButton"].IsEnabled = $true if ($sync["WPFWin11ISOOutputSection"].Visibility -ne "Visible") { $sync["WPFWin11ISOSelectSection"].Visibility = "Visible" $sync["WPFWin11ISOMountSection"].Visibility = "Visible" $sync["WPFWin11ISOModifySection"].Visibility = "Visible" } - }) + } } - }) - - $script.BeginInvoke() + } } function Invoke-WinUtilISOCheckExistingWork { if ($sync["Win11ISOContentsDir"] -and (Test-Path $sync["Win11ISOContentsDir"])) { return } - # Check if ISO modification is currently in progress - if ($sync["Win11ISOModifying"]) { - return - } + # Nothing to resume while a modification is still producing the working directory + if ($sync.ActiveJob) { return } $existingWorkDir = Get-Item -Path (Join-Path $env:TEMP "WinUtil_Win11ISO*") | Where-Object { $_.PSIsContainer } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 @@ -373,82 +381,51 @@ function Invoke-WinUtilISOCheckExistingWork { Write-WinUtilISOLog "Last modified: $modified - Skipping Steps 1-3 and resuming at Step 4." Write-WinUtilISOLog "Click 'Clean & Reset' if you want to start over with a new ISO." - [System.Windows.MessageBox]::Show( - "A previous WinUtil ISO working directory was found:`n`n$($existingWorkDir.FullName)`n`n(Last modified: $modified)`n`nStep 4 (output options) has been restored so you can save the already-modified image.`n`nClick 'Clean & Reset' in Step 4 if you want to start over.", - "Existing Work Found", "OK", "Info") + Show-WinUtilMessage -Message "A previous WinUtil ISO working directory was found:`n`n$($existingWorkDir.FullName)`n`n(Last modified: $modified)`n`nStep 4 (output options) has been restored so you can save the already-modified image.`n`nClick 'Clean & Reset' in Step 4 if you want to start over." -Title "Existing Work Found" -Button "OK" -Icon "Info" | Out-Null } function Invoke-WinUtilISOCleanAndReset { $workDir = $sync["Win11ISOWorkDir"] if ($workDir -and (Test-Path $workDir)) { - $confirm = [System.Windows.MessageBox]::Show( - "This will delete the temporary working directory:`n`n$workDir`n`nAnd reset the interface back to the start.`n`nContinue?", - "Clean & Reset", "YesNo", "Warning") + $confirm = Show-WinUtilMessage -Message "This will delete the temporary working directory:`n`n$workDir`n`nAnd reset the interface back to the start.`n`nContinue?" -Title "Clean & Reset" -Button "YesNo" -Icon "Warning" if ($confirm -ne "Yes") { return } } - $sync["WPFWin11ISOCleanResetButton"].IsEnabled = $false - $sync["Win11ISOProcessRunning"] = $true - - $runspace = [Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() - $runspace.ApartmentState = "STA" - $runspace.ThreadOptions = "ReuseThread" - $runspace.Open() - $runspace.SessionStateProxy.SetVariable("sync", $sync) - $runspace.SessionStateProxy.SetVariable("workDir", $workDir) - - $script = [Management.Automation.PowerShell]::Create() - $script.Runspace = $runspace - $script.AddScript({ - - function Log($msg) { - $ts = (Get-Date).ToString("HH:mm:ss") - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFWin11ISOStatusLog"].Text += "`n[$ts] $msg" - $sync["WPFWin11ISOStatusLog"].CaretIndex = $sync["WPFWin11ISOStatusLog"].Text.Length - $sync["WPFWin11ISOStatusLog"].ScrollToEnd() - }) - Add-Content -Path (Join-Path $workDir "WinUtil_Win11ISO.log") -Value "[$ts] $msg" - } + Start-WinUtilJob -Name "ISO cleanup" -Description "Cleaning up" -Parameters @{ + WorkDir = $workDir + } -ScriptBlock { + param($workDir) - function SetProgress($label, $pct) { - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFTweaksProgressBar"].Visibility = "Visible" - $sync["WPFTweaksProgressLabel"].Text = $label - $sync["WPFTweaksProgressLabel"].ToolTip = $label - $sync["WPFTweaksProgressValue"].Value = [Math]::Max($pct, 5) - }) - } + Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOCleanResetButton"].IsEnabled = $false } try { if ($workDir) { $mountDir = Join-Path $workDir "wim_mount" try { - $mountedImages = Get-WindowsImage -Mounted | - Where-Object { $_.Path -like "$workDir*" } + $mountedImages = Get-WindowsImage -Mounted | Where-Object { $_.Path -like "$workDir*" } if ($mountedImages) { foreach ($img in $mountedImages) { - Log "Dismounting WIM at: $($img.Path) (discarding changes)..." - SetProgress "Dismounting WIM image..." 3 + Write-WinUtilISOLog "Dismounting WIM at: $($img.Path) (discarding changes)..." + Step-WinUtilJob -Status "Dismounting WIM image..." -Percent 3 Dismount-WindowsImage -Path $img.Path -Discard - Log "WIM dismounted successfully." + Write-WinUtilISOLog "WIM dismounted successfully." } } elseif (Test-Path $mountDir) { - Log "No mounted WIM reported by Get-WindowsImage. Running DISM /Cleanup-Wim as a precaution..." - SetProgress "Running DISM cleanup..." 3 - & dism /English /Cleanup-Wim | ForEach-Object { Log $_ } + Write-WinUtilISOLog "No mounted WIM reported by Get-WindowsImage. Running DISM /Cleanup-Wim as a precaution..." + Step-WinUtilJob -Status "Running DISM cleanup..." -Percent 3 + & dism /English /Cleanup-Wim | ForEach-Object { Write-WinUtilISOLog $_ } } } catch { - Log "Warning: could not dismount WIM cleanly. Attempting DISM /Cleanup-Wim fallback: $_" - try { & dism /English /Cleanup-Wim | ForEach-Object { Log $_ } } - catch { Log "Warning: DISM /Cleanup-Wim also failed: $_" } + Write-WinUtilISOLog -Level "WARN" -Message "Could not dismount WIM cleanly. Attempting DISM /Cleanup-Wim fallback: $_" + try { & dism /English /Cleanup-Wim | ForEach-Object { Write-WinUtilISOLog $_ } } + catch { Write-WinUtilISOLog -Level "WARN" -Message "DISM /Cleanup-Wim also failed: $_" } } } if ($workDir -and (Test-Path $workDir)) { - Log "Scanning files to delete in: $workDir" - SetProgress "Scanning files..." 5 + Write-WinUtilISOLog "Scanning files to delete in: $workDir" + Step-WinUtilJob -Status "Scanning files..." -Percent 5 $allFiles = @(Get-ChildItem -Path $workDir -File -Recurse -Force) $allDirs = @(Get-ChildItem -Path $workDir -Directory -Recurse -Force | @@ -456,45 +433,45 @@ function Invoke-WinUtilISOCleanAndReset { $total = $allFiles.Count $deleted = 0 - Log "Found $total files to delete." + Write-WinUtilISOLog "Found $total files to delete." foreach ($f in $allFiles) { - try { Remove-Item -Path $f.FullName -Force } catch { Log "WARNING: could not delete $($f.FullName): $_" } + try { Remove-Item -Path $f.FullName -Force } catch { Write-WinUtilISOLog -Level "WARN" -Message "Could not delete $($f.FullName): $_" } $deleted++ if ($deleted % 100 -eq 0 -or $deleted -eq $total) { $pct = [math]::Round(($deleted / [Math]::Max($total, 1)) * 85) + 5 - SetProgress "Deleting files in $($f.Directory.Name)... ($deleted / $total)" $pct + Step-WinUtilJob -Status "Deleting files in $($f.Directory.Name)... ($deleted / $total)" -Percent $pct } } foreach ($d in $allDirs) { - try { Remove-Item -Path $d.FullName -Force } catch { Log "WARNING: could not delete $($d.FullName): $_" } + try { Remove-Item -Path $d.FullName -Force } catch { Write-WinUtilISOLog -Level "WARN" -Message "Could not delete $($d.FullName): $_" } } - try { Remove-Item -Path $workDir -Recurse -Force } catch { Log "WARNING: could not delete temp directory ${workDir}: $_" } + try { Remove-Item -Path $workDir -Recurse -Force } catch { Write-WinUtilISOLog -Level "WARN" -Message "Could not delete temp directory ${WorkDir}: $_" } if (Test-Path $workDir) { - Log "WARNING: some items could not be deleted in $workDir" + Write-WinUtilISOLog -Level "WARN" -Message "Some items could not be deleted in $workDir" } else { - Log "Temp directory deleted successfully." + Write-WinUtilISOLog "Temp directory deleted successfully." } } else { - Log "No temp directory found - resetting UI." + Write-WinUtilISOLog "No temp directory found - resetting UI." } - SetProgress "Resetting UI..." 95 - Log "Resetting interface..." + Step-WinUtilJob -Status "Resetting UI..." -Percent 95 + Write-WinUtilISOLog "Resetting interface..." - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["Win11ISOWorkDir"] = $null - $sync["Win11ISOContentsDir"] = $null - $sync["Win11ISOImagePath"] = $null - $sync["Win11ISODriveLetter"] = $null - $sync["Win11ISOWimPath"] = $null - $sync["Win11ISOImageInfo"] = $null - $sync["Win11ISOUSBDisks"] = $null + $sync["Win11ISOWorkDir"] = $null + $sync["Win11ISOContentsDir"] = $null + $sync["Win11ISOImagePath"] = $null + $sync["Win11ISODriveLetter"] = $null + $sync["Win11ISOWimPath"] = $null + $sync["Win11ISOImageInfo"] = $null + $sync["Win11ISOUSBDisks"] = $null - $sync["WPFWin11ISOPath"].Text = "No ISO selected..." + Invoke-WPFUIThread -ScriptBlock { + $sync["WPFWin11ISOPath"].Text = "No ISO selected..." $sync["WPFWin11ISOFileInfo"].Visibility = "Collapsed" $sync["WPFWin11ISOVerifyResultPanel"].Visibility = "Collapsed" $sync["WPFWin11ISOOptionUSB"].Visibility = "Collapsed" @@ -503,30 +480,13 @@ function Invoke-WinUtilISOCleanAndReset { $sync["WPFWin11ISOMountSection"].Visibility = "Collapsed" $sync["WPFWin11ISOSelectSection"].Visibility = "Visible" $sync["WPFWin11ISOModifyButton"].IsEnabled = $true - $sync["WPFWin11ISOCleanResetButton"].IsEnabled = $true - - $sync["WPFTweaksProgressBar"].Visibility = "Collapsed" - $sync["WPFTweaksProgressLabel"].Text = "" - $sync["WPFTweaksProgressLabel"].ToolTip = "" - $sync["WPFTweaksProgressValue"].Value = 0 - - $sync["WPFWin11ISOStatusLog"].Text = "Ready. Please select a Windows 11 ISO to begin." - }) - } catch { - Log "ERROR during Clean & Reset: $_" - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFTweaksProgressBar"].Visibility = "Collapsed" - $sync["WPFTweaksProgressLabel"].Text = "" - $sync["WPFTweaksProgressLabel"].ToolTip = "" - $sync["WPFTweaksProgressValue"].Value = 0 - $sync["WPFWin11ISOCleanResetButton"].IsEnabled = $true - }) + $sync["WPFWin11ISOStatusLog"].Text = "Ready. Please select a Windows 11 ISO to begin." + } + Step-WinUtilJob -Hide } finally { - $sync["Win11ISOProcessRunning"] = $false + Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOCleanResetButton"].IsEnabled = $true } } - }) - - $script.BeginInvoke() + } } function Get-WinUtilOSCDImgPath { @@ -561,9 +521,7 @@ function Invoke-WinUtilISOExport { $contentsDir = $sync["Win11ISOContentsDir"] if (-not $contentsDir -or -not (Test-Path $contentsDir)) { - [System.Windows.MessageBox]::Show( - "No modified ISO content found. Please complete Steps 1-3 first.", - "Not Ready", "OK", "Warning") + Show-WinUtilMessage -Message "No modified ISO content found. Please complete Steps 1-3 first." -Title "Not Ready" -Button "OK" -Icon "Warning" | Out-Null return } @@ -577,66 +535,23 @@ function Invoke-WinUtilISOExport { if ($dlg.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { return } - $outputISO = $dlg.FileName + Start-WinUtilJob -Name "ISO export" -Description "Building ISO" -Parameters @{ + ContentsDir = $contentsDir + OutputISO = $dlg.FileName + } -ScriptBlock { + param($contentsDir, $outputISO) - $oscdimg = Get-WinUtilOSCDImgPath + Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOChooseISOButton"].IsEnabled = $false } - if (-not $oscdimg) { - Write-WinUtilISOLog "oscdimg.exe not found. Attempting to install via winget..." try { - # First ensure winget is installed and operational - Install-WinUtilWinget - - $winget = Get-Command winget - $result = & $winget install -e --id Microsoft.OSCDIMG --accept-package-agreements --accept-source-agreements - Write-WinUtilISOLog "winget output: $result" - $oscdimg = Get-WinUtilOSCDImgPath - } catch { - Write-WinUtilISOLog "winget not available or install failed: $_" - } - - if (-not $oscdimg) { - Write-WinUtilISOLog "oscdimg.exe still not found after install attempt." - [System.Windows.MessageBox]::Show( - "oscdimg.exe could not be found or installed automatically.`n`nPlease install it manually:`n winget install -e --id Microsoft.OSCDIMG`n`nOr install the Windows ADK from:`nhttps://learn.microsoft.com/windows-hardware/get-started/adk-install", - "oscdimg Not Found", "OK", "Warning") - return - } - Write-WinUtilISOLog "oscdimg.exe installed successfully." - } - - $sync["WPFWin11ISOChooseISOButton"].IsEnabled = $false - $sync["Win11ISOProcessRunning"] = $true - - $runspace = [Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() - $runspace.ApartmentState = "STA" - $runspace.ThreadOptions = "ReuseThread" - $runspace.Open() - $runspace.SessionStateProxy.SetVariable("sync", $sync) - $runspace.SessionStateProxy.SetVariable("contentsDir", $contentsDir) - $runspace.SessionStateProxy.SetVariable("outputISO", $outputISO) - $runspace.SessionStateProxy.SetVariable("oscdimg", $oscdimg) - - $win11ISOLogFuncDef = "function Write-WinUtilISOLog {`n" + ${function:Write-WinUtilISOLog}.ToString() + "`n}" - $runspace.SessionStateProxy.SetVariable("win11ISOLogFuncDef", $win11ISOLogFuncDef) - - $script = [Management.Automation.PowerShell]::Create() - $script.Runspace = $runspace - $script.AddScript({ - . ([scriptblock]::Create($win11ISOLogFuncDef)) - - function SetProgress($label, $pct) { - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFTweaksProgressBar"].Visibility = "Visible" - $sync["WPFTweaksProgressLabel"].Text = $label - $sync["WPFTweaksProgressLabel"].ToolTip = $label - $sync["WPFTweaksProgressValue"].Value = [Math]::Max($pct, 5) - }) - } + $oscdimg = Get-WinUtilOscdimgPath + if (-not $oscdimg) { + Show-WinUtilMessage -Message "oscdimg.exe could not be found or installed automatically.`n`nPlease install it manually:`n winget install -e --id Microsoft.OSCDIMG`n`nOr install the Windows ADK from:`nhttps://learn.microsoft.com/windows-hardware/get-started/adk-install" -Title "oscdimg Not Found" -Button "OK" -Icon "Warning" | Out-Null + throw "oscdimg.exe could not be found or installed automatically." + } - try { Write-WinUtilISOLog "Exporting to ISO: $outputISO" - SetProgress "Building ISO..." 10 + Step-WinUtilJob -Status "Building ISO..." -Percent 10 $bootData = "2#p0,e,b`"$contentsDir\boot\etfsboot.com`"#pEF,e,b`"$contentsDir\efi\microsoft\boot\efisys.bin`"" $oscdimgArgs = @("-m", "-o", "-u2", "-udfver102", "-bootdata:$bootData", "-l`"CTOS_MODIFIED`"", "`"$contentsDir`"", "`"$outputISO`"") @@ -653,53 +568,111 @@ function Invoke-WinUtilISOExport { $proc = [System.Diagnostics.Process]::new() $proc.StartInfo = $psi - $proc.Start() - # Stream stdout line-by-line as oscdimg runs - while (-not $proc.StandardOutput.EndOfStream) { - $line = $proc.StandardOutput.ReadLine() - if ($line.Trim()) { Write-WinUtilISOLog $line } - } + # stderr is collected as it arrives rather than after the process exits. Reading it + # last deadlocks: once the stderr pipe fills, oscdimg blocks on its write and stops + # producing stdout, while this loop waits for stdout that will never come. + $stderrLines = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new()) + $proc.EnableRaisingEvents = $true + $errorHandler = Register-ObjectEvent -InputObject $proc -EventName ErrorDataReceived -Action { + if ($EventArgs.Data) { $null = $Event.MessageData.Add($EventArgs.Data) } + } -MessageData $stderrLines - $proc.WaitForExit() + try { + $proc.Start() | Out-Null + $proc.BeginErrorReadLine() - # Flush any stderr after process exits - $stderr = $proc.StandardError.ReadToEnd() - foreach ($line in ($stderr -split "`r?`n")) { - if ($line.Trim()) { Write-WinUtilISOLog "[stderr]$line" } + # Stream stdout line-by-line as oscdimg runs + while (-not $proc.StandardOutput.EndOfStream) { + $line = $proc.StandardOutput.ReadLine() + if ($line.Trim()) { Write-WinUtilISOLog $line } + } + + $proc.WaitForExit() + } finally { + Unregister-Event -SourceIdentifier $errorHandler.Name -ErrorAction SilentlyContinue + $errorHandler | Remove-Job -Force -ErrorAction SilentlyContinue } - if ($proc.ExitCode -eq 0) { - SetProgress "ISO exported" 100 - Write-WinUtilISOLog "ISO exported successfully: $outputISO" - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - [System.Windows.MessageBox]::Show("ISO exported successfully!`n`n$outputISO", "Export Complete", "OK", "Info") - }) - } else { - Write-WinUtilISOLog "oscdimg exited with code $($proc.ExitCode)." - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - [System.Windows.MessageBox]::Show( - "oscdimg exited with code $($proc.ExitCode).`nCheck the status log for details.", - "Export Error", "OK", "Error") - }) + foreach ($line in @($stderrLines)) { + if ($line.Trim()) { Write-WinUtilISOLog -Level "WARN" -Message "[stderr]$line" } } + + if ($proc.ExitCode -ne 0) { + throw "oscdimg exited with code $($proc.ExitCode). Check the status log for details." + } + + Step-WinUtilJob -Status "ISO exported" -Percent 100 + Write-WinUtilISOLog "ISO exported successfully: $outputISO" + Show-WinUtilMessage -Message "ISO exported successfully!`n`n$outputISO" -Title "Export Complete" -Button "OK" -Icon "Info" | Out-Null } catch { - Write-WinUtilISOLog "ERROR during ISO export: $_" - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - [System.Windows.MessageBox]::Show("ISO export failed:`n`n$_", "Error", "OK", "Error") - }) + Write-WinUtilISOLog -Level "ERROR" -Message "ISO export failed: $_" + Show-WinUtilMessage -Message "ISO export failed:`n`n$_" -Title "Error" -Button "OK" -Icon "Error" | Out-Null + throw } finally { - Start-Sleep -Milliseconds 800 - $sync["Win11ISOProcessRunning"] = $false - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFTweaksProgressBar"].Visibility = "Collapsed" - $sync["WPFTweaksProgressLabel"].Text = "" - $sync["WPFTweaksProgressLabel"].ToolTip = "" - $sync["WPFTweaksProgressValue"].Value = 0 - $sync["WPFWin11ISOChooseISOButton"].IsEnabled = $true - }) + Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOChooseISOButton"].IsEnabled = $true } } - }) + } +} + +function Find-WinUtilOscdimg { + <# + .SYNOPSIS + Looks for oscdimg.exe in every place it is known to land + + .DESCRIPTION + PATH first, since that covers an ADK installed anywhere and a manual copy, then the + default ADK location, then the per-user WinGet package root. Used both before and after + the install attempt, so a package that lands outside the per-user root is still found. + #> + + $onPath = Get-Command oscdimg.exe -ErrorAction SilentlyContinue + if ($onPath) { return $onPath.Source } + + foreach ($root in @( + "${env:ProgramFiles(x86)}\Windows Kits", + "$env:ProgramFiles\Windows Kits", + "$env:LOCALAPPDATA\Microsoft\WinGet\Packages")) { + + if (-not $root -or -not (Test-Path $root)) { continue } - $script.BeginInvoke() + $found = Get-ChildItem $root -Recurse -Filter "oscdimg.exe" -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if ($found) { return $found } + } + + return $null +} + +function Get-WinUtilOscdimgPath { + <# + .SYNOPSIS + Returns the path to oscdimg.exe, installing it through winget when it is missing. + #> + + $oscdimg = Find-WinUtilOscdimg + if ($oscdimg) { return $oscdimg } + + Write-WinUtilISOLog "oscdimg.exe not found. Attempting to install via winget..." + try { + # First ensure winget is installed and operational + Install-WinUtilWinget + + $winget = Get-Command winget + $result = & $winget install -e --id Microsoft.OSCDIMG --accept-package-agreements --accept-source-agreements + Write-WinUtilISOLog "winget output: $result" + + # The same search as before the install: winget honours a configured scope and package + # root, so the file does not necessarily land under the per-user package directory + $oscdimg = Find-WinUtilOscdimg + } catch { + Write-WinUtilISOLog -Level "WARN" -Message "winget not available or install failed: $_" + } + + if ($oscdimg) { + Write-WinUtilISOLog "oscdimg.exe installed successfully." + } else { + Write-WinUtilISOLog -Level "ERROR" -Message "oscdimg.exe still not found after install attempt." + } + return $oscdimg } diff --git a/functions/private/Invoke-WinUtilISOUSB.ps1 b/functions/private/Invoke-WinUtilISOUSB.ps1 index f080450eed..3c9387b31e 100644 --- a/functions/private/Invoke-WinUtilISOUSB.ps1 +++ b/functions/private/Invoke-WinUtilISOUSB.ps1 @@ -21,25 +21,34 @@ function Invoke-WinUtilISORefreshUSBDrives { $sync["Win11ISOUSBDisks"] = $removable } +function Get-WinUtilFreeDriveLetter { + <# + .SYNOPSIS + Returns the first unused drive letter between D and Z, or $null when there is none. + #> + + $used = (Get-PSDrive -PSProvider FileSystem).Name + foreach ($c in [char[]](68..90)) { + if ($used -notcontains [string]$c) { return $c } + } + return $null +} + function Invoke-WinUtilISOWriteUSB { $contentsDir = $sync["Win11ISOContentsDir"] $usbDisks = $sync["Win11ISOUSBDisks"] if (-not $contentsDir -or -not (Test-Path $contentsDir)) { - [System.Windows.MessageBox]::Show("No modified ISO content found. Please complete Steps 1-3 first.", "Not Ready", "OK", "Warning") + Show-WinUtilMessage -Message "No modified ISO content found. Please complete Steps 1-3 first." -Title "Not Ready" -Button "OK" -Icon "Warning" | Out-Null return } - $installWim = Join-Path $contentsDir "sources\install.wim" $installEsd = Join-Path $contentsDir "sources\install.esd" if (Test-Path $installEsd) { - $installEsdFile = Get-Item $installEsd - $esdSizeBytes = $installEsdFile.Length - $esdSizeMB = [math]::Ceiling($esdSizeBytes / 1MB) + $esdSizeBytes = (Get-Item $installEsd).Length if ($esdSizeBytes -ge 4GB) { - [System.Windows.MessageBox]::Show( - "This ISO uses an install.esd file that is $esdSizeMB MB. WinUtil's FAT32 USB format cannot store files larger than 4 GB.`n`nExport an ISO instead or use media with install.wim.", - "USB Creation Not Supported", "OK", "Warning") + $esdSizeMB = [math]::Ceiling($esdSizeBytes / 1MB) + Show-WinUtilMessage -Message "This ISO uses an install.esd file that is $esdSizeMB MB. WinUtil's FAT32 USB format cannot store files larger than 4 GB.`n`nExport an ISO instead or use media with install.wim." -Title "USB Creation Not Supported" -Button "OK" -Icon "Warning" | Out-Null return } } @@ -58,95 +67,59 @@ function Invoke-WinUtilISOWriteUSB { } if (-not $targetDisk) { - [System.Windows.MessageBox]::Show("Please select a USB drive from the dropdown.", "No Drive Selected", "OK", "Warning") + Show-WinUtilMessage -Message "Please select a USB drive from the dropdown." -Title "No Drive Selected" -Button "OK" -Icon "Warning" | Out-Null return } - $diskNum = $targetDisk.Number - $sizeGB = [math]::Round($targetDisk.Size / 1GB, 1) - - $confirm = [System.Windows.MessageBox]::Show( - "ALL data on Disk $diskNum ($($targetDisk.FriendlyName), $sizeGB GB) will be PERMANENTLY ERASED.`n`nAre you sure you want to continue?", - "Confirm USB Erase", "YesNo", "Warning") + $diskNum = $targetDisk.Number + $sizeGB = [math]::Round($targetDisk.Size / 1GB, 1) + $confirm = Show-WinUtilMessage -Message "ALL data on Disk $diskNum ($($targetDisk.FriendlyName), $sizeGB GB) will be PERMANENTLY ERASED.`n`nAre you sure you want to continue?" -Title "Confirm USB Erase" -Button "YesNo" -Icon "Warning" if ($confirm -ne "Yes") { Write-WinUtilISOLog "USB write cancelled by user." return } - $sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $false - $sync["Win11ISOProcessRunning"] = $true - Write-WinUtilISOLog "Starting USB write to Disk $diskNum..." - - $runspace = [Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() - $runspace.ApartmentState = "STA" - $runspace.ThreadOptions = "ReuseThread" - $runspace.Open() - $runspace.SessionStateProxy.SetVariable("sync", $sync) - $runspace.SessionStateProxy.SetVariable("diskNum", $diskNum) - $runspace.SessionStateProxy.SetVariable("contentsDir", $contentsDir) - - $script = [Management.Automation.PowerShell]::Create() - $script.Runspace = $runspace - $script.AddScript({ - - function Log($msg) { - $ts = (Get-Date).ToString("HH:mm:ss") - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFWin11ISOStatusLog"].Text += "`n[$ts] $msg" - $sync["WPFWin11ISOStatusLog"].CaretIndex = $sync["WPFWin11ISOStatusLog"].Text.Length - $sync["WPFWin11ISOStatusLog"].ScrollToEnd() - }) - } + Start-WinUtilJob -Name "USB write" -Description "Writing USB drive" -Parameters @{ + DiskNumber = $diskNum + ContentsDir = $contentsDir + } -ScriptBlock { + param($DiskNumber, $contentsDir) - function SetProgress($label, $pct) { - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFTweaksProgressBar"].Visibility = "Visible" - $sync["WPFTweaksProgressLabel"].Text = $label - $sync["WPFTweaksProgressLabel"].ToolTip = $label - $sync["WPFTweaksProgressValue"].Value = [Math]::Max($pct, 5) - }) - } - - function Get-FreeDriveLetter { - $used = (Get-PSDrive -PSProvider FileSystem).Name - foreach ($c in [char[]](68..90)) { - if ($used -notcontains [string]$c) { return $c } - } - return $null - } + Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $false } try { - SetProgress "Formatting USB drive..." 10 + Write-WinUtilISOLog "Starting USB write to Disk $DiskNumber..." + Step-WinUtilJob -Status "Formatting USB drive..." -Percent 10 # Phase 1: Clean disk via diskpart (retry once if the drive is not yet ready) $dpFile1 = Join-Path $env:TEMP "winutil_diskpart_$(Get-Random).txt" - "select disk $diskNum`nclean`nexit" | Set-Content -Path $dpFile1 -Encoding ASCII - Log "Running diskpart clean on Disk $diskNum..." + "select disk $DiskNumber`nclean`nexit" | Set-Content -Path $dpFile1 -Encoding ASCII + Write-WinUtilISOLog "Running diskpart clean on Disk $DiskNumber..." $dpCleanOut = diskpart /s $dpFile1 - $dpCleanOut | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" } + $dpCleanOut | Where-Object { $_ -match '\S' } | ForEach-Object { Write-WinUtilISOLog " diskpart: $_" } Remove-Item $dpFile1 -Force if (($dpCleanOut -join ' ') -match 'device is not ready') { - Log "Disk $diskNum was not ready; waiting 5 seconds and retrying clean..." + Write-WinUtilISOLog "Disk $DiskNumber was not ready; waiting 5 seconds and retrying clean..." Start-Sleep -Seconds 5 - Update-Disk -Number $diskNum + Update-Disk -Number $DiskNumber $dpFile1b = Join-Path $env:TEMP "winutil_diskpart_$(Get-Random).txt" - "select disk $diskNum`nclean`nexit" | Set-Content -Path $dpFile1b -Encoding ASCII - diskpart /s $dpFile1b | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" } + "select disk $DiskNumber`nclean`nexit" | Set-Content -Path $dpFile1b -Encoding ASCII + diskpart /s $dpFile1b | Where-Object { $_ -match '\S' } | ForEach-Object { Write-WinUtilISOLog " diskpart: $_" } Remove-Item $dpFile1b -Force } # Phase 2: Initialize as GPT Start-Sleep -Seconds 2 - Update-Disk -Number $diskNum - $diskObj = Get-Disk -Number $diskNum + Update-Disk -Number $DiskNumber + $diskObj = Get-Disk -Number $DiskNumber if ($diskObj.PartitionStyle -eq 'RAW') { - Initialize-Disk -Number $diskNum -PartitionStyle GPT - Log "Disk $diskNum initialized as GPT." + Initialize-Disk -Number $DiskNumber -PartitionStyle GPT + Write-WinUtilISOLog "Disk $DiskNumber initialized as GPT." } else { - Set-Disk -Number $diskNum -PartitionStyle GPT - Log "Disk $diskNum converted to GPT (was $($diskObj.PartitionStyle))." + Set-Disk -Number $DiskNumber -PartitionStyle GPT + Write-WinUtilISOLog "Disk $DiskNumber converted to GPT (was $($diskObj.PartitionStyle))." } # Phase 3: Create FAT32 partition via diskpart, then format with Format-Volume @@ -154,64 +127,64 @@ function Invoke-WinUtilISOWriteUSB { $volLabel = "W11-" + (Get-Date).ToString('yyMMdd') $dpFile2 = Join-Path $env:TEMP "winutil_diskpart2_$(Get-Random).txt" $maxFat32PartitionMB = 32768 - $diskSizeMB = [int][Math]::Floor((Get-Disk -Number $diskNum).Size / 1MB) + $diskSizeMB = [int][Math]::Floor((Get-Disk -Number $DiskNumber).Size / 1MB) $createPartitionCommand = "create partition primary" if ($diskSizeMB -gt $maxFat32PartitionMB) { $createPartitionCommand = "create partition primary size=$maxFat32PartitionMB" - Log "Disk $diskNum is $diskSizeMB MB; creating FAT32 partition capped at $maxFat32PartitionMB MB (32 GB)." + Write-WinUtilISOLog "Disk $DiskNumber is $diskSizeMB MB; creating FAT32 partition capped at $maxFat32PartitionMB MB (32 GB)." } @( - "select disk $diskNum" + "select disk $DiskNumber" $createPartitionCommand "exit" ) | Set-Content -Path $dpFile2 -Encoding ASCII - Log "Creating partitions on Disk $diskNum..." - diskpart /s $dpFile2 | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" } + Write-WinUtilISOLog "Creating partitions on Disk $DiskNumber..." + diskpart /s $dpFile2 | Where-Object { $_ -match '\S' } | ForEach-Object { Write-WinUtilISOLog " diskpart: $_" } Remove-Item $dpFile2 -Force - SetProgress "Formatting USB partition..." 25 + Step-WinUtilJob -Status "Formatting USB partition..." -Percent 25 Start-Sleep -Seconds 3 - Update-Disk -Number $diskNum + Update-Disk -Number $DiskNumber - $partitions = Get-Partition -DiskNumber $diskNum - Log "Partitions on Disk $diskNum after creation: $($partitions.Count)" + $partitions = Get-Partition -DiskNumber $DiskNumber + Write-WinUtilISOLog "Partitions on Disk $DiskNumber after creation: $($partitions.Count)" foreach ($p in $partitions) { - Log " Partition $($p.PartitionNumber) Type=$($p.Type) Letter=$($p.DriveLetter) Size=$([math]::Round($p.Size/1MB))MB" + Write-WinUtilISOLog " Partition $($p.PartitionNumber) Type=$($p.Type) Letter=$($p.DriveLetter) Size=$([math]::Round($p.Size/1MB))MB" } $winpePart = $partitions | Where-Object { $_.Type -eq "Basic" } | Select-Object -Last 1 if (-not $winpePart) { - throw "Could not find the Basic partition on Disk $diskNum after creation." + throw "Could not find the Basic partition on Disk $DiskNumber after creation." } # Format using Format-Volume (reliable on fresh drives; diskpart format fails # with 'no volume selected' when the partition has never been formatted before) - Log "Formatting Partition $($winpePart.PartitionNumber) as FAT32 (label: $volLabel)..." - Get-Partition -DiskNumber $diskNum -PartitionNumber $winpePart.PartitionNumber | + Write-WinUtilISOLog "Formatting Partition $($winpePart.PartitionNumber) as FAT32 (label: $volLabel)..." + Get-Partition -DiskNumber $DiskNumber -PartitionNumber $winpePart.PartitionNumber | Format-Volume -FileSystem FAT32 -NewFileSystemLabel $volLabel -Force -Confirm:$false - Log "Partition $($winpePart.PartitionNumber) formatted as FAT32." + Write-WinUtilISOLog "Partition $($winpePart.PartitionNumber) formatted as FAT32." - SetProgress "Assigning drive letters..." 30 + Step-WinUtilJob -Status "Assigning drive letters..." -Percent 30 Start-Sleep -Seconds 2 - Update-Disk -Number $diskNum + Update-Disk -Number $DiskNumber - try { Remove-PartitionAccessPath -DiskNumber $diskNum -PartitionNumber $winpePart.PartitionNumber -AccessPath "$($winpePart.DriveLetter):" } catch { Log "Warning: could not remove existing partition access path: $_" } - $usbLetter = Get-FreeDriveLetter + try { Remove-PartitionAccessPath -DiskNumber $DiskNumber -PartitionNumber $winpePart.PartitionNumber -AccessPath "$($winpePart.DriveLetter):" } catch { Write-WinUtilISOLog -Level "WARN" -Message "Could not remove existing partition access path: $_" } + $usbLetter = Get-WinUtilFreeDriveLetter if (-not $usbLetter) { throw "No free drive letters (D-Z) available to assign to the USB data partition." } - Set-Partition -DiskNumber $diskNum -PartitionNumber $winpePart.PartitionNumber -NewDriveLetter $usbLetter - Log "Assigned drive letter $usbLetter to WINPE partition (Partition $($winpePart.PartitionNumber))." + Set-Partition -DiskNumber $DiskNumber -PartitionNumber $winpePart.PartitionNumber -NewDriveLetter $usbLetter + Write-WinUtilISOLog "Assigned drive letter $usbLetter to WINPE partition (Partition $($winpePart.PartitionNumber))." Start-Sleep -Seconds 2 $usbDrive = "${usbLetter}:" $retries = 0 while (-not (Test-Path $usbDrive) -and $retries -lt 6) { $retries++ - Log "Waiting for $usbDrive to become accessible (attempt $retries/6)..." + Write-WinUtilISOLog "Waiting for $usbDrive to become accessible (attempt $retries/6)..." Start-Sleep -Seconds 2 } if (-not (Test-Path $usbDrive)) { throw "Drive $usbDrive is not accessible after letter assignment." } - Log "USB data partition: $usbDrive" + Write-WinUtilISOLog "USB data partition: $usbDrive" $contentSizeBytes = (Get-ChildItem -LiteralPath $contentsDir -File -Recurse -Force | Measure-Object -Property Length -Sum).Sum if (-not $contentSizeBytes) { $contentSizeBytes = 0 } @@ -223,7 +196,7 @@ function Invoke-WinUtilISOWriteUSB { $partitionCapacityGB = [math]::Round($partitionCapacityBytes / 1GB, 2) $partitionFreeGB = [math]::Round($partitionFreeBytes / 1GB, 2) - Log "Source content size: $contentSizeGB GB. USB partition capacity: $partitionCapacityGB GB, free: $partitionFreeGB GB." + Write-WinUtilISOLog "Source content size: $contentSizeGB GB. USB partition capacity: $partitionCapacityGB GB, free: $partitionFreeGB GB." if ($contentSizeBytes -gt $partitionCapacityBytes) { throw "ISO content ($contentSizeGB GB) is larger than the USB partition capacity ($partitionCapacityGB GB). Use a larger USB drive or reduce image size." @@ -233,55 +206,40 @@ function Invoke-WinUtilISOWriteUSB { throw "Insufficient free space on USB partition. Required: $contentSizeGB GB, available: $partitionFreeGB GB." } - SetProgress "Copying Windows 11 files to USB..." 45 + Step-WinUtilJob -Status "Copying Windows 11 files to USB..." -Percent 45 # Copy files; split install.wim if > 4 GB (FAT32 limit) $installWim = Join-Path $contentsDir "sources\install.wim" if (Test-Path $installWim) { $wimSizeMB = [math]::Round((Get-Item $installWim).Length / 1MB) if ($wimSizeMB -gt 3800) { - Log "install.wim is $wimSizeMB MB - splitting for FAT32 compatibility... This will take several minutes." + Write-WinUtilISOLog "install.wim is $wimSizeMB MB - splitting for FAT32 compatibility... This will take several minutes." Set-ItemProperty -LiteralPath $installWim -Name IsReadOnly -Value $false $splitDest = Join-Path $usbDrive "sources\install.swm" - New-Item -ItemType Directory -Path (Split-Path $splitDest) -Force + New-Item -ItemType Directory -Path (Split-Path $splitDest) -Force | Out-Null Split-WindowsImage -ImagePath $installWim -SplitImagePath $splitDest -FileSize 3800 -CheckIntegrity - Log "install.wim split complete." - Log "Copying remaining files to USB..." - & robocopy $contentsDir $usbDrive /E /XF install.wim /NFL /NDL /NJH /NJS + Write-WinUtilISOLog "install.wim split complete." + Write-WinUtilISOLog "Copying remaining files to USB..." + Invoke-WinUtilRobocopy -Source $contentsDir -Destination $usbDrive -Arguments @("/E","/XF","install.wim","/NFL","/NDL","/NJH","/NJS") } else { - & robocopy $contentsDir $usbDrive /E /NFL /NDL /NJH /NJS + Invoke-WinUtilRobocopy -Source $contentsDir -Destination $usbDrive -Arguments @("/E","/NFL","/NDL","/NJH","/NJS") } } else { - & robocopy $contentsDir $usbDrive /E /NFL /NDL /NJH /NJS + Invoke-WinUtilRobocopy -Source $contentsDir -Destination $usbDrive -Arguments @("/E","/NFL","/NDL","/NJH","/NJS") } - SetProgress "Finalising USB drive..." 90 - Log "Files copied to USB." - SetProgress "USB write complete" 100 - Log "USB drive is ready for use." + Step-WinUtilJob -Status "Finalising USB drive..." -Percent 90 + Write-WinUtilISOLog "Files copied to USB." + Step-WinUtilJob -Status "USB write complete" -Percent 100 + Write-WinUtilISOLog "USB drive is ready for use." - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - [System.Windows.MessageBox]::Show( - "USB drive created successfully!`n`nYou can now boot from this drive to install Windows 11.", - "USB Ready", "OK", "Info") - }) + Show-WinUtilMessage -Message "USB drive created successfully!`n`nYou can now boot from this drive to install Windows 11." -Title "USB Ready" -Button "OK" -Icon "Info" | Out-Null } catch { - Log "ERROR during USB write: $_" - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - [System.Windows.MessageBox]::Show("USB write failed:`n`n$_", "USB Write Error", "OK", "Error") - }) + Write-WinUtilISOLog -Level "ERROR" -Message "USB write failed: $_" + Show-WinUtilMessage -Message "USB write failed:`n`n$_" -Title "USB Write Error" -Button "OK" -Icon "Error" | Out-Null + throw } finally { - Start-Sleep -Milliseconds 800 - $sync["Win11ISOProcessRunning"] = $false - $sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{ - $sync["WPFTweaksProgressBar"].Visibility = "Collapsed" - $sync["WPFTweaksProgressLabel"].Text = "" - $sync["WPFTweaksProgressLabel"].ToolTip = "" - $sync["WPFTweaksProgressValue"].Value = 0 - $sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $true - }) + Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $true } } - }) - - $script.BeginInvoke() + } } diff --git a/functions/private/Invoke-WinUtilInstallPSProfile.ps1 b/functions/private/Invoke-WinUtilInstallPSProfile.ps1 index c103f03c14..d34cb9d843 100644 --- a/functions/private/Invoke-WinUtilInstallPSProfile.ps1 +++ b/functions/private/Invoke-WinUtilInstallPSProfile.ps1 @@ -1,15 +1,51 @@ function Invoke-WinUtilInstallPSProfile { - if (-not (Get-Command wt)) { - Write-Host "Windows Terminal not found. Installing..." + <# + .SYNOPSIS + Installs the CTT PowerShell profile + + .DESCRIPTION + The profile targets PowerShell 7, so its setup script has to run under pwsh rather than + the runspace this job is on. It runs as a child process with its output captured, so the + job log records what happened instead of it scrolling past in a terminal nobody kept. + #> + + if (-not (Get-Command pwsh -ErrorAction SilentlyContinue)) { + Step-WinUtilJob -Status "Installing PowerShell 7" -State "Indeterminate" + Write-WinUtilLog -Component "Feature" -Message "PowerShell 7 not found, installing it first." + Install-WinUtilWinget - winget install Microsoft.WindowsTerminal --source winget --silent + Install-WinUtilProgramWinget -Action Install -Programs @("Microsoft.PowerShell") | Out-Null + + if (-not (Get-Command pwsh -ErrorAction SilentlyContinue)) { + throw "PowerShell 7 could not be installed, so the profile cannot be set up." + } } - if (-not (Get-Command pwsh)) { - Write-Host "PowerShell 7 not found. Installing..." - Install-WinUtilWinget - winget install Microsoft.PowerShell --source winget --installer-type wix --silent + Step-WinUtilJob -Status "Running the profile setup" -State "Indeterminate" + + $setupUrl = "https://github.com/ChrisTitusTech/powershell-profile/raw/main/setup.ps1" + # Stop in the child, so a setup failure is a nonzero exit rather than a logged error and a + # exit code of zero + $output = & pwsh -NoProfile -NonInteractive -Command "`$ErrorActionPreference = 'Stop'; irm '$setupUrl' | iex" 2>&1 + $exitCode = $LASTEXITCODE + + $failures = 0 + foreach ($line in @($output)) { + if ($line -is [System.Management.Automation.ErrorRecord]) { + $failures++ + Write-WinUtilErrorRecord -ErrorRecord $line -Component "Feature" -Context "PowerShell profile setup" + } elseif (-not [string]::IsNullOrWhiteSpace($line)) { + Write-WinUtilLog -Component "Feature" -Message ([string]$line).Trim() + } + } + + if ($exitCode -ne 0) { + throw "The profile setup script exited with code $exitCode." + } + + if ($failures -gt 0) { + throw "The profile setup script reported $failures error(s); see the log." } - wt new-tab pwsh -NoExit -Command "irm https://github.com/ChrisTitusTech/powershell-profile/raw/main/setup.ps1 | iex" + Write-WinUtilLog -Component "Feature" -Message "CTT PowerShell profile installed. Open a new PowerShell 7 session to use it." } diff --git a/functions/private/Invoke-WinUtilUninstallPSProfile.ps1 b/functions/private/Invoke-WinUtilUninstallPSProfile.ps1 index fe05b480b2..fed90dff2c 100644 --- a/functions/private/Invoke-WinUtilUninstallPSProfile.ps1 +++ b/functions/private/Invoke-WinUtilUninstallPSProfile.ps1 @@ -1,10 +1,35 @@ function Invoke-WinUtilUninstallPSProfile { + <# + .SYNOPSIS + Restores the PowerShell 7 profile the CTT profile replaced - if (Test-Path ($Profile + ".bak")) { - Move-Item -Path ($Profile + ".bak") -Destination $Profile - } else { - Remove-Item -Path $Profile + .DESCRIPTION + The profile path has to come from pwsh itself. $PROFILE inside this job is the worker's + own Windows PowerShell profile, which is not the file the install wrote. + #> + + if (-not (Get-Command pwsh -ErrorAction SilentlyContinue)) { + throw "PowerShell 7 is not installed, so there is no CTT profile to remove." + } + + $profilePath = (& pwsh -NoProfile -NonInteractive -Command '$PROFILE' | Select-Object -First 1) + if ([string]::IsNullOrWhiteSpace($profilePath)) { + throw "Could not determine the PowerShell 7 profile path." + } + $profilePath = $profilePath.Trim() + $backupPath = "$profilePath.bak" + + if (Test-Path $backupPath) { + Move-Item -Path $backupPath -Destination $profilePath -Force + Write-WinUtilLog -Component "Feature" -Message "Restored the profile that was in place before: $profilePath" + return + } + + if (Test-Path $profilePath) { + Remove-Item -Path $profilePath -Force + Write-WinUtilLog -Component "Feature" -Message "Removed the CTT PowerShell profile: $profilePath" + return } - Write-Host "Successfully uninstalled CTT PowerShell Profile." -ForegroundColor Green + Write-WinUtilLog -Level "WARN" -Component "Feature" -Message "No PowerShell 7 profile found at $profilePath, nothing to remove." } diff --git a/functions/private/Measure-WinUtilStep.ps1 b/functions/private/Measure-WinUtilStep.ps1 new file mode 100644 index 0000000000..f28c98bb76 --- /dev/null +++ b/functions/private/Measure-WinUtilStep.ps1 @@ -0,0 +1,85 @@ +function Measure-WinUtilStep { + <# + .SYNOPSIS + Times one step of a pipeline and records it for the timing summary + + .DESCRIPTION + Output passes through untouched, so this can wrap an existing expression without + changing what the caller receives. Each step is logged as a "timing:" line and kept + in $sync.StepTimings for the summary to rank. + + .PARAMETER Name + What the step is, as it should read in the log. + + .PARAMETER ScriptBlock + The work to time. + + .PARAMETER Scope + Groups steps that belong to the same run, normally a job name or "UI". + #> + param( + [Parameter(Mandatory, Position = 0)] + [string]$Name, + + [Parameter(Mandatory, Position = 1)] + [scriptblock]$ScriptBlock, + + [string]$Scope = "WinUtil" + ) + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + try { + & $ScriptBlock + } finally { + $stopwatch.Stop() + + if ($null -ne $sync.StepTimings) { + $null = $sync.StepTimings.Add([pscustomobject]@{ + Scope = $Scope + Step = $Name + Milliseconds = $stopwatch.ElapsedMilliseconds + }) + } + + Write-WinUtilLog -Component $Scope -Message "timing: $Name took $($stopwatch.ElapsedMilliseconds) ms" + } +} + +function Write-WinUtilTimingSummary { + <# + .SYNOPSIS + Logs the slowest steps of a scope, so the log answers "what took so long" + + .PARAMETER Scope + Which group of steps to report on. + + .PARAMETER Top + How many of the slowest steps to list. + + .PARAMETER TotalMilliseconds + Wall clock total. Without it the summary sums the steps, missing whatever happened + between them. + #> + param( + [Parameter(Mandatory)] + [string]$Scope, + + [int]$Top = 5, + + [long]$TotalMilliseconds = -1 + ) + + $steps = @($sync.StepTimings | Where-Object { $_.Scope -eq $Scope }) + if ($steps.Count -eq 0) { + return + } + + $measured = ($steps | Measure-Object -Property Milliseconds -Sum).Sum + $total = if ($TotalMilliseconds -ge 0) { $TotalMilliseconds } else { $measured } + + Write-WinUtilLog -Component $Scope -Message "timing summary: $($steps.Count) step(s), $measured ms measured of $total ms total" + foreach ($step in ($steps | Sort-Object Milliseconds -Descending | Select-Object -First $Top)) { + $share = if ($total -gt 0) { [int](($step.Milliseconds / $total) * 100) } else { 0 } + Write-WinUtilLog -Component $Scope -Message "timing summary: $($step.Milliseconds) ms ($share%) $($step.Step)" + } +} diff --git a/functions/private/New-WinUtilSessionState.ps1 b/functions/private/New-WinUtilSessionState.ps1 new file mode 100644 index 0000000000..5eb24e1b2c --- /dev/null +++ b/functions/private/New-WinUtilSessionState.ps1 @@ -0,0 +1,55 @@ +function New-WinUtilSessionState { + <# + .SYNOPSIS + Builds the InitialSessionState every WinUtil runspace is created from + + .DESCRIPTION + The interface runspace and the worker pool start from the same state: the shared + $sync hashtable, the compiled script's globals, and every WinUtil function. That is + what lets the interface build a tab and a job body call any helper without injecting + definitions by hand. PowerShell's own functions are skipped, the default session + state already carries them. + + Cached: an InitialSessionState is a template any number of runspaces are created + from, and building it is not free. + #> + + if ($sync.SessionState) { + return $sync.SessionState + } + + $initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + + $variables = @( + @{ Name = "sync"; Value = $sync }, + @{ Name = "PARAM_OFFLINE"; Value = $PARAM_OFFLINE }, + @{ Name = "inputXML"; Value = $inputXML }, + @{ Name = "WinUtilAutounattendXml"; Value = $WinUtilAutounattendXml } + ) + + foreach ($variable in $variables) { + $initialSessionState.Variables.Add( + (New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList $variable.Name, $variable.Value, $null) + ) + } + + $builtInFunctions = [System.Collections.Generic.HashSet[string]]::new( + [string[]]@($initialSessionState.Commands | + Where-Object { $_ -is [System.Management.Automation.Runspaces.SessionStateFunctionEntry] } | + ForEach-Object { $_.Name }), + [StringComparer]::OrdinalIgnoreCase + ) + + foreach ($function in (Get-ChildItem function:\)) { + if ($builtInFunctions.Contains($function.Name)) { + continue + } + + $initialSessionState.Commands.Add( + (New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList $function.Name, $function.Definition) + ) + } + + $sync.SessionState = $initialSessionState + return $initialSessionState +} diff --git a/functions/private/Register-WinUtilRunspaceCleanup.ps1 b/functions/private/Register-WinUtilRunspaceCleanup.ps1 new file mode 100644 index 0000000000..0f1462162b --- /dev/null +++ b/functions/private/Register-WinUtilRunspaceCleanup.ps1 @@ -0,0 +1,67 @@ +function Register-WinUtilRunspaceCleanup { + <# + .SYNOPSIS + Disposes a PowerShell instance once its work has finished + + .DESCRIPTION + Ends the invocation and disposes the instance from a thread pool callback, so + nothing has to wait for a fire-and-forget runspace to complete just to clean it up. + + .PARAMETER PowerShell + The instance to dispose. + + .PARAMETER Handle + The handle returned by its BeginInvoke. + #> + param( + [Parameter(Mandatory)] + $PowerShell, + + [Parameter(Mandatory)] + $Handle + ) + + if (-not ("WinUtilRunspaceCleanup" -as [type])) { + Add-Type @" +using System; +using System.Management.Automation; + +public sealed class WinUtilRunspaceCleanupState +{ + public PowerShell PowerShell { get; set; } + public IAsyncResult Handle { get; set; } +} + +public static class WinUtilRunspaceCleanup +{ + public static readonly System.Threading.WaitOrTimerCallback Callback = Cleanup; + + public static void Cleanup(object state, bool timedOut) + { + var cleanupState = state as WinUtilRunspaceCleanupState; + if (cleanupState == null || cleanupState.PowerShell == null || cleanupState.Handle == null) + { + return; + } + + try + { + cleanupState.PowerShell.EndInvoke(cleanupState.Handle); + } + catch + { + } + finally + { + cleanupState.PowerShell.Dispose(); + } + } +} +"@ + } + + $cleanupState = [WinUtilRunspaceCleanupState]::new() + $cleanupState.PowerShell = $PowerShell + $cleanupState.Handle = $Handle + [System.Threading.ThreadPool]::RegisterWaitForSingleObject($Handle.AsyncWaitHandle, [WinUtilRunspaceCleanup]::Callback, $cleanupState, -1, $true) | Out-Null +} diff --git a/functions/private/Reset-WPFCheckBoxes.ps1 b/functions/private/Reset-WPFCheckBoxes.ps1 index 83b9165814..181765bddd 100644 --- a/functions/private/Reset-WPFCheckBoxes.ps1 +++ b/functions/private/Reset-WPFCheckBoxes.ps1 @@ -22,19 +22,24 @@ function Reset-WPFCheckBoxes { ) $selectedSet = [System.Collections.Generic.HashSet[string]]::new([string[]]@($sync.selectedApps + $sync.selectedTweaks + $sync.selectedFeatures + $sync.selectedAppx), [StringComparer]::OrdinalIgnoreCase) - foreach ($syncEntry in $sync.GetEnumerator()) { + # Snapshotted: setting IsChecked runs handlers that add to $sync, and the tab warmup is + # building controls into it at the same time, either of which invalidates a live enumerator + foreach ($syncEntry in @($sync.GetEnumerator())) { if ($syncEntry.Value -is [System.Windows.Controls.CheckBox] -and $syncEntry.Name -notlike "WPFToggle*" -and $syncEntry.Name -like $checkboxfilterpattern) { $checkboxName = $syncEntry.Key $sync.$checkboxName.IsChecked = $selectedSet.Contains($checkboxName) } } - # Update Installs tab UI values - $count = $sync.SelectedApps.Count - $sync.WPFselectedAppsButton.Content = "Selected Apps: $count" - # On every change, remove all entries inside the Popup Menu. This is done, so we can keep the alphabetical order even if elements are selected in a random way - $sync.selectedAppsstackPanel.Children.Clear() - $sync.selectedApps | Foreach-Object { Add-SelectedAppsMenuItem -name $($sync.configs.applicationsHashtable.$_.Content) -key $_ } + # Update Installs tab UI values. These are built with the Install tab, and this runs for + # whichever tab is built first: offline starts on Tweaks, so they are not there yet. + if ($sync.selectedAppsstackPanel) { + $count = $sync.SelectedApps.Count + $sync.WPFselectedAppsButton.Content = "Selected Apps: $count" + # On every change, remove all entries inside the Popup Menu. This is done, so we can keep the alphabetical order even if elements are selected in a random way + $sync.selectedAppsstackPanel.Children.Clear() + $sync.selectedApps | Foreach-Object { Add-SelectedAppsMenuItem -name $($sync.configs.applicationsHashtable.$_.Content) -key $_ } + } if($doToggles) { # Restore toggle switch states from imported config. @@ -42,7 +47,7 @@ function Reset-WPFCheckBoxes { # from the export file were not part of the saved config and should keep whatever # state the live system already has (set during UI initialisation via Get-WinUtilToggleStatus). $importedToggles = [System.Collections.Generic.HashSet[string]]::new([string[]]@($sync.selectedToggles), [StringComparer]::OrdinalIgnoreCase) - foreach ($toggle in $sync.GetEnumerator()) { + foreach ($toggle in @($sync.GetEnumerator())) { if ($toggle.Key -like "WPFToggle*" -and $toggle.Value -is [System.Windows.Controls.CheckBox] -and $importedToggles.Contains($toggle.Key)) { $sync[$toggle.Key].IsChecked = $true } diff --git a/functions/private/Set-WinUtilTweaksProgressIndicator.ps1 b/functions/private/Set-WinUtilTweaksProgressIndicator.ps1 deleted file mode 100644 index c0ea946939..0000000000 --- a/functions/private/Set-WinUtilTweaksProgressIndicator.ps1 +++ /dev/null @@ -1,39 +0,0 @@ -function Set-WinUtilTweaksProgressIndicator { - <# - .SYNOPSIS - Shows, updates, or hides the window-level progress indicator used by long-running - workflows such as app management, Tweaks, AppX management, and Win11 Creator. - It lives outside the TabControl, so it stays visible no matter which tab is active. - .PARAMETER Visible - Whether the indicator should be shown or hidden. - .PARAMETER Label - The text to display above the progress bar. - .PARAMETER Percent - The percentage of the progress bar that should be filled (0-100). - #> - param( - [bool]$Visible, - [string]$Label, - [ValidateRange(0,100)] - [int]$Percent - ) - - if ($null -eq $sync.form -or $null -eq $sync.form.Dispatcher) { - return - } - - $indicatorVisible = if ($Visible) { [Windows.Visibility]::Visible } else { [Windows.Visibility]::Collapsed } - $indicatorLabel = $Label - $hasLabel = $PSBoundParameters.ContainsKey('Label') - $hasPercent = $PSBoundParameters.ContainsKey('Percent') - - Invoke-WPFUIThread -ScriptBlock { - $sync.WPFTweaksProgressBar.Visibility = $indicatorVisible - if ($hasLabel) { - $sync.WPFTweaksProgressLabel.Text = $indicatorLabel - } - if ($hasPercent) { - $sync.WPFTweaksProgressValue.Value = $Percent - } - } -} diff --git a/functions/private/Show-WinUtilMessage.ps1 b/functions/private/Show-WinUtilMessage.ps1 index f52256ab3d..4ed8f4ae8a 100644 --- a/functions/private/Show-WinUtilMessage.ps1 +++ b/functions/private/Show-WinUtilMessage.ps1 @@ -2,6 +2,14 @@ function Show-WinUtilMessage { <# .SYNOPSIS Shows a WinUtil message box and returns the selected result. + + .DESCRIPTION + Message boxes need the interface thread, so this marshals onto it and can therefore be + called from a job body as well as from an event handler. Every prompt is also written to + the session log so the log shows what the user was asked and not just what happened next. + + With no window there is nobody to click, so nothing is shown. A modal put up in that + state never returns and takes the worker with it. #> param ( [string]$Message, @@ -10,5 +18,24 @@ function Show-WinUtilMessage { $Icon = "Information" ) - [System.Windows.MessageBox]::Show($Message, $Title, $Button, $Icon) + Write-WinUtilLog -Component "Dialog" -Message "$Title : $($Message -replace '\r?\n', ' ')" + + if (-not (Test-WinUtilUIAlive)) { + # Anything with a choice is answered with the one that does not go ahead, so a prompt + # nobody saw can never stand in for consent + $unattended = if ("$Button" -eq "OK") { "OK" } else { "No" } + Write-WinUtilLog -Level "WARN" -Component "Dialog" -Message "No window to ask on, answering '$unattended' for: $Title" + return $unattended + } + + return Invoke-WPFUIThread -PassThru -Parameters @{ + Message = $Message + Title = $Title + Button = $Button + Icon = $Icon + } -ScriptBlock { + param($Message, $Title, $Button, $Icon) + + [System.Windows.MessageBox]::Show($Message, $Title, $Button, $Icon) + } } diff --git a/functions/private/Start-WinUtilAssetRendering.ps1 b/functions/private/Start-WinUtilAssetRendering.ps1 new file mode 100644 index 0000000000..f634e3c159 --- /dev/null +++ b/functions/private/Start-WinUtilAssetRendering.ps1 @@ -0,0 +1,36 @@ +function Start-WinUtilAssetRendering { + <# + .SYNOPSIS + Renders the taskbar overlay bitmaps on a thread of their own + + .DESCRIPTION + Rasterising the overlays costs the interface thread time it could spend getting the + window up. The bitmaps are frozen before publication, so they can be built anywhere. + + Nothing waits on this: if the render has not finished when an overlay is asked for, + Set-WinUtilTaskbaritem renders it in place. + + Needs STA for RenderTargetBitmap, which the shared worker pool is not. + #> + + $runspace = [runspacefactory]::CreateRunspace((New-WinUtilSessionState)) + $runspace.ApartmentState = "STA" + $runspace.ThreadOptions = "ReuseThread" + $runspace.Open() + + $shell = [powershell]::Create() + $shell.Runspace = $runspace + [void]$shell.AddScript({ + Measure-WinUtilStep -Scope "UI" -Name "render taskbar overlays (off thread)" -ScriptBlock { + Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $true -IncludeStatusAssets $true + } + }) + + $handle = $shell.BeginInvoke() + + # One STA runspace for the app's lifetime: disposing it from the cleanup callback would mean + # reshaping the compiled helper type, which is built once per session. + Register-WinUtilRunspaceCleanup -PowerShell $shell -Handle $handle + + return $handle +} diff --git a/functions/private/Start-WinUtilBackgroundQueue.ps1 b/functions/private/Start-WinUtilBackgroundQueue.ps1 new file mode 100644 index 0000000000..eb6b9fec6b --- /dev/null +++ b/functions/private/Start-WinUtilBackgroundQueue.ps1 @@ -0,0 +1,156 @@ +function Start-WinUtilBackgroundQueue { + <# + .SYNOPSIS + Drains a queue of interface work one item at a time, between the things the user does + + .DESCRIPTION + For work that must run on the interface thread but that nobody waits on: unopened + tabs, app list entries. One item per queued operation, so input is answered between + them instead of after the whole list. + + Re-posted rather than looped: only returning to the dispatcher lets it service input. + Posted as a compiled action rather than through Invoke-WPFUIThread, whose body + crosses runspaces as text and would recompile on each of the hundreds of posts a full + app list costs. + + .PARAMETER Name + Identifies the queue in $sync so a re-posted pump finds its state. + + .PARAMETER Queue + The queue to drain. Items mean whatever Step says they mean. + + .PARAMETER Step + Runs one item. Receives the dequeued item. + + .PARAMETER OnComplete + Runs once on the interface thread after the last item. + + .PARAMETER RequiresTab + Work drawing into this tab waits while another tab is shown. + + .PARAMETER DeferWhile + Extra reason to hold off, tested each round. Lets a more urgent queue go first. + #> + param( + [Parameter(Mandatory)] + [string]$Name, + + [Parameter(Mandatory)] + $Queue, + + [Parameter(Mandatory)] + [scriptblock]$Step, + + [scriptblock]$OnComplete, + + [string]$RequiresTab, + + [scriptblock]$DeferWhile + ) + + if ($null -eq $sync.BackgroundQueues) { + $sync.BackgroundQueues = [hashtable]::Synchronized(@{}) + } + + $sync.BackgroundQueues[$Name] = @{ + Queue = $Queue + Step = $Step + OnComplete = $OnComplete + RequiresTab = $RequiresTab + DeferWhile = $DeferWhile + } + + # No window means no dispatcher to spread over and nothing competing for the thread + if (-not (Test-WinUtilUIAlive)) { + while ($Queue.Count -gt 0) { + # One failing item must not abandon the rest or strand the state, matching the + # dispatcher path + try { + & $Step $Queue.Dequeue() + } catch { + Write-WinUtilErrorRecord -ErrorRecord $_ -Component "UI" -Context "Background queue '$Name'" + } + } + if ($OnComplete) { & $OnComplete } + $sync.BackgroundQueues.Remove($Name) + return + } + + Request-WinUtilBackgroundQueueStep -Name $Name +} + +function Request-WinUtilBackgroundQueueStep { + <# + .SYNOPSIS + Posts the next step of a queue at background priority + + .PARAMETER Name + Which queue to advance. + #> + param( + [Parameter(Mandatory)] + [string]$Name + ) + + if (-not (Test-WinUtilUIAlive)) { + return + } + + # The name travels as the dispatcher's argument, not captured: this function has returned by + # the time the block runs, and a closure would bind command lookup to a copied scope. + $null = $sync.Form.Dispatcher.BeginInvoke( + [System.Windows.Threading.DispatcherPriority]::Background, + [System.Windows.Threading.DispatcherOperationCallback]{ + param($QueueName) + Invoke-WinUtilBackgroundQueueStep -Name $QueueName + return $null + }, + $Name) +} + +function Invoke-WinUtilBackgroundQueueStep { + <# + .SYNOPSIS + Runs one item of a queue and asks for the next, or finishes + + .PARAMETER Name + Which queue to advance. + #> + param( + [Parameter(Mandatory)] + [string]$Name + ) + + $state = $sync.BackgroundQueues[$Name] + if ($null -eq $state) { + return + } + + if ($state.Queue.Count -gt 0) { + $defer = (Test-WinUtilDeferBackgroundWork -RequiresTab $state.RequiresTab) -or + ($state.DeferWhile -and (& $state.DeferWhile)) + + # Waits rather than competing with whatever the user is doing + if ($defer) { + Invoke-WinUtilWhenIdle -Argument $Name -Callback { + param($QueueName) + Invoke-WinUtilBackgroundQueueStep -Name $QueueName + } + return + } + + try { + & $state.Step $state.Queue.Dequeue() + } catch { + Write-WinUtilErrorRecord -ErrorRecord $_ -Component "UI" -Context "Background queue '$Name'" + } + } + + if ($state.Queue.Count -gt 0) { + Request-WinUtilBackgroundQueueStep -Name $Name + return + } + + $sync.BackgroundQueues.Remove($Name) + if ($state.OnComplete) { & $state.OnComplete } +} diff --git a/functions/private/Start-WinUtilInstallAppRendering.ps1 b/functions/private/Start-WinUtilInstallAppRendering.ps1 index 1290de0eb2..64826b3a0c 100644 --- a/functions/private/Start-WinUtilInstallAppRendering.ps1 +++ b/functions/private/Start-WinUtilInstallAppRendering.ps1 @@ -4,8 +4,35 @@ function Invoke-WinUtilInstallAppRenderBatch { $CategoryBatch ) - foreach ($appKey in $CategoryBatch.AppKeys) { - $sync.$appKey = Initialize-InstallAppEntry -TargetElement $CategoryBatch.TargetElement -AppKey $appKey + # A count is not a unit of time. How long a fixed number of entries takes depends on the + # machine and on the category, so the pass runs to a deadline instead and hands back + # whatever it did not reach. That caps how long a click can be left waiting. + $budgetMs = 25 + $keys = @($CategoryBatch.AppKeys) + + # The count is the step's return value rather than a variable the loop updates: a scriptblock + # runs in a child scope, so assigning to an outer variable from inside it silently writes to + # a copy, and the pass would re-queue everything it had just drawn. + $rendered = Measure-WinUtilStep -Scope "AppRender" -Name $CategoryBatch.Category -ScriptBlock { + $clock = [System.Diagnostics.Stopwatch]::StartNew() + $done = 0 + foreach ($appKey in $keys) { + $sync.$appKey = Initialize-InstallAppEntry -TargetElement $CategoryBatch.TargetElement -AppKey $appKey + $done++ + # at least one per pass, or a slow machine would never finish the list + if ($clock.ElapsedMilliseconds -ge $budgetMs) { + break + } + } + $done + } + + if ($rendered -lt $keys.Count) { + $sync.InstallAppRenderQueue.Enqueue([pscustomobject]@{ + Category = $CategoryBatch.Category + TargetElement = $CategoryBatch.TargetElement + AppKeys = @($keys[$rendered..($keys.Count - 1)]) + }) } # Entries render in batches, so a filter that is already active has to be applied to each new @@ -21,23 +48,7 @@ function Invoke-WinUtilInstallAppRenderBatch { function Complete-WinUtilInstallAppRendering { $sync.InstallAppEntriesRendered = $true -} - -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 { @@ -47,18 +58,15 @@ function Start-WinUtilInstallAppRendering { $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 + Start-WinUtilBackgroundQueue -Name "InstallAppRender" -Queue $sync.InstallAppRenderQueue ` + -RequiresTab "Install" ` + -Step { param($CategoryBatch) Invoke-WinUtilInstallAppRenderBatch -CategoryBatch $CategoryBatch } ` + -OnComplete { Complete-WinUtilInstallAppRendering } ` + -DeferWhile { + # Tabs that have never been built come first. This list is already on screen and + # filling in, while another tab is empty until it is built, so a click on one costs + # the whole build. The list finishing a little later is not felt; a tab that takes + # half a second to open is. + $sync.TabWarmupQueue -and $sync.TabWarmupQueue.Count -gt 0 + } } diff --git a/functions/private/Start-WinUtilJob.ps1 b/functions/private/Start-WinUtilJob.ps1 new file mode 100644 index 0000000000..36e247f881 --- /dev/null +++ b/functions/private/Start-WinUtilJob.ps1 @@ -0,0 +1,202 @@ +function Start-WinUtilJob { + <# + .SYNOPSIS + Runs a long operation off the UI thread with the shared progress, taskbar, log and + error handling applied around it + + .DESCRIPTION + One job at a time. Owns the busy flag, the progress bar, the taskbar item, the + console banner and the log lines for the job's lifetime. The body does the work and + calls Step-WinUtilJob; it must not print a banner or set the busy flag itself. A body + that throws is caught and the interface restored in a finally, so a failure cannot + leave the UI stuck busy. + + .PARAMETER Name + Log component and progress text, for example Install. + + .PARAMETER ScriptBlock + The work. Receives Parameters as named parameters. + + .PARAMETER Parameters + Values passed to the body by name. + + .PARAMETER Description + Progress text shown while the job starts. Defaults to the job name. + + .PARAMETER DisableAppList + Greys out the app list for the duration, for jobs that change what is installed. + + .EXAMPLE + Start-WinUtilJob -Name "Install" -Parameters @{ Packages = $packages } -ScriptBlock { + param($Packages) + Step-WinUtilJob -Status "Installing" -Percent 10 + } + #> + param( + [Parameter(Mandatory)] + [string]$Name, + + [Parameter(Mandatory)] + [scriptblock]$ScriptBlock, + + [hashtable]$Parameters = @{}, + + [string]$Description, + + [switch]$DisableAppList + ) + + if ($sync.ShuttingDown -or $sync.FinishInConsole) { + Write-WinUtilLog -Level "WARN" -Component $Name -Message "Refused to start $Name, WinUtil is closing." + return $null + } + + # A nested job runs inline: the outer already owns the slot and the reporting, so claiming + # again would refuse it and skip its work. Feature installs arrive twice, from feature.json + # and from Invoke-WPFFeatureInstall. + if ($global:WinUtilIsJobWorker) { + & $ScriptBlock @Parameters + return $null + } + + # Locked: a headless or scheduled caller is not serialised by the dispatcher, where + # test-then-assign lets two jobs both own the slot. The token identifies the run, so a worker + # still unwinding cannot release a slot the next job holds. + $jobToken = [guid]::NewGuid().ToString() + $blockedBy = $null + [System.Threading.Monitor]::Enter($sync.SyncRoot) + try { + if ($sync.ActiveJob) { + $blockedBy = $sync.ActiveJob + } else { + $sync.ActiveJob = $Name + $sync.ActiveJobToken = $jobToken + } + } finally { + [System.Threading.Monitor]::Exit($sync.SyncRoot) + } + + if ($blockedBy) { + Show-WinUtilMessage -Message "$blockedBy is still running. Wait for it to finish before starting another action." -Title "WinUtil" -Button "OK" -Icon "Warning" | Out-Null + return $null + } + + $label = if ($Description) { $Description } else { $Name } + Write-WinUtilLog -Component $Name -Message "$Name job started." + Write-WinUtilJobBanner -Message $label + Step-WinUtilJob -Status "$label..." -Percent 0 -State "Normal" -Overlay "logo" + + if ($DisableAppList -and (Test-WinUtilUIAlive)) { + Invoke-WPFUIThread -ScriptBlock { + if ($null -ne $sync.ItemsControl) { $sync.ItemsControl.IsEnabled = $false } + } + } + + # Rebuilt from its text inside the runspace: a scriptblock carries the session state it was + # defined in, and recreating it there binds it to the worker. The handle is discarded, + # printing it puts an IAsyncResult table on the console on every button press. + $null = Invoke-WPFRunspace -ParameterList @( + ("JobName", $Name), + ("JobLabel", $label), + ("JobBody", $ScriptBlock.ToString()), + ("JobParameters", $Parameters), + ("JobRestoresAppList", [bool]$DisableAppList), + ("JobToken", $jobToken) + ) -ScriptBlock { + param($JobName, $JobLabel, $JobBody, $JobParameters, $JobRestoresAppList, $JobToken) + + # Marks this runspace as the one doing the work, so a pause holds here and not in + # whoever asked for it + $global:WinUtilIsJobWorker = $true + + $jobClock = [System.Diagnostics.Stopwatch]::StartNew() + $errorsBefore = if ($sync.LoggedErrors) { $sync.LoggedErrors.Count } else { 0 } + try { + $body = [scriptblock]::Create($JobBody) + + # A worker's warning and error streams buffer on a PowerShell object nobody reads. + # Merging them into the output stream is what gets them to the log. + & $body @JobParameters 2>&1 3>&1 | ForEach-Object { + if ($_ -is [System.Management.Automation.WarningRecord]) { + Write-WinUtilLog -Level "WARN" -Component $JobName -Message $_.Message + } elseif ($_ -is [System.Management.Automation.ErrorRecord]) { + Write-WinUtilErrorRecord -ErrorRecord $_ -Component $JobName -Context "Non-terminating error" + } + } + + $jobClock.Stop() + + # A step can fail without throwing, for example a registry write refused by policy + $newErrors = if ($sync.LoggedErrors) { $sync.LoggedErrors.Count - $errorsBefore } else { 0 } + if ($newErrors -gt 0) { + Write-WinUtilLog -Level "WARN" -Component $JobName -Message "$JobName job finished in $($jobClock.ElapsedMilliseconds) ms with $newErrors error(s)." + Write-WinUtilJobBanner -Message "$JobLabel finished with $newErrors error(s), see the log" -Level "ERROR" + Step-WinUtilJob -Status "$JobName finished with $newErrors error(s)" -Percent 100 -State "Paused" -Overlay "warning" + } else { + Write-WinUtilLog -Component $JobName -Message "$JobName job finished in $($jobClock.ElapsedMilliseconds) ms." + Write-WinUtilJobBanner -Message "$JobLabel finished" + Step-WinUtilJob -Status "$JobName finished" -Percent 100 -State "None" -Overlay "checkmark" + } + } catch { + $jobClock.Stop() + Write-WinUtilErrorRecord -ErrorRecord $_ -Component $JobName -Context "$JobName failed after $($jobClock.ElapsedMilliseconds) ms" + Write-WinUtilJobBanner -Message "$JobLabel failed: $($_.Exception.Message)" -Level "ERROR" + Step-WinUtilJob -Status "$JobName failed" -Percent 100 -State "Error" -Overlay "warning" + } finally { + # Pool runspaces are reused, so leaving this set would make the next piece of + # background work on this runspace believe it is a job worker + $global:WinUtilIsJobWorker = $false + + Write-WinUtilTimingSummary -Scope $JobName -TotalMilliseconds $jobClock.ElapsedMilliseconds + + # A worker the watchdog cut off can reach here after the next job claimed the slot, + # and everything below releases shared state. + $stillOwns = $false + [System.Threading.Monitor]::Enter($sync.SyncRoot) + try { + $stillOwns = $sync.ActiveJobToken -eq $JobToken + } finally { + [System.Threading.Monitor]::Exit($sync.SyncRoot) + } + + if ($stillOwns) { + if ($JobRestoresAppList -and (Test-WinUtilUIAlive)) { + Invoke-WPFUIThread -ScriptBlock { + if ($null -ne $sync.ItemsControl) { $sync.ItemsControl.IsEnabled = $true } + } + } + + # Last, because the main thread may be waiting on it to know the run is over + $null = Clear-WinUtilActiveJob -Token $JobToken + } else { + Write-WinUtilLog -Level "WARN" -Component $JobName -Message "$JobName unwound after another job had started; leaving its state alone." + } + } + } +} + +function Clear-WinUtilActiveJob { + <# + .SYNOPSIS + Releases the active job slot, clearing its name and its token together + + .DESCRIPTION + A token left set still matches a later run and blocks new work. + + .PARAMETER Token + Release only if this run still owns the slot. Omit to release unconditionally. + #> + param([string]$Token) + + [System.Threading.Monitor]::Enter($sync.SyncRoot) + try { + if (-not $Token -or $sync.ActiveJobToken -eq $Token) { + $sync.ActiveJobToken = $null + $sync.ActiveJob = $null + return $true + } + return $false + } finally { + [System.Threading.Monitor]::Exit($sync.SyncRoot) + } +} diff --git a/functions/private/Start-WinUtilTabWarmup.ps1 b/functions/private/Start-WinUtilTabWarmup.ps1 new file mode 100644 index 0000000000..d42b6415ad --- /dev/null +++ b/functions/private/Start-WinUtilTabWarmup.ps1 @@ -0,0 +1,38 @@ +function Start-WinUtilTabWarmup { + <# + .SYNOPSIS + Builds the tabs the user has not opened yet, while the interface is idle + + .DESCRIPTION + Tab content has to be built on the interface thread, so a tab that is still empty + when it is first clicked makes that click pay for the build. Queueing the builds + moves that cost to where nothing is waiting on it. + + Queued at background priority rather than idle priority. At idle priority this never + ran until the app list had finished, which is the exact window in which a tab the + user clicks is still empty and costs a full build to open. + #> + + # Win11ISO is left out: building it runs the existing work check, which raises the resume + # prompt while the user is on another tab. That check belongs to opening the tab, not warming + # it. + $pending = [System.Collections.Queue]::new() + foreach ($tab in @("Tweaks", "Config", "AppX")) { + if (-not $sync.InitializedTabs[$tab]) { + $pending.Enqueue($tab) + } + } + + if ($pending.Count -eq 0) { + return + } + + $sync.TabWarmupQueue = $pending + Start-WinUtilBackgroundQueue -Name "TabWarmup" -Queue $pending -Step { + param($Tab) + + Measure-WinUtilStep -Scope "UI" -Name "warm $Tab tab" -ScriptBlock { + Initialize-WinUtilTabContent -TabName $Tab -Yield + } + } +} diff --git a/functions/private/Start-WinUtilUserInterface.ps1 b/functions/private/Start-WinUtilUserInterface.ps1 new file mode 100644 index 0000000000..27df41eeff --- /dev/null +++ b/functions/private/Start-WinUtilUserInterface.ps1 @@ -0,0 +1,520 @@ +function Start-WinUtilUserInterface { + <# + .SYNOPSIS + Builds the WinUtil window, wires its event handlers and runs it to completion + + .DESCRIPTION + This is the whole interface. It runs on the dedicated STA interface runspace that + main.ps1 starts, so the thread that owns the window does nothing but paint and + dispatch: every long operation goes to the worker pool through Start-WinUtilJob. + + The call blocks until the window is closed, and the interface runspace is the only + place that is allowed to touch controls directly. + #> + + $buildClock = [System.Diagnostics.Stopwatch]::StartNew() + + Measure-WinUtilStep -Scope "UI" -Name "load WPF assemblies" -ScriptBlock { + [void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework') + } + + [xml]$XAML = $inputXML + + # Read the XAML file + $readerOperationSuccessful = $false # There's more cases of failure then success. + $reader = (New-Object System.Xml.XmlNodeReader $xaml) + try { + Measure-WinUtilStep -Scope "UI" -Name "parse XAML" -ScriptBlock { + $sync["Form"] = [Windows.Markup.XamlReader]::Load( $reader ) + } + $readerOperationSuccessful = $true + } catch [System.Management.Automation.MethodInvocationException] { + Write-Host "We ran into a problem with the XAML code. Check the syntax for this control..." -ForegroundColor Red + Write-Host $error[0].Exception.Message -ForegroundColor Red + + If ($error[0].Exception.Message -like "*button*") { + write-Host "Ensure your <button in the `$inputXML does NOT have a Click=ButtonClick property. PS can't handle this`n`n`n`n" -ForegroundColor Red + } + } catch { + Write-Host "Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed." -ForegroundColor Red + } + + if (-NOT ($readerOperationSuccessful)) { + Write-Host "Failed to parse xaml content using Windows.Markup.XamlReader's Load Method." -ForegroundColor Red + Write-Host "Quitting WinUtil..." -ForegroundColor Red + Write-WinUtilLog -Level "ERROR" -Component "UI" -Message "Failed to parse the XAML content. WinUtil cannot start." + return + } + + # Setup the Window to follow listen for windows Theme Change events and update the winutil theme + # throttle logic needed, because windows seems to send more than one theme change event per change + $themeState = @{ LastChange = [datetime]::MinValue } + $debounceInterval = [timespan]::FromSeconds(2) + $sync.Form.Add_Loaded({ + $interopHelper = New-Object System.Windows.Interop.WindowInteropHelper $sync.Form + $hwndSource = [System.Windows.Interop.HwndSource]::FromHwnd($interopHelper.Handle) + $hwndSource.AddHook({ + param ( + [System.IntPtr]$hwnd, + [int]$msg, + [System.IntPtr]$wParam, + [System.IntPtr]$lParam, + [ref]$handled + ) + $null = $hwnd, $wParam, $lParam + # Check for the Event WM_SETTINGCHANGE (0x1001A) and validate that Button shows the icon for "Auto" => [char]0xF08C + if (($msg -eq 0x001A) -and $sync.ThemeButton.Content -eq [char]0xF08C) { + $currentTime = [datetime]::Now + if ($currentTime - $themeState.LastChange -gt $debounceInterval) { + Invoke-WinutilThemeChange -theme "Auto" + $themeState.LastChange = $currentTime + # [ref] out-parameter: assigning to $handled would only replace the local + $handled.Value = $true + } + } + return 0 + }) + }) + + Measure-WinUtilStep -Scope "UI" -Name "apply theme" -ScriptBlock { + Invoke-WinutilThemeChange -theme $sync.preferences.theme + } + + # No tab content is built before first paint. Invoke-WPFTab builds whichever tab it + # activates, and ContentRendered activates the default one. + $sync.InitializedTabs = @{} + + #=========================================================================== + # Store Form Objects In PowerShell + #=========================================================================== + + Measure-WinUtilStep -Scope "UI" -Name "map named controls" -ScriptBlock { + $xaml.SelectNodes("//*[@Name]") | ForEach-Object {$sync["$("$($psitem.Name)")"] = $sync["Form"].FindName($psitem.Name)} + } + + # Built here so it carries this runspace's session state: posted work then runs as ordinary + # interface code, not a much slower cross-runspace nested pipeline. Invoke-WPFUIThread is the + # caller-facing side. + $sync.UIDispatchDelegate = [System.Func[object, object]]{ + param($Work) + + try { + $body = [scriptblock]::Create($Work.Body) + $parameters = $Work.Parameters + if ($parameters -and $parameters.Count -gt 0) { + & $body @parameters + } else { + & $body + } + } catch { + # An unhandled failure here would surface as a dispatcher exception with no trace + # back to the work that caused it + Write-WinUtilErrorRecord -ErrorRecord $_ -Component "UI" -Context "Interface work" + } + } + + Measure-WinUtilStep -Scope "UI" -Name "wire static button clicks" -ScriptBlock { + # CheckBox and RadioButton also derive from ButtonBase, so the exact type name is what + # decides, not -is + $clickableTypes = [System.Collections.Generic.HashSet[string]]::new([string[]]@("Button", "ToggleButton"), [StringComparer]::OrdinalIgnoreCase) + $alreadyWired = [System.Collections.Generic.HashSet[string]]::new([string[]]@($sync.Buttons), [StringComparer]::OrdinalIgnoreCase) + + $clickHandler = { + [System.Object]$Sender = $args[0] + Invoke-WPFButton $Sender.name + } + + foreach ($entry in @($sync.GetEnumerator())) { + $control = $entry.Value + if ($null -eq $control -or -not $clickableTypes.Contains($control.GetType().Name)) { + continue + } + if (-not $alreadyWired.Add([string]$entry.Key)) { + continue + } + + $control.Add_Click($clickHandler) + $sync.Buttons.Add($entry.Key) | Out-Null + } + } + + #=========================================================================== + # Setup and Show the Form + #=========================================================================== + + # Progress bar in taskbaritem > Set-WinUtilProgressbar + $sync["Form"].TaskbarItemInfo = New-Object System.Windows.Shell.TaskbarItemInfo + Set-WinUtilTaskbaritem -state "None" + + # Wired before the window is shown, so work queued during startup already knows to stand + # aside for anything the user does + Register-WinUtilInputWatch + + # Set the titlebar + $sync["Form"].title = $sync["Form"].title + " " + $sync.version + # Set the commands that will run when the form is closed + $sync["Form"].Add_Closing({ + param($eventSender, $closingArgs) + + # The pool cannot be torn down under work that is still running: the runspace error that + # follows is unhandled and ends the process + if ($sync.ActiveJob -and -not $sync.ForceClose) { + $closingArgs.Cancel = $true + Invoke-WinUtilCloseRequest -RunningJob $sync.ActiveJob + return + } + + # Work that is meant to outlive the window needs the pool it is running on. main.ps1 + # waits for it and shuts the pool down once it is done. + if ($sync.FinishInConsole) { + Write-WinUtilLog -Component "UI" -Message "Window closing, leaving $($sync.ActiveJob) to finish in the console." + return + } + + Write-WinUtilLog -Component "UI" -Message "Window closing, shutting down the worker pool." + Close-WinUtilRunspacePool + [System.GC]::Collect() + }) + + # Attach the event handler to the Click event + $sync.SearchBarClearButton.Add_Click({ + $sync.SearchBar.Text = "" + $sync.SearchBarClearButton.Visibility = "Collapsed" + + # Focus the search bar after clearing the text + $sync.SearchBar.Focus() + $sync.SearchBar.SelectAll() + }) + + # add some shortcuts for people that don't like clicking + function Invoke-WinUtilFontScaleStep([double]$Step) { $sync.FontScalingSlider.Value = [math]::Max(0.75, [math]::Min(2.0, $sync.FontScalingSlider.Value + $Step)); Invoke-WinUtilFontScaling -ScaleFactor $sync.FontScalingSlider.Value } + + $commonKeyEvents = { + if ($sync.ActiveJob) { + return + } + + # Handle key presses of single keys + switch ($_.Key) { + "Escape" { $sync.SearchBar.Text = "" } + } + # Handle Alt key combinations for navigation + if ($_.KeyboardDevice.Modifiers -eq "Alt") { + $keyEventArgs = $_ + switch ($_.SystemKey) { + "I" { Invoke-WPFButton "WPFTab1BT"; $keyEventArgs.Handled = $true } # Navigate to Install tab and suppress Windows Warning Sound + "T" { Invoke-WPFButton "WPFTab2BT"; $keyEventArgs.Handled = $true } # Navigate to Tweaks tab + "C" { Invoke-WPFButton "WPFTab3BT"; $keyEventArgs.Handled = $true } # Navigate to Config tab + "U" { Invoke-WPFButton "WPFTab4BT"; $keyEventArgs.Handled = $true } # Navigate to Updates tab + "W" { Invoke-WPFButton "WPFTab5BT"; $keyEventArgs.Handled = $true } # Navigate to Win11ISO tab + } + } + # Handle Ctrl key combinations for specific actions + if ($_.KeyboardDevice.Modifiers -eq "Ctrl") { + $keyEventArgs = $_ + switch ($_.Key) { + "F" { $sync.SearchBar.Focus() } # Focus on the search bar + "Q" { $this.Close() } # Close the application + } + } + $ctrlShiftModifiers = [Windows.Input.ModifierKeys]::Control -bor [Windows.Input.ModifierKeys]::Shift + if ($_.KeyboardDevice.Modifiers -eq "Ctrl" -or $_.KeyboardDevice.Modifiers -eq $ctrlShiftModifiers) { + $keyEventArgs = $_ + switch ($_.Key) { + { $_ -in "OemPlus", "Add" } { Invoke-WinUtilFontScaleStep 0.05; $keyEventArgs.Handled = $true } + { $_ -in "OemMinus", "Subtract" } { Invoke-WinUtilFontScaleStep -0.05; $keyEventArgs.Handled = $true } + } + } + } + $sync["Form"].Add_PreViewKeyDown($commonKeyEvents) + $sync["Form"].Add_PreviewMouseWheel({ + if ([Windows.Input.Keyboard]::Modifiers -eq "Ctrl") { Invoke-WinUtilFontScaleStep $(if ($_.Delta -gt 0) { 0.05 } else { -0.05 }); $_.Handled = $true } + }) + + $sync["Form"].Add_MouseLeftButtonDown({ + Invoke-WPFPopup -Action "Hide" -Popups @("Settings", "Theme", "FontScaling") + $sync["Form"].DragMove() + }) + + $sync["Form"].Add_MouseDoubleClick({ + if ($_.OriginalSource.Name -eq "NavDockPanel" -or + $_.OriginalSource.Name -eq "GridBesideNavDockPanel") { + if ($sync["Form"].WindowState -eq [Windows.WindowState]::Normal) { + [Windows.SystemCommands]::MaximizeWindow($sync.Form) + } + else{ + [Windows.SystemCommands]::RestoreWindow($sync.Form) + } + } + }) + + $sync["Form"].Add_Deactivated({ + Invoke-WPFPopup -Action "Hide" -Popups @("Settings", "Theme", "FontScaling") + }) + + $sync["Form"].Add_ContentRendered({ + # Load the Windows Forms assembly + Add-Type -AssemblyName System.Windows.Forms + $primaryScreen = [System.Windows.Forms.Screen]::PrimaryScreen + # Check if the primary screen is found + if ($primaryScreen) { + # Extract screen width and height for the primary monitor + $screenWidth = $primaryScreen.Bounds.Width + $screenHeight = $primaryScreen.Bounds.Height + $sync.Form.MinWidth = [Math]::Min([double]$sync.Form.MinWidth, [double]$screenWidth) + + # Compare with the primary monitor size + if ($sync.Form.ActualWidth -gt $screenWidth -or $sync.Form.ActualHeight -gt $screenHeight) { + $sync.Form.Left = 0 + $sync.Form.Top = 0 + $sync.Form.Width = $screenWidth + $sync.Form.Height = $screenHeight + } + } + + if ($PARAM_OFFLINE) { + # Show offline banner + $sync.WPFOfflineBanner.Visibility = [System.Windows.Visibility]::Visible + + # Disable the install tab + $sync.WPFTab1BT.IsEnabled = $false + $sync.WPFTab1BT.Opacity = 0.5 + $sync.WPFTab1BT.ToolTip = "Internet connection required for installing applications." + + # The install action buttons are generated with the Install tab, so + # Initialize-WinUtilInstallTabControls disables them when that tab is built + + # Show offline indicator + Write-Host "Offline mode detected - Install tab disabled." -ForegroundColor Yellow + + # Optionally switch to a different tab if install tab was going to be default + Invoke-WPFTab "WPFTab2BT" -Yield # Switch to Tweaks tab instead + } + else { + # Online - ensure install tab is enabled + $sync.WPFTab1BT.IsEnabled = $true + $sync.WPFTab1BT.Opacity = 1.0 + $sync.WPFTab1BT.ToolTip = $null + Invoke-WPFTab "WPFTab1BT" -Yield # Default to install tab + } + + $sync["Form"].Focus() + $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Initialize-WinUtilRunspacePool | Out-Null }) | Out-Null + $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ + Set-WinUtilTaskbaritem -overlay "logo" + }) | Out-Null + $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Start-WinUtilTabWarmup }) | Out-Null + }) + + # The SearchBarTimer is used to delay the search operation until the user has stopped typing for a short period + # This prevents the ui from stuttering when the user types quickly as it dosnt need to update the ui for every keystroke + + $searchBarTimer = New-Object System.Windows.Threading.DispatcherTimer + $searchBarTimer.Interval = [TimeSpan]::FromMilliseconds(300) + $searchBarTimer.IsEnabled = $false + + $searchBarTimer.add_Tick({ + $searchBarTimer.Stop() + switch ($sync.currentTab) { + "Install" { + Find-AppsByNameOrDescription -SearchString $sync.SearchBar.Text -Categories $sync.SelectedAppCategories.ToArray() + } + "Tweaks" { + Find-TweaksByNameOrDescription -SearchString $sync.SearchBar.Text + } + "AppX" { + Find-TweaksByNameOrDescription -SearchString $sync.SearchBar.Text + } + } + }) + $sync["SearchBar"].Add_TextChanged({ + if ($sync.SearchBar.Text -ne "") { + $sync.SearchBarClearButton.Visibility = "Visible" + $sync.SearchBarIcon.Visibility = "Collapsed" + } else { + $sync.SearchBarClearButton.Visibility = "Collapsed" + $sync.SearchBarIcon.Visibility = "Visible" + } + + if ($searchBarTimer.IsEnabled) { + $searchBarTimer.Stop() + } + $searchBarTimer.Start() + }) + + # Category filter chips. The chip carries its category in Tag, so one handler covers all of them. + $sync.AppCategoryChips = @( + @{ Name = "WPFSearchChipAll"; Category = "" } + @{ Name = "WPFSearchChipBrowsers"; Category = "Browsers" } + @{ Name = "WPFSearchChipCommunications"; Category = "Communications" } + @{ Name = "WPFSearchChipDevelopment"; Category = "Development" } + @{ Name = "WPFSearchChipDocument"; Category = "Document" } + @{ Name = "WPFSearchChipGames"; Category = "Games" } + @{ Name = "WPFSearchChipMicrosoftTools"; Category = "Microsoft Tools" } + @{ Name = "WPFSearchChipMultimediaTools"; Category = "Multimedia Tools" } + @{ Name = "WPFSearchChipProTools"; Category = "Pro Tools" } + @{ Name = "WPFSearchChipSelfhostedTools"; Category = "Selfhosted Tools" } + @{ Name = "WPFSearchChipUtilities"; Category = "Utilities" } + ) + $sync.SelectedAppCategories = [System.Collections.Generic.List[string]]::new() + + foreach ($appCategoryChip in $sync.AppCategoryChips) { + $sync[$appCategoryChip.Name].Tag = $appCategoryChip.Category + $sync[$appCategoryChip.Name].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this }) + } + + $sync["Form"].Add_Loaded({ + param($e) + $null = $e + $sync.Form.MinWidth = "1150" + $sync["Form"].MaxWidth = [Double]::PositiveInfinity + $sync["Form"].MaxHeight = [Double]::PositiveInfinity + }) + + Measure-WinUtilStep -Scope "UI" -Name "build nav logo" -ScriptBlock { + $NavLogoPanel = $sync["Form"].FindName("NavLogoPanel") + $NavLogoPanel.Children.Add((Invoke-WinUtilAssets -Type "logo" -Size 25)) | Out-Null + } + + $sync["Form"].Add_Activated({ + Set-WinUtilTaskbaritem -overlay "logo" + }) + + $sync["ThemeButton"].Add_Click({ + Invoke-WPFPopup -PopupActionTable @{ "Settings" = "Hide"; "Theme" = "Toggle"; "FontScaling" = "Hide" } + }) + $sync["AutoThemeMenuItem"].Add_Click({ + Invoke-WPFPopup -Action "Hide" -Popups @("Theme") + Invoke-WinutilThemeChange -theme "Auto" + }) + $sync["DarkThemeMenuItem"].Add_Click({ + Invoke-WPFPopup -Action "Hide" -Popups @("Theme") + Invoke-WinutilThemeChange -theme "Dark" + }) + $sync["LightThemeMenuItem"].Add_Click({ + Invoke-WPFPopup -Action "Hide" -Popups @("Theme") + Invoke-WinutilThemeChange -theme "Light" + }) + + $sync["SettingsButton"].Add_Click({ + Invoke-WPFPopup -PopupActionTable @{ "Settings" = "Toggle"; "Theme" = "Hide"; "FontScaling" = "Hide" } + }) + $sync["ImportMenuItem"].Add_Click({ + Invoke-WPFPopup -Action "Hide" -Popups @("Settings") + Invoke-WPFImpex -type "import" + }) + $sync["ExportMenuItem"].Add_Click({ + Invoke-WPFPopup -Action "Hide" -Popups @("Settings") + Invoke-WPFImpex -type "export" + }) + $sync["AboutMenuItem"].Add_Click({ + Invoke-WPFPopup -Action "Hide" -Popups @("Settings") + + $authorInfo = @" +Author : @ChrisTitusTech +UI : @MyDrift-user, @Marterich +Runspace : @DeveloperDurp, @Marterich +GitHub : ChrisTitusTech/winutil +Version : $($sync.version) +"@ + Show-CustomDialog -Title "About" -Message $authorInfo + }) + $sync["DocumentationMenuItem"].Add_Click({ + Invoke-WPFPopup -Action "Hide" -Popups @("Settings") + Start-Process "https://winutil.christitus.com/" + }) + $sync["SponsorMenuItem"].Add_Click({ + Invoke-WPFPopup -Action "Hide" -Popups @("Settings") + + $authorInfo = @" +Current sponsors for ChrisTitusTech: +"@ + $authorInfo += "`n" + try { + $sponsors = Invoke-WinUtilSponsors + foreach ($sponsor in $sponsors) { + $authorInfo += "$sponsor`n" + } + } catch { + $authorInfo += "An error occurred while fetching or processing the sponsors: $_`n" + } + Show-CustomDialog -Title "Sponsors" -Message $authorInfo -EnableScroll $true + }) + + # Font Scaling Event Handlers + $sync["FontScalingButton"].Add_Click({ + Invoke-WPFPopup -PopupActionTable @{ "Settings" = "Hide"; "Theme" = "Hide"; "FontScaling" = "Toggle" } + }) + + $sync["FontScalingSlider"].Add_ValueChanged({ + param($slider) + $percentage = [math]::Round($slider.Value * 100) + $sync.FontScalingValue.Text = "$percentage%" + }) + + $sync["FontScalingResetButton"].Add_Click({ + $sync.FontScalingSlider.Value = 1.0 + $sync.FontScalingValue.Text = "100%" + }) + + $sync["FontScalingApplyButton"].Add_Click({ + $scaleFactor = $sync.FontScalingSlider.Value + Invoke-WinUtilFontScaling -ScaleFactor $scaleFactor + Invoke-WPFPopup -Action "Hide" -Popups @("FontScaling") + }) + + # Win11ISO Tab button handlers + $sync["WPFWin11ISOBrowseButton"].Add_Click({ + Invoke-WinUtilISOBrowse + }) + + $sync["WPFWin11ISODownloadLink"].Add_Click({ + Start-Process "https://www.microsoft.com/software-download/windows11" + }) + + $sync["WPFWin11ISOMountButton"].Add_Click({ + Invoke-WinUtilISOMountAndVerify + }) + + $sync["WPFWin11ISOModifyButton"].Add_Click({ + Invoke-WinUtilISOModify + }) + + $sync["WPFWin11ISOChooseISOButton"].Add_Click({ + $sync["WPFWin11ISOOptionUSB"].Visibility = "Collapsed" + Invoke-WinUtilISOExport + }) + + $sync["WPFWin11ISOChooseUSBButton"].Add_Click({ + $sync["WPFWin11ISOOptionUSB"].Visibility = "Visible" + Invoke-WinUtilISORefreshUSBDrives + }) + + $sync["WPFWin11ISORefreshUSBButton"].Add_Click({ + Invoke-WinUtilISORefreshUSBDrives + }) + + $sync["WPFWin11ISOWriteUSBButton"].Add_Click({ + Invoke-WinUtilISOWriteUSB + }) + + $sync["WPFWin11ISOCleanResetButton"].Add_Click({ + Invoke-WinUtilISOCleanAndReset + }) + + $buildClock.Stop() + Write-WinUtilLog -Component "UI" -Message "Interface built in $($buildClock.ElapsedMilliseconds) ms, showing the window." + Write-WinUtilTimingSummary -Scope "UI" -TotalMilliseconds $buildClock.ElapsedMilliseconds + + # Input priority runs behind everything already queued, so this fires at the first moment + # the window could actually service a click + $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Input, [action]{ + $sinceStart = [int]((Get-Date) - $sync.StartedAt).TotalMilliseconds + Write-WinUtilLog -Component "UI" -Message "timing: interface ready for input $sinceStart ms after start." + }) | Out-Null + + $sync["Form"].ShowDialog() | Out-Null + + # ShowDialog returns once the window is gone; stop the dispatcher so this runspace can close + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.InvokeShutdown() +} diff --git a/functions/private/Step-WinUtilJob.ps1 b/functions/private/Step-WinUtilJob.ps1 new file mode 100644 index 0000000000..b78f6df473 --- /dev/null +++ b/functions/private/Step-WinUtilJob.ps1 @@ -0,0 +1,104 @@ +function Step-WinUtilJob { + <# + .SYNOPSIS + Advances a job to its next reportable point, honouring a pause or stop on the way + + .DESCRIPTION + Every loop calls this, so it is the one point a run reliably passes between steps and + therefore the only place it can be held or ended without cutting into a command in + flight. It blocks while the run is paused and throws OperationCanceledException once + a stop is asked for, so calling it from a finally, or from a catch already reporting + a failure, re-raises that stop. The job layer clears the flags before its own finish + reporting for that reason. + + Drives the progress bar and taskbar item together and does nothing without a window, + so job bodies need no UI checks. The update is posted rather than waited on: a job + reporting per item would otherwise stall on the interface thread each time. + + .PARAMETER Status + Text for the progress label + + .PARAMETER Percent + Completion between 0 and 100 + + .PARAMETER State + Taskbar state. Normal while working, Error on failure, None when finished. + + .PARAMETER Overlay + Taskbar overlay icon: logo, checkmark, warning or None + + .PARAMETER Hide + Clears and hides the progress bar. Used when leaving a finished job behind rather + than while one is running. + #> + param( + [string]$Status, + [int]$Percent = -1, + [ValidateSet("Normal", "Error", "Paused", "Indeterminate", "None")] + [string]$State, + [string]$Overlay, + [switch]$Hide + ) + + # With no window every update is thrown away, and a window closed over running work counts + # as none: its dispatcher accepts posts and discards them. The console is what is left. + if (-not (Test-WinUtilUIAlive)) { + if (-not $Hide) { + Write-WinUtilConsoleProgress -Status $Status -Percent $Percent + } + return + } + + Invoke-WPFUIThread -Async -Parameters @{ + Status = $Status + Percent = [Math]::Min([Math]::Max($Percent, -1), 100) + State = $State + Overlay = $Overlay + HideBar = [bool]$Hide + HasStatus = $PSBoundParameters.ContainsKey('Status') + HasState = $PSBoundParameters.ContainsKey('State') + HasOverlay = $PSBoundParameters.ContainsKey('Overlay') + } -ScriptBlock { + param($Status, $Percent, $State, $Overlay, $HideBar, $HasStatus, $HasState, $HasOverlay) + + if ($HideBar) { + $sync.WPFTweaksProgressBar.Visibility = [Windows.Visibility]::Collapsed + $sync.WPFTweaksProgressLabel.Text = "" + $sync.WPFTweaksProgressLabel.ToolTip = $null + $sync.WPFTweaksProgressValue.Value = 0 + return + } + + $hasPercent = $Percent -ge 0 + + if ($HasStatus -or $hasPercent) { + $sync.WPFTweaksProgressBar.Visibility = [Windows.Visibility]::Visible + } + if ($HasStatus) { + $sync.WPFTweaksProgressLabel.Text = $Status + $sync.WPFTweaksProgressLabel.ToolTip = $Status + } + if ($hasPercent) { + $sync.WPFTweaksProgressValue.Value = $Percent + $sync.Form.TaskbarItemInfo.ProgressValue = $Percent / 100 + } + if ($HasState) { + # Pulse in place at whatever progress has been reached. IsIndeterminate would make + # WPF discard Value and fill the whole bar, which reads as finished. + $sync.WPFTweaksProgressValue.Tag = if ($State -eq "Indeterminate") { "Pulse" } else { $null } + + # By resource reference rather than a fixed brush, so switching theme repaints it + $barColor = switch ($State) { + "Error" { "ProgressBarErrorColor" } + "Paused" { "ProgressBarWarningColor" } + default { "ProgressBarForegroundColor" } + } + $sync.WPFTweaksProgressValue.SetResourceReference([Windows.Controls.Control]::ForegroundProperty, $barColor) + + Set-WinUtilTaskbaritem -state $State + } + if ($HasOverlay) { + Set-WinUtilTaskbaritem -overlay $Overlay + } + } +} diff --git a/functions/private/Stop-WinUtilActiveWork.ps1 b/functions/private/Stop-WinUtilActiveWork.ps1 new file mode 100644 index 0000000000..dd367af657 --- /dev/null +++ b/functions/private/Stop-WinUtilActiveWork.ps1 @@ -0,0 +1,139 @@ +function Test-WinUtilShellRunning { + <# + .SYNOPSIS + Whether one instance is still running, treating a disposed one as finished + #> + param($PowerShell) + + try { + return $PowerShell.InvocationStateInfo.State -eq [System.Management.Automation.PSInvocationState]::Running + } catch { + return $false + } +} + +function Register-WinUtilActiveShell { + <# + .SYNOPSIS + Records a PowerShell instance that is running on the worker pool + + .DESCRIPTION + An instance still queued when the pool closes starts on a closing runspace, throws on + a thread pool thread where nothing catches, and takes the process down with it. + Tracking what is in flight is what lets those be stopped first. + #> + param( + [Parameter(Mandatory)] + $PowerShell + ) + + # Synchronized protects one operation, not a test followed by an assignment, so the + # collection is created under the shared lock + [System.Threading.Monitor]::Enter($sync.SyncRoot) + try { + if ($null -eq $sync.ActiveShells) { + $sync.ActiveShells = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new()) + } + } finally { + [System.Threading.Monitor]::Exit($sync.SyncRoot) + } + + # Nothing disposes these on the way out, so finished ones are dropped here instead of + # accumulating for the life of the session + foreach ($finished in (Get-WinUtilActiveShell)) { + if (-not (Test-WinUtilShellRunning $finished)) { + try { $sync.ActiveShells.Remove($finished) } catch { } + } + } + + $null = $sync.ActiveShells.Add($PowerShell) +} + +function Get-WinUtilActiveShell { + <# + .SYNOPSIS + A snapshot of the tracked instances, copied under the collection's own lock + + .DESCRIPTION + Enumerating a synchronized ArrayList is not itself synchronized; a concurrent Add or + Remove throws mid-loop. SyncRoot is the documented fix. + #> + + if ($null -eq $sync.ActiveShells) { + return @() + } + + [System.Threading.Monitor]::Enter($sync.ActiveShells.SyncRoot) + try { + return @($sync.ActiveShells.ToArray()) + } finally { + [System.Threading.Monitor]::Exit($sync.ActiveShells.SyncRoot) + } +} + +function Stop-WinUtilActiveWork { + <# + .SYNOPSIS + Asks everything running on the worker pool to stop, and waits for it + + .DESCRIPTION + Stop is a request, not a kill: a command already inside an installer runs until it + returns. The wait is bounded so a worker that never returns cannot hold the window + open. + + .PARAMETER TimeoutSeconds + How long to wait before giving up on it. + #> + param( + [int]$TimeoutSeconds = 15, + + # Issue the stop and return. The caller polls Test-WinUtilActiveWorkRunning instead of + # blocking here, which matters on the interface thread where a wait freezes the window. + [switch]$NoWait + ) + + $shells = Get-WinUtilActiveShell + if ($shells.Count -eq 0) { + return $true + } + + Write-WinUtilLog -Component "UI" -Message "Stopping $($shells.Count) running item(s) before closing." + + foreach ($shell in $shells) { + if (Test-WinUtilShellRunning $shell) { + try { $null = $shell.BeginStop($null, $null) } catch { } + } + } + + if ($NoWait) { + return $false + } + + $clock = [System.Diagnostics.Stopwatch]::StartNew() + while ($clock.Elapsed.TotalSeconds -lt $TimeoutSeconds) { + $stillRunning = @(Get-WinUtilActiveShell | Where-Object { Test-WinUtilShellRunning $_ }).Count + + if ($stillRunning -eq 0) { + Write-WinUtilLog -Component "UI" -Message "Everything stopped after $($clock.ElapsedMilliseconds) ms." + return $true + } + + Start-Sleep -Milliseconds 100 + } + + Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Gave up waiting for work to stop after $TimeoutSeconds seconds, closing anyway." + return $false +} + +function Test-WinUtilActiveWorkRunning { + <# + .SYNOPSIS + Whether any tracked instance is still running + #> + + foreach ($shell in (Get-WinUtilActiveShell)) { + if (Test-WinUtilShellRunning $shell) { return $true } + } + + return $false +} diff --git a/functions/private/Test-WinUtilDeferBackgroundWork.ps1 b/functions/private/Test-WinUtilDeferBackgroundWork.ps1 new file mode 100644 index 0000000000..fa3a3f7209 --- /dev/null +++ b/functions/private/Test-WinUtilDeferBackgroundWork.ps1 @@ -0,0 +1,97 @@ +function Register-WinUtilInputWatch { + <# + .SYNOPSIS + Records when the user last did something, so background work can step aside + + .DESCRIPTION + Preview events run before the control handles the input, so the timestamp is set + even for a click the control then spends time on. + #> + + $sync.LastInputAt = [datetime]::MinValue + + $stamp = { $sync.LastInputAt = [datetime]::Now } + $sync.Form.Add_PreviewMouseDown($stamp) + $sync.Form.Add_PreviewKeyDown($stamp) + $sync.Form.Add_PreviewMouseWheel($stamp) +} + +function Test-WinUtilDeferBackgroundWork { + <# + .SYNOPSIS + Whether speculative work should wait rather than run now + + .DESCRIPTION + Background priority queues work behind input but does not make it interruptible: + whatever is running must finish before a click is looked at, which is why the pieces + are kept short. Waits while the user is active, or while the work draws into a tab + that is not on screen. + + .PARAMETER RequiresTab + The tab this work draws into. Work for a hidden tab waits. + #> + param( + [string]$RequiresTab + ) + + if ($sync.LastInputAt) { + $sinceInput = ([datetime]::Now - $sync.LastInputAt).TotalMilliseconds + # long enough to cover a click and the work it starts, short enough not to be noticed + if ($sinceInput -lt 400) { + return $true + } + } + + if ($RequiresTab -and $sync.currentTab -and $sync.currentTab -ne $RequiresTab) { + return $true + } + + return $false +} + +function Invoke-WinUtilWhenIdle { + <# + .SYNOPSIS + Runs a callback once the interface is not being used + + .DESCRIPTION + A one shot timer, not a dispatcher post: a post at background priority runs straight + away and the point is to leave a gap. + + .PARAMETER Callback + What to run once the wait is over. + + .PARAMETER Argument + Passed to the callback. Carried on the timer rather than captured, so the callback + resolves commands where it was written, not in a copied scope. + + .PARAMETER DelayMilliseconds + How long to wait before looking again. + #> + param( + [Parameter(Mandatory)] + [scriptblock]$Callback, + + $Argument, + + [int]$DelayMilliseconds = 150 + ) + + if (-not (Test-WinUtilUIAlive)) { + return + } + + # Bound to the interface dispatcher explicitly: the default picks up the calling thread's, + # which is only correct while every caller reaches here through a UI post + $timer = New-Object System.Windows.Threading.DispatcherTimer([System.Windows.Threading.DispatcherPriority]::Background, $sync.Form.Dispatcher) + $timer.Interval = [timespan]::FromMilliseconds($DelayMilliseconds) + $timer.Tag = @{ Callback = $Callback; Argument = $Argument } + # Sender taken from the argument, matching how the rest of this codebase handles timer ticks + $timer.Add_Tick({ + param($eventSender) + $ticked = [System.Windows.Threading.DispatcherTimer]$eventSender + $ticked.Stop() + & $ticked.Tag.Callback $ticked.Tag.Argument + }) + $timer.Start() +} diff --git a/functions/private/Test-WinUtilPackageManager.ps1 b/functions/private/Test-WinUtilPackageManager.ps1 index ed7546d85b..1e0d0ce4fb 100644 --- a/functions/private/Test-WinUtilPackageManager.ps1 +++ b/functions/private/Test-WinUtilPackageManager.ps1 @@ -19,28 +19,20 @@ function Test-WinUtilPackageManager { if ($winget) { if (Get-Command winget -ErrorAction SilentlyContinue) { - Write-Host "===========================================" -ForegroundColor Green - Write-Host "--- WinGet is installed ---" -ForegroundColor Green - Write-Host "===========================================" -ForegroundColor Green + Write-WinUtilJobBanner -Message "WinGet is installed" $status = "installed" } else { - Write-Host "===========================================" -ForegroundColor Red - Write-Host "--- WinGet is not installed ---" -ForegroundColor Red - Write-Host "===========================================" -ForegroundColor Red + Write-WinUtilJobBanner -Message "WinGet is not installed" -Level "ERROR" $status = "not-installed" } } if ($choco) { if (Get-Command choco -ErrorAction SilentlyContinue) { - Write-Host "===========================================" -ForegroundColor Green - Write-Host "--- Chocolatey is installed ---" -ForegroundColor Green - Write-Host "===========================================" -ForegroundColor Green + Write-WinUtilJobBanner -Message "Chocolatey is installed" $status = "installed" } else { - Write-Host "===========================================" -ForegroundColor Red - Write-Host "--- Chocolatey is not installed ---" -ForegroundColor Red - Write-Host "===========================================" -ForegroundColor Red + Write-WinUtilJobBanner -Message "Chocolatey is not installed" -Level "ERROR" $status = "not-installed" } } diff --git a/functions/private/Update-WinUtilSelections.ps1 b/functions/private/Update-WinUtilSelections.ps1 index 933eaa3085..f6bae2313b 100644 --- a/functions/private/Update-WinUtilSelections.ps1 +++ b/functions/private/Update-WinUtilSelections.ps1 @@ -77,7 +77,11 @@ function Update-WinUtilSelections { foreach ($listName in $nextSelections.Keys) { foreach ($cbkey in $nextSelections[$listName]) { - $sync.$listName.Add($cbkey) + # Appending, so the same entry can already be there: a preset and a config that both + # name it would otherwise select it twice + if ($sync.$listName -notcontains $cbkey) { + $sync.$listName.Add($cbkey) + } } } } diff --git a/functions/private/Write-WinUtilConsoleProgress.ps1 b/functions/private/Write-WinUtilConsoleProgress.ps1 new file mode 100644 index 0000000000..e45effddf9 --- /dev/null +++ b/functions/private/Write-WinUtilConsoleProgress.ps1 @@ -0,0 +1,91 @@ +function Write-WinUtilConsoleProgress { + <# + .SYNOPSIS + Reports job progress on the console for runs that have no window + + .DESCRIPTION + The progress bar is the only thing telling a user how far along a job is, so a + headless run needs the same information in the only place it has. + + On a console the line is rewritten in place, because a package that reports every + few hundred milliseconds would otherwise scroll a screenful for one install. When + output is redirected there is no cursor to move, so each update is its own line and + they are throttled hard instead. + #> + param( + [string]$Status, + [int]$Percent = -1 + ) + + if ([string]::IsNullOrWhiteSpace($Status) -and $Percent -lt 0) { + return + } + + if ($null -eq $sync.ConsoleProgressState) { + $sync.ConsoleProgressState = [hashtable]::Synchronized(@{ + LastText = "" + LastWrite = [datetime]::MinValue + LineLength = 0 + LineOpen = $false + }) + } + $state = $sync.ConsoleProgressState + + $text = if ([string]::IsNullOrWhiteSpace($Status)) { $state.LastText } else { $Status } + if ([string]::IsNullOrWhiteSpace($text)) { + return + } + + $redirected = [Console]::IsOutputRedirected + $throttleMs = if ($redirected) { 1000 } else { 150 } + + $now = Get-Date + $sinceLast = ($now - $state.LastWrite).TotalMilliseconds + + # Redirected output cannot be rewritten in place, so every update is its own line and the + # throttle holds even when the text changed. A job reporting per package would otherwise + # scroll a screenful. + if ($redirected) { + if ($sinceLast -lt $throttleMs) { return } + } elseif ($text -eq $state.LastText -and $sinceLast -lt $throttleMs) { + return + } + + $state.LastText = $text + $state.LastWrite = $now + + $prefix = if ($Percent -ge 0) { "[{0,3}%] " -f $Percent } else { "[ =] " } + $line = "$prefix$text" + + if ($redirected) { + Write-Host $line -ForegroundColor DarkCyan + return + } + + # Pad to the previous length so a shorter line does not leave the tail of the longer one + $padding = [Math]::Max(0, $state.LineLength - $line.Length) + Write-Host ("`r$line" + (" " * $padding)) -NoNewline -ForegroundColor DarkCyan + $state.LineLength = $line.Length + $state.LineOpen = $true +} + +function Complete-WinUtilConsoleProgress { + <# + .SYNOPSIS + Ends the progress line so the next thing printed starts on its own + + .DESCRIPTION + The line is rewritten in place and therefore left without a newline. Anything else + reaching the console has to close it first, or it lands on top of the progress. + #> + + $state = $sync.ConsoleProgressState + if ($null -eq $state -or -not $state.LineOpen) { + return + } + + Write-Host "" + $state.LineOpen = $false + $state.LineLength = 0 + $state.LastText = "" +} diff --git a/functions/private/Write-WinUtilErrorRecord.ps1 b/functions/private/Write-WinUtilErrorRecord.ps1 new file mode 100644 index 0000000000..d3306363d9 --- /dev/null +++ b/functions/private/Write-WinUtilErrorRecord.ps1 @@ -0,0 +1,52 @@ +function Write-WinUtilErrorRecord { + <# + .SYNOPSIS + Logs a failure with enough context to act on it + + .DESCRIPTION + A bare "You cannot call a method on a null-valued expression." says nothing about + where it came from. This records the message together with the exception type, the + command and line that raised it, and the script stack, under a component name. + + .PARAMETER ErrorRecord + The error to report, normally $_ from a catch block. + + .PARAMETER Component + Which part of WinUtil was running, for example Install or UI. + + .PARAMETER Context + What was being attempted, for example the button name or the package. + #> + param( + [Parameter(Mandatory)] + $ErrorRecord, + + [string]$Component = "WinUtil", + + [string]$Context + ) + + $headline = if ($Context) { "$Context : $($ErrorRecord.Exception.Message)" } else { $ErrorRecord.Exception.Message } + Write-WinUtilLog -Level "ERROR" -Component $Component -Message $headline + + $invocation = $ErrorRecord.InvocationInfo + if ($invocation) { + $where = "$($invocation.ScriptName):$($invocation.ScriptLineNumber)" + if ([string]::IsNullOrWhiteSpace($invocation.ScriptName)) { + $where = "line $($invocation.ScriptLineNumber)" + } + Write-WinUtilLog -Level "ERROR" -Detail -Component $Component -Message " at $where in $($invocation.MyCommand): $($invocation.Line.Trim())" + } + + Write-WinUtilLog -Level "ERROR" -Detail -Component $Component -Message " type $($ErrorRecord.Exception.GetType().FullName), category $($ErrorRecord.CategoryInfo.Category)" + + if ($ErrorRecord.ScriptStackTrace) { + foreach ($frame in ($ErrorRecord.ScriptStackTrace -split "`r?`n")) { + if (-not [string]::IsNullOrWhiteSpace($frame)) { + Write-WinUtilLog -Level "ERROR" -Detail -Component $Component -Message " $($frame.Trim())" + } + } + } + + Write-Host "$Component : $headline" -ForegroundColor Red +} diff --git a/functions/private/Write-WinUtilJobBanner.ps1 b/functions/private/Write-WinUtilJobBanner.ps1 new file mode 100644 index 0000000000..6c3fcf779f --- /dev/null +++ b/functions/private/Write-WinUtilJobBanner.ps1 @@ -0,0 +1,54 @@ +function Write-WinUtilJobBanner { + <# + .SYNOPSIS + Writes the boxed start, finish or failure line a job prints to the console + + .DESCRIPTION + One place decides what a running operation looks like in the terminal, so every + workflow announces itself the same way instead of hand-drawing its own box. Called + by Start-WinUtilJob, not by job bodies. + + .PARAMETER Message + The line to box, for example "Installing apps". + + .PARAMETER Level + INFO for a normal banner, ERROR to colour it as a failure. + #> + param( + [Parameter(Mandatory)] + [string]$Message, + + [ValidateSet("INFO", "ERROR")] + [string]$Level = "INFO" + ) + + # A progress line is rewritten in place and left open, so the box would be drawn on top of it + Complete-WinUtilConsoleProgress + + # Wrapped, because a failure listing several packages would otherwise draw a box wider + # than the console + $width = 76 + $words = $Message -split '\s+' + $lines = [System.Collections.Generic.List[string]]::new() + $current = "" + foreach ($word in $words) { + if ($current.Length -gt 0 -and ($current.Length + 1 + $word.Length) -gt $width) { + $lines.Add($current) + $current = $word + } else { + $current = if ($current.Length -eq 0) { $word } else { "$current $word" } + } + } + if ($current.Length -gt 0) { $lines.Add($current) } + + $longest = ($lines | Measure-Object -Property Length -Maximum).Maximum + $border = "=" * ($longest + 6) + $colour = if ($Level -eq "ERROR") { "Red" } else { "Cyan" } + + Write-Host "" + Write-Host $border -ForegroundColor $colour + foreach ($line in $lines) { + Write-Host ("-- {0}$(' ' * ($longest - $line.Length)) --" -f $line) -ForegroundColor $colour + } + Write-Host $border -ForegroundColor $colour +} diff --git a/functions/private/Write-WinUtilLog.ps1 b/functions/private/Write-WinUtilLog.ps1 index da9e13ab7a..666615c75e 100644 --- a/functions/private/Write-WinUtilLog.ps1 +++ b/functions/private/Write-WinUtilLog.ps1 @@ -4,6 +4,12 @@ function Write-WinUtilLog { .SYNOPSIS Writes a timestamped WinUtil log entry to the active session log. + .DESCRIPTION + Called from the interface thread and from every job body, so the append is serialized + with a named mutex. The session log is deliberately not the file Start-Transcript owns: + a transcript only records the runspace it was started on, so anything a job logged would + otherwise never reach disk. + .PARAMETER Message The message to write. @@ -21,24 +27,22 @@ function Write-WinUtilLog { [ValidateSet("INFO", "WARN", "ERROR", "DEBUG")] [string]$Level = "INFO", - [string]$Component = "WinUtil" + [string]$Component = "WinUtil", + + # Continuation of an error already counted, such as a stack frame + [switch]$Detail ) + if ($Level -eq "ERROR" -and -not $Detail -and $null -ne $sync.LoggedErrors) { + $null = $sync.LoggedErrors.Add("[$Component] $Message") + } + try { $logPath = $null - $transcriptPath = $null if ($null -ne $sync -and $sync.ContainsKey("logPath")) { $logPath = $sync.logPath } - if ($null -ne $sync -and $sync.ContainsKey("transcriptPath")) { - $transcriptPath = $sync.transcriptPath - } - - if ([string]::IsNullOrWhiteSpace($logPath) -and -not [string]::IsNullOrWhiteSpace($transcriptPath)) { - $logPath = $transcriptPath - } - if ([string]::IsNullOrWhiteSpace($logPath) -and $null -ne $sync -and $sync.ContainsKey("winutildir")) { $logDirectory = Join-Path $sync.winutildir "logs" $logPath = Join-Path $logDirectory "winutil_$(Get-Date -Format "yyyy-MM-dd_HH-mm-ss").log" @@ -65,15 +69,29 @@ function Write-WinUtilLog { $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" $line = "[$timestamp] [$Level] [$Component] $Message" - if (-not [string]::IsNullOrWhiteSpace($transcriptPath) -and $logPath -eq $transcriptPath) { - Write-Host $line - return - } - + $mutex = [System.Threading.Mutex]::new($false, "WinUtilSessionLog") + $held = $false try { + try { + $held = $mutex.WaitOne(2000) + } catch [System.Threading.AbandonedMutexException] { + # A thread died holding the mutex; ownership transfers to us either way + $held = $true + } + + if (-not $held) { + # Writing anyway is what interleaves lines, and the wait only times out when + # contention is at its worst + Write-Host $line + return + } + Add-Content -Path $logPath -Value $line -Encoding UTF8 -ErrorAction Stop } catch [System.IO.IOException] { Write-Host $line + } finally { + if ($held) { $mutex.ReleaseMutex() } + $mutex.Dispose() } } catch { Write-Warning "Unable to write WinUtil log entry: $($_.Exception.Message)" diff --git a/functions/public/Invoke-WPFAppxInstall.ps1 b/functions/public/Invoke-WPFAppxInstall.ps1 index 902764533d..c9b05f6295 100644 --- a/functions/public/Invoke-WPFAppxInstall.ps1 +++ b/functions/public/Invoke-WPFAppxInstall.ps1 @@ -1,68 +1,26 @@ function Invoke-WPFAppxInstall { - if ($sync.ProcessRunning) { - Show-WinUtilMessage -Message "An AppX process is currently running." -Title "WinUtil" -Button "OK" -Icon "Warning" - return - } - if ($null -eq $sync.selectedAppx -or $sync.selectedAppx.Count -eq 0) { Show-WinUtilMessage -Message "No AppX Package selected" -Title "Error" -Button "OK" -Icon "Error" return } - $selected = @($sync.selectedAppx) - $apps = $sync.configs.appxHashtable - - $sync.ProcessRunning = $true - Invoke-WPFRunspace -ParameterList @(("selected", $selected), ("apps", $apps)) -ScriptBlock { - param($selected, $apps) - - $totalPackages = @($selected).Count - $hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher + Start-WinUtilJob -Name "AppX install" -Description "Installing AppX packages" -Parameters @{ + Selected = @($sync.selectedAppx) + Apps = $sync.configs.appxHashtable + } -ScriptBlock { + param($Selected, $Apps) - try { - Write-WinUtilLog -Component "AppX" -Message "Starting AppX install for $totalPackages selected package(s)." - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Preparing AppX install (0/$totalPackages)" -Percent 0 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" } - } + $totalPackages = @($Selected).Count + Write-WinUtilLog -Component "AppX" -Message "Starting AppX install for $totalPackages selected package(s)." - for ($index = 0; $index -lt $totalPackages; $index++) { - $key = $selected[$index] - $app = $apps[$key] - $position = $index + 1 - $startPercent = [int](($index / $totalPackages) * 100) + for ($index = 0; $index -lt $totalPackages; $index++) { + $app = $Apps[$Selected[$index]] + $position = $index + 1 - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installing $($app.Content) ($position/$totalPackages)" -Percent $startPercent - } - Write-Host "Installing $($app.Content)" - Install-WinUtilAPPX -Name $app.PackageId -StoreId $app.StoreId - - $completedPercent = [int](($position / $totalPackages) * 100) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installed $($app.Content) ($position/$totalPackages)" -Percent $completedPercent - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) } - } - } - - Write-Host "=================================" - Write-Host "-- AppX Install Finished ---" - Write-Host "=================================" - Write-WinUtilLog -Component "AppX" -Message "AppX install finished." - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "AppX install finished" -Percent 100 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } - } - } - catch { - Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message "AppX install failed: $($_.Exception.Message)" - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "AppX install failed" -Percent 100 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" } - } - } - finally { - $sync.ProcessRunning = $false + Step-WinUtilJob -Status "Installing $($app.Content) ($position/$totalPackages)" -Percent ([int](($index / $totalPackages) * 100)) + Write-Host "Installing $($app.Content)" + Install-WinUtilAPPX -Name $app.PackageId -StoreId $app.StoreId + Step-WinUtilJob -Status "Installed $($app.Content) ($position/$totalPackages)" -Percent ([int](($position / $totalPackages) * 100)) } } } diff --git a/functions/public/Invoke-WPFAppxRemoval.ps1 b/functions/public/Invoke-WPFAppxRemoval.ps1 index e7760ec941..d15e0312c6 100644 --- a/functions/public/Invoke-WPFAppxRemoval.ps1 +++ b/functions/public/Invoke-WPFAppxRemoval.ps1 @@ -1,100 +1,64 @@ function Invoke-WPFAppxRemoval { - if ($sync.ProcessRunning) { - Show-WinUtilMessage -Message "An AppX process is currently running." -Title "WinUtil" -Button "OK" -Icon "Warning" - return - } + <# + + .SYNOPSIS + Removes the selected AppX packages + + #> if ($null -eq $sync.selectedAppx -or $sync.selectedAppx.Count -eq 0) { Show-WinUtilMessage -Message "No AppX Package selected" -Title "Error" -Button "OK" -Icon "Error" return } - $selected = @($sync.selectedAppx) - $apps = $sync.configs.appxHashtable - - $sync.ProcessRunning = $true - Invoke-WPFRunspace -ParameterList @(("selected", $selected), ("apps", $apps)) -ScriptBlock { - param($selected, $apps) + Start-WinUtilJob -Name "AppX" -Description "Removing AppX packages" -Parameters @{ + Selected = @($sync.selectedAppx) + Apps = $sync.configs.appxHashtable + } -ScriptBlock { + param($Selected, $Apps) - $totalPackages = @($selected).Count - $hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher + $total = @($Selected).Count $packageList = [System.Collections.Generic.List[string]]::new() + Write-WinUtilLog -Component "AppX" -Message "Starting AppX removal for $total selected package(s)." - try { - Write-WinUtilLog -Component "AppX" -Message "Starting AppX removal for $totalPackages selected package(s)." - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Preparing AppX removal (0/$totalPackages)" -Percent 0 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" } - } - - for ($index = 0; $index -lt $totalPackages; $index++) { - $key = $selected[$index] - $app = $apps[$key] - $position = $index + 1 - $startPercent = [int](($index / $totalPackages) * 90) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Removing $($app.Content) ($position/$totalPackages)" -Percent $startPercent - } - - if ($key -eq "WPFAppxMicrosoft_XboxGamingOverlay") { - # Making sure Game Bar isn't running - Write-WinUtilLog -Component "AppX" -Message "Stopping GameBarFTServer before removing Xbox Gaming Overlay." - Stop-Process -Name GameBarFTServer -Force -Confirm:$false -ErrorAction SilentlyContinue - - # This stops annoying ms-gamebar popup when launching games. - Write-WinUtilLog -Component "AppX" -Message "Disabling Game DVR capture before removing Xbox Gaming Overlay." - Set-ItemProperty -Path HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\GameDVR -Name AppCaptureEnabled -Value 0 - } + for ($index = 0; $index -lt $total; $index++) { + $key = $Selected[$index] + $app = $Apps[$key] + $position = $index + 1 + Step-WinUtilJob -Status "Removing $($app.Content) ($position/$total)" -Percent ([int](($index / $total) * 90)) - if ($key -eq "WPFAppxMicrosoft_WindowsNotepad") { - Write-WinUtilLog -Component "AppX" -Message "Stopping dllhost before removing Notepad." - Stop-Process -Name dllhost -Force -Confirm:$false -ErrorAction SilentlyContinue - } + if ($key -eq "WPFAppxMicrosoft_XboxGamingOverlay") { + # Making sure Game Bar isn't running + Write-WinUtilLog -Component "AppX" -Message "Stopping GameBarFTServer before removing Xbox Gaming Overlay." + Stop-Process -Name GameBarFTServer -Force -Confirm:$false -ErrorAction SilentlyContinue - Write-Host "Removing $($app.Content)" - Write-WinUtilLog -Component "AppX" -Message "Removing $($app.Content) ($($app.PackageId))." - Remove-WinUtilAPPX -Name $app.PackageId - $packageList.Add($app.PackageId) - - if ($key -eq "WPFAppxMSTeams") { - # Uninstalls Microsoft Teams Meeting Add-in for Microsoft Office - Write-WinUtilLog -Component "AppX" -Message "Uninstalling Microsoft Teams meeting add-in package." - Get-Package -Name "Microsoft Teams*" -ErrorAction SilentlyContinue | Uninstall-Package -Force - } - - $completedPercent = [int](($position / $totalPackages) * 90) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Removed $($app.Content) ($position/$totalPackages)" -Percent $completedPercent - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) } - } + # This stops annoying ms-gamebar popup when launching games. + Write-WinUtilLog -Component "AppX" -Message "Disabling Game DVR capture before removing Xbox Gaming Overlay." + Set-ItemProperty -Path HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\GameDVR -Name AppCaptureEnabled -Value 0 } - if ($packageList.Count -gt 0) { - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Removing provisioned AppX packages" -Percent 90 - } - Remove-WinUtilProvisionedAPPX -PackageList $packageList.ToArray() + if ($key -eq "WPFAppxMicrosoft_WindowsNotepad") { + Write-WinUtilLog -Component "AppX" -Message "Stopping dllhost before removing Notepad." + Stop-Process -Name dllhost -Force -Confirm:$false -ErrorAction SilentlyContinue } - Write-Host "=================================" - Write-Host "-- AppX Removal Finished ---" - Write-Host "=================================" - Write-WinUtilLog -Component "AppX" -Message "AppX removal finished." - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "AppX removal finished" -Percent 100 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } - } - } - catch { - Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message "AppX removal failed: $($_.Exception.Message)" - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "AppX removal failed" -Percent 100 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" } + Write-Host "Removing $($app.Content)" + Write-WinUtilLog -Component "AppX" -Message "Removing $($app.Content) ($($app.PackageId))." + Remove-WinUtilAPPX -Name $app.PackageId + $packageList.Add($app.PackageId) + + if ($key -eq "WPFAppxMSTeams") { + # Uninstalls Microsoft Teams Meeting Add-in for Microsoft Office + Write-WinUtilLog -Component "AppX" -Message "Uninstalling Microsoft Teams meeting add-in package." + Get-Package -Name "Microsoft Teams*" -ErrorAction SilentlyContinue | Uninstall-Package -Force } - } - finally { - $sync.ProcessRunning = $false + + Step-WinUtilJob -Status "Removed $($app.Content) ($position/$total)" -Percent ([int](($position / $total) * 90)) } - } | Out-Null + if ($packageList.Count -gt 0) { + Step-WinUtilJob -Status "Removing provisioned AppX packages" -Percent 90 + Remove-WinUtilProvisionedAPPX -PackageList $packageList.ToArray() + } + } } diff --git a/functions/public/Invoke-WPFButton.ps1 b/functions/public/Invoke-WPFButton.ps1 index 80771a901b..944de19583 100644 --- a/functions/public/Invoke-WPFButton.ps1 +++ b/functions/public/Invoke-WPFButton.ps1 @@ -3,7 +3,14 @@ function Invoke-WPFButton { <# .SYNOPSIS - Invokes the function associated with the clicked button + Routes a button press, deciding whether it is interface work or a job + + .DESCRIPTION + This is the one place that classifies a button. Anything that changes the system runs + through Start-WinUtilJob, which means it gets the busy flag, the progress bar, the + taskbar item, the console banner and the log lines without each workflow arranging that + for itself. Anything that only changes what the interface is showing runs here and now, + because pushing it onto a worker would just make it slower. .PARAMETER Button The name of the button that was clicked @@ -12,12 +19,97 @@ function Invoke-WPFButton { Param ([string]$Button) - # Use this to get the name of the button - #[System.Windows.MessageBox]::Show("$Button","Chris Titus Tech's Windows Utility","OK","Info") - if (-not $sync.ProcessRunning -and -not $sync.Win11ISOProcessRunning) { - Set-WinUtilTweaksProgressIndicator -Visible $false + # Clear the progress left behind by the previous job, but never while one is running + if (-not $sync.ActiveJob) { + Step-WinUtilJob -Hide + } + + # Switch-driven buttons that change the system. Anything in feature.json counts too. The + # WPFPanel* entries are the exception only when they hand off to a Windows applet, which is + # what a missing function means; the two that carry one change the system themselves and + # would otherwise run their waits on the interface thread with nothing reporting them. + # + # This is a whitelist on purpose: window chrome and popup toggles also reach here, because + # every Button in $sync gets wired to this function, and they must not become jobs. + $workButtons = @( + "WPFInstallUpgrade", "WPFAddUltPerf", "WPFRemoveUltPerf", + "WPFUpdatesdefault", "WPFUpdatesdisable", "WPFUpdatessecurity", + "WPFGetInstalledAppx" + ) + + $featureEntry = $sync.configs.feature.$Button + $isConfigWork = $featureEntry -and ($Button -notlike "WPFPanel*" -or $featureEntry.function) + + if ($isConfigWork -or $workButtons -contains $Button) { + Start-WinUtilJob -Name (Get-WinUtilButtonLabel -Button $Button) -Parameters @{ + Button = $Button + } -ScriptBlock { + param($Button) + + Invoke-WPFButtonAction -Button $Button + } + return + } + + # A handler that throws on the interface thread would otherwise reach the user as a bare + # message with no indication of which control produced it + try { + Invoke-WPFButtonAction -Button $Button + } catch { + Write-WinUtilErrorRecord -ErrorRecord $_ -Component "UI" -Context "Button '$Button'" + } +} + +function Get-WinUtilButtonLabel { + <# + .SYNOPSIS + Returns the name a button's job should be reported under. + + .DESCRIPTION + Whatever the button says is what the progress bar, the banner and the log say, so the + wording never drifts from the interface and there is no second list to maintain. Falls + back to the button name when there is nothing to read. + #> + param([string]$Button) + + $content = $sync.configs.feature.$Button.Content + if (-not [string]::IsNullOrWhiteSpace($content)) { + return $content + } + + $control = $sync.$Button + if ($control -and $control.Content) { + $text = if ($control.Content -is [string]) { $control.Content } else { $control.Content.Text } + if (-not [string]::IsNullOrWhiteSpace($text)) { + return ([string]$text).Trim() + } } + $fallback = ($Button -replace '^WPF', '') + if ([string]::IsNullOrWhiteSpace($fallback)) { + return "WinUtil" + } + return $fallback +} + +function Invoke-WPFButtonAction { + + <# + + .SYNOPSIS + Invokes the function associated with the clicked button + + .DESCRIPTION + The work itself. Called by Invoke-WPFButton, either directly or from inside a job, so it + must not concern itself with progress, busy state or banners. + + .PARAMETER Button + The name of the button that was clicked + + #> + + Param ([string]$Button) + # Check if button is defined in feature config with function or InvokeScript if ($sync.configs.feature.$Button) { $buttonConfig = $sync.configs.feature.$Button @@ -78,13 +170,18 @@ function Invoke-WPFButton { } "WPFGetInstalledAppx" { $installedAppxPackages = Get-WinUtilInstalledAPPX - foreach ($appx in $sync.configs.appxHashtable.GetEnumerator()) { - if ($appx.Value.PackageId -in $installedAppxPackages) { - $sync.$($appx.Key).IsChecked = $true + Invoke-WPFUIThread -Parameters @{ Installed = $installedAppxPackages } -ScriptBlock { + param($Installed) + foreach ($appx in $sync.configs.appxHashtable.GetEnumerator()) { + if ($appx.Value.PackageId -in $Installed) { + $sync.$($appx.Key).IsChecked = $true + } } } } - "WPFCloseButton" {$sync.Form.Close(); Write-Host "Bye bye!"} + # Closing may be declined, or leave a job running that outlives the window, so the + # goodbye belongs at the point the process actually ends rather than here + "WPFCloseButton" {$sync.Form.Close()} "WPFMinimizeButton" {[Windows.SystemCommands]::MinimizeWindow($sync.Form)} "WPFMaximizeButton" { if ($sync.Form.WindowState -eq [Windows.WindowState]::Normal) { diff --git a/functions/public/Invoke-WPFFeatureInstall.ps1 b/functions/public/Invoke-WPFFeatureInstall.ps1 index 981feaee46..69242c2df4 100644 --- a/functions/public/Invoke-WPFFeatureInstall.ps1 +++ b/functions/public/Invoke-WPFFeatureInstall.ps1 @@ -6,35 +6,28 @@ function Invoke-WPFFeatureInstall { #> - if($sync.ProcessRunning) { - $msg = "[Invoke-WPFFeatureInstall] Install process is currently running." - [System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning) + if ($null -eq $sync.selectedFeatures -or $sync.selectedFeatures.Count -eq 0) { + Show-WinUtilMessage -Message "No Windows Feature selected" -Title "WinUtil" -Button "OK" -Icon "Warning" return } - Invoke-WPFRunspace -ScriptBlock { - $Features = $sync.selectedFeatures - $sync.ProcessRunning = $true - if ($Features.count -eq 1) { - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Indeterminate" -value 0.01 -overlay "logo" } - } else { - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" } + Start-WinUtilJob -Name "Features" -Description "Installing Windows features" -Parameters @{ + Features = @($sync.selectedFeatures) + } -ScriptBlock { + param($Features) + + $total = @($Features).Count + $completed = 0 + + foreach ($feature in $Features) { + $completed++ + Step-WinUtilJob -Status "Installing $feature ($completed/$total)" -Percent ([int]((($completed - 1) / $total) * 100)) + Measure-WinUtilStep -Scope "Features" -Name $feature -ScriptBlock { + Invoke-WinUtilFeatureInstall $feature + } + Step-WinUtilJob -Status "Installed $feature ($completed/$total)" -Percent ([int](($completed / $total) * 100)) } - $x = 0 - - $Features | ForEach-Object { - Invoke-WinUtilFeatureInstall $_ - $X++ - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($x/$Features.Count) } - } - - $sync.ProcessRunning = $false - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } - - Write-Host "===================================" - Write-Host "--- Features are Installed ---" - Write-Host "--- A Reboot may be required ---" - Write-Host "===================================" - } | Out-Null + Write-Host "A reboot may be required." + } } diff --git a/functions/public/Invoke-WPFFixesNTPPool.ps1 b/functions/public/Invoke-WPFFixesNTPPool.ps1 index f858879f2d..610ff0741e 100644 --- a/functions/public/Invoke-WPFFixesNTPPool.ps1 +++ b/functions/public/Invoke-WPFFixesNTPPool.ps1 @@ -14,7 +14,4 @@ function Invoke-WPFFixesNTPPool { Restart-Service w32time w32tm /resync - Write-Host "=================================" - Write-Host "-- NTP Configuration Complete ---" - Write-Host "=================================" } diff --git a/functions/public/Invoke-WPFFixesUpdate.ps1 b/functions/public/Invoke-WPFFixesUpdate.ps1 index 7fc04550c2..c7b899e6b4 100644 --- a/functions/public/Invoke-WPFFixesUpdate.ps1 +++ b/functions/public/Invoke-WPFFixesUpdate.ps1 @@ -30,7 +30,7 @@ function Invoke-WPFFixesUpdate { param($Aggressive = $false) Write-Progress -Id 0 -Activity "Repairing Windows Update" -PercentComplete 0 - Set-WinUtilTaskbaritem -state "Indeterminate" -overlay "logo" + Step-WinUtilJob -State "Indeterminate" Write-Host "Starting Windows Update Repair..." # Wait for the first progress bar to show, otherwise the second one won't show Start-Sleep -Milliseconds 200 @@ -192,24 +192,14 @@ function Invoke-WPFFixesUpdate { try { (New-Object -ComObject Microsoft.Update.AutoUpdate).DetectNow() } catch { - Set-WinUtilTaskbaritem -state "Error" -overlay "warning" + Write-WinUtilLog -Level "ERROR" -Component "Updates" -Message "Failed to create Windows Update COM object: $_" Write-Warning "Failed to create Windows Update COM object: $_" } Start-Process -NoNewWindow -FilePath "wuauclt" -ArgumentList "/resetauthorization", "/detectnow" Write-Progress -Id 10 -ParentId 0 -Activity "Forcing discovery" -Status "Completed" -PercentComplete 100 Write-Progress -Id 0 -Activity "Repairing Windows Update" -Status "Completed" -PercentComplete 100 - Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" - - $ButtonType = [System.Windows.MessageBoxButton]::OK - $MessageboxTitle = "Reset Windows Update " - $Messageboxbody = ("Stock settings loaded.`n Please reboot your computer") - $MessageIcon = [System.Windows.MessageBoxImage]::Information - - [System.Windows.MessageBox]::Show($Messageboxbody, $MessageboxTitle, $ButtonType, $MessageIcon) - Write-Host "===============================================" - Write-Host "-- Reset All Windows Update Settings to Stock -" - Write-Host "===============================================" + Show-WinUtilMessage -Message "Stock settings loaded.`n Please reboot your computer" -Title "Reset Windows Update" -Button "OK" -Icon "Information" | Out-Null # Remove the progress bars Write-Progress -Id 0 -Activity "Repairing Windows Update" -Completed diff --git a/functions/public/Invoke-WPFFixesWinget.ps1 b/functions/public/Invoke-WPFFixesWinget.ps1 index f41f371081..6a4ed46244 100644 --- a/functions/public/Invoke-WPFFixesWinget.ps1 +++ b/functions/public/Invoke-WPFFixesWinget.ps1 @@ -7,17 +7,7 @@ function Invoke-WPFFixesWinget { .DESCRIPTION BravoNorris for the fantastic idea of a button to reinstall WinGet #> - # Install Choco if not already present - try { - Set-WinUtilTaskbaritem -state "Indeterminate" -overlay "logo" - Write-Host "==> Starting WinGet Repair" - Install-WinUtilWinget - } catch { - Write-Error "Failed to install WinGet: $_" - Set-WinUtilTaskbaritem -state "Error" -overlay "warning" - } finally { - Write-Host "==> Finished WinGet Repair" - Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" - } + Step-WinUtilJob -Status "Repairing WinGet" -State "Indeterminate" + Install-WinUtilWinget -Force } diff --git a/functions/public/Invoke-WPFGetInstalled.ps1 b/functions/public/Invoke-WPFGetInstalled.ps1 index bc28623dad..b71f85e80d 100644 --- a/functions/public/Invoke-WPFGetInstalled.ps1 +++ b/functions/public/Invoke-WPFGetInstalled.ps1 @@ -1,89 +1,52 @@ function Invoke-WPFGetInstalled { <# .SYNOPSIS - Invokes the function that gets the checkboxes to check in a new runspace + Detects what is already installed or applied and ticks the matching boxes .PARAMETER checkbox Indicates whether to check for installed 'winget' programs or applied 'tweaks' #> param($checkbox) - if ($sync.ProcessRunning) { - $msg = "[Invoke-WPFGetInstalled] Install process is currently running." - [System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning) - return - } if (($sync.ChocoRadioButton.IsChecked -eq $false) -and ((Test-WinUtilPackageManager -winget) -eq "not-installed") -and $checkbox -eq "winget") { return } - $managerPreference = $sync.preferences.packagemanager - $operation = [Hashtable]::Synchronized(@{ - Checkboxes = @() - Error = $null - }) - $completeAction = [Action[hashtable, string]]{ - param( - [hashtable]$completedOperation, - [string]$completedCheckbox - ) - try { - if ($completedOperation.Error) { - Write-WinUtilLog -Level "ERROR" -Component "Install" -Message "Get installed state failed: $($completedOperation.Error)" - Write-Warning "Unable to get installed state: $($completedOperation.Error)" - return - } - if ($completedCheckbox -eq "winget") { - foreach ($checkboxName in $completedOperation.Checkboxes) { - if (-not $sync.selectedApps.Contains($checkboxName)) { - $sync.selectedApps.Add($checkboxName) + Start-WinUtilJob -Name "Detect installed" -Description "Checking what is already installed" -Parameters @{ + Checkbox = $checkbox + ManagerPreference = $sync.preferences.packagemanager + } -ScriptBlock { + param($Checkbox, $ManagerPreference) + + Step-WinUtilJob -Status "Checking what is already installed" -State "Indeterminate" + + $found = @() + if ($Checkbox -eq "winget") { + $source = if ($ManagerPreference -eq "Choco") { "choco" } else { $Checkbox } + $found = @(Invoke-WinUtilCurrentSystem -CheckBox $source) + } elseif ($Checkbox -eq "tweaks") { + $found = @(Invoke-WinUtilCurrentSystem -CheckBox $Checkbox) + } + + Write-WinUtilLog -Component "Install" -Message "Detected $($found.Count) existing item(s) for $Checkbox." + + # Ticking boxes touches the controls, so it happens on the interface thread + Invoke-WPFUIThread -Parameters @{ Checkbox = $Checkbox; Found = $found } -ScriptBlock { + param($Checkbox, $Found) + + if ($Checkbox -eq "winget") { + foreach ($name in $Found) { + if (-not $sync.selectedApps.Contains($name)) { + $sync.selectedApps.Add($name) } } Reset-WPFCheckBoxes -checkboxfilterpattern "WPFInstall*" } else { - foreach ($checkboxName in $completedOperation.Checkboxes) { - $sync.$checkboxName.ischecked = $True - } - } - } finally { - $sync.ProcessRunning = $false - Set-WinUtilTaskbaritem -state "None" - } - } - - $sync.ProcessRunning = $true - Set-WinUtilTaskbaritem -state "Indeterminate" - try { - Invoke-WPFRunspace -ParameterList @( - ("managerPreference", $managerPreference), - ("checkbox", $checkbox), - ("operation", $operation), - ("completeAction", $completeAction) - ) -ScriptBlock { - param ( - [string]$checkbox, - [string]$managerPreference, - [hashtable]$operation, - [Action[hashtable, string]]$completeAction - ) - try { - if ($checkbox -eq "winget") { - switch ($managerPreference) { - "Choco" { $operation.Checkboxes = @(Invoke-WinUtilCurrentSystem -CheckBox "choco"); break } - "Winget" { $operation.Checkboxes = @(Invoke-WinUtilCurrentSystem -CheckBox $checkbox); break } - } - } elseif ($checkbox -eq "tweaks") { - $operation.Checkboxes = @(Invoke-WinUtilCurrentSystem -CheckBox $checkbox) + foreach ($name in $Found) { + $sync.$name.ischecked = $true } - } catch { - $operation.Error = $_.Exception.Message - } finally { - $sync.Form.Dispatcher.BeginInvoke($completeAction, [object[]]@($operation, $checkbox)) | Out-Null } } - } catch { - $operation.Error = $_.Exception.Message - $completeAction.Invoke($operation, $checkbox) } } diff --git a/functions/public/Invoke-WPFImpex.ps1 b/functions/public/Invoke-WPFImpex.ps1 index cabf0ff43b..9ee3b6f613 100644 --- a/functions/public/Invoke-WPFImpex.ps1 +++ b/functions/public/Invoke-WPFImpex.ps1 @@ -16,7 +16,11 @@ function Invoke-WPFImpex { #> param( $type, - $Config = $null + $Config = $null, + + # Add to the current selection instead of replacing it. Used when a preset has already + # set a baseline that the imported file is meant to extend. + [switch]$Merge ) function ConfigDialog { @@ -46,9 +50,9 @@ function Invoke-WPFImpex { if ($Config) { $allConfs = ($sync.selectedApps + $sync.selectedTweaks + $sync.selectedToggles + $sync.selectedFeatures + $sync.selectedAppx) | ForEach-Object { [string]$_ } if (-not $allConfs) { - [System.Windows.MessageBox]::Show( - "No settings are selected to export. Please select at least one app, tweak, toggle, feature, or AppX package before exporting.", - "Nothing to Export", "OK", "Warning") + Show-WinUtilMessage -Message ( + "No settings are selected to export. Please select at least one app, tweak, toggle, feature, or AppX package before exporting." + ) -Title "Nothing to Export" -Button "OK" -Icon "Warning" | Out-Null return } $jsonFile = $allConfs | ConvertTo-Json @@ -100,16 +104,19 @@ function Invoke-WPFImpex { } if (-not $flattenedJson) { - [System.Windows.MessageBox]::Show( - "The selected file contains no settings to import. No changes have been made.", - "Empty Configuration", "OK", "Warning") + Show-WinUtilMessage -Message "The selected file contains no settings to import. No changes have been made." -Title "Empty Configuration" -Button "OK" -Icon "Warning" | Out-Null return } + # Replace unless this import is merging onto something already selected, + # which the headless path does when it is given a preset and a config + $replaceMode = @{} + if (-not $Merge) { $replaceMode["Replace"] = $true } + # Modern configs stay strict. Legacy configs can reference entries that no # longer exist, so restore supported selections and report the retired keys. if ($isLegacyConfig) { - $skippedSelections = @(Update-WinUtilSelections -flatJson $flattenedJson -Replace -SkipUnknown) + $skippedSelections = @(Update-WinUtilSelections -flatJson $flattenedJson @replaceMode -SkipUnknown) if ($skippedSelections.Count -gt 0) { $skippedSummary = $skippedSelections -join ", " @@ -135,7 +142,7 @@ function Invoke-WPFImpex { } else { # Build and validate every imported selection before replacing the current # state. This keeps a malformed config from leaving partial selections behind. - Update-WinUtilSelections -flatJson $flattenedJson -Replace + Update-WinUtilSelections -flatJson $flattenedJson @replaceMode } if ($sync.Form) { diff --git a/functions/public/Invoke-WPFInstall.ps1 b/functions/public/Invoke-WPFInstall.ps1 index 78f93960a5..7229e9d30d 100644 --- a/functions/public/Invoke-WPFInstall.ps1 +++ b/functions/public/Invoke-WPFInstall.ps1 @@ -8,13 +8,6 @@ function Invoke-WPFInstall { [PSObject[]]$PackagesToInstall = $($sync.selectedApps | Foreach-Object { $sync.configs.applicationsHashtable.$_ }) ) - - if($sync.ProcessRunning) { - $msg = "[Invoke-WPFInstall] An Install process is currently running." - Show-WinUtilMessage -Message $msg -Title "WinUtil" -Button "OK" -Icon "Warning" - return - } - if ($PackagesToInstall.Count -eq 0) { $WarningMsg = "Please select the program(s) to install or upgrade." Show-WinUtilMessage -Message $WarningMsg -Title "WinUtil" -Button "OK" -Icon "Warning" @@ -23,92 +16,54 @@ function Invoke-WPFInstall { $ManagerPreference = $sync.preferences.packagemanager Write-WinUtilLog -Component "Install" -Message "Install requested for $(@($PackagesToInstall).Count) selected package(s) using preference: $ManagerPreference" - $packageSummary = Get-WinUtilPackageLogSummary -Packages $PackagesToInstall -Preference $ManagerPreference - Write-WinUtilLog -Component "Install" -Message "Install selected package(s): $($packageSummary -join '; ')" - Invoke-WPFRunspace -ParameterList @(("PackagesToInstall", $PackagesToInstall),("ManagerPreference", $ManagerPreference)) -ScriptBlock { + Start-WinUtilJob -Name "Install" -Description "Installing apps" -DisableAppList -Parameters @{ + PackagesToInstall = $PackagesToInstall + ManagerPreference = $ManagerPreference + } -ScriptBlock { param($PackagesToInstall, $ManagerPreference) - $packagesSorted = Get-WinUtilSelectedPackages -PackageList $PackagesToInstall -Preference $ManagerPreference + # Summarising the selection reads every package, so it runs here rather than on the UI thread + $packageSummary = Get-WinUtilPackageLogSummary -Packages $PackagesToInstall -Preference $ManagerPreference + Write-WinUtilLog -Component "Install" -Message "Install selected package(s): $($packageSummary -join '; ')" + $packagesSorted = Get-WinUtilSelectedPackages -PackageList $PackagesToInstall -Preference $ManagerPreference $packagesWinget = $packagesSorted['Winget'] $packagesChoco = $packagesSorted['Choco'] $totalPackages = @($packagesWinget).Count + @($packagesChoco).Count $completedPackages = 0 - $hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher Write-WinUtilLog -Component "Install" -Message "Install package manager split: winget=$(@($packagesWinget).Count), choco=$(@($packagesChoco).Count)" - try { - $sync.ProcessRunning = $true - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Preparing app install (0/$totalPackages)" -Percent 0 - Invoke-WPFUIThread -ScriptBlock { - if ($null -ne $sync.ItemsControl) { - $sync.ItemsControl.IsEnabled = $false - } - } - } + $results = @() - if($packagesWinget.Count -gt 0 -and $packagesWinget -ne "0") { - Install-WinUtilWinget - foreach ($program in $packagesWinget) { - $position = $completedPackages + 1 - $startPercent = [int](($completedPackages / $totalPackages) * 100) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installing $program ($position/$totalPackages)" -Percent $startPercent - } + if ($packagesWinget.Count -gt 0 -and $packagesWinget -ne "0") { + Install-WinUtilWinget + foreach ($program in $packagesWinget) { + $position = $completedPackages + 1 + Step-WinUtilJob -Status "Installing $program ($position/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) + $results += Measure-WinUtilStep -Scope "Install" -Name "winget $program" -ScriptBlock { Install-WinUtilProgramWinget -Action Install -Programs @($program) - $completedPackages++ - $completedPercent = [int](($completedPackages / $totalPackages) * 100) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installed $program ($completedPackages/$totalPackages)" -Percent $completedPercent - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) } - } } + $completedPackages++ + Step-WinUtilJob -Status "Installed $program ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) } - if($packagesChoco.Count -gt 0) { - $position = $completedPackages + 1 - $startPercent = [int](($completedPackages / $totalPackages) * 100) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installing Chocolatey packages ($position/$totalPackages)" -Percent $startPercent - } + } - Install-WinUtilChoco - Install-WinUtilProgramChoco -Action Install -Programs $packagesChoco - $completedPackages += @($packagesChoco).Count - $completedPercent = [int](($completedPackages / $totalPackages) * 100) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installed Chocolatey packages ($completedPackages/$totalPackages)" -Percent $completedPercent - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) } - } - } - Write-Host "===========================================" - Write-Host "-- Installs have finished ---" - Write-Host "===========================================" - Write-WinUtilLog -Component "Install" -Message "Install workflow completed." - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "App install finished" -Percent 100 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } - } - } catch { - Write-Host "===========================================" - Write-Host "Error: $_" - Write-Host "===========================================" - Write-WinUtilLog -Level "ERROR" -Component "Install" -Message "Install workflow failed: $($_.Exception.Message)" - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "App install failed" -Percent 100 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" } - } - } finally { - if ($hasUI) { - Invoke-WPFUIThread -ScriptBlock { - if ($null -ne $sync.ItemsControl) { - $sync.ItemsControl.IsEnabled = $true - } - } + if ($packagesChoco.Count -gt 0) { + $position = $completedPackages + 1 + Step-WinUtilJob -Status "Installing Chocolatey packages ($position/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) + + Install-WinUtilChoco + $chocoBase = [int](($completedPackages / $totalPackages) * 100) + $chocoSpan = [int]((@($packagesChoco).Count / $totalPackages) * 100) + $results += Measure-WinUtilStep -Scope "Install" -Name "choco $($packagesChoco -join ', ')" -ScriptBlock { + Install-WinUtilProgramChoco -Action Install -Programs $packagesChoco -ProgressBase $chocoBase -ProgressSpan $chocoSpan } - $sync.ProcessRunning = $False + $completedPackages += @($packagesChoco).Count + Step-WinUtilJob -Status "Installed Chocolatey packages ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) } - } | Out-Null + + Complete-WinUtilPackageRun -Action "Install" -Results $results + } } diff --git a/functions/public/Invoke-WPFInstallUpgrade.ps1 b/functions/public/Invoke-WPFInstallUpgrade.ps1 index 8db0167414..a265b777db 100644 --- a/functions/public/Invoke-WPFInstallUpgrade.ps1 +++ b/functions/public/Invoke-WPFInstallUpgrade.ps1 @@ -1,21 +1,95 @@ function Invoke-WPFInstallUpgrade { - if ($sync.ChocoRadioButton.IsChecked) { - Install-WinUtilChoco # Ensure Chocolatey is installed before upgrading + <# - Write-Host "===========================================" - Write-Host "-- Updates started ---" - Write-Host "-- You can close this window if desired ---" - Write-Host "===========================================" + .SYNOPSIS + Upgrades every package that has an update available - Start-Process -FilePath powershell.exe -ArgumentList 'choco upgrade all -y' - } else { - Install-WinUtilWinget # Ensure WinGet is installed before upgrading + .DESCRIPTION + Runs on the worker like any other package work, so the progress bar, the taskbar item + and the log report it the same way an install does. Each package is a step of the run + rather than the whole thing being one opaque wait. - Write-Host "===========================================" - Write-Host "-- Updates started ---" - Write-Host "-- You can close this window if desired ---" - Write-Host "===========================================" + #> - Start-Process -FilePath powershell.exe -ArgumentList '-NoExit winget upgrade --all --include-unknown --silent --accept-source-agreements --accept-package-agreements' + # The radio button belongs to the interface thread; this body runs on a worker. The + # preference it maintains carries the same answer and is what every other workflow reads. + if ($sync.preferences.packagemanager -eq "Choco") { + Step-WinUtilJob -Status "Preparing Chocolatey" -State "Indeterminate" + Install-WinUtilChoco + + Write-WinUtilLog -Component "Install" -Message "Upgrading all Chocolatey packages." + Step-WinUtilJob -Status "Upgrading all Chocolatey packages" -State "Indeterminate" + + # "all" is choco's own name for every installed package, so this stays one call + $result = Measure-WinUtilStep -Scope "Install" -Name "choco upgrade all" -ScriptBlock { + Install-WinUtilProgramChoco -Action Upgrade -Programs @("all") + } + Complete-WinUtilPackageRun -Action "Upgrade" -Results @($result) + return + } + + Step-WinUtilJob -Status "Preparing WinGet" -State "Indeterminate" + Install-WinUtilWinget + + Step-WinUtilJob -Status "Looking for available updates" -State "Indeterminate" + $upgradable = Get-WinUtilUpgradablePackage + + if (@($upgradable).Count -eq 0) { + Write-WinUtilLog -Component "Install" -Message "No packages have an update available." + Step-WinUtilJob -Status "Everything is up to date" -Percent 100 + return } + + Write-WinUtilLog -Component "Install" -Message "Upgrading $(@($upgradable).Count) package(s): $($upgradable -join ', ')" + + $total = @($upgradable).Count + $completed = 0 + $results = @() + + foreach ($package in $upgradable) { + $position = $completed + 1 + Step-WinUtilJob -Status "Upgrading $package ($position/$total)" -Percent ([int](($completed / $total) * 100)) + + $results += Measure-WinUtilStep -Scope "Install" -Name "winget upgrade $package" -ScriptBlock { + Install-WinUtilProgramWinget -Action Upgrade -Programs @($package) + } + + $completed++ + Step-WinUtilJob -Status "Upgraded $package ($completed/$total)" -Percent ([int](($completed / $total) * 100)) + } + + Complete-WinUtilPackageRun -Action "Upgrade" -Results $results +} + +function Get-WinUtilUpgradablePackage { + <# + .SYNOPSIS + Returns the package identifiers WinGet reports as having an update available + #> + + # The table is localised and its columns are truncated to the console width, so a shape + # matched out of it is not an identifier: a wrapped version, a translated header or a + # diagnostic line all match the same pattern. Every candidate is therefore confirmed against + # winget itself before it is upgraded, and stderr is kept out of the parse. + $output = & winget upgrade --include-unknown --accept-source-agreements 2>$null | Out-String + + $candidates = New-Object System.Collections.Generic.List[string] + foreach ($line in ($output -split "`r?`n")) { + if ($line -match '^\s*\S.*?\s{2,}(?[\w\.\-\+]+)\s{2,}\S+\s{2,}\S+') { + $null = $candidates.Add($Matches['id']) + } + } + + $ids = New-Object System.Collections.Generic.List[string] + foreach ($candidate in ($candidates | Sort-Object -Unique)) { + # An exact-id query returns nothing for a header, a separator or a stray column value + $confirmed = & winget list --id $candidate --exact --accept-source-agreements 2>$null | Out-String + if ($confirmed -match [regex]::Escape($candidate)) { + $null = $ids.Add($candidate) + } else { + Write-WinUtilLog -Component "Install" -Message "Ignoring '$candidate' from the upgrade table: winget does not report it as an installed package." + } + } + + return @($ids) } diff --git a/functions/public/Invoke-WPFOOSU.ps1 b/functions/public/Invoke-WPFOOSU.ps1 index 993e32a79e..3e6ef74909 100644 --- a/functions/public/Invoke-WPFOOSU.ps1 +++ b/functions/public/Invoke-WPFOOSU.ps1 @@ -1,50 +1,18 @@ function Invoke-WPFOOSU { - if ($sync.ProcessRunning) { - Show-WinUtilMessage -Message "Another process is currently running." -Title "WinUtil" -Button "OK" -Icon "Warning" - return - } - - $downloadPath = Join-Path $sync.winutildir "ooshutup10.exe" - $sync.ProcessRunning = $true - - Invoke-WPFRunspace -ParameterList @(,("downloadPath", $downloadPath)) -ScriptBlock { - param($downloadPath) - - $hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher - - try { - Write-WinUtilLog -Component "OOSU" -Message "Downloading O&O ShutUp10++." - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Downloading O&O ShutUp10++ (0%)" -Percent 0 - } + Start-WinUtilJob -Name "OOSU" -Description "Downloading O&O ShutUp10++" -Parameters @{ + DownloadPath = Join-Path $sync.winutildir "ooshutup10.exe" + } -ScriptBlock { + param($DownloadPath) - Save-WinUtilFile -Uri "https://dl5.oo-software.com/files/ooshutup10/OOSU10.exe" -DestinationPath $downloadPath -ProgressCallback { - param($percent) + Write-WinUtilLog -Component "OOSU" -Message "Downloading O&O ShutUp10++." - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Downloading O&O ShutUp10++ ($percent%)" -Percent $percent - } - } - - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Launching O&O ShutUp10++" -Percent 100 - } - Start-Process -FilePath $downloadPath - - Write-WinUtilLog -Component "OOSU" -Message "O&O ShutUp10++ launched." - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "O&O ShutUp10++ launched" -Percent 100 - } - } - catch { - Write-WinUtilLog -Level "ERROR" -Component "OOSU" -Message "O&O ShutUp10++ download failed: $($_.Exception.Message)" - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "O&O ShutUp10++ download failed" -Percent 100 - } - Write-Error "Couldn't download O&O ShutUp10. Please make sure you have an active Internet connection." - } - finally { - $sync.ProcessRunning = $false + Save-WinUtilFile -Uri "https://dl5.oo-software.com/files/ooshutup10/OOSU10.exe" -DestinationPath $DownloadPath -ProgressCallback { + param($percent) + Step-WinUtilJob -Status "Downloading O&O ShutUp10++ ($percent%)" -Percent $percent } + + Step-WinUtilJob -Status "Launching O&O ShutUp10++" -Percent 100 + Start-Process -FilePath $DownloadPath + Write-WinUtilLog -Component "OOSU" -Message "O&O ShutUp10++ launched." } } diff --git a/functions/public/Invoke-WPFRunspace.ps1 b/functions/public/Invoke-WPFRunspace.ps1 index d57a76c244..51354ba1db 100644 --- a/functions/public/Invoke-WPFRunspace.ps1 +++ b/functions/public/Invoke-WPFRunspace.ps1 @@ -31,43 +31,11 @@ function Invoke-WPFRunspace { $ParameterList ) - if (-not ("WinUtilRunspaceCleanup" -as [type])) { - Add-Type @" -using System; -using System.Management.Automation; - -public sealed class WinUtilRunspaceCleanupState -{ - public PowerShell PowerShell { get; set; } - public IAsyncResult Handle { get; set; } -} - -public static class WinUtilRunspaceCleanup -{ - public static readonly System.Threading.WaitOrTimerCallback Callback = Cleanup; - - public static void Cleanup(object state, bool timedOut) - { - var cleanupState = state as WinUtilRunspaceCleanupState; - if (cleanupState == null || cleanupState.PowerShell == null || cleanupState.Handle == null) - { - return; - } - - try - { - cleanupState.PowerShell.EndInvoke(cleanupState.Handle); - } - catch - { - } - finally - { - cleanupState.PowerShell.Dispose(); - } - } -} -"@ + # Starting work into a pool that is closing gives that instance a runspace it can never run + # on, and it throws on a thread pool thread where nothing is catching + if ($sync.ShuttingDown) { + Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Refused to start background work, WinUtil is closing." + return $null } Initialize-WinUtilRunspacePool | Out-Null @@ -80,6 +48,11 @@ public static class WinUtilRunspaceCleanup [void]$powershell.AddArgument($ArgumentList) foreach ($parameter in $ParameterList) { + # A single pair written as @(("Name", $value)) collapses to a two element array, and + # indexing it then yields the first two characters of the name + if ($parameter -is [string] -or $parameter.Count -ne 2) { + throw "ParameterList takes name and value pairs. Received '$parameter'. A single pair needs a leading comma: -ParameterList (,('Name', `$value))" + } [void]$powershell.AddParameter($parameter[0], $parameter[1]) } @@ -88,10 +61,11 @@ public static class WinUtilRunspaceCleanup # Execute the RunspacePool $handle = $powershell.BeginInvoke() - $cleanupState = [WinUtilRunspaceCleanupState]::new() - $cleanupState.PowerShell = $powershell - $cleanupState.Handle = $handle - [System.Threading.ThreadPool]::RegisterWaitForSingleObject($handle.AsyncWaitHandle, [WinUtilRunspaceCleanup]::Callback, $cleanupState, -1, $true) | Out-Null + # Registered after the invocation starts: a NotStarted instance is indistinguishable from a + # finished one to the pruning pass, which would drop it and hide its work from shutdown + Register-WinUtilActiveShell -PowerShell $powershell + + Register-WinUtilRunspaceCleanup -PowerShell $powershell -Handle $handle # Return the handle return $handle diff --git a/functions/public/Invoke-WPFSSHServer.ps1 b/functions/public/Invoke-WPFSSHServer.ps1 index 0ea6de59ad..3702248bd6 100644 --- a/functions/public/Invoke-WPFSSHServer.ps1 +++ b/functions/public/Invoke-WPFSSHServer.ps1 @@ -2,16 +2,9 @@ function Invoke-WPFSSHServer { <# .SYNOPSIS - Invokes the OpenSSH Server install in a runspace + Installs and starts the OpenSSH Server #> - Invoke-WPFRunspace -ScriptBlock { - - Invoke-WinUtilSSHServer - - Write-Host "=======================================" - Write-Host "-- OpenSSH Server installed! ---" - Write-Host "=======================================" - } + Invoke-WinUtilSSHServer } diff --git a/functions/public/Invoke-WPFSystemRepair.ps1 b/functions/public/Invoke-WPFSystemRepair.ps1 index a44a5c0c25..e1b261dde3 100644 --- a/functions/public/Invoke-WPFSystemRepair.ps1 +++ b/functions/public/Invoke-WPFSystemRepair.ps1 @@ -10,10 +10,51 @@ function Invoke-WPFSystemRepair { 3. DISM - Repair a corrupted Windows operating system image #> - Start-Process cmd.exe -ArgumentList "/c chkdsk /scan /perf" -NoNewWindow -Wait - Start-Process cmd.exe -ArgumentList "/c sfc /scannow" -NoNewWindow -Wait - Start-Process cmd.exe -ArgumentList "/c dism /online /cleanup-image /restorehealth" -NoNewWindow -Wait + # SuccessCodes maps the non-zero exits a step treats as success to what they mean. The codes + # are per step because the same number means different things: 1 and 2 are ordinary chkdsk + # outcomes, while 1 from sfc is a failure, and 3010 is a repaired image from DISM only. + $steps = @( + @{ + Label = "Checking the disk for errors" + Arguments = "/c chkdsk /scan /perf" + # 3 is left out: the disk could not be checked, or has errors an online scan cannot + # fix, and the steps after this one are not worth running on a disk in that state. + SuccessCodes = @{ + 1 = "errors were found and fixed" + 2 = "cleanup was performed, or was skipped because /f was not given" + } + }, + @{ + Label = "Scanning protected system files" + Arguments = "/c sfc /scannow" + SuccessCodes = @{} + }, + @{ + Label = "Repairing the Windows image" + Arguments = "/c dism /online /cleanup-image /restorehealth" + SuccessCodes = @{ + 3010 = "a restart is needed for the repair to take effect" + } + } + ) - Write-Host "==> Finished System Repair" - Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" + $completed = 0 + foreach ($step in $steps) { + Step-WinUtilJob -Status "$($step.Label) ($($completed + 1)/$($steps.Count))" -Percent ([int](($completed / $steps.Count) * 100)) + Write-WinUtilLog -Component "SystemRepair" -Message $step.Label + # Start-Process does not throw on a nonzero exit, so without this a failed chkdsk, sfc + # or dism run would still be reported as a completed repair + $process = Start-Process cmd.exe -ArgumentList $step.Arguments -NoNewWindow -Wait -PassThru + $exitCode = $process.ExitCode + + if ($exitCode -ne 0) { + if ($step.SuccessCodes.ContainsKey($exitCode)) { + Write-WinUtilLog -Level "WARN" -Component "SystemRepair" -Message "$($step.Label) finished: $($step.SuccessCodes[$exitCode])." + } else { + throw "$($step.Label) failed with exit code $exitCode." + } + } + + $completed++ + } } diff --git a/functions/public/Invoke-WPFTab.ps1 b/functions/public/Invoke-WPFTab.ps1 index d0bac4f660..a138306e5a 100644 --- a/functions/public/Invoke-WPFTab.ps1 +++ b/functions/public/Invoke-WPFTab.ps1 @@ -8,55 +8,59 @@ function Invoke-WPFTab { .PARAMETER ClickedTab The name of the tab that was clicked + .PARAMETER Yield + Build the tab's content in slices, letting the interface answer between them. For the + tab opened at startup, where the window is already on screen and filling in gradually + reads better than holding the thread until it is complete. + #> Param ( [Parameter(Mandatory,position=0)] - [string]$ClickedTab + [string]$ClickedTab, + + [switch]$Yield ) - $tabNav = Get-WinUtilVariables | Where-Object {$psitem -like "WPFTabNav"} $tabNumber = [int]($ClickedTab -replace "WPFTab","" -replace "BT","") - 1 - $filter = Get-WinUtilVariables -Type ToggleButton | Where-Object {$psitem -like "WPFTab?BT"} - $sync.$tabNav.Items[$tabNumber].IsSelected = $true - ($sync.GetEnumerator()).where{$psitem.Key -in $filter} | ForEach-Object { - if ($ClickedTab -ne $PSItem.name) { - $sync[$PSItem.Name].IsChecked = $false - } else { - $sync["$ClickedTab"].IsChecked = $true + Measure-WinUtilStep -Scope "Tab" -Name "$ClickedTab select" -ScriptBlock { + $filter = Get-WinUtilVariables -Type ToggleButton | Where-Object {$psitem -like "WPFTab?BT"} + $sync.WPFTabNav.Items[$tabNumber].IsSelected = $true + ($sync.GetEnumerator()).where{$psitem.Key -in $filter} | ForEach-Object { + if ($ClickedTab -ne $PSItem.name) { + $sync[$PSItem.Name].IsChecked = $false + } else { + $sync["$ClickedTab"].IsChecked = $true + } } + $sync.currentTab = $sync.WPFTabNav.Items[$tabNumber].Header } - $sync.currentTab = $sync.$tabNav.Items[$tabNumber].Header - Initialize-WinUtilTabContent -TabName $sync.currentTab - - # Always reset the filter for the current tab - if ($sync.currentTab -eq "Install") { - # Reset the search text, but keep the categories the chips are still showing as selected - $selectedCategories = if ($sync.SelectedAppCategories) { $sync.SelectedAppCategories.ToArray() } else { @() } - Find-AppsByNameOrDescription -SearchString "" -Categories $selectedCategories - } elseif ($sync.currentTab -eq "Tweaks") { - # Reset Tweaks tab filter - Find-TweaksByNameOrDescription -SearchString "" - } elseif ($sync.currentTab -eq "AppX") { - # Reset AppX tab filter - Find-TweaksByNameOrDescription -SearchString "" + + Measure-WinUtilStep -Scope "Tab" -Name "$ClickedTab content" -ScriptBlock { + Initialize-WinUtilTabContent -TabName $sync.currentTab -Yield:$Yield } - # Show search bar in Install, Tweaks, and AppX tabs - if ($tabNumber -eq 0 -or $tabNumber -eq 1 -or $tabNumber -eq 5) { - $sync.SearchBar.Visibility = "Visible" - $searchIcon = ($sync.Form.FindName("SearchBar").Parent.Children | Where-Object { $_ -is [System.Windows.Controls.TextBlock] -and $_.Text -eq [char]0xE721 })[0] - if ($searchIcon) { - $searchIcon.Visibility = "Visible" + Measure-WinUtilStep -Scope "Tab" -Name "$ClickedTab filter reset" -ScriptBlock { + if ($sync.currentTab -eq "Install") { + # Clears the search text but keeps whatever the category chips are still showing + $selectedCategories = if ($sync.SelectedAppCategories) { $sync.SelectedAppCategories.ToArray() } else { @() } + Find-AppsByNameOrDescription -SearchString "" -Categories $selectedCategories + } elseif ($sync.currentTab -eq "Tweaks" -or $sync.currentTab -eq "AppX") { + Find-TweaksByNameOrDescription -SearchString "" } - } else { - $sync.SearchBar.Visibility = "Collapsed" + } + + Measure-WinUtilStep -Scope "Tab" -Name "$ClickedTab search bar" -ScriptBlock { + # Show search bar in Install, Tweaks, and AppX tabs $searchIcon = ($sync.Form.FindName("SearchBar").Parent.Children | Where-Object { $_ -is [System.Windows.Controls.TextBlock] -and $_.Text -eq [char]0xE721 })[0] - if ($searchIcon) { - $searchIcon.Visibility = "Collapsed" + if ($tabNumber -eq 0 -or $tabNumber -eq 1 -or $tabNumber -eq 5) { + $sync.SearchBar.Visibility = "Visible" + if ($searchIcon) { $searchIcon.Visibility = "Visible" } + } else { + $sync.SearchBar.Visibility = "Collapsed" + if ($searchIcon) { $searchIcon.Visibility = "Collapsed" } + $sync.SearchBarClearButton.Visibility = "Collapsed" } - # Hide the clear button if it's visible - $sync.SearchBarClearButton.Visibility = "Collapsed" } } diff --git a/functions/public/Invoke-WPFToggleSelections.ps1 b/functions/public/Invoke-WPFToggleSelections.ps1 new file mode 100644 index 0000000000..6f89a8b53e --- /dev/null +++ b/functions/public/Invoke-WPFToggleSelections.ps1 @@ -0,0 +1,40 @@ +function Invoke-WPFToggleSelections { + <# + + .SYNOPSIS + Applies every selected toggle + + .DESCRIPTION + In the window a toggle applies itself the moment it is switched, so nothing ever had to + apply a list of them. An imported configuration carries toggles the same way it carries + tweaks, and without this they would be read and then ignored. + + #> + + $toggles = @($sync.selectedToggles) + + if ($toggles.Count -eq 0) { + Show-WinUtilMessage -Message "No toggles are selected." -Title "WinUtil" -Button "OK" -Icon "Warning" | Out-Null + return + } + + Write-WinUtilLog -Component "Toggles" -Message "Toggles requested: $($toggles.Count) selected." + + Start-WinUtilJob -Name "Toggles" -Description "Applying toggles" -Parameters @{ + Toggles = $toggles + } -ScriptBlock { + param($Toggles) + + $total = [Math]::Max(@($Toggles).Count, 1) + $completed = 0 + + foreach ($toggle in $Toggles) { + Step-WinUtilJob -Status "Applying $toggle ($($completed + 1)/$total)" -Percent ([int](($completed / $total) * 100)) + Measure-WinUtilStep -Scope "Toggles" -Name $toggle -ScriptBlock { + Invoke-WinUtilTweaks $toggle + } + $completed++ + Step-WinUtilJob -Percent ([int](($completed / $total) * 100)) + } + } +} diff --git a/functions/public/Invoke-WPFUIElements.ps1 b/functions/public/Invoke-WPFUIElements.ps1 index 01559fdf2d..0b3bbe83be 100644 --- a/functions/public/Invoke-WPFUIElements.ps1 +++ b/functions/public/Invoke-WPFUIElements.ps1 @@ -22,7 +22,11 @@ function Invoke-WPFUIElements { [string]$targetGridName, [Parameter(Mandatory, Position = 2)] - [int]$columncount + [int]$columncount, + + # Let the interface answer between batches of entries. Only for content nobody is + # waiting on: a user who just clicked the tab is better served by finishing at once. + [switch]$Yield ) $window = $sync.form @@ -100,7 +104,7 @@ function Invoke-WPFUIElements { $panelcount = 0 # Iterate through 'organizedData' by panel, category, and application - $count = 0 + $yieldClock = [System.Diagnostics.Stopwatch]::StartNew() foreach ($panelKey in ($organizedData.Keys | Sort-Object)) { # Create a Border for each column $border = New-Object Windows.Controls.Border @@ -150,7 +154,6 @@ function Invoke-WPFUIElements { # Now proceed with adding category labels and entries to $stackPanelContainer foreach ($category in ($organizedData[$panelKey].Keys | Sort-Object)) { - $count++ $label = New-Object Windows.Controls.Label $categoryCleanName = $category -replace ".*__", "" @@ -174,7 +177,19 @@ function Invoke-WPFUIElements { } }}, Content foreach ($entryInfo in $entries) { - $count++ + + # Constructing a panel's worth of controls in one go holds the interface for + # hundreds of milliseconds. Draining the queue on a deadline rather than every + # nth entry keeps the wait bounded whatever the entries cost to build. + if ($Yield -and $yieldClock.ElapsedMilliseconds -ge 25 -and (Test-WinUtilUIAlive)) { + $yieldClock.Restart() + $frame = New-Object Windows.Threading.DispatcherFrame + $null = $sync.Form.Dispatcher.BeginInvoke( + [Windows.Threading.DispatcherPriority]::Background, + [action]{ $frame.Continue = $false }) + [Windows.Threading.Dispatcher]::PushFrame($frame) + } + # Create the UI elements based on the entry type switch ($entryInfo.Type) { "Toggle" { diff --git a/functions/public/Invoke-WPFUIThread.ps1 b/functions/public/Invoke-WPFUIThread.ps1 index b5b8c30d03..32359defbe 100644 --- a/functions/public/Invoke-WPFUIThread.ps1 +++ b/functions/public/Invoke-WPFUIThread.ps1 @@ -1,7 +1,107 @@ -function Invoke-WPFUIThread ($ScriptBlock) { - if ($null -eq $sync.form -or $null -eq $sync.form.Dispatcher) { +function Test-WinUtilUIAlive { + <# + .SYNOPSIS + Whether there is a window that can still be posted to + + .DESCRIPTION + False for a headless run, and for a window closed over running work: a shut down + dispatcher accepts posts and discards them. + #> + + return $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher -and -not $sync.Form.Dispatcher.HasShutdownStarted +} + +function Invoke-WPFUIThread { + <# + .SYNOPSIS + Runs a scriptblock on the interface thread + + .DESCRIPTION + Controls may only be touched from the thread that owns the window. + + The body is handed over as text and rebuilt in the interface runspace rather than + marshalled as a scriptblock: a scriptblock keeps the session state it was written in, + and running one across runspaces costs roughly twenty times as much per command. So + values come in through Parameters rather than captured from the caller's scope. + + A no-op once the window is gone, so a job outliving the interface finishes quietly. + + .PARAMETER ScriptBlock + The work to run on the interface thread. Declare a param block for anything it needs. + + .PARAMETER Parameters + Values passed to the body by name. + + .PARAMETER Async + Post and return instead of waiting. For progress and log updates, which must never + stall the caller. + + .PARAMETER PassThru + Return what the body produced. Off by default so a caller that only wanted a control + updated gets no stray output. + #> + param( + [Parameter(Mandatory, Position = 0)] + [scriptblock]$ScriptBlock, + + [hashtable]$Parameters = @{}, + + [switch]$Async, + + [switch]$PassThru + ) + + if (-not (Test-WinUtilUIAlive)) { + return + } + $dispatcher = $sync.Form.Dispatcher + + if (-not $Async -and $dispatcher.CheckAccess()) { + $inlineResult = & $ScriptBlock @Parameters + if ($PassThru) { return $inlineResult } + return + } + + $executor = $sync.UIDispatchDelegate + if ($null -eq $executor) { + # No interface runspace to hand the work to, so the block itself is marshalled. It has to + # receive its parameters and return what it produced, and [action] carries neither. + if ($Async) { + # The values travel as the dispatcher's argument, since this call returns before the + # block runs and anything captured from here would be gone by then + $null = $dispatcher.BeginInvoke( + [Windows.Threading.DispatcherPriority]::Background, + [System.Windows.Threading.DispatcherOperationCallback]{ + param($Work) + $body = $Work.Body + $arguments = $Work.Parameters + if ($arguments -and $arguments.Count -gt 0) { + $null = & $body @arguments + } else { + $null = & $body + } + return $null + }, + @{ Body = $ScriptBlock; Parameters = $Parameters }) + return + } + + # Synchronous, so this frame is still alive while the block runs and can be captured from + $fallbackResult = $dispatcher.Invoke([System.Func[object]]{ & $ScriptBlock @Parameters }) + if ($PassThru) { return $fallbackResult } + return + } + + $work = @{ + Body = $ScriptBlock.ToString() + Parameters = $Parameters + } + + if ($Async) { + $null = $dispatcher.BeginInvoke([Windows.Threading.DispatcherPriority]::Background, $executor, $work) return } - $sync.form.Dispatcher.Invoke([action]$ScriptBlock) + $result = $dispatcher.Invoke($executor, @($work)) + if ($PassThru) { return $result } } diff --git a/functions/public/Invoke-WPFUltimatePerformance.ps1 b/functions/public/Invoke-WPFUltimatePerformance.ps1 index 20ffa02672..41b6487716 100644 --- a/functions/public/Invoke-WPFUltimatePerformance.ps1 +++ b/functions/public/Invoke-WPFUltimatePerformance.ps1 @@ -1,9 +1,40 @@ function Invoke-WPFUltimatePerformance ([switch]$Enable) { + <# + + .SYNOPSIS + Adds or removes the Ultimate Performance power plan + + #> + if ($Enable) { - powercfg /setactive (powercfg /duplicatescheme e9a42b02-d5df-448d-aa00-03f14749eb61 | Select-String -Pattern '[A-Fa-f0-9-]{36}').Matches.Value - [System.Windows.MessageBox]::Show("Ultimate Power Plan plan installed and activated.","Success","OK","Information") + Step-WinUtilJob -Status "Adding the Ultimate Performance power plan" -State "Indeterminate" + Write-WinUtilLog -Component "Power" -Message "Duplicating and activating the Ultimate Performance power plan." + + $duplicated = powercfg /duplicatescheme e9a42b02-d5df-448d-aa00-03f14749eb61 + if ($LASTEXITCODE -ne 0) { + throw "powercfg could not duplicate the Ultimate Performance scheme (exit code $LASTEXITCODE)." + } + + $guid = ($duplicated | Select-String -Pattern '[A-Fa-f0-9-]{36}').Matches.Value + if (-not $guid) { + throw "powercfg did not report a scheme GUID to activate." + } + + powercfg /setactive $guid + if ($LASTEXITCODE -ne 0) { + throw "powercfg could not activate the Ultimate Performance scheme (exit code $LASTEXITCODE)." + } + + Write-WinUtilLog -Component "Power" -Message "Ultimate Performance power plan installed and activated." } else { + Step-WinUtilJob -Status "Restoring the default power plans" -State "Indeterminate" + Write-WinUtilLog -Component "Power" -Message "Restoring the default power schemes." + powercfg /restoredefaultschemes - [System.Windows.MessageBox]::Show("Power Plan was reset to defaults.","Success","OK","Information") + if ($LASTEXITCODE -ne 0) { + throw "powercfg could not restore the default power schemes (exit code $LASTEXITCODE)." + } + + Write-WinUtilLog -Component "Power" -Message "Power plans were reset to defaults." } } diff --git a/functions/public/Invoke-WPFUnInstall.ps1 b/functions/public/Invoke-WPFUnInstall.ps1 index 4144ad3712..1f09dc4056 100644 --- a/functions/public/Invoke-WPFUnInstall.ps1 +++ b/functions/public/Invoke-WPFUnInstall.ps1 @@ -9,12 +9,6 @@ function Invoke-WPFUnInstall { Uninstalls the selected programs #> - if($sync.ProcessRunning) { - $msg = "[Invoke-WPFUnInstall] Install process is currently running" - Show-WinUtilMessage -Message $msg -Title "WinUtil" -Button "OK" -Icon "Warning" - return - } - if ($PackagesToUninstall.Count -eq 0) { $WarningMsg = "Please select the program(s) to uninstall" Show-WinUtilMessage -Message $WarningMsg -Title "WinUtil" -Button "OK" -Icon "Warning" @@ -28,100 +22,59 @@ function Invoke-WPFUnInstall { $confirm = Show-WinUtilMessage -Message $Messageboxbody -Title $MessageboxTitle -Button $ButtonType -Icon $MessageIcon - if($confirm -eq "No") {return} + if ($confirm -ne "Yes") { return } $ManagerPreference = $sync.preferences.packagemanager Write-WinUtilLog -Component "Uninstall" -Message "Uninstall requested for $(@($PackagesToUninstall).Count) selected package(s) using preference: $ManagerPreference" - $packageSummary = Get-WinUtilPackageLogSummary -Packages $PackagesToUninstall -Preference $ManagerPreference - Write-WinUtilLog -Component "Uninstall" -Message "Uninstall selected package(s): $($packageSummary -join '; ')" - Invoke-WPFRunspace -ParameterList @(("PackagesToUninstall", $PackagesToUninstall),("ManagerPreference", $ManagerPreference)) -ScriptBlock { + Start-WinUtilJob -Name "Uninstall" -Description "Uninstalling apps" -DisableAppList -Parameters @{ + PackagesToUninstall = $PackagesToUninstall + ManagerPreference = $ManagerPreference + } -ScriptBlock { param($PackagesToUninstall, $ManagerPreference) - $packagesSorted = Get-WinUtilSelectedPackages -PackageList $PackagesToUninstall -Preference $ManagerPreference + $packageSummary = Get-WinUtilPackageLogSummary -Packages $PackagesToUninstall -Preference $ManagerPreference + Write-WinUtilLog -Component "Uninstall" -Message "Uninstall selected package(s): $($packageSummary -join '; ')" + $packagesSorted = Get-WinUtilSelectedPackages -PackageList $PackagesToUninstall -Preference $ManagerPreference $packagesWinget = $packagesSorted['Winget'] $packagesChoco = $packagesSorted['Choco'] $totalPackages = @($packagesWinget).Count + @($packagesChoco).Count $completedPackages = 0 - $hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher Write-WinUtilLog -Component "Uninstall" -Message "Uninstall package manager split: winget=$(@($packagesWinget).Count), choco=$(@($packagesChoco).Count)" - try { - $sync.ProcessRunning = $true - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Preparing app uninstall (0/$totalPackages)" -Percent 0 - Invoke-WPFUIThread -ScriptBlock { - if ($null -ne $sync.ItemsControl) { - $sync.ItemsControl.IsEnabled = $false - } - } - } + if ($packagesWinget -contains "Microsoft.Edge") { + New-Item -Path "$Env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe\MicrosoftEdge.exe" -Force | Out-Null + } - if ($packagesWinget -contains "Microsoft.Edge") { - New-Item -Path "$Env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe\MicrosoftEdge.exe" -Force - } + $results = @() - # Uninstall all selected programs in new window - if($packagesWinget.Count -gt 0) { - foreach ($program in $packagesWinget) { - $position = $completedPackages + 1 - $startPercent = [int](($completedPackages / $totalPackages) * 100) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Uninstalling $program ($position/$totalPackages)" -Percent $startPercent - } + if ($packagesWinget.Count -gt 0) { + foreach ($program in $packagesWinget) { + $position = $completedPackages + 1 + Step-WinUtilJob -Status "Uninstalling $program ($position/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) + $results += Measure-WinUtilStep -Scope "Uninstall" -Name "winget $program" -ScriptBlock { Install-WinUtilProgramWinget -Action Uninstall -Programs @($program) - $completedPackages++ - $completedPercent = [int](($completedPackages / $totalPackages) * 100) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Uninstalled $program ($completedPackages/$totalPackages)" -Percent $completedPercent - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) } - } } + $completedPackages++ + Step-WinUtilJob -Status "Uninstalled $program ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) } - if($packagesChoco.Count -gt 0) { - $position = $completedPackages + 1 - $startPercent = [int](($completedPackages / $totalPackages) * 100) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Uninstalling Chocolatey packages ($position/$totalPackages)" -Percent $startPercent - } + } - Install-WinUtilProgramChoco -Action Uninstall -Programs $packagesChoco - $completedPackages += @($packagesChoco).Count - $completedPercent = [int](($completedPackages / $totalPackages) * 100) - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Uninstalled Chocolatey packages ($completedPackages/$totalPackages)" -Percent $completedPercent - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) } - } - } - Write-Host "===========================================" - Write-Host "-- Uninstalls have finished ---" - Write-Host "===========================================" - Write-WinUtilLog -Component "Uninstall" -Message "Uninstall workflow completed." - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "App uninstall finished" -Percent 100 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } - } - } catch { - Write-Host "===========================================" - Write-Host "Error: $_" - Write-Host "===========================================" - Write-WinUtilLog -Level "ERROR" -Component "Uninstall" -Message "Uninstall workflow failed: $($_.Exception.Message)" - if ($hasUI) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "App uninstall failed" -Percent 100 - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" } - } - } finally { - if ($hasUI) { - Invoke-WPFUIThread -ScriptBlock { - if ($null -ne $sync.ItemsControl) { - $sync.ItemsControl.IsEnabled = $true - } - } + if ($packagesChoco.Count -gt 0) { + $position = $completedPackages + 1 + Step-WinUtilJob -Status "Uninstalling Chocolatey packages ($position/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) + + $chocoBase = [int](($completedPackages / $totalPackages) * 100) + $chocoSpan = [int]((@($packagesChoco).Count / $totalPackages) * 100) + $results += Measure-WinUtilStep -Scope "Uninstall" -Name "choco $($packagesChoco -join ', ')" -ScriptBlock { + Install-WinUtilProgramChoco -Action Uninstall -Programs $packagesChoco -ProgressBase $chocoBase -ProgressSpan $chocoSpan } - $sync.ProcessRunning = $False + $completedPackages += @($packagesChoco).Count + Step-WinUtilJob -Status "Uninstalled Chocolatey packages ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100)) } + Complete-WinUtilPackageRun -Action "Uninstall" -Results $results } } diff --git a/functions/public/Invoke-WPFUpdatesdefault.ps1 b/functions/public/Invoke-WPFUpdatesdefault.ps1 index 8b250caa5f..0374ebcbc3 100644 --- a/functions/public/Invoke-WPFUpdatesdefault.ps1 +++ b/functions/public/Invoke-WPFUpdatesdefault.ps1 @@ -82,9 +82,6 @@ function Invoke-WPFUpdatesdefault { Get-ScheduledTask -TaskPath $Task -ErrorAction SilentlyContinue | Enable-ScheduledTask -ErrorAction SilentlyContinue } - Write-Host "===================================================" -ForegroundColor Green - Write-Host "--- Windows Update Settings Reset to Default ---" -ForegroundColor Green - Write-Host "===================================================" -ForegroundColor Green Write-Host "Note: You must restart your system in order for all changes to take effect." -ForegroundColor Yellow Write-WinUtilLog -Component "Updates" -Message "Windows Update default workflow completed. Restart required." diff --git a/functions/public/Invoke-WPFUpdatesdisable.ps1 b/functions/public/Invoke-WPFUpdatesdisable.ps1 index 547bb9f0ee..d7b9d9ad1c 100644 --- a/functions/public/Invoke-WPFUpdatesdisable.ps1 +++ b/functions/public/Invoke-WPFUpdatesdisable.ps1 @@ -57,9 +57,6 @@ function Invoke-WPFUpdatesdisable { Get-ScheduledTask -TaskPath $Task -ErrorAction SilentlyContinue | Disable-ScheduledTask -ErrorAction SilentlyContinue } - Write-Host "=================================" -ForegroundColor Green - Write-Host "--- Windows Update Is Disabled ---" -ForegroundColor Green - Write-Host "=================================" -ForegroundColor Green Write-Host "Note: You must restart your system in order for all changes to take effect." -ForegroundColor Yellow Write-WinUtilLog -Component "Updates" -Message "Windows Update disable workflow completed. Restart required." diff --git a/functions/public/Invoke-WPFUpdatessecurity.ps1 b/functions/public/Invoke-WPFUpdatessecurity.ps1 index 09eaf2028e..e5aed39c61 100644 --- a/functions/public/Invoke-WPFUpdatessecurity.ps1 +++ b/functions/public/Invoke-WPFUpdatessecurity.ps1 @@ -76,8 +76,5 @@ function Invoke-WPFUpdatessecurity { Set-ItemProperty -Path $automaticUpdatePolicyPath -Name "NoAutoRebootWithLoggedOnUsers" -Type DWord -Value 1 Set-ItemProperty -Path $automaticUpdatePolicyPath -Name "AUPowerManagement" -Type DWord -Value 0 - Write-Host "=================================" - Write-Host "-- Updates Set to Recommended ---" - Write-Host "=================================" Write-WinUtilLog -Component "Updates" -Message "Recommended Windows Update settings workflow completed." } diff --git a/functions/public/Invoke-WPFtweaksbutton.ps1 b/functions/public/Invoke-WPFtweaksbutton.ps1 index bca2080fa5..a64983b48c 100644 --- a/functions/public/Invoke-WPFtweaksbutton.ps1 +++ b/functions/public/Invoke-WPFtweaksbutton.ps1 @@ -6,94 +6,59 @@ function Invoke-WPFtweaksbutton { #> - if($sync.ProcessRunning) { - $msg = "[Invoke-WPFtweaksbutton] Install process is currently running." - [System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning) - return - } - $Tweaks = $sync.selectedTweaks $dnsProvider = $sync["WPFchangedns"].text if (-not ($dnsProvider)) { $dnsProvider = "Default" } - $restorePointTweak = "WPFTweaksRestorePoint" - $restorePointSelected = $Tweaks -contains $restorePointTweak - $tweaksToRun = @($Tweaks | Where-Object { $_ -ne $restorePointTweak }) - $totalSteps = [Math]::Max($Tweaks.Count, 1) - $completedSteps = 0 - Write-WinUtilLog -Component "Tweaks" -Message "Tweaks requested: $(@($Tweaks).Count) selected tweak(s), DNS provider: $dnsProvider" - if ($tweaks.count -eq 0 -and $dnsProvider -eq "Default") { - $msg = "Please check the tweaks you wish to perform." - [System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning) + if ($Tweaks.count -eq 0 -and $dnsProvider -eq "Default") { + Show-WinUtilMessage -Message "Please check the tweaks you wish to perform." -Title "WinUtil" -Button "OK" -Icon "Warning" return } - if ($restorePointSelected) { - $sync.ProcessRunning = $true + Write-WinUtilLog -Component "Tweaks" -Message "Tweaks requested: $(@($Tweaks).Count) selected tweak(s), DNS provider: $dnsProvider" - if ($Tweaks.Count -eq 1) { - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Indeterminate" -value 0.01 -overlay "logo" } - } else { - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" } - } + Start-WinUtilJob -Name "Tweaks" -Description "Applying tweaks" -Parameters @{ + Tweaks = @($Tweaks) + DnsProvider = $dnsProvider + } -ScriptBlock { + param($Tweaks, $DnsProvider) - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Creating restore point" -Percent 0 - Write-WinUtilLog -Component "Tweaks" -Message "Creating restore point before applying selected tweaks." - Invoke-WinUtilTweaks $restorePointTweak - $completedSteps = 1 + # The restore point has to be taken before anything else changes + $restorePointTweak = "WPFTweaksRestorePoint" + $tweaksToRun = @($Tweaks | Where-Object { $_ -ne $restorePointTweak }) + $totalSteps = [Math]::Max(@($Tweaks).Count, 1) + $completedSteps = 0 - if ($tweaksToRun.Count -eq 0 -and $dnsProvider -eq "Default") { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Tweaks finished" -Percent 100 - $sync.ProcessRunning = $false - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } - Write-Host "=================================" - Write-Host "-- Tweaks are Finished ---" - Write-Host "=================================" - Write-WinUtilLog -Component "Tweaks" -Message "Tweaks workflow completed after restore point." - return + if ($Tweaks -contains $restorePointTweak) { + Step-WinUtilJob -Status "Creating restore point" -Percent 0 + Write-WinUtilLog -Component "Tweaks" -Message "Creating restore point before applying selected tweaks." + Measure-WinUtilStep -Scope "Tweaks" -Name $restorePointTweak -ScriptBlock { + Invoke-WinUtilTweaks $restorePointTweak + } + $completedSteps = 1 } - } - - # The leading "," in the ParameterList is necessary because we only provide one argument and powershell cannot be convinced that we want a nested loop with only one argument otherwise - Invoke-WPFRunspace -ParameterList @(("tweaks", $tweaksToRun), ("dnsProvider", $dnsProvider), ("completedSteps", $completedSteps), ("totalSteps", $totalSteps)) -ScriptBlock { - param($tweaks, $dnsProvider, $completedSteps, $totalSteps) - - $sync.ProcessRunning = $true - if ($completedSteps -eq 0) { - if ($Tweaks.count -eq 1) { - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Indeterminate" -value 0.01 -overlay "logo" } - } else { - Invoke-WPFUIThread -ScriptBlock{ Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" } + if ($DnsProvider -ne "Default") { + $dnsResult = Measure-WinUtilStep -Scope "Tweaks" -Name "Set DNS to $DnsProvider" -ScriptBlock { + @(Set-WinUtilDNS -DNSProvider $DnsProvider) } - } - if ($dnsProvider -ne "Default") { - $dnsResult = @(Set-WinUtilDNS -DNSProvider $dnsProvider) - if ($dnsResult[-1] -ne $true) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "DNS change failed" -Percent 100 - $sync.ProcessRunning = $false - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" } - Write-WinUtilLog -Level "ERROR" -Component "Tweaks" -Message "Tweaks workflow stopped because the DNS change failed." - return + # Carrying on after the DNS change failed leaves the machine half configured, so the run + # ends here and the job layer reports it + if (@($dnsResult)[-1] -ne $true) { + throw "The DNS change to $DnsProvider failed, so the remaining tweaks were not applied." } } - for ($i = 0; $i -lt $tweaks.Count; $i++) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Applying $($tweaks[$i]) ($($completedSteps + 1)/$totalSteps)" -Percent ($completedSteps / $totalSteps * 100) - Invoke-WinUtilTweaks $tweaks[$i] + foreach ($tweak in $tweaksToRun) { + Step-WinUtilJob -Status "Applying $tweak ($($completedSteps + 1)/$totalSteps)" -Percent ([int](($completedSteps / $totalSteps) * 100)) + Measure-WinUtilStep -Scope "Tweaks" -Name $tweak -ScriptBlock { + Invoke-WinUtilTweaks $tweak + } $completedSteps++ - $progress = $completedSteps / $totalSteps - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value $progress } + Step-WinUtilJob -Percent ([int](($completedSteps / $totalSteps) * 100)) } - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Tweaks finished" -Percent 100 - $sync.ProcessRunning = $false - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } - Write-Host "=================================" - Write-Host "-- Tweaks are Finished ---" - Write-Host "=================================" - Write-WinUtilLog -Component "Tweaks" -Message "Tweaks workflow completed." - } | Out-Null + } } diff --git a/functions/public/Invoke-WPFundoall.ps1 b/functions/public/Invoke-WPFundoall.ps1 index 47903009a8..d7658c12f9 100644 --- a/functions/public/Invoke-WPFundoall.ps1 +++ b/functions/public/Invoke-WPFundoall.ps1 @@ -6,45 +6,27 @@ function Invoke-WPFundoall { #> - if($sync.ProcessRunning) { - $msg = "[Invoke-WPFundoall] Install process is currently running." - [System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning) - return - } - $tweaks = $sync.selectedTweaks if ($tweaks.count -eq 0) { - $msg = "Please check the tweaks you wish to undo." - [System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning) + Show-WinUtilMessage -Message "Please check the tweaks you wish to undo." -Title "WinUtil" -Button "OK" -Icon "Warning" return } - Invoke-WPFRunspace -ArgumentList $tweaks -ScriptBlock { - param($tweaks) + Start-WinUtilJob -Name "Undo tweaks" -Description "Undoing tweaks" -Parameters @{ + Tweaks = @($tweaks) + } -ScriptBlock { + param($Tweaks) - $sync.ProcessRunning = $true - Write-WinUtilLog -Component "Tweaks" -Message "Undo tweaks requested: $(@($tweaks).Count) selected tweak(s)." - if ($tweaks.count -eq 1) { - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Indeterminate" -value 0.01 -overlay "logo" } - } else { - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" } - } + $total = @($Tweaks).Count + Write-WinUtilLog -Component "Tweaks" -Message "Undo tweaks requested: $total selected tweak(s)." - - for ($i = 0; $i -lt $tweaks.Count; $i++) { - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Undoing $($tweaks[$i]) ($($i + 1)/$($tweaks.Count))" -Percent ($i / $tweaks.Count * 100) - Invoke-WinUtiltweaks $tweaks[$i] -undo $true - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($i/$tweaks.Count) } + for ($i = 0; $i -lt $total; $i++) { + Step-WinUtilJob -Status "Undoing $($Tweaks[$i]) ($($i + 1)/$total)" -Percent ([int](($i / $total) * 100)) + Measure-WinUtilStep -Scope "Undo tweaks" -Name $Tweaks[$i] -ScriptBlock { + Invoke-WinUtiltweaks $Tweaks[$i] -undo $true + } + Step-WinUtilJob -Percent ([int]((($i + 1) / $total) * 100)) } - - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Undo Tweaks Finished" -Percent 100 - $sync.ProcessRunning = $false - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } - Write-Host "==================================" - Write-Host "--- Undo Tweaks are Finished ---" - Write-Host "==================================" - Write-WinUtilLog -Component "Tweaks" -Message "Undo tweaks workflow completed." - } } diff --git a/functions/public/Invoke-WinUtilAutoRun.ps1 b/functions/public/Invoke-WinUtilAutoRun.ps1 index c4b094db95..bb41f431a6 100644 --- a/functions/public/Invoke-WinUtilAutoRun.ps1 +++ b/functions/public/Invoke-WinUtilAutoRun.ps1 @@ -2,39 +2,151 @@ function Invoke-WinUtilAutoRun { <# .SYNOPSIS - Runs Install, Tweaks, and Features with optional UI invocation. + Runs every selected action to completion without a window + + .DESCRIPTION + The headless path. Each action is the same job the button would start, run one at a time + because the job layer allows one at a time, and waited on until the worker clears the + busy flag. + + Returns a summary of what ran so the caller can decide the exit code. Nothing here + touches the interface, so it behaves the same whether a window exists or not. + + .PARAMETER StopTimeoutSeconds + How long a step that timed out is given to stop before the run gives up on the rest. + + .PARAMETER StepTimeoutSeconds + How long a single action may take before the run gives up on it. Without a ceiling an + installer waiting on something that will never arrive hangs the run for good. + #> + param( + [int]$StepTimeoutSeconds = 3600, + + [int]$StopTimeoutSeconds = 30 + ) + + $steps = @( + [pscustomobject]@{ Name = "Tweaks"; Count = @($sync.selectedTweaks).Count; Action = { Invoke-WPFtweaksbutton } } + [pscustomobject]@{ Name = "Toggles"; Count = @($sync.selectedToggles).Count; Action = { Invoke-WPFToggleSelections } } + [pscustomobject]@{ Name = "Features"; Count = @($sync.selectedFeatures).Count; Action = { Invoke-WPFFeatureInstall } } + [pscustomobject]@{ Name = "Applications"; Count = @($sync.selectedApps).Count; Action = { Invoke-WPFInstall } } + [pscustomobject]@{ Name = "AppX removal"; Count = @($sync.selectedAppx).Count; Action = { Invoke-WPFAppxRemoval } } + ) + + $planned = @($steps | Where-Object { $_.Count -gt 0 }) + if ($planned.Count -eq 0) { + Write-WinUtilLog -Level "WARN" -Component "AutoRun" -Message "Nothing was selected, so there is nothing to do." + return [pscustomobject]@{ Steps = @(); Failed = 0; TimedOut = 0; Errors = 0 } + } + + Write-WinUtilLog -Component "AutoRun" -Message "Headless run starting: $(($planned | ForEach-Object { "$($_.Name) ($($_.Count))" }) -join ', ')" + + $results = New-Object System.Collections.ArrayList + $runClock = [System.Diagnostics.Stopwatch]::StartNew() + + foreach ($step in $planned) { + $errorsBefore = if ($sync.LoggedErrors) { $sync.LoggedErrors.Count } else { 0 } + $stepClock = [System.Diagnostics.Stopwatch]::StartNew() + $timedOut = $false + + Write-WinUtilLog -Component "AutoRun" -Message "$($step.Name): starting $($step.Count) item(s)." + + try { + & $step.Action + } catch { + Write-WinUtilErrorRecord -ErrorRecord $_ -Component "AutoRun" -Context "Starting $($step.Name)" + } - function BusyWait { - Start-Sleep -Milliseconds 100 - while ($sync.ProcessRunning) { - Start-Sleep -Milliseconds 100 + # The action starts a job and returns; the run is over when the worker clears the flag + while ($sync.ActiveJob) { + if ($stepClock.Elapsed.TotalSeconds -ge $StepTimeoutSeconds) { + $timedOut = $true + Write-WinUtilLog -Level "ERROR" -Component "AutoRun" -Message "$($step.Name) did not finish within $StepTimeoutSeconds seconds, moving on." + break + } + Start-Sleep -Milliseconds 200 + } + + $stepClock.Stop() + $newErrors = if ($sync.LoggedErrors) { $sync.LoggedErrors.Count - $errorsBefore } else { 0 } + + $null = $results.Add([pscustomobject]@{ + Name = $step.Name + Items = $step.Count + Seconds = [int]$stepClock.Elapsed.TotalSeconds + Errors = $newErrors + TimedOut = $timedOut + }) + + $outcome = if ($timedOut) { "timed out" } elseif ($newErrors -gt 0) { "finished with $newErrors error(s)" } else { "finished" } + Write-WinUtilLog -Component "AutoRun" -Message "$($step.Name): $outcome after $([int]$stepClock.Elapsed.TotalSeconds)s." + + if ($timedOut) { + # The worker is still on the pool. Clearing the slot on its own would let the next + # step start beside it, so two runs would be changing the machine at once. + Stop-WinUtilActiveWork -NoWait | Out-Null + + $stopDeadline = (Get-Date).AddSeconds($StopTimeoutSeconds) + while ((Test-WinUtilActiveWorkRunning) -and (Get-Date) -lt $stopDeadline) { + Start-Sleep -Milliseconds 200 + } + + # A job that never cleared the flag would make every later step refuse to start + $null = Clear-WinUtilActiveJob + + if (Test-WinUtilActiveWorkRunning) { + Write-WinUtilLog -Level "ERROR" -Component "AutoRun" -Message "$($step.Name) could not be stopped, so the remaining steps are abandoned rather than run beside it." + break + } } } - if ($sync.selectedTweaks.Count -gt 0) { - Write-Host "Applying tweaks..." - Invoke-WPFtweaksbutton - BusyWait + $runClock.Stop() + Write-WinUtilTimingSummary -Scope "AutoRun" -TotalMilliseconds $runClock.ElapsedMilliseconds + + return [pscustomobject]@{ + Steps = @($results) + Failed = @($results | Where-Object { $_.Errors -gt 0 }).Count + TimedOut = @($results | Where-Object { $_.TimedOut }).Count + Errors = (@($results | Measure-Object -Property Errors -Sum).Sum) } +} + +function Write-WinUtilAutoRunSummary { + <# + .SYNOPSIS + Prints what a headless run did and returns the exit code it should end with + #> + param( + [Parameter(Mandatory)] + $Summary + ) + + Write-Host "" + Write-Host "=== WinUtil headless run ===" -ForegroundColor Cyan - if ($sync.selectedFeatures.Count -gt 0) { - Write-Host "Applying features..." - Invoke-WPFFeatureInstall - BusyWait + foreach ($step in @($Summary.Steps)) { + $state = if ($step.TimedOut) { "TIMED OUT" } elseif ($step.Errors -gt 0) { "$($step.Errors) error(s)" } else { "ok" } + $colour = if ($step.TimedOut -or $step.Errors -gt 0) { "Yellow" } else { "Green" } + Write-Host (" {0,-14} {1,3} item(s) {2,5}s {3}" -f $step.Name, $step.Items, $step.Seconds, $state) -ForegroundColor $colour } - if ($sync.selectedApps.Count -gt 0) { - Write-Host "Installing applications..." - Invoke-WPFInstall - BusyWait + if (@($Summary.Steps).Count -eq 0) { + Write-Host " nothing was selected" -ForegroundColor Yellow + Write-Host "" + return 2 } - if ($sync.selectedAppx.Count -gt 0) { - Write-Host "Removing AppX packages..." - Invoke-WPFAppxRemoval - BusyWait + if ($Summary.TimedOut -gt 0 -or $Summary.Failed -gt 0) { + Write-Host "" + Write-Host "Finished with problems. See $($sync.logPath)" -ForegroundColor Yellow + Write-Host "" + return 1 } - Write-Host "Done." + Write-Host "" + Write-Host "All steps completed. Log: $($sync.logPath)" -ForegroundColor Green + Write-Host "" + return 0 } diff --git a/pester/appx.Tests.ps1 b/pester/appx.Tests.ps1 index e055a10f78..283d6c2c00 100644 --- a/pester/appx.Tests.ps1 +++ b/pester/appx.Tests.ps1 @@ -37,6 +37,12 @@ BeforeAll { function Write-WinUtilLog { param($Message, $Level, $Component) } + function Start-WinUtilJob { + param([string]$Name, [scriptblock]$ScriptBlock, [hashtable]$Parameters, [string]$Description, [switch]$DisableAppList) + } + function Step-WinUtilJob { + param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay) + } function Show-WinUtilMessage { param($Message, $Title, $Button, $Icon) } @@ -44,10 +50,7 @@ BeforeAll { param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) } function Invoke-WPFUIThread { - param([scriptblock]$ScriptBlock) - } - function Set-WinUtilTweaksProgressIndicator { - param($Visible, $Label, $Percent) + param([scriptblock]$ScriptBlock, [hashtable]$Parameters, [switch]$Async) } function powershell.exe { } function Get-AppxPackage { @@ -220,7 +223,7 @@ Describe "Install-WinUtilAPPX" { Describe "Get installed AppX selection" { BeforeEach { $script:sync = [Hashtable]::Synchronized(@{ - ProcessRunning = $false + ActiveJob = $null configs = @{ feature = @{} appxHashtable = @{ @@ -232,9 +235,14 @@ Describe "Get installed AppX selection" { WPFAppxMissing = [pscustomobject]@{ IsChecked = $false } }) - Mock Set-WinUtilTweaksProgressIndicator { } Mock Get-WinUtilInstalledAPPX { @("Example.Package") } Mock Invoke-WPFAppxInstall { } + Mock Step-WinUtilJob { } + Mock Invoke-WPFUIThread { $uiParameters = $Parameters; & $ScriptBlock @uiParameters } + Mock Start-WinUtilJob { + $jobParameters = $Parameters + & $ScriptBlock @jobParameters + } } AfterEach { @@ -244,6 +252,9 @@ Describe "Get installed AppX selection" { It "selects configured packages returned by the compatibility-safe query" { Invoke-WPFButton -Button "WPFGetInstalledAppx" + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "GetInstalledAppx" + } Should -Invoke -CommandName Get-WinUtilInstalledAPPX -Times 1 -Exactly $script:sync.WPFAppxExample.IsChecked | Should -BeTrue $script:sync.WPFAppxMissing.IsChecked | Should -BeFalse @@ -350,7 +361,7 @@ Describe "Remove-WinUtilProvisionedAPPX" { Describe "Invoke-WPFAppxInstall" { BeforeEach { $script:sync = [Hashtable]::Synchronized(@{ - ProcessRunning = $false + ActiveJob = $null Form = [pscustomobject]@{ Dispatcher = [pscustomobject]@{} } selectedAppx = [System.Collections.Generic.List[string]]::new() configs = @{ @@ -365,19 +376,16 @@ Describe "Invoke-WPFAppxInstall" { }) $script:capturedAppxInstallScriptBlock = $null $script:capturedAppxInstallParameterList = $null - $script:appxInstallProcessRunningAtLaunch = $null Mock Show-WinUtilMessage { "OK" } Mock Write-Host { } Mock Write-WinUtilLog { } - Mock Set-WinUtilTweaksProgressIndicator { } Mock Invoke-WPFUIThread { } Mock Install-WinUtilAPPX { } - Mock Invoke-WPFRunspace { - $script:appxInstallProcessRunningAtLaunch = $script:sync.ProcessRunning + Mock Step-WinUtilJob { } + Mock Start-WinUtilJob { $script:capturedAppxInstallScriptBlock = $ScriptBlock - $script:capturedAppxInstallParameterList = $ParameterList - [pscustomobject]@{ MockHandle = $true } + $script:capturedAppxInstallParameters = $Parameters } } @@ -385,7 +393,6 @@ Describe "Invoke-WPFAppxInstall" { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue Remove-Variable -Name capturedAppxInstallScriptBlock -Scope Script -ErrorAction SilentlyContinue Remove-Variable -Name capturedAppxInstallParameterList -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name appxInstallProcessRunningAtLaunch -Scope Script -ErrorAction SilentlyContinue } It "prompts and exits when no AppX packages are selected for install" { @@ -397,158 +404,60 @@ Describe "Invoke-WPFAppxInstall" { $Button -eq "OK" -and $Icon -eq "Error" } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly } - It "prevents overlapping AppX install operations" { - $script:sync.ProcessRunning = $true + It "queues an install job for the selected AppX packages" { $script:sync.selectedAppx.Add("WPFAppxExample") Invoke-WPFAppxInstall - Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { - $Message -eq "An AppX process is currently running." -and - $Title -eq "WinUtil" -and - $Button -eq "OK" -and - $Icon -eq "Warning" + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "AppX install" } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + $script:capturedAppxInstallParameters.Selected[0] | Should -Be "WPFAppxExample" } It "installs selected AppX packages with their Store IDs" { $script:sync.selectedAppx.Add("WPFAppxExample") Invoke-WPFAppxInstall - $script:appxInstallProcessRunningAtLaunch | Should -BeTrue - & $script:capturedAppxInstallScriptBlock -selected @("WPFAppxExample") -apps $script:sync.configs.appxHashtable + $jobParameters = $script:capturedAppxInstallParameters + & $script:capturedAppxInstallScriptBlock @jobParameters - $script:capturedAppxInstallParameterList[0][1][0] | Should -Be "WPFAppxExample" Should -Invoke -CommandName Install-WinUtilAPPX -Times 1 -Exactly -ParameterFilter { $Name -eq "Example.Package" -and $StoreId -eq "9EXAMPLE1234" } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Installing Example App (1/1)" -and $Percent -eq 0 + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Installing Example App (1/1)" -and $Percent -eq 0 } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Installed Example App (1/1)" -and $Percent -eq 100 + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Installed Example App (1/1)" -and $Percent -eq 100 } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "AppX install finished" -and $Percent -eq 100 - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*' - } - $script:sync.ProcessRunning | Should -BeFalse } - It "shows failure feedback and clears ProcessRunning when install fails" { + It "lets an install failure surface so the job layer can handle it" { $script:sync.selectedAppx.Add("WPFAppxExample") Mock Install-WinUtilAPPX { throw "Install failed" } Invoke-WPFAppxInstall - & $script:capturedAppxInstallScriptBlock -selected @("WPFAppxExample") -apps $script:sync.configs.appxHashtable - - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "AppX install failed" -and $Percent -eq 100 - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*' - } - $script:sync.ProcessRunning | Should -BeFalse - } -} - -Describe "Invoke-WPFAppxRemoval entrypoint" { - BeforeEach { - $script:sync = [Hashtable]::Synchronized(@{ - ProcessRunning = $false - selectedAppx = [System.Collections.Generic.List[string]]::new() - configs = @{ - appxHashtable = @{} - } - }) - $script:capturedAppxScriptBlock = $null - $script:capturedAppxParameterList = $null - $script:appxRemovalProcessRunningAtLaunch = $null - - Mock Show-WinUtilMessage { "OK" } - Mock Invoke-WPFRunspace { - $script:appxRemovalProcessRunningAtLaunch = $script:sync.ProcessRunning - $script:capturedAppxScriptBlock = $ScriptBlock - $script:capturedAppxParameterList = $ParameterList - [pscustomobject]@{ MockHandle = $true } - } - } - - AfterEach { - Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedAppxScriptBlock -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedAppxParameterList -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name appxRemovalProcessRunningAtLaunch -Scope Script -ErrorAction SilentlyContinue - } - - It "prompts and exits when no AppX packages are selected" { - Invoke-WPFAppxRemoval - - Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { - $Message -eq "No AppX Package selected" -and - $Title -eq "Error" -and - $Button -eq "OK" -and - $Icon -eq "Error" - } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly - } - - It "prevents overlapping AppX removal operations" { - $script:sync.ProcessRunning = $true - $script:sync.selectedAppx.Add("WPFAppxExample") - - Invoke-WPFAppxRemoval - - Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { - $Message -eq "An AppX process is currently running." -and - $Title -eq "WinUtil" -and - $Button -eq "OK" -and - $Icon -eq "Warning" - } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly - } - - It "passes selected AppX keys and app metadata to the removal runspace" { - $script:sync.selectedAppx.Add("WPFAppxExample") - $script:sync.configs.appxHashtable["WPFAppxExample"] = [pscustomobject]@{ - Content = "Example App" - PackageId = "Example.Package" - } - - Invoke-WPFAppxRemoval - $script:sync.selectedAppx.Add("WPFAppxChangedAfterLaunch") + $jobParameters = $script:capturedAppxInstallParameters - $script:appxRemovalProcessRunningAtLaunch | Should -BeTrue - $script:capturedAppxParameterList[0][1] | Should -HaveCount 1 - Should -Invoke -CommandName Show-WinUtilMessage -Times 0 -Exactly - Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { - $ScriptBlock -is [scriptblock] -and - $ParameterList.Count -eq 2 -and - $ParameterList[0][0] -eq "selected" -and - $ParameterList[0][1][0] -eq "WPFAppxExample" -and - $ParameterList[1][0] -eq "apps" -and - $ParameterList[1][1]["WPFAppxExample"].PackageId -eq "Example.Package" - } + { & $script:capturedAppxInstallScriptBlock @jobParameters } | Should -Throw "Install failed" } } - -Describe "Invoke-WPFAppxRemoval runspace body" { +Describe "Invoke-WPFAppxRemoval" { BeforeEach { $script:sync = [Hashtable]::Synchronized(@{ - ProcessRunning = $false + ActiveJob = $null Form = [pscustomobject]@{ Dispatcher = [pscustomobject]@{} } selectedAppx = [System.Collections.Generic.List[string]]::new() configs = @{ appxHashtable = @{} } }) - $script:capturedAppxScriptBlock = $null + $script:capturedAppxRemovalScriptBlock = $null + $script:capturedAppxRemovalParameters = $null $script:apps = @{ WPFAppxExample = [pscustomobject]@{ Content = "Example App" @@ -568,15 +477,10 @@ Describe "Invoke-WPFAppxRemoval runspace body" { } } - Mock Invoke-WPFRunspace { - $script:capturedAppxScriptBlock = $ScriptBlock - [pscustomobject]@{ MockHandle = $true } - } Mock Show-WinUtilMessage { "OK" } Mock Write-Host { } Mock Write-WinUtilLog { } - Mock Set-WinUtilTweaksProgressIndicator { } - Mock Invoke-WPFUIThread { } + Mock Step-WinUtilJob { } Mock Stop-Process { } Mock Set-ItemProperty { } Mock Get-AppxPackage { @@ -593,21 +497,54 @@ Describe "Invoke-WPFAppxRemoval runspace body" { } } Mock Uninstall-Package { } + Mock Start-WinUtilJob { + $script:capturedAppxRemovalScriptBlock = $ScriptBlock + $script:capturedAppxRemovalParameters = $Parameters + } } AfterEach { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedAppxScriptBlock -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedAppxRemovalScriptBlock -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedAppxRemovalParameters -Scope Script -ErrorAction SilentlyContinue Remove-Variable -Name apps -Scope Script -ErrorAction SilentlyContinue } - It "removes selected AppX packages and clears ProcessRunning when finished" { - $selected = @("WPFAppxExample") + It "prompts and exits when no AppX packages are selected" { + Invoke-WPFAppxRemoval + + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Message -eq "No AppX Package selected" -and + $Title -eq "Error" -and + $Button -eq "OK" -and + $Icon -eq "Error" + } + Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly + } + + It "queues a removal job with a snapshot of the selection and app metadata" { $script:sync.selectedAppx.Add("WPFAppxExample") $script:sync.configs.appxHashtable = $script:apps Invoke-WPFAppxRemoval - & $script:capturedAppxScriptBlock -selected $selected -apps $script:apps + $script:sync.selectedAppx.Add("WPFAppxChangedAfterLaunch") + + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "AppX" -and $ScriptBlock -is [scriptblock] + } + Should -Invoke -CommandName Show-WinUtilMessage -Times 0 -Exactly + $script:capturedAppxRemovalParameters.Selected | Should -HaveCount 1 + $script:capturedAppxRemovalParameters.Selected[0] | Should -Be "WPFAppxExample" + $script:capturedAppxRemovalParameters.Apps["WPFAppxExample"].PackageId | Should -Be "Example.Package" + } + + It "removes the selected packages and reports progress for each one" { + $script:sync.selectedAppx.Add("WPFAppxExample") + $script:sync.configs.appxHashtable = $script:apps + + Invoke-WPFAppxRemoval + $jobParameters = $script:capturedAppxRemovalParameters + & $script:capturedAppxRemovalScriptBlock @jobParameters Should -Invoke -CommandName Get-AppxPackage -Times 1 -Exactly -ParameterFilter { $Name -eq "*Example.Package*" -and $AllUsers -eq $true @@ -618,93 +555,49 @@ Describe "Invoke-WPFAppxRemoval runspace body" { Should -Invoke -CommandName Remove-WinUtilProvisionedAPPX -Times 1 -Exactly -ParameterFilter { $PackageList.Count -eq 1 -and $PackageList[0] -eq "Example.Package" } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Removing Example App (1/1)" -and $Percent -eq 0 + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Removing Example App (1/1)" -and $Percent -eq 0 } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Removed Example App (1/1)" -and $Percent -eq 90 + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Removed Example App (1/1)" -and $Percent -eq 90 } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "AppX removal finished" -and $Percent -eq 100 + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Removing provisioned AppX packages" -and $Percent -eq 90 } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*' - } - $script:sync.ProcessRunning | Should -BeFalse } - It "shows failure feedback and clears ProcessRunning when removal fails" { - $selected = @("WPFAppxExample") + It "lets a removal failure surface so the job layer can handle it" { $script:sync.selectedAppx.Add("WPFAppxExample") $script:sync.configs.appxHashtable = $script:apps Mock Remove-WinUtilAPPX { throw "Removal failed" } Invoke-WPFAppxRemoval - & $script:capturedAppxScriptBlock -selected $selected -apps $script:apps + $jobParameters = $script:capturedAppxRemovalParameters - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "AppX removal failed" -and $Percent -eq 100 - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*' - } + { & $script:capturedAppxRemovalScriptBlock @jobParameters } | Should -Throw "Removal failed" Should -Invoke -CommandName Remove-WinUtilProvisionedAPPX -Times 0 -Exactly - $script:sync.ProcessRunning | Should -BeFalse } - It "removes packages without UI progress during headless autorun" { - $selected = @("WPFAppxExample") - $script:sync.Remove("Form") - $script:sync.selectedAppx.Add("WPFAppxExample") - $script:sync.configs.appxHashtable = $script:apps - - Invoke-WPFAppxRemoval - & $script:capturedAppxScriptBlock -selected $selected -apps $script:apps - - Should -Invoke -CommandName Remove-AppxPackage -Times 1 -Exactly - Should -Invoke -CommandName Remove-WinUtilProvisionedAPPX -Times 1 -Exactly - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 0 -Exactly - Should -Invoke -CommandName Invoke-WPFUIThread -Times 0 -Exactly - $script:sync.ProcessRunning | Should -BeFalse - } - - It "shows failure feedback when provisioned package removal fails" { - $selected = @("WPFAppxExample") + It "lets a provisioned removal failure surface so the job layer can handle it" { $script:sync.selectedAppx.Add("WPFAppxExample") $script:sync.configs.appxHashtable = $script:apps Mock Remove-WinUtilProvisionedAPPX { throw "Provisioned removal failed" } Invoke-WPFAppxRemoval - & $script:capturedAppxScriptBlock -selected $selected -apps $script:apps + $jobParameters = $script:capturedAppxRemovalParameters - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "AppX removal failed" -and $Percent -eq 100 - } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 0 -Exactly -ParameterFilter { - $Label -eq "AppX removal finished" - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*' - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 0 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*' - } - $script:sync.ProcessRunning | Should -BeFalse + { & $script:capturedAppxRemovalScriptBlock @jobParameters } | Should -Throw "Provisioned removal failed" } It "applies special cleanup for Xbox overlay, Notepad, and Teams selections" { - $selected = @( - "WPFAppxMicrosoft_XboxGamingOverlay", - "WPFAppxMicrosoft_WindowsNotepad", - "WPFAppxMSTeams" - ) - foreach ($key in $selected) { + foreach ($key in @("WPFAppxMicrosoft_XboxGamingOverlay", "WPFAppxMicrosoft_WindowsNotepad", "WPFAppxMSTeams")) { $script:sync.selectedAppx.Add($key) } $script:sync.configs.appxHashtable = $script:apps Invoke-WPFAppxRemoval - & $script:capturedAppxScriptBlock -selected $selected -apps $script:apps + $jobParameters = $script:capturedAppxRemovalParameters + & $script:capturedAppxRemovalScriptBlock @jobParameters Should -Invoke -CommandName Stop-Process -Times 1 -Exactly -ParameterFilter { $Name -eq "GameBarFTServer" -and @@ -736,6 +629,5 @@ Describe "Invoke-WPFAppxRemoval runspace body" { $PackageList[1] -eq "Microsoft.WindowsNotepad" -and $PackageList[2] -eq "MSTeams" } - $script:sync.ProcessRunning | Should -BeFalse } } diff --git a/pester/assets.Tests.ps1 b/pester/assets.Tests.ps1 index e4340f0aa6..5725e45749 100644 --- a/pester/assets.Tests.ps1 +++ b/pester/assets.Tests.ps1 @@ -1,35 +1,7 @@ #=========================================================================== # 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' - } - -} diff --git a/pester/background-deferral.Tests.ps1 b/pester/background-deferral.Tests.ps1 new file mode 100644 index 0000000000..a271b865b5 --- /dev/null +++ b/pester/background-deferral.Tests.ps1 @@ -0,0 +1,115 @@ +#=========================================================================== +# Tests - Speculative work stands aside for the user + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + $script:functionRoot = Join-Path $script:repoRoot "functions" + + . (Join-Path $script:functionRoot "private\Test-WinUtilDeferBackgroundWork.ps1") + + function Test-WinUtilUIAlive { $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher } + +} + +Describe "Test-WinUtilDeferBackgroundWork" { + BeforeEach { + $global:sync = [hashtable]::Synchronized(@{}) + $sync.currentTab = "Install" + $sync.LastInputAt = [datetime]::MinValue + } + + It "runs work when the user is idle and the tab is the one being drawn" { + Test-WinUtilDeferBackgroundWork -RequiresTab "Install" | Should -BeFalse + } + + It "waits while the user is interacting" { + # background priority puts work behind input in the queue but does not make the piece + # already running interruptible + $sync.LastInputAt = [datetime]::Now + + Test-WinUtilDeferBackgroundWork | Should -BeTrue + } + + It "runs again once the interaction has passed" { + $sync.LastInputAt = [datetime]::Now.AddSeconds(-2) + + Test-WinUtilDeferBackgroundWork | Should -BeFalse + } + + It "waits while the work is for a tab that is not on screen" { + $sync.currentTab = "Tweaks" + + Test-WinUtilDeferBackgroundWork -RequiresTab "Install" | Should -BeTrue + } + + It "does not care which tab is open for work that belongs to no tab" { + $sync.currentTab = "Tweaks" + + Test-WinUtilDeferBackgroundWork | Should -BeFalse + } + + It "copes with the timestamp never having been set" { + $sync.Remove("LastInputAt") + + { Test-WinUtilDeferBackgroundWork } | Should -Not -Throw + Test-WinUtilDeferBackgroundWork | Should -BeFalse + } +} + +Describe "Invoke-WinUtilWhenIdle" { + BeforeEach { + Add-Type -AssemblyName WindowsBase + $global:sync = [hashtable]::Synchronized(@{}) + $global:sync.Form = [pscustomobject]@{ Dispatcher = [System.Windows.Threading.Dispatcher]::CurrentDispatcher } + } + + It "actually runs the callback" { + # a retry that never fires abandons whatever was deferred, and nothing reports it + $global:ranCount = 0 + Invoke-WinUtilWhenIdle -Callback { $global:ranCount++ } -DelayMilliseconds 20 + + $frame = New-Object System.Windows.Threading.DispatcherFrame + $guard = New-Object System.Windows.Threading.DispatcherTimer + $guard.Interval = [timespan]::FromMilliseconds(20) + $guard.Tag = @{ Frame = $frame; Clock = [Diagnostics.Stopwatch]::StartNew() } + $guard.Add_Tick({ + param($eventSender) + $t = [System.Windows.Threading.DispatcherTimer]$eventSender + if ($global:ranCount -gt 0 -or $t.Tag.Clock.Elapsed.TotalSeconds -gt 3) { + $t.Stop() + $t.Tag.Frame.Continue = $false + } + }) + $guard.Start() + [System.Windows.Threading.Dispatcher]::PushFrame($frame) + + $global:ranCount | Should -Be 1 + } + + It "runs the callback exactly once, not on every tick" { + $global:ranCount = 0 + Invoke-WinUtilWhenIdle -Callback { $global:ranCount++ } -DelayMilliseconds 20 + + $frame = New-Object System.Windows.Threading.DispatcherFrame + $stop = New-Object System.Windows.Threading.DispatcherTimer + $stop.Interval = [timespan]::FromMilliseconds(300) + $stop.Tag = $frame + $stop.Add_Tick({ + param($eventSender) + $t = [System.Windows.Threading.DispatcherTimer]$eventSender + $t.Stop() + $t.Tag.Continue = $false + }) + $stop.Start() + [System.Windows.Threading.Dispatcher]::PushFrame($frame) + + $global:ranCount | Should -Be 1 + } + + It "does nothing when the window has gone" { + $global:sync.Form = $null + + { Invoke-WinUtilWhenIdle -Callback { throw "should not run" } } | Should -Not -Throw + } +} + diff --git a/pester/generated-controls.Tests.ps1 b/pester/generated-controls.Tests.ps1 new file mode 100644 index 0000000000..f2dccf8f8f --- /dev/null +++ b/pester/generated-controls.Tests.ps1 @@ -0,0 +1,39 @@ +#=========================================================================== +# Tests - Generated control lifetime + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + $script:xamlText = Get-Content -Path (Join-Path $script:repoRoot "xaml\inputXML.xaml") -Raw + + # Names that exist only once a tab has been built from config + $script:generatedNames = @( + Get-ChildItem -Path (Join-Path $script:repoRoot "config") -Filter *.json | ForEach-Object { + $config = Get-Content -Path $_.FullName -Raw | ConvertFrom-Json + $config.PSObject.Properties.Name + } + ) | Sort-Object -Unique | Where-Object { $script:xamlText -notmatch "Name=`"$([regex]::Escape($_))`"" } +} + +Describe "Generated controls" { + # Tab content is built after first paint, so anything referencing a generated control from + # the interface build runs while that control is still $null. That produced three silent + # "You cannot call a method on a null-valued expression" errors. + It "are not touched while the interface is being built" { + $uiScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1") -Raw + + $referenced = @( + [regex]::Matches($uiScript, '\$sync(?:\["([A-Za-z_][A-Za-z0-9_]*)"\]|\.([A-Za-z_][A-Za-z0-9_]*))') | + ForEach-Object { if ($_.Groups[1].Success) { $_.Groups[1].Value } else { $_.Groups[2].Value } } + ) | Sort-Object -Unique + + $tooEarly = @($referenced | Where-Object { $script:generatedNames -contains $_ }) + + if ($tooEarly.Count -gt 0) { + throw "Start-WinUtilUserInterface touches generated control(s) that do not exist yet: $($tooEarly -join ', '). Wire them from Initialize-WinUtilInstallTabControls or the tab that creates them." + } + } + + + + +} diff --git a/pester/headless-ui.Tests.ps1 b/pester/headless-ui.Tests.ps1 index 1095498883..5db15f6bc5 100644 --- a/pester/headless-ui.Tests.ps1 +++ b/pester/headless-ui.Tests.ps1 @@ -19,11 +19,12 @@ namespace Windows } . (Join-Path $script:repoRoot "functions\public\Invoke-WPFUIThread.ps1") - . (Join-Path $script:repoRoot "functions\private\Set-WinUtilTweaksProgressIndicator.ps1") function script:New-WinUtilFakeForm { $dispatcher = New-Object psobject $dispatcher | Add-Member -MemberType NoteProperty -Name InvokeCount -Value 0 + $dispatcher | Add-Member -MemberType NoteProperty -Name HasShutdownStarted -Value $false + $dispatcher | Add-Member -MemberType ScriptMethod -Name CheckAccess -Value { return $false } $dispatcher | Add-Member -MemberType ScriptMethod -Name Invoke -Value { param($Action) @@ -75,33 +76,3 @@ Describe "Invoke-WPFUIThread without a window" { $form.Dispatcher.InvokeCount | Should -Be 1 } } - -Describe "Set-WinUtilTweaksProgressIndicator without a window" { - AfterEach { - Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - } - - It "returns before resolving WPF types when the form is missing" { - $script:sync = [Hashtable]::Synchronized(@{}) - - { Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Creating restore point" -Percent 0 } | Should -Not -Throw - } - - It "still updates the indicator controls when a window exists" { - $controls = New-WinUtilFakeIndicatorControlSet - $script:sync = [Hashtable]::Synchronized(@{ - Form = New-WinUtilFakeForm - WPFTweaksProgressBar = $controls.Bar - WPFTweaksProgressLabel = $controls.Label - WPFTweaksProgressValue = $controls.Value - }) - - Mock Invoke-WPFUIThread { & $ScriptBlock } - - Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Applying WPFTweaksTelemetry (1/17)" -Percent 42 - - $controls.Bar.Visibility | Should -Be ([Windows.Visibility]::Visible) - $controls.Label.Text | Should -Be "Applying WPFTweaksTelemetry (1/17)" - $controls.Value.Value | Should -Be 42 - } -} diff --git a/pester/headless.Tests.ps1 b/pester/headless.Tests.ps1 new file mode 100644 index 0000000000..2b6a8070c5 --- /dev/null +++ b/pester/headless.Tests.ps1 @@ -0,0 +1,237 @@ +#=========================================================================== +# Tests - Headless runs never need a window + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + $script:functionRoot = Join-Path $script:repoRoot "functions" + $script:mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw + $script:startScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\start.ps1") -Raw + + . (Join-Path $script:functionRoot "private\Update-WinUtilSelections.ps1") + + # Stubs so the mocks below have something to replace; the real ones live in other files + function Write-WinUtilLog { param($Level, $Component, $Message, [switch]$Detail) } + function Write-WinUtilTimingSummary { param($Scope, $TotalMilliseconds) } + function Clear-WinUtilActiveJob { param([string]$Token) $sync.ActiveJobToken = $null; $sync.ActiveJob = $null; return $true } + # A timed out step stops its worker before the next one starts, so the run needs both of these + function Stop-WinUtilActiveWork { param([switch]$NoWait) } + function Test-WinUtilActiveWorkRunning { return $false } + function Write-WinUtilErrorRecord { param($ErrorRecord, $Component, $Context) } + function Invoke-WPFtweaksbutton { } + function Invoke-WPFToggleSelections { } + function Invoke-WPFFeatureInstall { } + function Invoke-WPFInstall { } + function Invoke-WPFAppxRemoval { } +} + +Describe "Headless entry point" { + It "handles preset and config through one path" { + $script:mainScript | Should -Match 'if \(\$Preset -or \$Config\) \{' + } + + It "ends with an exit code an automated caller can read" { + $script:mainScript | Should -Match 'exit \$headlessCode' + $script:mainScript | Should -Match 'Write-WinUtilAutoRunSummary' + } + + It "cleans up even when the run throws" { + # Without a finally a failed run leaves the worker pool open and the transcript running + $script:mainScript | Should -Match '\} finally \{[\s\S]*Close-WinUtilRunspacePool[\s\S]*Stop-Transcript' + } + + It "names the presets that exist when given one that does not" { + $script:mainScript | Should -Match "There is no preset called" + } + + It "waits for the elevated run and passes its code back" { + # Start-Process without -Wait returns immediately, so the caller would see success + # regardless of what the run did + $script:startScript | Should -Match '\$elevated = Start-Process[^\r\n]*-Wait -PassThru' + $script:startScript | Should -Match 'exit \$elevated\.ExitCode' + } +} + +Describe "Invoke-WinUtilAutoRun" { + BeforeAll { + . (Join-Path $script:functionRoot "public\Invoke-WinUtilAutoRun.ps1") + } + + BeforeEach { + $global:sync = [hashtable]::Synchronized(@{}) + $sync.selectedTweaks = [System.Collections.Generic.List[string]]::new() + $sync.selectedToggles = [System.Collections.Generic.List[string]]::new() + $sync.selectedApps = [System.Collections.Generic.List[string]]::new() + $sync.selectedFeatures = [System.Collections.Generic.List[string]]::new() + $sync.selectedAppx = [System.Collections.Generic.List[string]]::new() + $sync.LoggedErrors = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new()) + $sync.ActiveJob = $null + + Mock Write-WinUtilLog { } + Mock Write-WinUtilTimingSummary { } + Mock Write-WinUtilErrorRecord { } + } + + It "does nothing and says so when nothing is selected" { + $summary = Invoke-WinUtilAutoRun + + @($summary.Steps).Count | Should -Be 0 + Should -Invoke -CommandName Write-WinUtilLog -ParameterFilter { $Level -eq "WARN" -and $Message -like "*nothing to do*" } + } + + It "applies toggles, which nothing outside the window ever did" { + $sync.selectedToggles.Add("WPFToggleDarkMode") + Mock Invoke-WPFToggleSelections { } + + $summary = Invoke-WinUtilAutoRun + + Should -Invoke -CommandName Invoke-WPFToggleSelections -Times 1 -Exactly + @($summary.Steps | Where-Object { $_.Name -eq "Toggles" }).Count | Should -Be 1 + } + + It "runs only the steps that have something selected" { + $sync.selectedApps.Add("WPFInstall7zip") + Mock Invoke-WPFInstall { } + Mock Invoke-WPFtweaksbutton { } + + $summary = Invoke-WinUtilAutoRun + + @($summary.Steps).Count | Should -Be 1 + $summary.Steps[0].Name | Should -Be "Applications" + Should -Invoke -CommandName Invoke-WPFtweaksbutton -Times 0 -Exactly + } + + It "gives up on a step that never finishes instead of hanging for good" { + $sync.selectedApps.Add("WPFInstall7zip") + # a job that sets the flag and never clears it + Mock Invoke-WPFInstall { $sync.ActiveJob = "Install" } + + $summary = Invoke-WinUtilAutoRun -StepTimeoutSeconds 1 + + $summary.TimedOut | Should -Be 1 + # and the flag must be released, or every later step would be refused + $sync.ActiveJob | Should -BeNullOrEmpty + } + + It "abandons the remaining steps when a timed out worker will not stop" { + $sync.selectedTweaks.Add("WPFTweaksAH") + $sync.selectedApps.Add("WPFInstall7zip") + # the first step times out, and its worker is still running afterwards + Mock Invoke-WPFtweaksbutton { $sync.ActiveJob = "Tweaks" } + Mock Invoke-WPFInstall { } + Mock Stop-WinUtilActiveWork { } + Mock Test-WinUtilActiveWorkRunning { return $true } + + $summary = Invoke-WinUtilAutoRun -StepTimeoutSeconds 1 -StopTimeoutSeconds 1 + + $summary.TimedOut | Should -Be 1 + # the later step must not run beside work that is still changing the machine + Should -Invoke -CommandName Invoke-WPFInstall -Times 0 -Exactly + Should -Invoke -CommandName Stop-WinUtilActiveWork -Times 1 -Exactly + } + + It "counts a step's errors against the run" { + $sync.selectedApps.Add("WPFInstall7zip") + Mock Invoke-WPFInstall { $null = $sync.LoggedErrors.Add("boom") } + + $summary = Invoke-WinUtilAutoRun + + $summary.Failed | Should -Be 1 + $summary.Errors | Should -Be 1 + } + + It "keeps going after a step throws rather than abandoning the run" { + $sync.selectedTweaks.Add("WPFTweaksDiskCleanup") + $sync.selectedApps.Add("WPFInstall7zip") + Mock Invoke-WPFtweaksbutton { throw "tweaks blew up" } + Mock Invoke-WPFInstall { } + + $summary = Invoke-WinUtilAutoRun + + Should -Invoke -CommandName Invoke-WPFInstall -Times 1 -Exactly + @($summary.Steps).Count | Should -Be 2 + } +} + +Describe "Write-WinUtilAutoRunSummary" { + BeforeAll { + . (Join-Path $script:functionRoot "public\Invoke-WinUtilAutoRun.ps1") + } + + BeforeEach { + $global:sync = @{ logPath = "C:\temp\winutil.log" } + Mock Write-Host { } + } + + It "returns 0 when every step was clean" { + $summary = [pscustomobject]@{ + Steps = @([pscustomobject]@{ Name = "Tweaks"; Items = 1; Seconds = 1; Errors = 0; TimedOut = $false }) + Failed = 0; TimedOut = 0; Errors = 0 + } + + Write-WinUtilAutoRunSummary -Summary $summary | Should -Be 0 + } + + It "returns 1 when a step failed or timed out" { + $failed = [pscustomobject]@{ + Steps = @([pscustomobject]@{ Name = "Tweaks"; Items = 1; Seconds = 1; Errors = 2; TimedOut = $false }) + Failed = 1; TimedOut = 0; Errors = 2 + } + Write-WinUtilAutoRunSummary -Summary $failed | Should -Be 1 + + $timedOut = [pscustomobject]@{ + Steps = @([pscustomobject]@{ Name = "Apps"; Items = 1; Seconds = 60; Errors = 0; TimedOut = $true }) + Failed = 0; TimedOut = 1; Errors = 0 + } + Write-WinUtilAutoRunSummary -Summary $timedOut | Should -Be 1 + } + + It "returns 2 when nothing was selected, which is not the same as success" { + $summary = [pscustomobject]@{ Steps = @(); Failed = 0; TimedOut = 0; Errors = 0 } + + Write-WinUtilAutoRunSummary -Summary $summary | Should -Be 2 + } +} + +Describe "Update-WinUtilSelections" { + BeforeEach { + $global:sync = [hashtable]::Synchronized(@{}) + foreach ($list in @("selectedApps","selectedTweaks","selectedToggles","selectedFeatures","selectedAppx")) { + $sync.$list = [System.Collections.Generic.List[string]]::new() + } + # The selection sorter now checks each key against the real catalogue before taking it, + # so the fixture has to carry the configs it reads + $sync.configs = @{ + applicationsHashtable = @{ "WPFInstall7zip" = @{} } + appxHashtable = @{ "WPFAppxBing" = @{} } + tweaks = [pscustomobject]@{ "WPFTweaksDiskCleanup" = @{}; "WPFToggleDarkMode" = @{} } + feature = [pscustomobject]@{ "WPFFeaturesdotnet" = @{} } + } + Mock Write-WinUtilLog { } + } + + It "sorts each prefix into its own list" { + Update-WinUtilSelections -flatJson @("WPFInstall7zip","WPFTweaksDiskCleanup","WPFToggleDarkMode","WPFFeaturesdotnet","WPFAppxBing") + + $sync.selectedApps | Should -Contain "WPFInstall7zip" + $sync.selectedTweaks | Should -Contain "WPFTweaksDiskCleanup" + $sync.selectedToggles | Should -Contain "WPFToggleDarkMode" + $sync.selectedFeatures | Should -Contain "WPFFeaturesdotnet" + $sync.selectedAppx | Should -Contain "WPFAppxBing" + } + + It "hands back an unrecognised entry rather than throwing, which is what the headless run relies on" { + # The headless path passes SkipUnknown so a retired entry names itself and the run goes + # on. Without it the call is strict, which is what the window wants. + $skipped = @(Update-WinUtilSelections -flatJson @("NotAWinUtilEntry") -SkipUnknown) + + $skipped | Should -Contain "NotAWinUtilEntry" + { Update-WinUtilSelections -flatJson @("NotAWinUtilEntry") } | Should -Throw + } + + It "does not select the same entry twice when a preset and a config both list it" { + Update-WinUtilSelections -flatJson @("WPFInstall7zip") + Update-WinUtilSelections -flatJson @("WPFInstall7zip") + + @($sync.selectedApps).Count | Should -Be 1 + } +} diff --git a/pester/install-rendering.Tests.ps1 b/pester/install-rendering.Tests.ps1 index 33398243e8..8ab7797d2c 100644 --- a/pester/install-rendering.Tests.ps1 +++ b/pester/install-rendering.Tests.ps1 @@ -1,44 +1,21 @@ #=========================================================================== # 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 'Find-AppsByNameOrDescription -SearchString \$sync\.SearchBar\.Text -Categories \$selectedCategories' - # A batch has to be filtered when either filter is on, not only when there is search text - $renderScript | Should -Match '\$selectedCategories\.Count -gt 0' - $renderScript | Should -Match '\$sync\.InstallAppEntriesRendered = \$true' - } - - 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 + function global:Test-WinUtilUIAlive { $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher } + . (Join-Path $script:repoRoot "functions\private\Start-WinUtilBackgroundQueue.ps1") . (Join-Path $script:repoRoot "functions\private\Start-WinUtilInstallAppRendering.ps1") $previousSync = Get-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue @@ -61,6 +38,15 @@ Describe "Install app rendering startup contract" { return "entry:$AppKey" } + + function global:Test-WinUtilDeferBackgroundWork { param($RequiresTab) $false } + function global:Invoke-WinUtilWhenIdle { param($Callback, $DelayMilliseconds) } + + function global:Measure-WinUtilStep { + param($Scope, $Name, [scriptblock]$ScriptBlock) + & $ScriptBlock + } + function global:Find-AppsByNameOrDescription { param($SearchString, $Category) throw "Search should not run for an empty search box in this test." @@ -112,17 +98,11 @@ Describe "Install app rendering startup contract" { } } - It "keeps app-entry metadata lookup independent from the old caller scope" { - $entryScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-InstallAppEntry.ps1") -Raw + - $entryScript | Should -Match '\$app = \$sync\.configs\.applicationsHashtable\.\$appKey' - $entryScript | Should -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/install-workflow.Tests.ps1 b/pester/install-workflow.Tests.ps1 index 1689bf1c3a..6e70ec1162 100644 --- a/pester/install-workflow.Tests.ps1 +++ b/pester/install-workflow.Tests.ps1 @@ -4,6 +4,7 @@ BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\private\Measure-WinUtilStep.ps1") . (Join-Path $script:repoRoot "functions\private\Get-WinUtilPackageLogSummary.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFInstall.ps1") @@ -12,15 +13,24 @@ BeforeAll { function Show-WinUtilMessage { param($Message, $Title, $Button, $Icon) } + function Start-WinUtilJob { + param( + [string]$Name, + [scriptblock]$ScriptBlock, + [hashtable]$Parameters, + [string]$Description, + [switch]$DisableAppList + ) + } + function Step-WinUtilJob { + param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay) + } function Invoke-WPFRunspace { param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) } function Get-WinUtilSelectedPackages { param($PackageList, [string]$Preference) } - function Set-WinUtilTweaksProgressIndicator { - param($Visible, $Label, $Percent) - } function Install-WinUtilWinget { } function Install-WinUtilChoco { } function Install-WinUtilProgramWinget { @@ -29,8 +39,11 @@ BeforeAll { function Install-WinUtilProgramChoco { param($Action, $Programs) } + function Complete-WinUtilPackageRun { + param([string]$Action, [object[]]$Results) + } function Invoke-WPFUIThread { - param([scriptblock]$ScriptBlock) + param([scriptblock]$ScriptBlock, [hashtable]$Parameters, [switch]$Async) } function Write-WinUtilLog { param($Message, $Level, $Component) @@ -107,41 +120,38 @@ Describe "Invoke-WPFInstall entrypoint" { BeforeEach { $script:package = New-WinUtilPackage New-WinUtilInstallTestContext -Packages @($script:package) - $script:capturedInstallScriptBlock = $null - $script:capturedInstallParameterList = $null + $script:capturedInstallJob = $null Mock Show-WinUtilMessage { "OK" } - Mock Invoke-WPFRunspace { - $script:capturedInstallScriptBlock = $ScriptBlock - $script:capturedInstallParameterList = $ParameterList - [pscustomobject]@{ MockHandle = $true } + Mock Start-WinUtilJob { + $script:capturedInstallJob = [pscustomobject]@{ + Name = $Name + ScriptBlock = $ScriptBlock + Parameters = $Parameters + Description = $Description + DisableAppList = [bool]$DisableAppList + } } Mock Write-WinUtilLog { } } AfterEach { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedInstallScriptBlock -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedInstallParameterList -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedInstallJob -Scope Script -ErrorAction SilentlyContinue } - It "queues selected packages with the configured package manager preference" { + It "queues an install job with the configured package manager preference" { Invoke-WPFInstall - Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { - $ScriptBlock -is [scriptblock] -and - $ParameterList.Count -eq 2 -and - $ParameterList[0][0] -eq "PackagesToInstall" -and - @($ParameterList[0][1]).Count -eq 1 -and - @($ParameterList[0][1])[0].winget -eq "Git.Git" -and - $ParameterList[1][0] -eq "ManagerPreference" -and - $ParameterList[1][1] -eq "Winget" + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "Install" -and + $ScriptBlock -is [scriptblock] -and + $DisableAppList -and + @($Parameters.PackagesToInstall).Count -eq 1 -and + @($Parameters.PackagesToInstall)[0].winget -eq "Git.Git" -and + $Parameters.ManagerPreference -eq "Winget" } Should -Invoke -CommandName Show-WinUtilMessage -Times 0 -Exactly - Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { - $Component -eq "Install" -and - $Message -eq "Install selected package(s): Git (winget: Git.Git)" - } } It "queues the explicit app popup package over the selected apps" { @@ -149,14 +159,9 @@ Describe "Invoke-WPFInstall entrypoint" { Invoke-WPFInstall -PackagesToInstall $explicitPackage - Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { - $ScriptBlock -is [scriptblock] -and - $ParameterList.Count -eq 2 -and - $ParameterList[0][0] -eq "PackagesToInstall" -and - @($ParameterList[0][1]).Count -eq 1 -and - @($ParameterList[0][1])[0].winget -eq "VideoLAN.VLC" -and - $ParameterList[1][0] -eq "ManagerPreference" -and - $ParameterList[1][1] -eq "Winget" + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + @($Parameters.PackagesToInstall).Count -eq 1 -and + @($Parameters.PackagesToInstall)[0].winget -eq "VideoLAN.VLC" } Should -Invoke -CommandName Show-WinUtilMessage -Times 0 -Exactly } @@ -172,73 +177,49 @@ Describe "Invoke-WPFInstall entrypoint" { $Button -eq "OK" -and $Icon -eq "Warning" } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly - } - - It "prompts and exits when another install process is running" { - New-WinUtilInstallTestContext -ProcessRunning $true -Packages @($script:package) - - Invoke-WPFInstall - - Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { - $Message -eq "[Invoke-WPFInstall] An Install process is currently running." -and - $Title -eq "WinUtil" -and - $Button -eq "OK" -and - $Icon -eq "Warning" - } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly } } -Describe "Invoke-WPFInstall runspace body" { +Describe "Invoke-WPFInstall job body" { BeforeEach { $script:package = New-WinUtilPackage New-WinUtilInstallTestContext -Packages @($script:package) - $script:capturedInstallScriptBlock = $null + $script:capturedInstallJob = $null Mock Show-WinUtilMessage { "OK" } - Mock Invoke-WPFRunspace { - $script:capturedInstallScriptBlock = $ScriptBlock - [pscustomobject]@{ MockHandle = $true } + Mock Start-WinUtilJob { + $script:capturedInstallJob = [pscustomobject]@{ + ScriptBlock = $ScriptBlock + Parameters = $Parameters + } } Mock Get-WinUtilSelectedPackages { New-WinUtilPackageSplit -Winget @("Git.Git") -Choco @("vlc") } - Mock Set-WinUtilTweaksProgressIndicator { } + Mock Step-WinUtilJob { } Mock Install-WinUtilWinget { } Mock Install-WinUtilChoco { } Mock Install-WinUtilProgramWinget { } Mock Install-WinUtilProgramChoco { } - Mock Invoke-WPFUIThread { } Mock Write-WinUtilLog { } Mock Write-Host { } } AfterEach { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedInstallScriptBlock -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedInstallJob -Scope Script -ErrorAction SilentlyContinue } - It "installs split winget and choco packages and cleans up on success" { + It "installs split winget and choco packages and reports progress" { Invoke-WPFInstall - & $script:capturedInstallScriptBlock -PackagesToInstall @($script:package) -ManagerPreference "Winget" + $jobParameters = $script:capturedInstallJob.Parameters + & $script:capturedInstallJob.ScriptBlock @jobParameters Should -Invoke -CommandName Get-WinUtilSelectedPackages -Times 1 -Exactly -ParameterFilter { @($PackageList).Count -eq 1 -and $Preference -eq "Winget" } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Preparing app install (0/2)" -and $Percent -eq 0 - } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Installed Git.Git (1/2)" -and $Percent -eq 50 - } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Installed Chocolatey packages (2/2)" -and $Percent -eq 100 - } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "App install finished" -and $Percent -eq 100 - } Should -Invoke -CommandName Install-WinUtilWinget -Times 1 -Exactly Should -Invoke -CommandName Install-WinUtilProgramWinget -Times 1 -Exactly -ParameterFilter { $Action -eq "Install" -and @($Programs)[0] -eq "Git.Git" @@ -247,41 +228,34 @@ Describe "Invoke-WPFInstall runspace body" { Should -Invoke -CommandName Install-WinUtilProgramChoco -Times 1 -Exactly -ParameterFilter { $Action -eq "Install" -and @($Programs)[0] -eq "vlc" } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $false*' - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $true*' + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Installed Git.Git (1/2)" -and $Percent -eq 50 } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*' + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Installed Chocolatey packages (2/2)" -and $Percent -eq 100 } - $script:sync.ProcessRunning | Should -BeFalse } - It "shows failure progress, sets taskbar error state, and clears ProcessRunning on failure" { - Mock Install-WinUtilProgramWinget { throw "winget failed" } - + It "logs the package summary from inside the job rather than on the UI thread" { Invoke-WPFInstall - & $script:capturedInstallScriptBlock -PackagesToInstall @($script:package) -ManagerPreference "Winget" + $jobParameters = $script:capturedInstallJob.Parameters + & $script:capturedInstallJob.ScriptBlock @jobParameters - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "App install failed" -and $Percent -eq 100 - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $false*' - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $true*' - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*' - } Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { - $Level -eq "ERROR" -and $Component -eq "Install" -and $Message -like "Install workflow failed:*" + $Component -eq "Install" -and + $Message -eq "Install selected package(s): Git (winget: Git.Git)" } - $script:sync.ProcessRunning | Should -BeFalse + } + + It "lets a failure surface so the job layer can handle it" { + Mock Install-WinUtilProgramWinget { throw "winget failed" } + + Invoke-WPFInstall + + { $jobParameters = $script:capturedInstallJob.Parameters + & $script:capturedInstallJob.ScriptBlock @jobParameters } | + Should -Throw "winget failed" } } @@ -289,172 +263,115 @@ Describe "Invoke-WPFUnInstall entrypoint" { BeforeEach { $script:package = New-WinUtilPackage New-WinUtilInstallTestContext -Packages @($script:package) - $script:capturedUninstallScriptBlock = $null - $script:capturedUninstallParameterList = $null + $script:capturedUninstallJob = $null Mock Show-WinUtilMessage { "Yes" } - Mock Invoke-WPFRunspace { - $script:capturedUninstallScriptBlock = $ScriptBlock - $script:capturedUninstallParameterList = $ParameterList - [pscustomobject]@{ MockHandle = $true } + Mock Start-WinUtilJob { + $script:capturedUninstallJob = [pscustomobject]@{ + Name = $Name + ScriptBlock = $ScriptBlock + Parameters = $Parameters + DisableAppList = [bool]$DisableAppList + } } Mock Write-WinUtilLog { } } AfterEach { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedUninstallScriptBlock -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedUninstallParameterList -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedUninstallJob -Scope Script -ErrorAction SilentlyContinue } - It "confirms and queues selected packages with the configured package manager preference" { - Invoke-WPFUnInstall -PackagesToUninstall @($script:package) + It "confirms and queues an uninstall job with the configured package manager preference" { + Invoke-WPFUnInstall Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { - $Message -like "*This will uninstall the following applications:*" -and - $Message -like "*Git*" -and - $Title -eq "Are you sure?" -and - "$Button" -eq "YesNo" -and - "$Icon" -eq "Information" - } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { - $ScriptBlock -is [scriptblock] -and - $ParameterList.Count -eq 2 -and - $ParameterList[0][0] -eq "PackagesToUninstall" -and - @($ParameterList[0][1]).Count -eq 1 -and - @($ParameterList[0][1])[0].winget -eq "Git.Git" -and - $ParameterList[1][0] -eq "ManagerPreference" -and - $ParameterList[1][1] -eq "Winget" + $Title -eq "Are you sure?" -and $Button -eq "YesNo" } - Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { - $Component -eq "Uninstall" -and - $Message -eq "Uninstall selected package(s): Git (winget: Git.Git)" + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "Uninstall" -and + $ScriptBlock -is [scriptblock] -and + $DisableAppList -and + @($Parameters.PackagesToUninstall).Count -eq 1 -and + @($Parameters.PackagesToUninstall)[0].winget -eq "Git.Git" -and + $Parameters.ManagerPreference -eq "Winget" } } It "prompts and exits when no packages are selected" { - Invoke-WPFUnInstall -PackagesToUninstall @() - - Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { - $Message -eq "Please select the program(s) to uninstall" -and - $Title -eq "WinUtil" -and - $Button -eq "OK" -and - $Icon -eq "Warning" - } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly - } - - It "prompts and exits when another install process is running" { - $script:sync.ProcessRunning = $true + New-WinUtilInstallTestContext - Invoke-WPFUnInstall -PackagesToUninstall @($script:package) + Invoke-WPFUnInstall Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { - $Message -eq "[Invoke-WPFUnInstall] Install process is currently running" -and - $Title -eq "WinUtil" -and - $Button -eq "OK" -and - $Icon -eq "Warning" + $Message -eq "Please select the program(s) to uninstall" } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly } It "exits without queueing uninstall when confirmation is declined" { - Mock Show-WinUtilMessage { "No" } -ParameterFilter { $Title -eq "Are you sure?" } + Mock Show-WinUtilMessage { "No" } - Invoke-WPFUnInstall -PackagesToUninstall @($script:package) + Invoke-WPFUnInstall - Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly } } -Describe "Invoke-WPFUnInstall runspace body" { +Describe "Invoke-WPFUnInstall job body" { BeforeEach { $script:package = New-WinUtilPackage New-WinUtilInstallTestContext -Packages @($script:package) - $script:capturedUninstallScriptBlock = $null + $script:capturedUninstallJob = $null Mock Show-WinUtilMessage { "Yes" } - Mock Invoke-WPFRunspace { - $script:capturedUninstallScriptBlock = $ScriptBlock - [pscustomobject]@{ MockHandle = $true } + Mock Start-WinUtilJob { + $script:capturedUninstallJob = [pscustomobject]@{ + ScriptBlock = $ScriptBlock + Parameters = $Parameters + } } Mock Get-WinUtilSelectedPackages { New-WinUtilPackageSplit -Winget @("Git.Git") -Choco @("vlc") } - Mock Set-WinUtilTweaksProgressIndicator { } + Mock Step-WinUtilJob { } Mock Install-WinUtilProgramWinget { } Mock Install-WinUtilProgramChoco { } - Mock Invoke-WPFUIThread { } Mock Write-WinUtilLog { } Mock Write-Host { } - Mock New-Item { } } AfterEach { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedUninstallScriptBlock -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedUninstallJob -Scope Script -ErrorAction SilentlyContinue } - It "uninstalls split winget and choco packages and cleans up on success" { - Invoke-WPFUnInstall -PackagesToUninstall @($script:package) + It "uninstalls split winget and choco packages and reports progress" { + Invoke-WPFUnInstall - & $script:capturedUninstallScriptBlock -PackagesToUninstall @($script:package) -ManagerPreference "Winget" + $jobParameters = $script:capturedUninstallJob.Parameters + & $script:capturedUninstallJob.ScriptBlock @jobParameters - Should -Invoke -CommandName Get-WinUtilSelectedPackages -Times 1 -Exactly -ParameterFilter { - @($PackageList).Count -eq 1 -and $Preference -eq "Winget" - } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Preparing app uninstall (0/2)" -and $Percent -eq 0 - } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Uninstalled Git.Git (1/2)" -and $Percent -eq 50 - } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Uninstalled Chocolatey packages (2/2)" -and $Percent -eq 100 - } - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "App uninstall finished" -and $Percent -eq 100 - } Should -Invoke -CommandName Install-WinUtilProgramWinget -Times 1 -Exactly -ParameterFilter { $Action -eq "Uninstall" -and @($Programs)[0] -eq "Git.Git" } Should -Invoke -CommandName Install-WinUtilProgramChoco -Times 1 -Exactly -ParameterFilter { $Action -eq "Uninstall" -and @($Programs)[0] -eq "vlc" } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $false*' - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $true*' + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Uninstalled Git.Git (1/2)" -and $Percent -eq 50 } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*' + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Uninstalled Chocolatey packages (2/2)" -and $Percent -eq 100 } - $script:sync.ProcessRunning | Should -BeFalse } - It "shows failure progress, sets taskbar error state, and clears ProcessRunning on failure" { + It "lets a failure surface so the job layer can handle it" { Mock Install-WinUtilProgramWinget { throw "winget failed" } - Invoke-WPFUnInstall -PackagesToUninstall @($script:package) - - & $script:capturedUninstallScriptBlock -PackagesToUninstall @($script:package) -ManagerPreference "Winget" + Invoke-WPFUnInstall - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "App uninstall failed" -and $Percent -eq 100 - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $false*' - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $true*' - } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*' - } - Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { - $Level -eq "ERROR" -and $Component -eq "Uninstall" -and $Message -like "Uninstall workflow failed:*" - } - $script:sync.ProcessRunning | Should -BeFalse + $jobParameters = $script:capturedUninstallJob.Parameters + { & $script:capturedUninstallJob.ScriptBlock @jobParameters } | Should -Throw "winget failed" } } diff --git a/pester/job-layer.Tests.ps1 b/pester/job-layer.Tests.ps1 new file mode 100644 index 0000000000..ec3a4c1425 --- /dev/null +++ b/pester/job-layer.Tests.ps1 @@ -0,0 +1,280 @@ +#=========================================================================== +# Tests - Job layer + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\private\Measure-WinUtilStep.ps1") + . (Join-Path $script:repoRoot "functions\private\Write-WinUtilErrorRecord.ps1") + . (Join-Path $script:repoRoot "functions\private\Start-WinUtilJob.ps1") + . (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilCloseRequest.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFUIThread.ps1") + + function Invoke-WPFRunspace { + param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) + } + function Write-WinUtilJobBanner { + param([string]$Message, [string]$Level) + } + function Write-WinUtilLog { + param($Message, $Level, $Component) + } + function Step-WinUtilJob { + param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay, [switch]$Hide) + } + function Show-WinUtilMessage { + param($Message, $Title, $Button, $Icon) + } + + function script:Get-WinUtilJobRunspaceBody { + param([hashtable]$ParameterList) + + $named = @{} + foreach ($parameter in $ParameterList) { + $named[$parameter[0]] = $parameter[1] + } + return $named + } +} + +Describe "Interface thread dispatch" { + # Work handed to the interface thread must arrive as body text plus parameters. A scriptblock + # marshalled from a worker runspace keeps that runspace's session state, so every command it + # invokes is resolved back through the originating runspace. Measured on 400 invocations: + # 5354 ms marshalled against 3 ms rebuilt from text. + + It "passes deferred values as parameters rather than capturing them" { + foreach ($path in @( + "functions\private\Step-WinUtilJob.ps1", + "functions\private\Invoke-WinUtilISO.ps1" + )) { + $source = Get-Content -Path (Join-Path $script:repoRoot $path) -Raw + + $source | Should -Match ([regex]::Escape('Invoke-WPFUIThread -Async -Parameters @{')) + $source | Should -Not -Match ([regex]::Escape('GetNewClosure')) + } + } +} + +Describe "Invoke-WPFUIThread output" { + BeforeEach { + $dispatcher = [pscustomobject]@{ HasShutdownStarted = $false } + $dispatcher | Add-Member -MemberType ScriptMethod -Name CheckAccess -Value { $true } + $script:sync = [Hashtable]::Synchronized(@{ + Form = [pscustomobject]@{ Dispatcher = $dispatcher } + }) + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + } + + # Output from the body must not join the caller's own return value: a caller that only + # wanted a control updated would otherwise return an array and every index read wrong. + It "swallows the body's output by default" { + @(Invoke-WPFUIThread -ScriptBlock { "stray" }).Count | Should -Be 0 + } + + It "returns the body's output when asked" { + Invoke-WPFUIThread -PassThru -ScriptBlock { "wanted" } | Should -Be "wanted" + } + + It "passes values in rather than relying on the caller's scope" { + Invoke-WPFUIThread -PassThru -Parameters @{ Value = 7 } -ScriptBlock { + param($Value) + $Value * 2 + } | Should -Be 14 + } +} + +Describe "Start-WinUtilJob" { + BeforeEach { + $script:sync = [Hashtable]::Synchronized(@{ + ActiveJob = $null + Form = [pscustomobject]@{ Dispatcher = [pscustomobject]@{} } + ItemsControl = [pscustomobject]@{ IsEnabled = $true } + }) + $script:capturedRunspaceBody = $null + $script:capturedRunspaceArgs = $null + $script:activeJobAtQueueTime = $null + + Mock Show-WinUtilMessage { "OK" } + Mock Write-WinUtilLog { } + Mock Step-WinUtilJob { } + Mock Invoke-WPFUIThread { & $ScriptBlock } + Mock Invoke-WPFRunspace { + $script:activeJobAtQueueTime = $script:sync.ActiveJob + $script:capturedRunspaceBody = $ScriptBlock + $script:capturedRunspaceArgs = @{} + foreach ($parameter in $ParameterList) { + $script:capturedRunspaceArgs[$parameter[0]] = $parameter[1] + } + [pscustomobject]@{ MockHandle = $true } + } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedRunspaceBody -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedRunspaceArgs -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name activeJobAtQueueTime -Scope Script -ErrorAction SilentlyContinue + } + + It "claims the busy state before the work is queued" { + Start-WinUtilJob -Name "Example" -ScriptBlock { } | Out-Null + + $script:activeJobAtQueueTime | Should -Be "Example" + Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Component -eq "Example" -and $Message -eq "Example job started." + } + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Example..." -and $Percent -eq 0 + } + } + + It "uses the description for the initial progress text" { + Start-WinUtilJob -Name "Example" -Description "Doing the thing" -ScriptBlock { } | Out-Null + + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Doing the thing..." -and $Percent -eq 0 -and $State -eq "Normal" -and $Overlay -eq "logo" + } + } + + It "refuses a second job while one is running" { + $script:sync.ActiveJob = "Install" + + $result = Start-WinUtilJob -Name "Tweaks" -ScriptBlock { } + + $result | Should -BeNullOrEmpty + Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Message -like "Install is still running*" + } + $script:sync.ActiveJob | Should -Be "Install" + } + + It "claims the slot with a token that identifies the run, not just its name" { + Start-WinUtilJob -Name "Install" -ScriptBlock { } | Out-Null + $first = $script:sync.ActiveJobToken + + $first | Should -Not -BeNullOrEmpty + $script:capturedRunspaceArgs["JobToken"] | Should -Be $first + + # a second run of the same name gets its own token, so a late worker cannot release it + $script:sync.ActiveJob = $null + $script:sync.ActiveJobToken = $null + Start-WinUtilJob -Name "Install" -ScriptBlock { } | Out-Null + + $script:sync.ActiveJobToken | Should -Not -Be $first + } + + It "passes the body text and its parameters to the worker" { + Start-WinUtilJob -Name "Example" -Parameters @{ Value = 42 } -ScriptBlock { param($Value) $Value } | Out-Null + + $script:capturedRunspaceArgs["JobName"] | Should -Be "Example" + $script:capturedRunspaceArgs["JobBody"] | Should -BeOfType [string] + $script:capturedRunspaceArgs["JobBody"] | Should -Match 'param\(\$Value\)' + $script:capturedRunspaceArgs["JobParameters"].Value | Should -Be 42 + $script:capturedRunspaceArgs["JobRestoresAppList"] | Should -BeFalse + } + + It "greys out the app list only when asked to" { + Start-WinUtilJob -Name "Install" -DisableAppList -ScriptBlock { } | Out-Null + + $script:sync.ItemsControl.IsEnabled | Should -BeFalse + $script:capturedRunspaceArgs["JobRestoresAppList"] | Should -BeTrue + } + + It "reports completion and releases the busy state when the body succeeds" { + Start-WinUtilJob -Name "Example" -ScriptBlock { } | Out-Null + + & $script:capturedRunspaceBody ` + -JobName "Example" ` + -JobBody '$null = $true' ` + -JobParameters @{} ` + -JobRestoresAppList $false ` + -JobToken $script:capturedRunspaceArgs["JobToken"] + + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Component -eq "Example" -and $Message -like "Example job finished in * ms." + } + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Example finished" -and $Percent -eq 100 -and $State -eq "None" -and $Overlay -eq "checkmark" + } + $script:sync.ActiveJob | Should -BeNullOrEmpty + } + + It "logs the failure and releases the busy state when the body throws" { + Start-WinUtilJob -Name "Example" -ScriptBlock { } | Out-Null + $script:sync.ActiveJob = "Example" + Mock Write-Host { } + + { + & $script:capturedRunspaceBody ` + -JobName "Example" ` + -JobBody 'throw "boom"' ` + -JobParameters @{} ` + -JobRestoresAppList $false ` + -JobToken $script:capturedRunspaceArgs["JobToken"] + } | Should -Not -Throw + + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Level -eq "ERROR" -and $Component -eq "Example" -and $Message -like "*failed after * ms : boom" + } + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Example failed" -and $State -eq "Error" -and $Overlay -eq "warning" + } + $script:sync.ActiveJob | Should -BeNullOrEmpty + } + + It "reports a job that logged errors without throwing" { + $script:sync.LoggedErrors = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new()) + Start-WinUtilJob -Name "Example" -ScriptBlock { } | Out-Null + + & $script:capturedRunspaceBody ` + -JobName "Example" ` + -JobLabel "Example" ` + -JobBody '$null = $sync.LoggedErrors.Add("[Registry] refused by policy")' ` + -JobParameters @{} ` + -JobRestoresAppList $false ` + -JobToken $script:capturedRunspaceArgs["JobToken"] + + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Example finished with 1 error(s)" -and $State -eq "Paused" -and $Overlay -eq "warning" + } + $script:sync.ActiveJob | Should -BeNullOrEmpty + } + + It "surfaces warnings and non-terminating errors raised by the body" { + $script:sync.LoggedErrors = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new()) + Start-WinUtilJob -Name "Example" -ScriptBlock { } | Out-Null + + & $script:capturedRunspaceBody ` + -JobName "Example" ` + -JobLabel "Example" ` + -JobBody 'Write-Warning "a warning"' ` + -JobParameters @{} ` + -JobRestoresAppList $false ` + -JobToken $script:capturedRunspaceArgs["JobToken"] + + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Level -eq "WARN" -and $Message -eq "a warning" + } + } + + It "restores the app list after a failing job that disabled it" { + Start-WinUtilJob -Name "Install" -DisableAppList -ScriptBlock { } | Out-Null + $script:sync.ActiveJob = "Install" + Mock Write-Host { } + + & $script:capturedRunspaceBody ` + -JobName "Install" ` + -JobBody 'throw "boom"' ` + -JobParameters @{} ` + -JobRestoresAppList $true ` + -JobToken $script:capturedRunspaceArgs["JobToken"] + + $script:sync.ItemsControl.IsEnabled | Should -BeTrue + $script:sync.ActiveJob | Should -BeNullOrEmpty + } +} diff --git a/pester/job-routing.Tests.ps1 b/pester/job-routing.Tests.ps1 new file mode 100644 index 0000000000..c3e62f273c --- /dev/null +++ b/pester/job-routing.Tests.ps1 @@ -0,0 +1,70 @@ +#=========================================================================== +# Tests - Everything that changes the system goes through the job layer + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + $script:functionRoot = Join-Path $script:repoRoot "functions" + $script:configRoot = Join-Path $script:repoRoot "config" +} + +Describe "Work routing" { + It "never hands work to a console window of its own" { + # A separate console takes the work outside the job layer, so it gets no progress bar, + # no taskbar state and no log lines, and the window outlives WinUtil + $offenders = @() + foreach ($file in (Get-ChildItem -Path $script:functionRoot -Filter *.ps1 -Recurse)) { + $text = Get-Content -Path $file.FullName -Raw + foreach ($line in ($text -split "`r?`n")) { + if ($line -match '^\s*#') { continue } + if ($line -match 'wt\s+new-tab') { $offenders += "$($file.Name): $($line.Trim())" } + if ($line -match 'Start-Process\s+.*(powershell|pwsh)\.exe' -and $line -notmatch '-NoNewWindow') { + $offenders += "$($file.Name): $($line.Trim())" + } + } + } + + if ($offenders.Count -gt 0) { throw ($offenders -join "`n") } + } + + + + + + It "probes for optional commands without throwing when they are absent" { + $offenders = @() + foreach ($file in (Get-ChildItem -Path $script:functionRoot -Filter *.ps1 -Recurse)) { + $text = Get-Content -Path $file.FullName -Raw + foreach ($line in ($text -split "`r?`n")) { + if ($line -match 'if\s*\(\s*-not\s*\(Get-Command\s+[^\)]+\)\s*\)' -and $line -notmatch 'ErrorAction') { + $offenders += "$($file.Name): $($line.Trim())" + } + } + } + + if ($offenders.Count -gt 0) { throw ($offenders -join "`n") } + } + + + + + + It "never leaves the runspace handle in the pipeline" { + # Invoke-WPFRunspace returns an IAsyncResult. An unassigned call prints it to the + # console, or folds it into whatever the calling workflow returns. + $offenders = @() + foreach ($file in (Get-ChildItem -Path $script:functionRoot -Filter *.ps1 -Recurse)) { + if ($file.Name -eq "Invoke-WPFRunspace.ps1") { continue } + foreach ($line in ((Get-Content -Path $file.FullName -Raw) -split "`r?`n")) { + if ($line -match '^\s*Invoke-WPFRunspace\b') { $offenders += "$($file.Name): $($line.Trim())" } + } + } + + if ($offenders.Count -gt 0) { throw ($offenders -join "`n") } + } + + + + + + +} diff --git a/pester/lazy-tabs.Tests.ps1 b/pester/lazy-tabs.Tests.ps1 index 8a3e412985..8ba53fdfee 100644 --- a/pester/lazy-tabs.Tests.ps1 +++ b/pester/lazy-tabs.Tests.ps1 @@ -1,14 +1,20 @@ #=========================================================================== # Tests - Lazy tab initialization -#=========================================================================== BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\private\Measure-WinUtilStep.ps1") + + function Test-WinUtilUIAlive { $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher } + function Write-WinUtilLog { + param($Message, $Level, $Component) + } function Invoke-WPFUIElements { param($configVariable, [string]$targetGridName, [int]$columncount) } function Invoke-WinUtilISOCheckExistingWork { } + function Initialize-WinUtilInstallTabControls { } function Reset-WPFCheckBoxes { param([bool]$doToggles) } . (Join-Path $script:repoRoot "functions\public\Initialize-WPFUI.ps1") @@ -49,21 +55,18 @@ Describe "Initialize-WinUtilTabContent" { $script:sync.InitializedTabs["Install"] | Should -BeTrue } - It "re-applies checkbox selections after building a tab's controls" { - Initialize-WinUtilTabContent -TabName "Tweaks" + It "leaves the app navigation to Initialize-WPFUI so its buttons keep their handlers" { + # Rendering it here as well built the navigation twice. The second pass cleared the + # first one's controls, and because the "already wired" guard goes by name the + # replacements counted as wired and never got a click handler, so Install and + # Uninstall did nothing at all. + Initialize-WinUtilTabContent -TabName "Install" - Should -Invoke -CommandName Reset-WPFCheckBoxes -Times 1 -Exactly -ParameterFilter { - $doToggles -eq $true + Should -Invoke -CommandName Invoke-WPFUIElements -Times 0 -Exactly -ParameterFilter { + $targetGridName -eq "appscategory" } } - It "does not re-apply checkbox selections on a tab that's already built" { - Initialize-WinUtilTabContent -TabName "Tweaks" - Initialize-WinUtilTabContent -TabName "Tweaks" - - Should -Invoke -CommandName Reset-WPFCheckBoxes -Times 1 -Exactly - } - It "initializes deferred config-backed tabs once" { Initialize-WinUtilTabContent -TabName "Tweaks" Initialize-WinUtilTabContent -TabName "Config" @@ -83,6 +86,22 @@ Describe "Initialize-WinUtilTabContent" { } } + It "re-applies checkbox selections after building a tab's controls" { + # controls built just now start unchecked, so an import or a preset has to reach them + Initialize-WinUtilTabContent -TabName "Tweaks" + + Should -Invoke -CommandName Reset-WPFCheckBoxes -Times 1 -Exactly -ParameterFilter { + $doToggles -eq $true + } + } + + It "does not re-apply checkbox selections on a tab that is already built" { + Initialize-WinUtilTabContent -TabName "Tweaks" + Initialize-WinUtilTabContent -TabName "Tweaks" + + Should -Invoke -CommandName Reset-WPFCheckBoxes -Times 1 -Exactly + } + It "checks for existing Win11ISO work when the tab is initialized" { Add-Type -AssemblyName WindowsBase $dispatcher = [pscustomobject]@{} @@ -128,14 +147,15 @@ Describe "Initialize-WPFUI" { } 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")) + It "builds no tab content before first paint" { + # Startup moved out of main.ps1 and onto the interface thread. Nothing is built up front + # at all now: Invoke-WPFTab builds whichever tab it selects, and the warmup does the rest. + $uiScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1") -Raw - $startupRegion | Should -Match 'Initialize-WinUtilTabContent -TabName "Install"' - $startupRegion | Should -Not -Match 'targetGridName "tweakspanel"' - $startupRegion | Should -Not -Match 'targetGridName "featurespanel"' - $startupRegion | Should -Not -Match 'targetGridName "appxpanel"' + $uiScript | Should -Match 'Invoke-WPFTab "WPFTab1BT"' + $uiScript | Should -Not -Match 'targetGridName "tweakspanel"' + $uiScript | Should -Not -Match 'targetGridName "featurespanel"' + $uiScript | Should -Not -Match 'targetGridName "appxpanel"' } It "initializes tab content when a tab is selected" { @@ -150,7 +170,9 @@ Describe "Startup lazy tab wiring" { $rendererScript | Should -Match '(?s)"Button"\s*\{.*\$button\.Add_Click\(\{.*Invoke-WPFButton \$Sender\.name' $rendererScript | Should -Match '\$sync\.Buttons\.Add\(\$button\.Name\)' - $mainScript | Should -Match '\$sync\.Buttons -notcontains \$psitem' + # The "already wired" gate moved to the interface thread with the rest of startup + $uiScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1") -Raw + $uiScript | Should -Match '\$alreadyWired' } It "binds generated documentation links when lazy panels are rendered" { diff --git a/pester/logging.Tests.ps1 b/pester/logging.Tests.ps1 index 79c3d745df..12ba10e59a 100644 --- a/pester/logging.Tests.ps1 +++ b/pester/logging.Tests.ps1 @@ -1,6 +1,5 @@ #=========================================================================== # Tests - WinUtil Logging -#=========================================================================== BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path @@ -35,22 +34,43 @@ Describe "Write-WinUtilLog" { Get-Content -Path $logPath -Raw | Should -Match "\[INFO\] \[Test\] same session log" } - It "uses the transcript stream when logPath is not set" { - $transcriptPath = Join-Path $script:testRoot "logs\winutil_2026-07-01_12-00-00.log" + It "writes entries produced concurrently by several threads" { + $logPath = Join-Path $script:testRoot "logs\winutil_2026-07-01_12-00-00.log" $script:sync = [hashtable]::Synchronized(@{ winutildir = $script:testRoot - transcriptPath = $transcriptPath + logPath = $logPath }) - Mock Add-Content { } - Mock Write-Host { } - Write-WinUtilLog -Component "Test" -Message "transcript fallback" + $logFunction = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Write-WinUtilLog.ps1") -Raw + $initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + $initialSessionState.Variables.Add( + (New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList "sync", $script:sync, $null) + ) + $pool = [runspacefactory]::CreateRunspacePool(1, 4, $initialSessionState, $Host) + $pool.Open() + + try { + $handles = foreach ($index in 1..12) { + $shell = [powershell]::Create() + $shell.RunspacePool = $pool + [void]$shell.AddScript($logFunction) + [void]$shell.AddScript("Write-WinUtilLog -Component 'Test' -Message 'entry $index'") + [pscustomobject]@{ Shell = $shell; Handle = $shell.BeginInvoke() } + } + + foreach ($item in $handles) { + $item.Shell.EndInvoke($item.Handle) + $item.Shell.Dispose() + } + } finally { + $pool.Close() + $pool.Dispose() + } - Should -Invoke -CommandName Add-Content -Times 0 -Exactly - Should -Invoke -CommandName Write-Host -Times 1 -Exactly -ParameterFilter { - $Object -match "\[INFO\] \[Test\] transcript fallback" + $content = Get-Content -Path $logPath + foreach ($index in 1..12) { + @($content | Where-Object { $_ -match "\[Test\] entry $index$" }).Count | Should -Be 1 } - Test-Path -Path (Join-Path $script:testRoot "winutil.log") | Should -BeFalse } It "creates one fallback log under logs when only winutildir is available" { @@ -70,38 +90,26 @@ Describe "Write-WinUtilLog" { $content | Should -Match "second fallback entry" } - It "does not append directly when the active log file is the transcript" { + It "falls back to host output when the log file cannot be opened" { $logPath = Join-Path $script:testRoot "logs\winutil_2026-07-01_12-00-00.log" $script:sync = [hashtable]::Synchronized(@{ winutildir = $script:testRoot logPath = $logPath - transcriptPath = $logPath }) - Mock Add-Content { throw [System.IO.IOException]::new("locked by transcript") } -ParameterFilter { + Mock Add-Content { throw [System.IO.IOException]::new("file is locked") } -ParameterFilter { $Path -eq $logPath -and $ErrorAction -eq "Stop" } Mock Write-Host { } Mock Write-Warning { } - Write-WinUtilLog -Component "Test" -Message "transcript stream fallback" + Write-WinUtilLog -Component "Test" -Message "locked file fallback" - Should -Invoke -CommandName Add-Content -Times 0 -Exactly Should -Invoke -CommandName Write-Host -Times 1 -Exactly -ParameterFilter { - $Object -match "\[INFO\] \[Test\] transcript stream fallback" + $Object -match "\[INFO\] \[Test\] locked file fallback" } Should -Invoke -CommandName Write-Warning -Times 0 -Exactly } } -Describe "WinUtil startup logging path" { - It "uses one timestamped log file under the logs directory" { - $startScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\start.ps1") -Raw - - $startScript | Should -Match '\$sync\.logPath = "\$logdir\\winutil_\$dateTime\.log"' - $startScript | Should -Match '\$sync\.transcriptPath = \$sync\.logPath' - $startScript | Should -Match 'Start-Transcript -Path \$sync\.logPath' - $startScript | Should -Not -Match '\$sync\.logPath = "\$winutildir\\winutil\.log"' - } -} diff --git a/pester/multiplane-overlay.Tests.ps1 b/pester/multiplane-overlay.Tests.ps1 index 54483aa156..3539ff8c6e 100644 --- a/pester/multiplane-overlay.Tests.ps1 +++ b/pester/multiplane-overlay.Tests.ps1 @@ -1,6 +1,5 @@ #=========================================================================== # Tests - Multiplane Overlay -#=========================================================================== BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path @@ -27,13 +26,7 @@ Describe "Multiplane Overlay configuration" { $script:states[1].Values.PSObject.Properties.Value | Should -Be @("", "", "1") } - It "uses the generic combo registry handler" { - $renderer = Get-Content (Join-Path $script:repoRoot "functions\public\Invoke-WPFUIElements.ps1") -Raw - - $renderer | Should -Match 'Get-WinUtilRegistryComboState' - $renderer | Should -Match 'Set-WinUtilRegistryComboState' - $renderer | Should -Not -Match 'WPFMultiplaneOverlay' - } + } Describe "Get-WinUtilRegistryComboState" { diff --git a/pester/oosu.Tests.ps1 b/pester/oosu.Tests.ps1 index f04b2a88f1..65eb840601 100644 --- a/pester/oosu.Tests.ps1 +++ b/pester/oosu.Tests.ps1 @@ -11,8 +11,11 @@ BeforeAll { function Invoke-WPFRunspace { param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) } - function Set-WinUtilTweaksProgressIndicator { - param($Visible, $Label, $Percent) + function Start-WinUtilJob { + param([string]$Name, [scriptblock]$ScriptBlock, [hashtable]$Parameters, [string]$Description, [switch]$DisableAppList) + } + function Step-WinUtilJob { + param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay) } function Show-WinUtilMessage { param($Message, $Title, $Button, $Icon) @@ -55,85 +58,61 @@ Describe "Save-WinUtilFile" { Describe "Invoke-WPFOOSU" { BeforeEach { New-WinUtilOOSUTestContext - $script:capturedScriptBlock = $null - $script:capturedParameterList = $null + $script:capturedJob = $null - Mock Invoke-WPFRunspace { - $script:capturedScriptBlock = $ScriptBlock - $script:capturedParameterList = $ParameterList - [pscustomobject]@{ MockHandle = $true } + Mock Start-WinUtilJob { + $script:capturedJob = [pscustomobject]@{ + Name = $Name + ScriptBlock = $ScriptBlock + Parameters = $Parameters + } } - Mock Set-WinUtilTweaksProgressIndicator { } + Mock Step-WinUtilJob { } Mock Show-WinUtilMessage { } Mock Write-WinUtilLog { } Mock Start-Process { } - Mock Write-Error { } } AfterEach { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedScriptBlock -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedParameterList -Scope Script -ErrorAction SilentlyContinue - } - - It "queues the download in a background runspace" { - Invoke-WPFOOSU - - $script:sync.ProcessRunning | Should -BeTrue - Should -Invoke Invoke-WPFRunspace -Times 1 -Exactly - $script:capturedParameterList[0][0] | Should -Be "downloadPath" - $script:capturedParameterList[0][1] | Should -Be (Join-Path $TestDrive "ooshutup10.exe") + Remove-Variable -Name capturedJob -Scope Script -ErrorAction SilentlyContinue } - It "does not start while another process is running" { - New-WinUtilOOSUTestContext -ProcessRunning $true - + It "queues the download as a job with the download path" { Invoke-WPFOOSU - Should -Invoke Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { - $Message -eq "Another process is currently running." -and - $Title -eq "WinUtil" -and - $Button -eq "OK" -and - $Icon -eq "Warning" - } - Should -Not -Invoke Invoke-WPFRunspace + Should -Invoke Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { $Name -eq "OOSU" } + $script:capturedJob.Parameters.DownloadPath | Should -Be (Join-Path $TestDrive "ooshutup10.exe") } - It "maps download progress to the window indicator and launches O&O ShutUp10++" { + It "maps download progress to the job indicator and launches O&O ShutUp10++" { Mock Save-WinUtilFile { & $ProgressCallback 35 & $ProgressCallback 100 } Invoke-WPFOOSU - & $script:capturedScriptBlock -downloadPath $script:capturedParameterList[0][1] + $jobParameters = $script:capturedJob.Parameters + & $script:capturedJob.ScriptBlock @jobParameters - Should -Invoke Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Downloading O&O ShutUp10++ (0%)" -and $Percent -eq 0 + Should -Invoke Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Downloading O&O ShutUp10++ (35%)" -and $Percent -eq 35 } - Should -Invoke Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "Downloading O&O ShutUp10++ (35%)" -and $Percent -eq 35 - } - Should -Invoke Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "O&O ShutUp10++ launched" -and $Percent -eq 100 + Should -Invoke Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Launching O&O ShutUp10++" -and $Percent -eq 100 } Should -Invoke Start-Process -Times 1 -Exactly -ParameterFilter { $FilePath -eq (Join-Path $TestDrive "ooshutup10.exe") } - $script:sync.ProcessRunning | Should -BeFalse } - It "shows failure progress and clears the running state when the download fails" { + It "lets a download failure surface so the job layer can handle it" { Mock Save-WinUtilFile { throw "download failed" } Invoke-WPFOOSU - & $script:capturedScriptBlock -downloadPath $script:capturedParameterList[0][1] + $jobParameters = $script:capturedJob.Parameters - Should -Invoke Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "O&O ShutUp10++ download failed" -and $Percent -eq 100 - } + { & $script:capturedJob.ScriptBlock @jobParameters } | Should -Throw "download failed" Should -Not -Invoke Start-Process - Should -Invoke Write-Error -Times 1 -Exactly - $script:sync.ProcessRunning | Should -BeFalse } } diff --git a/pester/package-outcome.Tests.ps1 b/pester/package-outcome.Tests.ps1 new file mode 100644 index 0000000000..b71bf5a119 --- /dev/null +++ b/pester/package-outcome.Tests.ps1 @@ -0,0 +1,152 @@ +#=========================================================================== +# Tests - Package run outcomes +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + + . (Join-Path $script:repoRoot "functions\private\Install-WinUtilProgramWinget.ps1") + . (Join-Path $script:repoRoot "functions\private\Install-WinUtilProgramChoco.ps1") + . (Join-Path $script:repoRoot "functions\private\Complete-WinUtilPackageRun.ps1") + + function Step-WinUtilJob { param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay, [switch]$Hide) } + function Write-WinUtilLog { + param($Message, $Level, $Component) + } +} + +Describe "Install-WinUtilProgramWinget outcomes" { + BeforeEach { + Mock Write-WinUtilLog { } + } + + It "reports success for exit code 0" { + Mock Start-Process { [pscustomobject]@{ ExitCode = 0 } } + + $result = Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git") + + $result.Outcome | Should -Be "Succeeded" + $result.Package | Should -Be "Git.Git" + $result.Manager | Should -Be "winget" + } + + + + + It "reports an already installed package as skipped rather than failed" { + Mock Start-Process { [pscustomobject]@{ ExitCode = -1978335135 } } + + $result = Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git") + + $result.Outcome | Should -Be "Skipped" + $result.Detail | Should -Be "already installed" + } + + It "reports any other exit code as a failure" { + Mock Start-Process { [pscustomobject]@{ ExitCode = -1978335212 } } + + $result = Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git") + + $result.Outcome | Should -Be "Failed" + $result.ExitCode | Should -Be -1978335212 + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Level -eq "ERROR" -and $Message -like "*failed: Git.Git*" + } + } + + It "returns one result per package" { + Mock Start-Process { [pscustomobject]@{ ExitCode = 0 } } + + $results = @(Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git", "VideoLAN.VLC")) + + $results.Count | Should -Be 2 + } + + It "treats a reboot requirement as success, not failure" { + # 3010 and 1641 mean the installer worked and Windows wants a restart. Counting them as + # failures marks a whole upgrade run broken when nothing went wrong. + foreach ($code in @(3010, 1641)) { + Mock Start-Process { [pscustomobject]@{ ExitCode = $code } }.GetNewClosure() + + $result = Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git") + + $result.Outcome | Should -Be "Succeeded" -Because "exit code $code means the install worked" + $result.Detail | Should -Match "restart" + } + } +} + +Describe "Install-WinUtilProgramChoco outcomes" { + BeforeAll { + function choco { $global:LASTEXITCODE = 0 } + } + + BeforeEach { + Mock Write-WinUtilLog { } + Mock Step-WinUtilJob { } + } + + It "treats a reboot-required exit code as success" { + Mock choco { $global:LASTEXITCODE = 3010 } + + $result = Install-WinUtilProgramChoco -Action Install -Programs @("git") + $result.Outcome | Should -Be "Succeeded" + $result.Detail | Should -Match "restart" + } + + It "reports a non-zero exit code as a failure" { + Mock choco { $global:LASTEXITCODE = 1 } + + (Install-WinUtilProgramChoco -Action Install -Programs @("git")).Outcome | Should -Be "Failed" + } + + It "reports nothing-to-do as skipped rather than failed" { + Mock choco { $global:LASTEXITCODE = 2 } + + (Install-WinUtilProgramChoco -Action Install -Programs @("git")).Outcome | Should -Be "Skipped" + } + + # The reason is in choco's own output, which is logged; the result carries the code + It "reports the exit code when choco fails" { + Mock choco { $global:LASTEXITCODE = 1; "git is not installed. Cannot uninstall a non-existent package." } + + $result = Install-WinUtilProgramChoco -Action Uninstall -Programs @("git") + + $result.Outcome | Should -Be "Failed" + $result.Detail | Should -Be "exit code 1" + } +} + +Describe "Complete-WinUtilPackageRun" { + BeforeEach { + Mock Write-WinUtilLog { } + Mock Write-Host { } + } + + It "reports the counts of each outcome" { + $results = @( + [pscustomobject]@{ Package = "a"; Outcome = "Succeeded"; Detail = "exit code 0" }, + [pscustomobject]@{ Package = "b"; Outcome = "Skipped"; Detail = "already installed" } + ) + + Complete-WinUtilPackageRun -Action "Install" -Results $results + + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Message -eq "Install summary: 1 succeeded, 1 skipped, 0 failed" + } + } + + It "fails the job when a package failed" { + $results = @( + [pscustomobject]@{ Package = "a"; Outcome = "Succeeded"; Detail = "exit code 0" }, + [pscustomobject]@{ Package = "b"; Outcome = "Failed"; Detail = "exit code 5" } + ) + + { Complete-WinUtilPackageRun -Action "Install" -Results $results } | + Should -Throw "1 of 2 package(s) failed: b. *" + } + + It "accepts an empty run" { + { Complete-WinUtilPackageRun -Action "Install" -Results @() } | Should -Not -Throw + } +} diff --git a/pester/package.Tests.ps1 b/pester/package.Tests.ps1 index 64949c751d..a9990a371f 100644 --- a/pester/package.Tests.ps1 +++ b/pester/package.Tests.ps1 @@ -11,6 +11,11 @@ BeforeAll { . (Join-Path $script:repoRoot "functions\private\Install-WinUtilProgramChoco.ps1") function Invoke-WPFUIThread { } + function Write-WinUtilJobBanner { + param([string]$Message, [string]$Level) + } + # The CLI path is what these tests cover; the module path is verified against real winget + function Step-WinUtilJob { param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay, [switch]$Hide) } function Write-WinUtilLog { } } @@ -71,6 +76,15 @@ Describe "Get-WinUtilSelectedPackages" { @($result["Winget"]).Count | Should -Be 0 } + It "returns exactly one object so the caller can index the split" { + # A stray value on the output stream would make this an array, and every package list + # would then read back empty. + $result = @(Get-WinUtilSelectedPackages -PackageList @([pscustomobject]@{ winget = "Git.Git" }) -Preference "Winget") + + $result.Count | Should -Be 1 + (@($result[0]["Winget"]) -join "|") | Should -Be "Git.Git" + } + It "returns empty package lists for an empty selection" { $result = Get-WinUtilSelectedPackages -PackageList @() -Preference "Winget" @@ -144,28 +158,56 @@ Describe "Install-WinUtilProgramWinget" { } Describe "Install-WinUtilProgramChoco" { + BeforeAll { + # choco is a native command, so it needs a function to stand in for it + function choco { $global:LASTEXITCODE = 0 } + } + BeforeEach { Mock Write-WinUtilLog { } - Mock Start-Process { [pscustomobject]@{ ExitCode = 0 } } + Mock Step-WinUtilJob { } + Mock choco { $global:LASTEXITCODE = 0 } } - It "starts choco with install arguments" { - Install-WinUtilProgramChoco -Action Install -Programs @("git", "vlc") + It "calls choco once per package so a failure names the one that failed" { + $results = @(Install-WinUtilProgramChoco -Action Install -Programs @("git", "vlc")) - Should -Invoke -CommandName Start-Process -Times 1 -Exactly -ParameterFilter { - $FilePath -eq "choco" -and - $ArgumentList -eq "install git vlc -y" -and - $NoNewWindow -eq $true -and - $Wait -eq $true -and - $PassThru -eq $true + $results.Count | Should -Be 2 + $results[0].Package | Should -Be "git" + $results[1].Package | Should -Be "vlc" + Should -Invoke -CommandName choco -Times 2 -Exactly + } + + It "passes the install verb and suppresses choco's own progress redraw" { + Install-WinUtilProgramChoco -Action Install -Programs @("git") + + Should -Invoke -CommandName choco -Times 1 -Exactly -ParameterFilter { + $args -contains "install" -and $args -contains "git" -and + $args -contains "-y" -and $args -contains "--no-progress" } } - It "starts choco with uninstall arguments" { + It "passes the uninstall verb" { Install-WinUtilProgramChoco -Action Uninstall -Programs @("git") - Should -Invoke -CommandName Start-Process -Times 1 -Exactly -ParameterFilter { - $FilePath -eq "choco" -and $ArgumentList -eq "uninstall git -y" + Should -Invoke -CommandName choco -Times 1 -Exactly -ParameterFilter { + $args -contains "uninstall" -and $args -contains "git" } } + + It "upgrades rather than installing when asked to upgrade" { + # choco install all -y would look for a package called "all" + Install-WinUtilProgramChoco -Action Upgrade -Programs @("all") + + Should -Invoke -CommandName choco -Times 1 -Exactly -ParameterFilter { + $args -contains "upgrade" -and $args -contains "all" + } + } + + It "moves the progress bar through the list" { + Install-WinUtilProgramChoco -Action Install -Programs @("git", "vlc") -ProgressBase 0 -ProgressSpan 100 + + Should -Invoke -CommandName Step-WinUtilJob -ParameterFilter { $Status -like "*git (1/2)*" } + Should -Invoke -CommandName Step-WinUtilJob -ParameterFilter { $Status -like "*vlc (2/2)*" } + } } diff --git a/pester/preferences-theme.Tests.ps1 b/pester/preferences-theme.Tests.ps1 index d4ad0fc661..ffe6e6db12 100644 --- a/pester/preferences-theme.Tests.ps1 +++ b/pester/preferences-theme.Tests.ps1 @@ -5,6 +5,13 @@ BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + # Theme application sets Thickness, CornerRadius and GridLength resources, so the real WPF + # types have to be loaded here rather than left to whichever other test file happened to + # load them first + Add-Type -AssemblyName PresentationFramework + Add-Type -AssemblyName PresentationCore + Add-Type -AssemblyName WindowsBase + if (-not ("Windows.Media.SolidColorBrush" -as [type])) { Add-Type @" namespace Windows.Media @@ -131,6 +138,9 @@ namespace System.Windows . (Join-Path $script:repoRoot "functions\private\Invoke-WinutilThemeChange.ps1") + function Test-WinUtilUIAlive { $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher } + + function Get-WinUtilToggleStatus { param($ToggleName) return $false diff --git a/pester/progress-state.Tests.ps1 b/pester/progress-state.Tests.ps1 new file mode 100644 index 0000000000..5b24e78155 --- /dev/null +++ b/pester/progress-state.Tests.ps1 @@ -0,0 +1,75 @@ +#=========================================================================== +# Tests - Progress bar reflects how a run ended +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + + Add-Type -AssemblyName PresentationFramework + Add-Type -AssemblyName PresentationCore + Add-Type -AssemblyName WindowsBase + + . (Join-Path $script:repoRoot "functions\private\Step-WinUtilJob.ps1") + + function Test-WinUtilUIAlive { return $true } + function Write-WinUtilConsoleProgress { param([string]$Status, [int]$Percent) } + function Write-WinUtilLog { param($Message, $Level, $Component) } + function Set-WinUtilTaskbaritem { param($state, $overlay, $value) } + function Invoke-WPFUIThread { + param([scriptblock]$ScriptBlock, [hashtable]$Parameters, [switch]$Async, [switch]$PassThru) + & $ScriptBlock @Parameters + } + + function script:New-ProgressFixture { + $bar = New-Object System.Windows.Controls.ProgressBar + # the brushes the job layer points the fill at, resolvable from the control itself + $bar.Resources.Add("ProgressBarForegroundColor", [System.Windows.Media.Brushes]::LimeGreen) + $bar.Resources.Add("ProgressBarErrorColor", [System.Windows.Media.Brushes]::Red) + $bar.Resources.Add("ProgressBarWarningColor", [System.Windows.Media.Brushes]::Orange) + + $global:sync = [hashtable]::Synchronized(@{ + WPFTweaksProgressBar = (New-Object System.Windows.Controls.Border) + WPFTweaksProgressLabel = (New-Object System.Windows.Controls.TextBlock) + WPFTweaksProgressValue = $bar + Form = [pscustomobject]@{ TaskbarItemInfo = [pscustomobject]@{ ProgressValue = 0 } } + }) + return $bar + } +} + +Describe "Step-WinUtilJob progress colour" { + It "fills green while a run is going normally" { + $bar = New-ProgressFixture + + Step-WinUtilJob -Status "working" -Percent 40 -State "Normal" + + $bar.Foreground | Should -Be ([System.Windows.Media.Brushes]::LimeGreen) + } + + It "turns the bar red when a run failed" { + $bar = New-ProgressFixture + + Step-WinUtilJob -Status "Tweaks failed" -Percent 100 -State "Error" + + $bar.Foreground | Should -Be ([System.Windows.Media.Brushes]::Red) + } + + It "warns rather than reporting success when a run finished with errors" { + $bar = New-ProgressFixture + + # this is the case that read as a clean finish: full bar, normal colour + Step-WinUtilJob -Status "Tweaks finished with 2 error(s)" -Percent 100 -State "Paused" + + $bar.Foreground | Should -Be ([System.Windows.Media.Brushes]::Orange) + $bar.Foreground | Should -Not -Be ([System.Windows.Media.Brushes]::LimeGreen) + } + + It "goes back to the normal colour when the next run starts" { + $bar = New-ProgressFixture + + Step-WinUtilJob -Status "Tweaks failed" -Percent 100 -State "Error" + Step-WinUtilJob -Status "starting" -Percent 0 -State "Normal" + + $bar.Foreground | Should -Be ([System.Windows.Media.Brushes]::LimeGreen) + } +} diff --git a/pester/runspace-lifecycle.Tests.ps1 b/pester/runspace-lifecycle.Tests.ps1 index 68a5007456..f53809b5fb 100644 --- a/pester/runspace-lifecycle.Tests.ps1 +++ b/pester/runspace-lifecycle.Tests.ps1 @@ -1,10 +1,11 @@ #=========================================================================== # 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\Stop-WinUtilActiveWork.ps1") + . (Join-Path $script:repoRoot "functions\private\New-WinUtilSessionState.ps1") . (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilRunspacePool.ps1") } @@ -39,26 +40,38 @@ Describe "Initialize-WinUtilRunspacePool" { } 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 + It "carries every WinUtil function into a new runspace" { + $sync = [Hashtable]::Synchronized(@{}) + $null = $sync + function Test-WinUtilSessionStateMarker { "marker" } + function Get-SomethingUnprefixed { "unprefixed" } - $runspaceScript | Should -Match 'Initialize-WinUtilRunspacePool \| Out-Null' + $runspace = [runspacefactory]::CreateRunspace((New-WinUtilSessionState)) + $runspace.Open() + try { + $shell = [powershell]::Create() + $shell.Runspace = $runspace + [void]$shell.AddScript('Test-WinUtilSessionStateMarker; Get-SomethingUnprefixed; (Get-Command mkdir).CommandType') + $result = $shell.Invoke() + $shell.Dispose() + + $result | Should -Contain "marker" + $result | Should -Contain "unprefixed" + } finally { + $runspace.Close() + $runspace.Dispose() + Remove-Item Function:\Test-WinUtilSessionStateMarker -ErrorAction SilentlyContinue + Remove-Item Function:\Get-SomethingUnprefixed -ErrorAction SilentlyContinue + } } + + } diff --git a/pester/runspace.Tests.ps1 b/pester/runspace.Tests.ps1 index 1ad019a351..491ff0ce8e 100644 --- a/pester/runspace.Tests.ps1 +++ b/pester/runspace.Tests.ps1 @@ -1,12 +1,19 @@ #=========================================================================== # Tests - Runspace Behavior -#=========================================================================== BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path . (Join-Path $script:repoRoot "functions\private\Close-WinUtilRunspacePool.ps1") + . (Join-Path $script:repoRoot "functions\private\Stop-WinUtilActiveWork.ps1") . (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilRunspacePool.ps1") - . (Join-Path $script:repoRoot "functions\public\Invoke-WPFRunspace.ps1") + . (Join-Path $script:repoRoot "functions\private\Register-WinUtilRunspaceCleanup.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFRunspace.ps1") + function Start-WinUtilJob { + param([string]$Name, [scriptblock]$ScriptBlock, [hashtable]$Parameters, [string]$Description, [switch]$DisableAppList) + } + function Show-WinUtilMessage { + param($Message, $Title, $Button, $Icon) + } . (Join-Path $script:repoRoot "functions\public\Invoke-WPFFeatureInstall.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFAppxRemoval.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFundoall.ps1") @@ -134,12 +141,7 @@ Describe "Invoke-WPFRunspace behavior" { $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' - } + It "exposes a strongly typed cleanup callback" { ([WinUtilRunspaceCleanup]::Callback -is [System.Threading.WaitOrTimerCallback]) | Should -BeTrue @@ -159,37 +161,40 @@ Describe "Public runspace callers" { }) Mock Invoke-WPFRunspace { [pscustomobject]@{ MockHandle = $true } } + Mock Start-WinUtilJob { } } AfterEach { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue } - It "queues selected feature installation without executing the runspace body" { + It "queues selected feature installation as a job without executing the body" { $script:sync.selectedFeatures.Add("WPFFeaturesSandbox") Invoke-WPFFeatureInstall - Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { - $ScriptBlock -is [scriptblock] -and $null -eq $ArgumentList -and $null -eq $ParameterList + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "Features" -and + $ScriptBlock -is [scriptblock] -and + @($Parameters.Features)[0] -eq "WPFFeaturesSandbox" } } - It "passes selected tweaks as the runspace argument list for undo all" { + It "queues selected tweak undo as a job without executing the body" { $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") $script:sync.selectedTweaks.Add("WPFTweaksServices") Invoke-WPFundoall - Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { - $ScriptBlock -is [scriptblock] -and - $ArgumentList.Count -eq 2 -and - $ArgumentList[0] -eq "WPFTweaksTelemetry" -and - $ArgumentList[1] -eq "WPFTweaksServices" + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "Undo tweaks" -and + $ScriptBlock -is [scriptblock] -and + @($Parameters.Tweaks).Count -eq 2 -and + @($Parameters.Tweaks)[0] -eq "WPFTweaksTelemetry" } } - It "passes selected AppX items and app metadata to the removal runspace" { + It "queues AppX removal as a job with the selection and app metadata" { $script:sync.selectedAppx.Add("WPFAppxExample") $script:sync.configs.appxHashtable["WPFAppxExample"] = [pscustomobject]@{ Content = "Example" @@ -198,13 +203,38 @@ Describe "Public runspace callers" { Invoke-WPFAppxRemoval - Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { - $ScriptBlock -is [scriptblock] -and - $ParameterList.Count -eq 2 -and - $ParameterList[0][0] -eq "selected" -and - $ParameterList[0][1][0] -eq "WPFAppxExample" -and - $ParameterList[1][0] -eq "apps" -and - $ParameterList[1][1].ContainsKey("WPFAppxExample") + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "AppX" -and + $ScriptBlock -is [scriptblock] -and + @($Parameters.Selected)[0] -eq "WPFAppxExample" -and + $Parameters.Apps.ContainsKey("WPFAppxExample") + } + } + + It "keeps every long workflow entrypoint on the job layer" { + $publicRoot = Join-Path $script:repoRoot "functions\public" + $privateRoot = Join-Path $script:repoRoot "functions\private" + $entrypoints = @( + (Join-Path $publicRoot "Invoke-WPFInstall.ps1"), + (Join-Path $publicRoot "Invoke-WPFUnInstall.ps1"), + (Join-Path $publicRoot "Invoke-WPFAppxInstall.ps1"), + (Join-Path $publicRoot "Invoke-WPFAppxRemoval.ps1"), + (Join-Path $publicRoot "Invoke-WPFFeatureInstall.ps1"), + (Join-Path $publicRoot "Invoke-WPFGetInstalled.ps1"), + (Join-Path $publicRoot "Invoke-WPFOOSU.ps1"), + (Join-Path $publicRoot "Invoke-WPFtweaksbutton.ps1"), + (Join-Path $publicRoot "Invoke-WPFundoall.ps1"), + (Join-Path $privateRoot "Invoke-WinUtilISO.ps1"), + (Join-Path $privateRoot "Invoke-WinUtilISOUSB.ps1") + ) + + foreach ($entrypoint in $entrypoints) { + $source = Get-Content -Path $entrypoint -Raw + $source | Should -Match 'Start-WinUtilJob -Name' + # Job bodies never build their own runspace or busy state + $source | Should -Not -Match 'RunspaceFactory\]::CreateRunspace' + $source | Should -Not -Match 'Invoke-WPFRunspace' + $source | Should -Not -Match '\$sync\.ProcessRunning' } } } diff --git a/pester/sanity.Tests.ps1 b/pester/sanity.Tests.ps1 index ba1d5d4b52..e30ac38144 100644 --- a/pester/sanity.Tests.ps1 +++ b/pester/sanity.Tests.ps1 @@ -132,10 +132,12 @@ Describe "Compiled WinUtil sanity" { ('$sync.configs.applications = @' + "'"), ('$inputXML = @' + "'"), ('$WinUtilAutounattendXml = @' + "'"), - "SessionStateVariableEntry -ArgumentList 'sync'", + "SessionStateVariableEntry", "SessionStateFunctionEntry", "[runspacefactory]::CreateRunspacePool", - "function Invoke-WPFRunspace" + "[runspacefactory]::CreateRunspace(`$Host, (New-WinUtilSessionState))", + "function Invoke-WPFRunspace", + "function Start-WinUtilJob" ) foreach ($snippet in $requiredSnippets) { @@ -178,7 +180,7 @@ Describe "Compiled WinUtil sanity" { ('$sync.configs.applications = @' + "'"), ('$inputXML = @' + "'"), ('$WinUtilAutounattendXml = @' + "'"), - '$sync.SearchBarClearButton.Add_Click({' + '$uiShell.AddScript({ Start-WinUtilUserInterface })' ) $lastIndex = -1 @@ -206,8 +208,11 @@ Describe "Compiled WinUtil sanity" { Describe "Runspace sanity" { BeforeAll { + . (Join-Path $script:repoRoot "functions\private\Register-WinUtilRunspaceCleanup.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFRunspace.ps1") . (Join-Path $script:repoRoot "functions\private\Close-WinUtilRunspacePool.ps1") + . (Join-Path $script:repoRoot "functions\private\Stop-WinUtilActiveWork.ps1") + . (Join-Path $script:repoRoot "functions\private\New-WinUtilSessionState.ps1") . (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilRunspacePool.ps1") } diff --git a/pester/shutdown.Tests.ps1 b/pester/shutdown.Tests.ps1 new file mode 100644 index 0000000000..f9bb0c567c --- /dev/null +++ b/pester/shutdown.Tests.ps1 @@ -0,0 +1,206 @@ +#=========================================================================== +# Tests - Closing while something is running + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + $script:functionRoot = Join-Path $script:repoRoot "functions" + + . (Join-Path $script:functionRoot "private\Stop-WinUtilActiveWork.ps1") + + function Test-WinUtilUIAlive { $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher } + + . (Join-Path $script:functionRoot "private\Invoke-WinUtilCloseRequest.ps1") + + function Write-WinUtilLog { param($Level, $Component, $Message, [switch]$Detail) } + function Step-WinUtilJob { param($Status, $Percent, $State, $Overlay, [switch]$Hide) } + function Show-WinUtilMessage { param($Message, $Title, $Button, $Icon) } + function Complete-WinUtilConsoleProgress { } +} + +Describe "Tracking what is running" { + BeforeEach { + $global:sync = [hashtable]::Synchronized(@{}) + } + + It "reports nothing to stop before anything has run" { + # @($null) is a one element array holding null, so a missing collection once read as one + # running item and the shutdown logged work that did not exist + Stop-WinUtilActiveWork | Should -BeTrue + } + + It "creates the collection on first use" { + $shell = [powershell]::Create() + try { + Register-WinUtilActiveShell -PowerShell $shell + @($sync.ActiveShells).Count | Should -Be 1 + } finally { + $shell.Dispose() + } + } + + It "drops instances that have finished instead of holding them for the session" { + $first = [powershell]::Create() + $second = [powershell]::Create() + try { + Register-WinUtilActiveShell -PowerShell $first + # the first is NotStarted, so registering another should sweep it away + Register-WinUtilActiveShell -PowerShell $second + + @($sync.ActiveShells).Count | Should -Be 1 + [object]::ReferenceEquals(@($sync.ActiveShells)[0], $second) | Should -BeTrue + } finally { + $first.Dispose(); $second.Dispose() + } + } + + It "survives an instance that was disposed underneath it" { + $shell = [powershell]::Create() + Register-WinUtilActiveShell -PowerShell $shell + $shell.Dispose() + + { Stop-WinUtilActiveWork -TimeoutSeconds 1 } | Should -Not -Throw + } + + It "stops work that is genuinely running" { + $pool = [runspacefactory]::CreateRunspacePool(1, 2) + $pool.Open() + $shell = [powershell]::Create() + $shell.RunspacePool = $pool + $null = $shell.AddScript('Start-Sleep -Seconds 30') + $null = $shell.BeginInvoke() + + Register-WinUtilActiveShell -PowerShell $shell + try { + $clock = [Diagnostics.Stopwatch]::StartNew() + $stopped = Stop-WinUtilActiveWork -TimeoutSeconds 10 + $clock.Stop() + + $stopped | Should -BeTrue + $clock.Elapsed.TotalSeconds | Should -BeLessThan 10 + $shell.InvocationStateInfo.State | Should -Not -Be ([System.Management.Automation.PSInvocationState]::Running) + } finally { + try { $shell.Dispose() } catch { } + $pool.Close(); $pool.Dispose() + } + } + + It "gives up rather than holding the window open for ever" { + # a worker inside a command that never returns cannot be stopped, and must not stop the + # window closing either + $global:sync.ActiveShells = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new()) + $stubborn = [pscustomobject]@{ + InvocationStateInfo = [pscustomobject]@{ State = [System.Management.Automation.PSInvocationState]::Running } + } + $stubborn | Add-Member -MemberType ScriptMethod -Name BeginStop -Value { param($a, $b) $null } + $null = $sync.ActiveShells.Add($stubborn) + + $clock = [Diagnostics.Stopwatch]::StartNew() + $result = Stop-WinUtilActiveWork -TimeoutSeconds 1 + $clock.Stop() + + $result | Should -BeFalse + $clock.Elapsed.TotalSeconds | Should -BeLessThan 5 + } +} + +Describe "The close question" { + BeforeEach { + $global:sync = [hashtable]::Synchronized(@{}) + $sync.ActiveJob = "Install" + Mock Write-WinUtilLog { } + Mock Step-WinUtilJob { } + } + + It "hands the job to the console and closes the window when asked to let it finish" { + Mock Show-WinUtilMessage { "Yes" } + Mock Write-Host { } + + Invoke-WinUtilCloseRequest -RunningJob "Install" + + # the window goes now; the job carries on without it + $sync.FinishInConsole | Should -BeTrue + $sync.ForceClose | Should -BeTrue + } + + It "keeps the window open when the close is cancelled" { + Mock Show-WinUtilMessage { "Cancel" } + + Invoke-WinUtilCloseRequest -RunningJob "Install" + + $sync.FinishInConsole | Should -Not -BeTrue + $sync.ForceClose | Should -Not -BeTrue + } + + + + It "offers all three choices" { + Mock Show-WinUtilMessage { "Cancel" } + + Invoke-WinUtilCloseRequest -RunningJob "Install" + + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Button -eq "YesNoCancel" -and $Message -like "*Install*" -and $Message -like "*console*" + } + } +} + +Describe "Waiting for work that outlived the window" { + BeforeEach { + $global:sync = [hashtable]::Synchronized(@{}) + Mock Write-WinUtilLog { } + Mock Write-Host { } + } + + It "returns at once when nothing was left running" { + $sync.FinishInConsole = $true + $sync.ActiveJob = $null + + { Wait-WinUtilRemainingWork } | Should -Not -Throw + } + + It "does not wait when the window was closed normally" { + # a job cannot be active without FinishInConsole, but the guard must hold either way + $sync.FinishInConsole = $false + $sync.ActiveJob = "Install" + + $clock = [Diagnostics.Stopwatch]::StartNew() + Wait-WinUtilRemainingWork + $clock.Elapsed.TotalSeconds | Should -BeLessThan 2 + } + + It "waits until the job clears the busy flag" { + $sync.FinishInConsole = $true + $sync.ActiveJob = "Install" + + # something else clears it, the way a worker's finally does + $timer = New-Object System.Timers.Timer + $timer.Interval = 700 + $timer.AutoReset = $false + Register-ObjectEvent -InputObject $timer -EventName Elapsed -Action { $global:sync.ActiveJob = $null } | Out-Null + $timer.Start() + + $clock = [Diagnostics.Stopwatch]::StartNew() + Wait-WinUtilRemainingWork + $clock.Stop() + + $sync.ActiveJob | Should -BeNullOrEmpty + $clock.Elapsed.TotalMilliseconds | Should -BeGreaterThan 500 + $timer.Dispose() + Get-EventSubscriber | Where-Object { $_.SourceObject -is [System.Timers.Timer] } | Unregister-Event + } + + It "gives up rather than keeping the process alive for ever" { + $sync.FinishInConsole = $true + $sync.ActiveJob = "Install" + + $clock = [Diagnostics.Stopwatch]::StartNew() + Wait-WinUtilRemainingWork -TimeoutMinutes 0.02 + $clock.Stop() + + # it waited rather than returning at once, and gave up rather than waiting for ever + $clock.Elapsed.TotalSeconds | Should -BeGreaterThan 0.5 + $clock.Elapsed.TotalSeconds | Should -BeLessThan 10 + $sync.ActiveJob | Should -Be "Install" + } +} + diff --git a/pester/slider.Tests.ps1 b/pester/slider.Tests.ps1 new file mode 100644 index 0000000000..036a265ec0 --- /dev/null +++ b/pester/slider.Tests.ps1 @@ -0,0 +1,55 @@ +#=========================================================================== +# Tests - Sliders move to where they are clicked +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + $script:xaml = [xml](Get-Content -Path (Join-Path $script:repoRoot "xaml\inputXML.xaml") -Raw) +} + +Describe "Sliders" { + It "goes to the point clicked instead of stepping" { + # WPF leaves IsMoveToPointEnabled off by default, so a click on the track pages by + # LargeChange rather than moving the thumb to the pointer + $sliders = @($script:xaml.SelectNodes('//*[local-name()="Slider"]')) + $sliders.Count | Should -BeGreaterThan 0 + + foreach ($slider in $sliders) { + $name = $slider.GetAttribute("Name") + $slider.GetAttribute("IsMoveToPointEnabled") | Should -Be "True" -Because "$name should follow the click" + } + } + + It "does not page by a full unit" { + # LargeChange defaults to 1.0. Over a range of 0.75 to 2.0 that is most of the track, so + # a click on it toggled between the two ends. + foreach ($slider in @($script:xaml.SelectNodes('//*[local-name()="Slider"]'))) { + $name = $slider.GetAttribute("Name") + $minimumText = $slider.GetAttribute("Minimum") + $maximumText = $slider.GetAttribute("Maximum") + $largeChange = $slider.GetAttribute("LargeChange") + + # a missing attribute comes back as an empty string, which throws on the cast below + $minimumText | Should -Not -BeNullOrEmpty -Because "$name should declare a Minimum" + $maximumText | Should -Not -BeNullOrEmpty -Because "$name should declare a Maximum" + $minimum = [double]$minimumText + $maximum = [double]$maximumText + + $largeChange | Should -Not -BeNullOrEmpty -Because "$name should say how far a page moves" + ([double]$largeChange) | Should -BeLessOrEqual (($maximum - $minimum) / 2) -Because "$name pages a sensible amount" + } + } + + It "keeps the font scaling steps on the ticks it draws" { + $slider = $script:xaml.SelectSingleNode('//*[local-name()="Slider"][@Name="FontScalingSlider"]') + + $slider | Should -Not -BeNullOrEmpty + $slider.GetAttribute("IsSnapToTickEnabled") | Should -Be "True" + + # both absent would compare two empty strings and pass for the very regression this + # test exists to catch + $tickFrequency = $slider.GetAttribute("TickFrequency") + $tickFrequency | Should -Not -BeNullOrEmpty -Because "the slider should declare its tick spacing" + $slider.GetAttribute("SmallChange") | Should -Be $tickFrequency + } +} diff --git a/pester/system-helpers.Tests.ps1 b/pester/system-helpers.Tests.ps1 index 356f542c8c..3e5dd1d1ad 100644 --- a/pester/system-helpers.Tests.ps1 +++ b/pester/system-helpers.Tests.ps1 @@ -16,6 +16,8 @@ BeforeAll { function choco { param([Parameter(ValueFromRemainingArguments = $true)]$Arguments) } + # The CLI path is what these tests cover; the module path is verified against real winget + function Step-WinUtilJob { param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay, [switch]$Hide) } function Write-WinUtilLog { } } diff --git a/pester/system-repair.Tests.ps1 b/pester/system-repair.Tests.ps1 new file mode 100644 index 0000000000..4e5ea3eba0 --- /dev/null +++ b/pester/system-repair.Tests.ps1 @@ -0,0 +1,99 @@ +#=========================================================================== +# Tests - System repair exit codes +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFSystemRepair.ps1") + + function Step-WinUtilJob { param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay, [switch]$Hide) } + function Write-WinUtilLog { param($Message, $Level, $Component) } +} + +Describe "Invoke-WPFSystemRepair chkdsk exit codes" { + BeforeEach { + Mock Write-WinUtilLog { } + Mock Step-WinUtilJob { } + } + + It "treats chkdsk exit code 0 as a clean disk" { + Mock Start-Process { [pscustomobject]@{ ExitCode = 0 } } + + { Invoke-WPFSystemRepair } | Should -Not -Throw + Should -Invoke -CommandName Start-Process -Times 3 -Exactly + } + + It "treats chkdsk exit code 1 as success and says what it means" { + Mock Start-Process { + [pscustomobject]@{ ExitCode = $(if (($ArgumentList -join " ") -match "chkdsk") { 1 } else { 0 }) } + } + + { Invoke-WPFSystemRepair } | Should -Not -Throw + Should -Invoke -CommandName Write-WinUtilLog -ParameterFilter { + $Message -like "*errors were found and fixed*" + } + } + + It "treats chkdsk exit code 2 as success and says what it means" { + Mock Start-Process { + [pscustomobject]@{ ExitCode = $(if (($ArgumentList -join " ") -match "chkdsk") { 2 } else { 0 }) } + } + + { Invoke-WPFSystemRepair } | Should -Not -Throw + Should -Invoke -CommandName Write-WinUtilLog -ParameterFilter { + $Message -like "*cleanup was performed*" + } + } + + It "fails on chkdsk exit code 3, which an online scan cannot repair" { + Mock Start-Process { + [pscustomobject]@{ ExitCode = $(if (($ArgumentList -join " ") -match "chkdsk") { 3 } else { 0 }) } + } + + { Invoke-WPFSystemRepair } | Should -Throw "*Checking the disk for errors failed with exit code 3*" + } + + It "does not run the later steps once chkdsk has failed" { + Mock Start-Process { + [pscustomobject]@{ ExitCode = $(if (($ArgumentList -join " ") -match "chkdsk") { 3 } else { 0 }) } + } + + { Invoke-WPFSystemRepair } | Should -Throw + Should -Invoke -CommandName Start-Process -Times 1 -Exactly + } +} + +Describe "Invoke-WPFSystemRepair per step codes" { + BeforeEach { + Mock Write-WinUtilLog { } + Mock Step-WinUtilJob { } + } + + It "accepts 3010 from DISM as a repaired image awaiting a restart" { + Mock Start-Process { + [pscustomobject]@{ ExitCode = $(if (($ArgumentList -join " ") -match "dism") { 3010 } else { 0 }) } + } + + { Invoke-WPFSystemRepair } | Should -Not -Throw + Should -Invoke -CommandName Write-WinUtilLog -ParameterFilter { + $Message -like "*a restart is needed*" + } + } + + It "fails on 3010 from chkdsk, where it does not mean a pending restart" { + Mock Start-Process { + [pscustomobject]@{ ExitCode = $(if (($ArgumentList -join " ") -match "chkdsk") { 3010 } else { 0 }) } + } + + { Invoke-WPFSystemRepair } | Should -Throw "*exit code 3010*" + } + + It "fails on any non-zero from sfc, which has no success codes of its own" { + Mock Start-Process { + [pscustomobject]@{ ExitCode = $(if (($ArgumentList -join " ") -match "sfc") { 1 } else { 0 }) } + } + + { Invoke-WPFSystemRepair } | Should -Throw "*Scanning protected system files failed with exit code 1*" + } +} diff --git a/pester/tweaks.Tests.ps1 b/pester/tweaks.Tests.ps1 index 1645a0d855..0028a2f5bb 100644 --- a/pester/tweaks.Tests.ps1 +++ b/pester/tweaks.Tests.ps1 @@ -4,8 +4,10 @@ BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\private\Measure-WinUtilStep.ps1") . (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilTweaks.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFtweaksbutton.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFundoall.ps1") function Set-WinUtilService { param($Name, $StartupType) @@ -29,14 +31,20 @@ BeforeAll { param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) } function Invoke-WPFUIThread { - param([scriptblock]$ScriptBlock) - } - function Set-WinUtilTweaksProgressIndicator { - param($Visible, $Label, $Percent) + param([scriptblock]$ScriptBlock, [hashtable]$Parameters, [switch]$Async) } function Write-WinUtilLog { param($Message, $Level, $Component) } + function Start-WinUtilJob { + param([string]$Name, [scriptblock]$ScriptBlock, [hashtable]$Parameters, [string]$Description, [switch]$DisableAppList) + } + function Step-WinUtilJob { + param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay, [switch]$Hide) + } + function Show-WinUtilMessage { + param($Message, $Title, $Button, $Icon) + } function script:New-WinUtilTweaksConfig { [pscustomobject]@{ @@ -169,99 +177,181 @@ Describe "Invoke-WinUtilTweaks" { $Name -eq "DiagTrack" -and $StartupType -eq "Disabled" } } - } Describe "Invoke-WPFtweaksbutton" { BeforeEach { $script:sync = [Hashtable]::Synchronized(@{ - ProcessRunning = $false + ActiveJob = $null selectedTweaks = [System.Collections.Generic.List[string]]::new() WPFchangedns = [pscustomobject]@{ text = "Cloudflare" } }) - $script:capturedTweaksScriptBlock = $null + $script:capturedTweaksJob = $null - Mock Invoke-WPFRunspace { - $script:capturedTweaksScriptBlock = $ScriptBlock - [pscustomobject]@{ MockHandle = $true } - } Mock Invoke-WinUtilTweaks { } - Mock Set-WinUtilTweaksProgressIndicator { } + # the real one returns $true on success, and the workflow now stops when it does not + Mock Set-WinUtilDNS { return $true } Mock Invoke-WPFUIThread { } Mock Write-WinUtilLog { } + Mock Step-WinUtilJob { } + Mock Show-WinUtilMessage { "OK" } Mock Write-Host { } + Mock Start-WinUtilJob { + $script:capturedTweaksJob = [pscustomobject]@{ + ScriptBlock = $ScriptBlock + Parameters = $Parameters + } + } } AfterEach { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name capturedTweaksScriptBlock -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedTweaksJob -Scope Script -ErrorAction SilentlyContinue } - It "passes selected tweaks, DNS provider, and progress counters to the tweak runspace" { + It "prompts and exits when nothing is selected and DNS is left at the default" { + $script:sync.WPFchangedns.text = "Default" + + Invoke-WPFtweaksbutton + + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Message -eq "Please check the tweaks you wish to perform." + } + Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly + } + + It "queues a tweak job with the selection and DNS provider" { $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") $script:sync.selectedTweaks.Add("WPFTweaksServices") Invoke-WPFtweaksbutton - Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { - $ParameterList.Count -eq 4 -and - $ParameterList[0][0] -eq "tweaks" -and - $ParameterList[0][1].Count -eq 2 -and - $ParameterList[0][1][0] -eq "WPFTweaksTelemetry" -and - $ParameterList[0][1][1] -eq "WPFTweaksServices" -and - $ParameterList[1][0] -eq "dnsProvider" -and - $ParameterList[1][1] -eq "Cloudflare" -and - $ParameterList[2][0] -eq "completedSteps" -and - $ParameterList[2][1] -eq 0 -and - $ParameterList[3][0] -eq "totalSteps" -and - $ParameterList[3][1] -eq 2 + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "Tweaks" -and $ScriptBlock -is [scriptblock] } + $script:capturedTweaksJob.Parameters.Tweaks | Should -HaveCount 2 + $script:capturedTweaksJob.Parameters.Tweaks[0] | Should -Be "WPFTweaksTelemetry" + $script:capturedTweaksJob.Parameters.DnsProvider | Should -Be "Cloudflare" } - It "runs the restore point first and advances progress before queueing remaining tweaks" { - $script:sync.selectedTweaks.Add("WPFTweaksRestorePoint") + It "applies every selected tweak and the DNS provider inside the job body" { $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") + $script:sync.selectedTweaks.Add("WPFTweaksServices") Invoke-WPFtweaksbutton + $jobParameters = $script:capturedTweaksJob.Parameters + & $script:capturedTweaksJob.ScriptBlock @jobParameters - Should -Invoke -CommandName Invoke-WinUtilTweaks -Times 1 -Exactly -ParameterFilter { - $CheckBox -eq "WPFTweaksRestorePoint" + Should -Invoke -CommandName Set-WinUtilDNS -Times 1 -Exactly -ParameterFilter { + $DNSProvider -eq "Cloudflare" + } + Should -Invoke -CommandName Invoke-WinUtilTweaks -Times 2 -Exactly + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Applying WPFTweaksTelemetry (1/2)" -and $Percent -eq 0 } - Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { - $ParameterList.Count -eq 4 -and - $ParameterList[0][0] -eq "tweaks" -and - $ParameterList[0][1].Count -eq 1 -and - $ParameterList[0][1][0] -eq "WPFTweaksTelemetry" -and - $ParameterList[1][0] -eq "dnsProvider" -and - $ParameterList[1][1] -eq "Cloudflare" -and - $ParameterList[2][0] -eq "completedSteps" -and - $ParameterList[2][1] -eq 1 -and - $ParameterList[3][0] -eq "totalSteps" -and - $ParameterList[3][1] -eq 2 + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Applying WPFTweaksServices (2/2)" -and $Percent -eq 50 } } - It "stops the tweak workflow when the DNS change fails" { + It "stops the run when the DNS change fails" { + # carrying on would leave the machine half configured, so the job ends and reports it $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") Mock Set-WinUtilDNS { return $false } Invoke-WPFtweaksbutton - & $script:capturedTweaksScriptBlock -tweaks @("WPFTweaksTelemetry") -dnsProvider "Mullvad" -completedSteps 0 -totalSteps 1 + $jobParameters = $script:capturedTweaksJob.Parameters + { & $script:capturedTweaksJob.ScriptBlock @jobParameters } | Should -Throw -ExpectedMessage "*DNS change to Cloudflare failed*" Should -Invoke -CommandName Invoke-WinUtilTweaks -Times 0 -Exactly - Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $true -and $Label -eq "DNS change failed" -and $Percent -eq 100 + } + + It "carries on when the DNS change succeeds" { + $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") + Mock Set-WinUtilDNS { return $true } + + Invoke-WPFtweaksbutton + $jobParameters = $script:capturedTweaksJob.Parameters + & $script:capturedTweaksJob.ScriptBlock @jobParameters + + Should -Invoke -CommandName Invoke-WinUtilTweaks -Times 1 -Exactly + } + + It "takes the restore point before any other tweak runs" { + $script:sync.selectedTweaks.Add("WPFTweaksRestorePoint") + $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") + $script:appliedOrder = [System.Collections.Generic.List[string]]::new() + Mock Invoke-WinUtilTweaks { $script:appliedOrder.Add($CheckBox) } + + Invoke-WPFtweaksbutton + $jobParameters = $script:capturedTweaksJob.Parameters + & $script:capturedTweaksJob.ScriptBlock @jobParameters + + $script:appliedOrder | Should -Be @("WPFTweaksRestorePoint", "WPFTweaksTelemetry") + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Creating restore point" -and $Percent -eq 0 } - Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { - $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*' + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Applying WPFTweaksTelemetry (2/2)" -and $Percent -eq 50 + } + } +} + +Describe "Invoke-WPFundoall" { + BeforeEach { + $script:sync = [Hashtable]::Synchronized(@{ + ActiveJob = $null + selectedTweaks = [System.Collections.Generic.List[string]]::new() + }) + $script:capturedUndoJob = $null + + Mock Invoke-WinUtilTweaks { } + Mock Write-WinUtilLog { } + Mock Step-WinUtilJob { } + Mock Show-WinUtilMessage { "OK" } + Mock Write-Host { } + Mock Start-WinUtilJob { + $script:capturedUndoJob = [pscustomobject]@{ + ScriptBlock = $ScriptBlock + Parameters = $Parameters + } + } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedUndoJob -Scope Script -ErrorAction SilentlyContinue + } + + It "prompts and exits when nothing is selected" { + Invoke-WPFundoall + + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Message -eq "Please check the tweaks you wish to undo." + } + Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly + } + + It "undoes every selected tweak inside the job body" { + $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") + $script:sync.selectedTweaks.Add("WPFTweaksServices") + + Invoke-WPFundoall + + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "Undo tweaks" + } + $jobParameters = $script:capturedUndoJob.Parameters + & $script:capturedUndoJob.ScriptBlock @jobParameters + + Should -Invoke -CommandName Invoke-WinUtilTweaks -Times 2 -Exactly -ParameterFilter { $undo -eq $true } + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Undoing WPFTweaksTelemetry (1/2)" -and $Percent -eq 0 } - Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { - $Level -eq "ERROR" -and - $Component -eq "Tweaks" -and - $Message -eq "Tweaks workflow stopped because the DNS change failed." + Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Status -eq "Undoing WPFTweaksServices (2/2)" -and $Percent -eq 50 } - $script:sync.ProcessRunning | Should -BeFalse } } diff --git a/pester/ui-state.Tests.ps1 b/pester/ui-state.Tests.ps1 index d09f471cf7..d5b4078363 100644 --- a/pester/ui-state.Tests.ps1 +++ b/pester/ui-state.Tests.ps1 @@ -72,14 +72,11 @@ namespace System.Windows.Controls . (Join-Path $script:repoRoot "functions\public\Invoke-WPFButton.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFToggleAllCategories.ps1") - function Set-WinUtilTweaksProgressIndicator { - param($Visible, $Label, $Percent) - } function Invoke-WPFRunspace { param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) } function Invoke-WPFUIThread { - param([scriptblock]$ScriptBlock) + param([scriptblock]$ScriptBlock, [hashtable]$Parameters, [switch]$Async) } function Invoke-WinUtilCurrentSystem { param($CheckBox) @@ -90,6 +87,12 @@ namespace System.Windows.Controls function Test-WinUtilPackageManager { param([switch]$winget) } + function Start-WinUtilJob { + param([string]$Name, [scriptblock]$ScriptBlock, [hashtable]$Parameters, [string]$Description, [switch]$DisableAppList) + } + function Step-WinUtilJob { + param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay) + } function Write-WinUtilLog { param($Message, $Level, $Component) } @@ -240,6 +243,31 @@ Describe "Update-WinUtilSelections" { } } +Describe "Reset-WPFCheckBoxes over a changing sync" { + It "survives entries being added to sync while it runs" { + # The tab warmup builds controls into $sync while this runs, and setting IsChecked runs + # handlers that add to it as well. Enumerating $sync live threw "Collection was modified". + $global:sync = [hashtable]::Synchronized(@{}) + # the grower has to actually change state, or its handler never runs and nothing grows + $sync.selectedApps = [System.Collections.Generic.List[string]]::new() + $sync.selectedApps.Add("WPFInstallgrower") + $sync.selectedTweaks = [System.Collections.Generic.List[string]]::new() + $sync.selectedFeatures = [System.Collections.Generic.List[string]]::new() + $sync.selectedAppx = [System.Collections.Generic.List[string]]::new() + $sync.selectedToggles = [System.Collections.Generic.List[string]]::new() + + # a checkbox that grows $sync the moment it is set, standing in for the real handlers + $grower = New-Object System.Windows.Controls.CheckBox + $grower.Add_Checked({ $sync["grown_$([guid]::NewGuid().ToString('N'))"] = 1 }) + $grower.Add_Unchecked({ $sync["grown_$([guid]::NewGuid().ToString('N'))"] = 1 }) + $sync["WPFInstallgrower"] = $grower + + foreach ($i in 1..40) { $sync["WPFInstallfiller$i"] = (New-Object System.Windows.Controls.CheckBox) } + + { Reset-WPFCheckBoxes -doToggles $true } | Should -Not -Throw + } +} + Describe "Invoke-WPFImpex import selection state" { BeforeEach { New-WinUtilUiStateTestContext @@ -460,14 +488,13 @@ Describe "Invoke-WPFGetInstalled selection state" { Mock Set-WinUtilTaskbaritem { } Mock Write-WinUtilLog { } Mock Write-Warning { } - Mock Invoke-WPFRunspace { + Mock Step-WinUtilJob { } + Mock Invoke-WPFUIThread { $uiParameters = $Parameters; & $ScriptBlock @uiParameters } + Mock Start-WinUtilJob { $script:capturedGetInstalledScriptBlock = $ScriptBlock - foreach ($parameter in $ParameterList) { - $script:capturedGetInstalledParameters[$parameter[0]] = $parameter[1] - } + $script:capturedGetInstalledParameters = $Parameters } } - AfterEach { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue @@ -477,11 +504,9 @@ Describe "Invoke-WPFGetInstalled selection state" { It "updates the selected app model, checkbox, count, and popup" { Invoke-WPFGetInstalled -CheckBox "winget" - & $script:capturedGetInstalledScriptBlock ` - -checkbox "winget" ` - -managerPreference "Winget" ` - -operation $script:capturedGetInstalledParameters.operation ` - -completeAction $script:capturedGetInstalledParameters.completeAction + + $jobParameters = $script:capturedGetInstalledParameters + & $script:capturedGetInstalledScriptBlock @jobParameters @($script:sync.selectedApps) | Should -Be @("WPFInstallGit") $script:sync.WPFInstallGit.IsChecked | Should -BeTrue @@ -490,169 +515,22 @@ Describe "Invoke-WPFGetInstalled selection state" { $script:sync.selectedAppsstackPanel.Children[0].Key | Should -Be "WPFInstallGit" } - It "clears the running state when detection fails" { - Mock Invoke-WinUtilCurrentSystem { throw "detection failed" } - + It "queues detection as a job with the manager preference" { Invoke-WPFGetInstalled -CheckBox "winget" - & $script:capturedGetInstalledScriptBlock ` - -checkbox "winget" ` - -managerPreference "Winget" ` - -operation $script:capturedGetInstalledParameters.operation ` - -completeAction $script:capturedGetInstalledParameters.completeAction - $script:sync.ProcessRunning | Should -BeFalse - Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { - $Level -eq "ERROR" -and - $Component -eq "Install" -and - $Message -eq "Get installed state failed: detection failed" + Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter { + $Name -eq "Detect installed" } - Should -Invoke -CommandName Set-WinUtilTaskbaritem -Times 1 -Exactly -ParameterFilter { $state -eq "None" } + $script:capturedGetInstalledParameters.Checkbox | Should -Be "winget" + $script:capturedGetInstalledParameters.ManagerPreference | Should -Be "Winget" } - It "clears the running state when the worker cannot be queued" { - Mock Invoke-WPFRunspace { throw "queue failed" } + It "lets a detection failure surface so the job layer can handle it" { + Mock Invoke-WinUtilCurrentSystem { throw "detection failed" } Invoke-WPFGetInstalled -CheckBox "winget" + $jobParameters = $script:capturedGetInstalledParameters - $script:sync.ProcessRunning | Should -BeFalse - Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { - $Level -eq "ERROR" -and - $Component -eq "Install" -and - $Message -eq "Get installed state failed: queue failed" - } - Should -Invoke -CommandName Set-WinUtilTaskbaritem -Times 1 -Exactly -ParameterFilter { $state -eq "None" } - } -} - -Describe "Reset-WPFCheckBoxes" { - BeforeEach { - New-WinUtilUiStateTestContext - - $script:sync.selectedApps.Add("WPFInstallGit") - $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") - $script:sync.selectedFeatures.Add("WPFFeatureSandbox") - $script:sync.selectedAppx.Add("WPFAppxExample") - $script:sync.selectedToggles.Add("WPFToggleDarkMode") - - $script:sync.WPFInstallGit = New-WinUtilFakeCheckBox - $script:sync.WPFInstallVlc = New-WinUtilFakeCheckBox -IsChecked $true - $script:sync.WPFTweaksTelemetry = New-WinUtilFakeCheckBox - $script:sync.WPFFeatureSandbox = New-WinUtilFakeCheckBox - $script:sync.WPFAppxExample = New-WinUtilFakeCheckBox - $script:sync.WPFToggleDarkMode = New-WinUtilFakeCheckBox - $script:sync.WPFToggleOther = New-WinUtilFakeCheckBox -IsChecked $true - } - - AfterEach { - Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue - } - - It "syncs non-toggle checkboxes from selected lists and updates selected app UI state" { - Reset-WPFCheckBoxes - - $script:sync.WPFInstallGit.IsChecked | Should -BeTrue - $script:sync.WPFInstallVlc.IsChecked | Should -BeFalse - $script:sync.WPFTweaksTelemetry.IsChecked | Should -BeTrue - $script:sync.WPFFeatureSandbox.IsChecked | Should -BeTrue - $script:sync.WPFAppxExample.IsChecked | Should -BeTrue - $script:sync.WPFToggleDarkMode.IsChecked | Should -BeFalse - $script:sync.WPFselectedAppsButton.Content | Should -Be "Selected Apps: 1" - $script:sync.selectedAppsstackPanel.Children.Count | Should -Be 1 - $script:sync.selectedAppsstackPanel.Children[0].Name | Should -Be "Git" - $script:sync.selectedAppsstackPanel.Children[0].Key | Should -Be "WPFInstallGit" - } - - It "restores imported toggles when requested without changing absent toggles" { - Reset-WPFCheckBoxes -doToggles $true - - $script:sync.WPFToggleDarkMode.IsChecked | Should -BeTrue - $script:sync.WPFToggleOther.IsChecked | Should -BeTrue - } -} - -Describe "Invoke-WPFToggleAllCategories" { - BeforeEach { - New-WinUtilUiStateTestContext - - $script:sync.ItemsControl = [pscustomobject]@{ - Items = [System.Collections.ArrayList]::new() - } - } - - AfterEach { - Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue - } - - It "expands all install categories and updates collapsed labels" { - $category = New-WinUtilFakeCategory -Label "+ Browsers" -Visibility ([Windows.Visibility]::Collapsed) - $null = $script:sync.ItemsControl.Items.Add($category) - - Invoke-WPFToggleAllCategories -Action "Expand" - - $category.Children[1].Visibility | Should -Be ([Windows.Visibility]::Visible) - $category.Children[0].Content | Should -Be "- Browsers" - } - - It "collapses all install categories and updates expanded labels" { - $category = New-WinUtilFakeCategory -Label "- Browsers" -Visibility ([Windows.Visibility]::Visible) - $null = $script:sync.ItemsControl.Items.Add($category) - - Invoke-WPFToggleAllCategories -Action "Collapse" - - $category.Children[1].Visibility | Should -Be ([Windows.Visibility]::Collapsed) - $category.Children[0].Content | Should -Be "+ Browsers" - } - - It "warns and exits when ItemsControl is not initialized" { - $script:sync.ItemsControl = $null - Mock Write-Warning { } - - Invoke-WPFToggleAllCategories -Action "Expand" - - Should -Invoke -CommandName Write-Warning -Times 1 -Exactly -ParameterFilter { - $Message -eq "ItemsControl not initialized" - } - } -} - -Describe "Invoke-WPFButton progress cleanup" { - BeforeEach { - New-WinUtilUiStateTestContext - Mock Set-WinUtilTweaksProgressIndicator { } - } - - AfterEach { - Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue - Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue - } - - It "clears completed progress on the next idle button click" { - $script:sync.ProcessRunning = $false - - Invoke-WPFButton -Button "WPFNoOp" - - Should -Invoke Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { - $Visible -eq $false - } - } - - It "leaves progress visible while a process is running" { - $script:sync.ProcessRunning = $true - - Invoke-WPFButton -Button "WPFNoOp" - - Should -Not -Invoke Set-WinUtilTweaksProgressIndicator - } - - It "leaves progress visible while a Win11 ISO process is running" { - $script:sync.ProcessRunning = $false - $script:sync.Win11ISOProcessRunning = $true - - Invoke-WPFButton -Button "WPFNoOp" - - Should -Not -Invoke Set-WinUtilTweaksProgressIndicator + { & $script:capturedGetInstalledScriptBlock @jobParameters } | Should -Throw "detection failed" } -} - +} \ No newline at end of file diff --git a/pester/win11creator.Tests.ps1 b/pester/win11creator.Tests.ps1 index b689b0a830..e18ef28116 100644 --- a/pester/win11creator.Tests.ps1 +++ b/pester/win11creator.Tests.ps1 @@ -1,6 +1,5 @@ #=========================================================================== # Tests - Win11 Creator -#=========================================================================== Describe "Win11 Creator setup media" { BeforeAll { @@ -98,7 +97,7 @@ Describe "Win11 Creator setup media" { It "starts each new ISO modification in a fresh working directory" { foreach ($expectedText in @( '$workDir = Join-Path $env:TEMP "WinUtil_Win11ISO_$(Get-Date -Format ''yyyyMMdd_HHmmss'')"', - '$workDir = Join-Path $env:TEMP "WinUtil_Win11ISO_$(Get-Date -Format ''yyyyMMdd_HHmmss'')_$(([guid]::NewGuid()).ToString(''N'').Substring(0, 8))"' + '$workDir = "$($workDir)_$(([guid]::NewGuid()).ToString(''N'').Substring(0, 8))"' )) { $script:modifyFunction | Should -Match ([regex]::Escape($expectedText)) } @@ -154,22 +153,35 @@ Describe "Win11 Creator setup media" { { Assert-WinUtilISOWimMetadata -Before $valid -After $invalidAfter } | Should -Throw '*validation failed*' } - It "tracks every background ISO workflow with the shared busy state" { - foreach ($functionText in @( - $script:mountAndVerifyFunction, - $script:modifyFunction, - $script:cleanAndResetFunction, - $script:exportFunction, - $script:writeUsbFunction + It "runs every background ISO workflow through the shared job layer" { + $jobNames = @{ + mountAndVerify = "ISO mount" + modify = "ISO modify" + cleanAndReset = "ISO cleanup" + export = "ISO export" + writeUsb = "USB write" + } + + foreach ($entry in @( + @{ Text = $script:mountAndVerifyFunction; Name = $jobNames.mountAndVerify }, + @{ Text = $script:modifyFunction; Name = $jobNames.modify }, + @{ Text = $script:cleanAndResetFunction; Name = $jobNames.cleanAndReset }, + @{ Text = $script:exportFunction; Name = $jobNames.export }, + @{ Text = $script:writeUsbFunction; Name = $jobNames.writeUsb } )) { - $functionText | Should -Match ([regex]::Escape('$sync["Win11ISOProcessRunning"] = $true')) - $functionText | Should -Match ([regex]::Escape('$sync["Win11ISOProcessRunning"] = $false')) + $entry.Text | Should -Match ([regex]::Escape("Start-WinUtilJob -Name `"$($entry.Name)`"")) + # The job layer owns the busy state, the progress bar, and the taskbar item + $entry.Text | Should -Not -Match ([regex]::Escape('Win11ISOProcessRunning')) + $entry.Text | Should -Not -Match ([regex]::Escape('RunspaceFactory]::CreateRunspace()')) + $entry.Text | Should -Not -Match ([regex]::Escape('SessionStateProxy.SetVariable')) + $entry.Text | Should -Not -Match ([regex]::Escape('[System.Windows.MessageBox]::Show')) } } It "runs ISO mount and verification outside the UI thread" { - $script:mountAndVerifyFunction | Should -Match ([regex]::Escape("Invoke-WPFRunspace -ParameterList @(,('isoPath', `$isoPath))")) - $script:mountAndVerifyFunction | Should -Match ([regex]::Escape('Invoke-WPFUIThread {')) + $script:mountAndVerifyFunction | Should -Match ([regex]::Escape('Start-WinUtilJob -Name "ISO mount"')) + $script:mountAndVerifyFunction | Should -Match ([regex]::Escape('IsoPath = $isoPath')) + $script:mountAndVerifyFunction | Should -Match ([regex]::Escape('Invoke-WPFUIThread -ScriptBlock {')) $script:mountAndVerifyFunction | Should -Match ([regex]::Escape('Write-WinUtilISOLog')) $script:mountAndVerifyFunction | Should -Not -Match ([regex]::Escape('Write-Win11ISOLog')) $script:mountAndVerifyFunction | Should -Match ([regex]::Escape('$sync["WPFWin11ISOBrowseButton"].IsEnabled = $false')) @@ -180,6 +192,20 @@ Describe "Win11 Creator setup media" { $script:mountAndVerifyFunction | Should -Match ([regex]::Escape('$sync["WPFWin11ISOModifyButton"].IsEnabled = $true')) } + It "reports ISO progress and logging through the shared helpers" { + $content = Get-Content -Path $script:isoWorkflowPath -Raw + $usbContent = Get-Content -Path $script:isoUsbWorkflowPath -Raw + + foreach ($source in @($content, $usbContent)) { + $source | Should -Match ([regex]::Escape('Step-WinUtilJob -Status')) + $source | Should -Not -Match '(?m)^\s*function (Log|SetProgress)\(' + $source | Should -Not -Match ([regex]::Escape('$sync["WPFTweaksProgressLabel"]')) + } + + # Every status-log line also lands in the session log + $content | Should -Match ([regex]::Escape('Write-WinUtilLog -Level $Level -Component "Win11Creator" -Message $Message')) + } + It "blocks oversized install.esd before USB erase confirmation" { $script:writeUsbFunction | Should -Match ([regex]::Escape('$installEsd = Join-Path $contentsDir "sources\install.esd"')) $script:writeUsbFunction | Should -Match ([regex]::Escape('$esdSizeBytes -ge 4GB')) @@ -256,64 +282,7 @@ Describe "Win11 Creator setup media" { } } - It "stages the complete WinUtil customization script and selected image index" { - $contentRoot = Join-Path ([IO.Path]::GetTempPath()) "WinUtilIsoAnswerFile_$([guid]::NewGuid())" - $template = Get-Content -Path $script:autoUnattendPath -Raw - - try { - New-Item -Path $contentRoot -ItemType Directory -Force | Out-Null - . $script:isoScriptPath - Invoke-WinUtilISOScript -ISOContentsDir $contentRoot -AutoUnattendXml $template -InstallEditionId "Core" -InstallImageIndex 6 - - [xml]$answerFile = Get-Content -Path (Join-Path $contentRoot "autounattend.xml") -Raw - $nsMgr = New-Object System.Xml.XmlNamespaceManager($answerFile.NameTable) - $nsMgr.AddNamespace("u", "urn:schemas-microsoft-com:unattend") - $nsMgr.AddNamespace("sg", "https://schneegans.de/windows/unattend-generator/") - - $answerFile.SelectSingleNode('/u:unattend/u:settings[@pass="windowsPE"]/u:component[@name="Microsoft-Windows-Setup"]/u:ImageInstall/u:OSImage/u:InstallFrom/u:MetaData[u:Key="/IMAGE/INDEX"]/u:Value', $nsMgr).InnerText | Should -Be '6' - - $postInstallFile = $answerFile.SelectSingleNode('//sg:File[@path="C:\Windows\Setup\Scripts\WinUtil-PostInstall.ps1"]', $nsMgr) - $postInstallFile | Should -Not -BeNullOrEmpty - $postInstallFile.InnerText | Should -Match 'Remove-AppxProvisionedPackage' - $postInstallFile.InnerText | Should -Match 'DisableWindowsConsumerFeatures' - $postInstallFile.InnerText | Should -Match 'Microsoft Compatibility Appraiser' - $postInstallFile.InnerText | Should -Match 'OneDriveSetup.exe' - $postInstallFile.InnerText | Should -Match 'function Set-WinUtilContentDeliveryManagerValues' - $postInstallFile.InnerText | Should -Match ([regex]::Escape('Set-WinUtilContentDeliveryManagerValues $defaultHive')) - $postInstallFile.InnerText | Should -Match ([regex]::Escape("Set-WinUtilContentDeliveryManagerValues 'HKCU'")) - $postInstallFile.InnerText | Should -Match ([regex]::Escape("Set-WinUtilRegistryValue 'HKCU\Control Panel\UnsupportedHardwareNotificationCache' 'SV1'")) - $postInstallFile.InnerText | Should -Match ([regex]::Escape("Set-WinUtilRegistryValue 'HKCU\Control Panel\UnsupportedHardwareNotificationCache' 'SV2'")) - foreach ($defaultProfilePath in @( - '$defaultHive\Software\Microsoft\Windows\CurrentVersion\AdvertisingInfo', - '$defaultHive\Software\Microsoft\Windows\CurrentVersion\Privacy', - '$defaultHive\Software\Microsoft\Speech_OneCore\Settings\OnlineSpeechPrivacy', - '$defaultHive\Software\Microsoft\Input\TIPC', - '$defaultHive\Software\Microsoft\InputPersonalization', - '$defaultHive\Software\Microsoft\InputPersonalization\TrainedDataStore', - '$defaultHive\Software\Microsoft\Personalization\Settings' - )) { - $postInstallFile.InnerText | Should -Match ([regex]::Escape($defaultProfilePath)) - } - - $firstLogonFile = $answerFile.SelectSingleNode('//sg:File[@path="C:\Windows\Setup\Scripts\FirstLogon.ps1"]', $nsMgr) - $firstLogonFile.InnerText | Should -Match 'WinUtil-PostInstall.ps1' - - $setupScriptsRoot = Join-Path $contentRoot 'sources\$OEM$\$$\Setup\Scripts' - Test-Path (Join-Path $setupScriptsRoot 'Specialize.ps1') | Should -BeTrue - Test-Path (Join-Path $setupScriptsRoot 'DefaultUser.ps1') | Should -BeTrue - Test-Path (Join-Path $setupScriptsRoot 'FirstLogon.ps1') | Should -BeTrue - Test-Path (Join-Path $setupScriptsRoot 'WinUtil-PostInstall.ps1') | Should -BeTrue - Get-Content -Path (Join-Path $setupScriptsRoot 'FirstLogon.ps1') -Raw | Should -Match 'WinUtil-PostInstall.ps1' - Get-Content -Path (Join-Path $setupScriptsRoot 'WinUtil-PostInstall.ps1') -Raw | Should -Match 'Remove-AppxProvisionedPackage' - - $tokens = $null - $errors = $null - [System.Management.Automation.Language.Parser]::ParseInput($postInstallFile.InnerText, [ref]$tokens, [ref]$errors) | Out-Null - $errors.Count | Should -Be 0 - } finally { - Remove-Item -Path $contentRoot -Recurse -Force -ErrorAction SilentlyContinue - } - } + It "stages storage drivers for WinPE and adds all drivers to one install.wim index" { $contentRoot = Join-Path ([IO.Path]::GetTempPath()) "WinUtilIsoDrivers_$([guid]::NewGuid())" @@ -512,12 +481,14 @@ Describe "Win11 Creator setup media" { $content | Should -Match ([regex]::Escape($expectedText)) } - $fallbackIndex = $content.IndexOf('oscdimg.exe not found. Attempting to install via winget...') - $notFoundDialogIndex = $content.IndexOf('oscdimg Not Found', $fallbackIndex) - $runspaceIndex = $content.IndexOf('[Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()', $fallbackIndex) + # The export job stops at the dialog instead of running oscdimg without a binary + $exportOscdimgIndex = $script:exportFunction.IndexOf('$oscdimg = Get-WinUtilOscdimgPath') + $exportDialogIndex = $script:exportFunction.IndexOf('oscdimg Not Found', $exportOscdimgIndex) + $exportRunIndex = $script:exportFunction.IndexOf('Running oscdimg...', $exportOscdimgIndex) - $fallbackIndex | Should -BeGreaterThan -1 - $notFoundDialogIndex | Should -BeGreaterThan $fallbackIndex - $runspaceIndex | Should -BeGreaterThan $notFoundDialogIndex + $exportOscdimgIndex | Should -BeGreaterThan -1 + $exportDialogIndex | Should -BeGreaterThan $exportOscdimgIndex + $exportRunIndex | Should -BeGreaterThan $exportDialogIndex + $script:exportFunction | Should -Match ([regex]::Escape('return')) } } diff --git a/pester/xaml.Tests.ps1 b/pester/xaml.Tests.ps1 index 5f9ce2fb16..b350cc320c 100644 --- a/pester/xaml.Tests.ps1 +++ b/pester/xaml.Tests.ps1 @@ -1,6 +1,5 @@ #=========================================================================== # Tests - XAML Control Wiring -#=========================================================================== BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path @@ -8,7 +7,7 @@ BeforeAll { $script:functionRoot = Join-Path $script:repoRoot "functions" $script:scriptsRoot = Join-Path $script:repoRoot "scripts" $script:xamlPath = Join-Path $script:repoRoot "xaml\inputXML.xaml" - $script:mainScriptPath = Join-Path $script:scriptsRoot "main.ps1" + $script:uiScriptPath = Join-Path $script:functionRoot "private\Start-WinUtilUserInterface.ps1" $script:buttonScriptPath = Join-Path $script:functionRoot "public\Invoke-WPFButton.ps1" $script:xamlText = Get-Content -Path $script:xamlPath -Raw $script:xaml = [xml]$script:xamlText @@ -180,15 +179,7 @@ Describe "XAML document" { } } - It "wires the Document search chip to an existing Document category" { - $mainScript = Get-Content -Path $script:mainScriptPath -Raw - $mainScript | Should -Match '@\{ Name = "WPFSearchChipDocument";\s+Category = "Document" \}' - $mainScript | Should -Match '\$sync\["WPFSearchChipDocument"\]\.Add_Click\(\{ Invoke-WinUtilAppCategoryChip -Chip \$this \}\)' - - $applications = Get-WinUtilConfigObject -Name "applications" - $categories = @($applications.PSObject.Properties | ForEach-Object { $_.Value.category } | Sort-Object -Unique) - $categories | Should -Contain "Document" - } + It "presents the three Updates profiles with accurate action labels" { $updatesTab = $script:xaml.SelectSingleNode('//*[local-name()="TabItem"][@Name="WPFTab4"]') @@ -265,28 +256,7 @@ Describe "XAML document" { } } - It "opens AppX removal from Tweaks and provides a return path" { - $navPanel = $script:xaml.SelectSingleNode('//*[local-name()="StackPanel"][@Name="NavDockPanel"]') - $tweaksTab = $script:xaml.SelectSingleNode('//*[local-name()="TabItem"][@Name="WPFTab2"]') - $appxTab = $script:xaml.SelectSingleNode('//*[local-name()="TabItem"][@Name="WPFTab6"]') - $openButton = $tweaksTab.SelectSingleNode('.//*[local-name()="Button"][@Name="WPFAppxRemoval"]') - $buttonNames = @($openButton.ParentNode.SelectNodes('./*[local-name()="Button"]') | ForEach-Object { $_.GetAttribute("Name") }) - $getInstalledIndex = [array]::IndexOf($buttonNames, "WPFGetInstalledTweaks") - $openAppxIndex = [array]::IndexOf($buttonNames, "WPFAppxRemoval") - $buttonSource = Get-Content -Path $script:buttonScriptPath -Raw - $tabSource = Get-Content -Path (Join-Path $script:functionRoot "public\Invoke-WPFTab.ps1") -Raw - - $navPanel.SelectSingleNode('./*[local-name()="ToggleButton"][@Name="WPFTab6BT"]') | Should -BeNullOrEmpty - $openButton.GetAttribute("Content").Trim() | Should -Be "AppX Removal" - $openAppxIndex | Should -Be ($getInstalledIndex + 1) - $appxTab.SelectSingleNode('.//*[local-name()="Button"][@Name="WPFBackToTweaks"]') | Should -Not -BeNullOrEmpty - $appxTab.SelectSingleNode('.//*[local-name()="Button"][@Name="WPFInstallSelectedAppx"]') | Should -Not -BeNullOrEmpty - $appxTab.SelectSingleNode('.//*[local-name()="Button"][@Name="WPFRemoveSelectedAppx"]') | Should -Not -BeNullOrEmpty - $buttonSource | Should -Match '"WPFAppxRemoval"\s*\{Invoke-WPFTab "WPFTab6BT"\}' - $buttonSource | Should -Match '"WPFBackToTweaks"\s*\{Invoke-WPFTab "WPFTab2BT"\}' - $buttonSource | Should -Match '"WPFInstallSelectedAppx"\s*\{Invoke-WPFAppxInstall\}' - $tabSource | Should -Match '\$sync\.\$tabNav\.Items\[\$tabNumber\]\.IsSelected = \$true' - } + It "centers top bar controls vertically" { $navPanel = $script:xaml.SelectSingleNode('//*[local-name()="StackPanel"][@Name="NavDockPanel"]') @@ -310,31 +280,9 @@ Describe "XAML document" { } } - It "keeps the responsive search controls within the available screen width" { - $window = $script:xaml.DocumentElement - $searchBar = $script:xaml.SelectSingleNode('//*[local-name()="TextBox"][@Name="SearchBar"]') - $searchBorder = $searchBar.ParentNode.ParentNode - $mainScript = Get-Content -Path $script:mainScriptPath -Raw - - $window.GetAttribute("MinWidth") | Should -Be "800" - $searchBorder.GetAttribute("Width") | Should -BeNullOrEmpty - $searchBorder.GetAttribute("HorizontalAlignment") | Should -Be "Stretch" - $searchBar.GetAttribute("Width") | Should -BeNullOrEmpty - $searchBar.GetAttribute("HorizontalAlignment") | Should -Be "Stretch" - $mainScript | Should -Match '\$sync\.Form\.MinWidth = "1150"' - $mainScript | Should -Match '\$sync\.Form\.MinWidth = \[Math\]::Min\(\[double\]\$sync\.Form\.MinWidth, \[double\]\$screenWidth\)' - } + - It "shows only one search action glyph at a time" { - $searchIcon = $script:xaml.SelectSingleNode('//*[local-name()="TextBlock"][@Name="SearchBarIcon"]') - $clearButton = $script:xaml.SelectSingleNode('//*[local-name()="Button"][@Name="SearchBarClearButton"]') - $mainScript = Get-Content -Path $script:mainScriptPath -Raw - - $searchIcon | Should -Not -BeNullOrEmpty - $clearButton | Should -Not -BeNullOrEmpty - $mainScript | Should -Match '\$sync\.SearchBarClearButton\.Visibility = "Visible"\s+\$sync\.SearchBarIcon\.Visibility = "Collapsed"' - $mainScript | Should -Match '\$sync\.SearchBarClearButton\.Visibility = "Collapsed"\s+\$sync\.SearchBarIcon\.Visibility = "Visible"' - } + It "scopes toggle button styles without leaking into combo boxes" { $resources = $script:xaml.SelectSingleNode('//*[local-name()="Window.Resources"]') @@ -385,10 +333,10 @@ Describe "XAML document" { Describe "XAML and sync wiring" { It "wires generated config panels to existing target grids" { $xamlNames = @(Get-WinUtilXamlRuntimeNamedControls | ForEach-Object { $_.Name }) - $mainLines = Get-Content -Path $script:mainScriptPath + $uiLines = Get-Content -Path $script:uiScriptPath $invalidTargets = New-Object System.Collections.Generic.List[string] - foreach ($line in $mainLines) { + foreach ($line in $uiLines) { if ($line.TrimStart().StartsWith("#")) { continue } @@ -431,8 +379,28 @@ Describe "XAML and sync wiring" { "version", "winutildir", "logPath", - "transcriptPath", - "ProcessRunning", + "ActiveJob", + "ActiveJobToken", + # Intrinsic to the synchronized hashtable rather than WinUtil state; the job layer + # locks on it to claim and release the active job slot + "SyncRoot", + "UIRunspace", + "UIDispatchDelegate", + "StepTimings", + "LoggedErrors", + "StartedAt", + "SessionState", + "TabWarmupQueue", + "BackgroundQueues", + "ConsoleProgressState", + "LastInputAt", + "ActiveShells", + "ShuttingDown", + "ForceClose", + "FinishInConsole", + "PendingCloseWork", + "StopWatchdogTimer", + "IconFetchRunning", "selected", "selectedAppx", "selectedApps", @@ -456,6 +424,9 @@ Describe "XAML and sync wiring" { "checkmarkrender", "warningrender", "InitializedTabs", + "AppCategoryChips", + "SelectedAppCategories", + "AppCategoryAutoExpanded", "RenderedAssetCache", "ToggleStatusCache", "InstallAppRenderQueue", @@ -465,14 +436,9 @@ Describe "XAML and sync wiring" { "Win11ISODriveLetter", "Win11ISOWimPath", "Win11ISOImagePath", - "Win11ISOModifying", - "Win11ISOProcessRunning", "Win11ISOWorkDir", "Win11ISOContentsDir", - "Win11ISOUSBDisks", - "AppCategoryChips", - "SelectedAppCategories", - "AppCategoryAutoExpanded" + "Win11ISOUSBDisks" ) $allowedNames = @($xamlNames + $generatedNames + $dynamicStateNames) | Sort-Object -Unique $bracketReferences = @( @@ -525,7 +491,7 @@ Describe "WPF handler wiring" { ) $buttonSwitchNames = @(Get-WinUtilButtonSwitchNames) $featureNames = @((Get-WinUtilConfigObject -Name "feature").PSObject.Properties.Name) - $mainScript = Get-Content -Path $script:mainScriptPath -Raw + $uiScript = Get-Content -Path $script:uiScriptPath -Raw $unhandledButtons = New-Object System.Collections.Generic.List[string] foreach ($button in $buttonControls) { @@ -533,9 +499,12 @@ Describe "WPF handler wiring" { $hasFeatureHandler = Test-WinUtilNameInSet -Name $button.Name -Set $featureNames $escapedName = [regex]::Escape($button.Name) $explicitHandlerPattern = '\$sync\s*(?:\[\s*["'']' + $escapedName + '["'']\s*\]|\.' + $escapedName + ')\.Add_Click' - $hasExplicitHandler = $mainScript -imatch $explicitHandlerPattern + $hasExplicitHandler = $uiScript -imatch $explicitHandlerPattern + + # The category chips share one handler, wired from the list that names them + $hasChipHandler = $uiScript -imatch ('@\{\s*Name\s*=\s*"' + $escapedName + '"') - if (-not ($hasSwitchHandler -or $hasFeatureHandler -or $hasExplicitHandler)) { + if (-not ($hasSwitchHandler -or $hasFeatureHandler -or $hasExplicitHandler -or $hasChipHandler)) { $unhandledButtons.Add($button.Name) } } diff --git a/scripts/main.ps1 b/scripts/main.ps1 index a37700199a..b73d9d4288 100644 --- a/scripts/main.ps1 +++ b/scripts/main.ps1 @@ -34,524 +34,126 @@ $sync.configs.appx.PSObject.Properties | ForEach-Object { $sync.preferences.theme = "Auto" $sync.preferences.packagemanager = "Winget" -if ($Preset) { - Initialize-WinUtilRunspacePool | Out-Null - - # Selects the tweaks from $Preset varible - Update-WinUtilSelections -flatJson $sync.configs.preset.$Preset - - # Run tweaks that were selected by Update-WinUtilSelections - Invoke-WinUtilAutoRun - - # Cleanup and exit - 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 - Close-WinUtilRunspacePool - [System.GC]::Collect() - Stop-Transcript - return -} +function Remove-WinUtilTempScript { + <# + .SYNOPSIS + Removes the temporary script downloaded by windev.ps1. -[void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework') -[xml]$XAML = $inputXML + .DESCRIPTION + Deletes the current script only when it is a winutil-*.ps1 file in + the system temporary directory. This preserves normal file-backed + and in-memory WinUtil launches. + #> -# Read the XAML file -$readerOperationSuccessful = $false # There's more cases of failure then success. -$reader = (New-Object System.Xml.XmlNodeReader $xaml) -try { - $sync["Form"] = [Windows.Markup.XamlReader]::Load( $reader ) - $readerOperationSuccessful = $true -} catch [System.Management.Automation.MethodInvocationException] { - Write-Host "We ran into a problem with the XAML code. Check the syntax for this control..." -ForegroundColor Red - Write-Host $error[0].Exception.Message -ForegroundColor Red + $scriptPath = $PSCommandPath + $tempPath = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') - If ($error[0].Exception.Message -like "*button*") { - write-Host "Ensure your <button in the `$inputXML does NOT have a Click=ButtonClick property. PS can't handle this`n`n`n`n" -ForegroundColor Red + if ( + $scriptPath -and + [IO.Path]::GetDirectoryName($scriptPath) -eq $tempPath -and + [IO.Path]::GetFileName($scriptPath) -like 'winutil-*.ps1' + ) { + Remove-Item -LiteralPath $scriptPath -Force -ErrorAction SilentlyContinue } -} catch { - Write-Host "Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed." -ForegroundColor Red -} - -if (-NOT ($readerOperationSuccessful)) { - Write-Host "Failed to parse xaml content using Windows.Markup.XamlReader's Load Method." -ForegroundColor Red - Write-Host "Quitting WinUtil..." -ForegroundColor Red - Close-WinUtilRunspacePool - [System.GC]::Collect() - exit 1 } -# Setup the Window to follow listen for windows Theme Change events and update the winutil theme -# throttle logic needed, because windows seems to send more than one theme change event per change -$lastThemeChangeTime = [datetime]::MinValue -$debounceInterval = [timespan]::FromSeconds(2) -$sync.Form.Add_Loaded({ - $interopHelper = New-Object System.Windows.Interop.WindowInteropHelper $sync.Form - $hwndSource = [System.Windows.Interop.HwndSource]::FromHwnd($interopHelper.Handle) - $hwndSource.AddHook({ - param ( - [System.IntPtr]$hwnd, - [int]$msg, - [System.IntPtr]$wParam, - [System.IntPtr]$lParam, - [ref]$handled - ) - $null = $hwnd, $wParam, $lParam - # Check for the Event WM_SETTINGCHANGE (0x1001A) and validate that Button shows the icon for "Auto" => [char]0xF08C - if (($msg -eq 0x001A) -and $sync.ThemeButton.Content -eq [char]0xF08C) { - $currentTime = [datetime]::Now - if ($currentTime - $lastThemeChangeTime -gt $debounceInterval) { - Invoke-WinutilThemeChange -theme "Auto" - $script:lastThemeChangeTime = $currentTime - $handled = $true - } - } - return 0 - }) -}) - -Invoke-WinutilThemeChange -theme $sync.preferences.theme - - -# Build only the default tab before first paint; other tabs initialize on first activation. -$sync.InitializedTabs = @{} -Initialize-WinUtilTabContent -TabName "Install" - #=========================================================================== -# Store Form Objects In PowerShell +# Headless runs never build a window #=========================================================================== -$xaml.SelectNodes("//*[@Name]") | ForEach-Object {$sync["$("$($psitem.Name)")"] = $sync["Form"].FindName($psitem.Name)} - -$sync.ChocoRadioButton.Add_Checked({ - $sync.preferences.packagemanager = "Choco" -}) -$sync.WingetRadioButton.Add_Checked({ - $sync.preferences.packagemanager = "Winget" -}) - -switch ($sync.preferences.packagemanager) { - "Choco" {$sync.ChocoRadioButton.IsChecked = $true; break} - "Winget" {$sync.WingetRadioButton.IsChecked = $true; break} -} +if ($Preset -or $Config) { + $headlessCode = 1 + try { + Initialize-WinUtilRunspacePool | Out-Null -$sync.keys | ForEach-Object { - if($sync.$psitem) { - if($($sync["$psitem"].GetType() | Select-Object -ExpandProperty Name) -eq "ToggleButton") { - if ($sync.Buttons -notcontains $psitem) { - $sync["$psitem"].Add_Click({ - [System.Object]$Sender = $args[0] - Invoke-WPFButton $Sender.name - }) - $sync.Buttons.Add($psitem) | Out-Null + if ($Preset) { + if (-not $sync.configs.preset.$Preset) { + throw "There is no preset called '$Preset'. Available: $(($sync.configs.preset.PSObject.Properties.Name) -join ', ')" + } + Write-WinUtilLog -Component "AutoRun" -Message "Applying preset '$Preset'." + # SkipUnknown so a retired entry in a preset is named and stepped over rather than + # ending a headless run that has nobody to read the error + $skipped = @(Update-WinUtilSelections -flatJson $sync.configs.preset.$Preset -SkipUnknown) + if ($skipped.Count -gt 0) { + Write-WinUtilLog -Level "WARN" -Component "AutoRun" -Message "Preset '$Preset' names $($skipped.Count) entr(y/ies) this version does not have: $($skipped -join ', ')" } } - if($($sync["$psitem"].GetType() | Select-Object -ExpandProperty Name) -eq "Button") { - if ($sync.Buttons -notcontains $psitem) { - $sync["$psitem"].Add_Click({ - [System.Object]$Sender = $args[0] - Invoke-WPFButton $Sender.name - }) - $sync.Buttons.Add($psitem) | Out-Null - } + # Both may be given: the preset sets a baseline and the config adds to it + if ($Config) { + Write-WinUtilLog -Component "AutoRun" -Message "Importing selections from '$Config'." + Invoke-WPFImpex -type "import" -Config $Config -Merge:([bool]$Preset) } - } + $summary = Invoke-WinUtilAutoRun + $headlessCode = Write-WinUtilAutoRunSummary -Summary $summary + } catch { + Write-WinUtilErrorRecord -ErrorRecord $_ -Component "AutoRun" -Context "Headless run" + Write-Host "WinUtil could not complete the headless run: $($_.Exception.Message)" -ForegroundColor Red + $headlessCode = 1 + } finally { + Close-WinUtilRunspacePool + [System.GC]::Collect() + Remove-WinUtilTempScript + Stop-Transcript | Out-Null + } + + # An automated caller has nothing else to go on, so the code has to carry the outcome + exit $headlessCode } #=========================================================================== -# Setup and Show the Form +# Start the interface on its own thread and manage it from here #=========================================================================== +# +# The main thread stays out of the window's way. It creates the dedicated STA runspace the +# interface lives on, waits for that window to close, and reports anything the interface +# thread failed with. Work started from the interface goes to the worker pool through +# Start-WinUtilJob, so neither the window nor this thread is ever blocked by it. -# Progress bar in taskbaritem > Set-WinUtilProgressbar -$sync["Form"].TaskbarItemInfo = New-Object System.Windows.Shell.TaskbarItemInfo -Set-WinUtilTaskbaritem -state "None" - -# Set the titlebar -$sync["Form"].title = $sync["Form"].title + " " + $sync.version -# Set the commands that will run when the form is closed -$sync["Form"].Add_Closing({ - Close-WinUtilRunspacePool - [System.GC]::Collect() -}) +$sync.UIRunspace = [runspacefactory]::CreateRunspace($Host, (New-WinUtilSessionState)) +$sync.UIRunspace.ApartmentState = "STA" +$sync.UIRunspace.ThreadOptions = "ReuseThread" +$sync.UIRunspace.Open() -# Attach the event handler to the Click event -$sync.SearchBarClearButton.Add_Click({ - $sync.SearchBar.Text = "" - $sync.SearchBarClearButton.Visibility = "Collapsed" +$uiShell = [powershell]::Create() +$uiShell.Runspace = $sync.UIRunspace +[void]$uiShell.AddScript({ Start-WinUtilUserInterface }) - # Focus the search bar after clearing the text - $sync.SearchBar.Focus() - $sync.SearchBar.SelectAll() -}) +Write-WinUtilLog -Component "UI" -Message "Starting the interface thread." +$uiHandle = $uiShell.BeginInvoke() -# add some shortcuts for people that don't like clicking -function Invoke-WinUtilFontScaleStep([double]$Step) { $sync.FontScalingSlider.Value = [math]::Max(0.75, [math]::Min(2.0, $sync.FontScalingSlider.Value + $Step)); Invoke-WinUtilFontScaling -ScaleFactor $sync.FontScalingSlider.Value } +# This thread has nothing to do but wait, so it pays for the overlay render rather than +# leaving it to the thread that is building the window +Start-WinUtilAssetRendering | Out-Null -$commonKeyEvents = { - # Prevent shortcuts from executing if a process is already running - if ($sync.ProcessRunning -eq $true) { - return - } +$uiHandle.AsyncWaitHandle.WaitOne() | Out-Null - # Handle key presses of single keys - switch ($_.Key) { - "Escape" { $sync.SearchBar.Text = "" } - } - # Handle Alt key combinations for navigation - if ($_.KeyboardDevice.Modifiers -eq "Alt") { - $keyEventArgs = $_ - switch ($_.SystemKey) { - "I" { Invoke-WPFButton "WPFTab1BT"; $keyEventArgs.Handled = $true } # Navigate to Install tab and suppress Windows Warning Sound - "T" { Invoke-WPFButton "WPFTab2BT"; $keyEventArgs.Handled = $true } # Navigate to Tweaks tab - "C" { Invoke-WPFButton "WPFTab3BT"; $keyEventArgs.Handled = $true } # Navigate to Config tab - "U" { Invoke-WPFButton "WPFTab4BT"; $keyEventArgs.Handled = $true } # Navigate to Updates tab - "W" { Invoke-WPFButton "WPFTab5BT"; $keyEventArgs.Handled = $true } # Navigate to Win11ISO tab - } - } - # Handle Ctrl key combinations for specific actions - if ($_.KeyboardDevice.Modifiers -eq "Ctrl") { - $keyEventArgs = $_ - switch ($_.Key) { - "F" { $sync.SearchBar.Focus() } # Focus on the search bar - "Q" { $this.Close() } # Close the application - } - } - $ctrlShiftModifiers = [Windows.Input.ModifierKeys]::Control -bor [Windows.Input.ModifierKeys]::Shift - if ($_.KeyboardDevice.Modifiers -eq "Ctrl" -or $_.KeyboardDevice.Modifiers -eq $ctrlShiftModifiers) { - $keyEventArgs = $_ - switch ($_.Key) { - { $_ -in "OemPlus", "Add" } { Invoke-WinUtilFontScaleStep 0.05; $keyEventArgs.Handled = $true } - { $_ -in "OemMinus", "Subtract" } { Invoke-WinUtilFontScaleStep -0.05; $keyEventArgs.Handled = $true } - } - } +try { + $uiShell.EndInvoke($uiHandle) | Out-Null +} catch { + Write-WinUtilErrorRecord -ErrorRecord $_ -Component "UI" -Context "Interface thread stopped" } -$sync["Form"].Add_PreViewKeyDown($commonKeyEvents) -$sync["Form"].Add_PreviewMouseWheel({ - if ([Windows.Input.Keyboard]::Modifiers -eq "Ctrl") { Invoke-WinUtilFontScaleStep $(if ($_.Delta -gt 0) { 0.05 } else { -0.05 }); $_.Handled = $true } -}) - -$sync["Form"].Add_MouseLeftButtonDown({ - Invoke-WPFPopup -Action "Hide" -Popups @("Settings", "Theme", "FontScaling") - $sync["Form"].DragMove() -}) - -$sync["Form"].Add_MouseDoubleClick({ - if ($_.OriginalSource.Name -eq "NavDockPanel" -or - $_.OriginalSource.Name -eq "GridBesideNavDockPanel") { - if ($sync["Form"].WindowState -eq [Windows.WindowState]::Normal) { - [Windows.SystemCommands]::MaximizeWindow($sync.Form) - } - else{ - [Windows.SystemCommands]::RestoreWindow($sync.Form) - } - } -}) - -$sync["Form"].Add_Deactivated({ - Invoke-WPFPopup -Action "Hide" -Popups @("Settings", "Theme", "FontScaling") -}) - -$sync["Form"].Add_ContentRendered({ - # Load the Windows Forms assembly - Add-Type -AssemblyName System.Windows.Forms - $primaryScreen = [System.Windows.Forms.Screen]::PrimaryScreen - # Check if the primary screen is found - if ($primaryScreen) { - # Extract screen width and height for the primary monitor - $screenWidth = $primaryScreen.Bounds.Width - $screenHeight = $primaryScreen.Bounds.Height - $sync.Form.MinWidth = [Math]::Min([double]$sync.Form.MinWidth, [double]$screenWidth) - - # Compare with the primary monitor size - if ($sync.Form.ActualWidth -gt $screenWidth -or $sync.Form.ActualHeight -gt $screenHeight) { - $sync.Form.Left = 0 - $sync.Form.Top = 0 - $sync.Form.Width = $screenWidth - $sync.Form.Height = $screenHeight - } - } - - if ($PARAM_OFFLINE) { - # Show offline banner - $sync.WPFOfflineBanner.Visibility = [System.Windows.Visibility]::Visible - - # Disable the install tab - $sync.WPFTab1BT.IsEnabled = $false - $sync.WPFTab1BT.Opacity = 0.5 - $sync.WPFTab1BT.ToolTip = "Internet connection required for installing applications." - # Disable install-related buttons - $sync.WPFInstall.IsEnabled = $false - $sync.WPFUninstall.IsEnabled = $false - $sync.WPFInstallUpgrade.IsEnabled = $false - $sync.WPFGetInstalled.IsEnabled = $false - - # Show offline indicator - Write-Host "Offline mode detected - Install tab disabled." -ForegroundColor Yellow - - # Optionally switch to a different tab if install tab was going to be default - Invoke-WPFTab "WPFTab2BT" # Switch to Tweaks tab instead - } - else { - # Online - ensure install tab is enabled - $sync.WPFTab1BT.IsEnabled = $true - $sync.WPFTab1BT.Opacity = 1.0 - $sync.WPFTab1BT.ToolTip = $null - Invoke-WPFTab "WPFTab1BT" # Default to install tab - } - - $sync["Form"].Focus() - $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Initialize-WinUtilRunspacePool | Out-Null }) | Out-Null - $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $false -IncludeStatusAssets $true }) | Out-Null -}) - -# The SearchBarTimer is used to delay the search operation until the user has stopped typing for a short period -# This prevents the ui from stuttering when the user types quickly as it dosnt need to update the ui for every keystroke - -$searchBarTimer = New-Object System.Windows.Threading.DispatcherTimer -$searchBarTimer.Interval = [TimeSpan]::FromMilliseconds(300) -$searchBarTimer.IsEnabled = $false - -$searchBarTimer.add_Tick({ - $searchBarTimer.Stop() - switch ($sync.currentTab) { - "Install" { - Find-AppsByNameOrDescription -SearchString $sync.SearchBar.Text -Categories $sync.SelectedAppCategories.ToArray() - } - "Tweaks" { - Find-TweaksByNameOrDescription -SearchString $sync.SearchBar.Text - } - "AppX" { - Find-TweaksByNameOrDescription -SearchString $sync.SearchBar.Text - } - } -}) -$sync["SearchBar"].Add_TextChanged({ - if ($sync.SearchBar.Text -ne "") { - $sync.SearchBarClearButton.Visibility = "Visible" - $sync.SearchBarIcon.Visibility = "Collapsed" - } else { - $sync.SearchBarClearButton.Visibility = "Collapsed" - $sync.SearchBarIcon.Visibility = "Visible" - } - - if ($searchBarTimer.IsEnabled) { - $searchBarTimer.Stop() - } - $searchBarTimer.Start() -}) - -# Category filter chips. The chip carries its category in Tag, so one handler covers all of them. -$sync.AppCategoryChips = @( - @{ Name = "WPFSearchChipAll"; Category = "" } - @{ Name = "WPFSearchChipBrowsers"; Category = "Browsers" } - @{ Name = "WPFSearchChipCommunications"; Category = "Communications" } - @{ Name = "WPFSearchChipDevelopment"; Category = "Development" } - @{ Name = "WPFSearchChipDocument"; Category = "Document" } - @{ Name = "WPFSearchChipGames"; Category = "Games" } - @{ Name = "WPFSearchChipMicrosoftTools"; Category = "Microsoft Tools" } - @{ Name = "WPFSearchChipMultimediaTools"; Category = "Multimedia Tools" } - @{ Name = "WPFSearchChipProTools"; Category = "Pro Tools" } - @{ Name = "WPFSearchChipSelfhostedTools"; Category = "Selfhosted Tools" } - @{ Name = "WPFSearchChipUtilities"; Category = "Utilities" } -) -$sync.SelectedAppCategories = [System.Collections.Generic.List[string]]::new() - -foreach ($appCategoryChip in $sync.AppCategoryChips) { - $sync[$appCategoryChip.Name].Tag = $appCategoryChip.Category +foreach ($uiWarning in $uiShell.Streams.Warning) { + Write-WinUtilLog -Level "WARN" -Component "UI" -Message $uiWarning.Message } -$sync["WPFSearchChipAll"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this }) -$sync["WPFSearchChipBrowsers"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this }) -$sync["WPFSearchChipCommunications"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this }) -$sync["WPFSearchChipDevelopment"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this }) -$sync["WPFSearchChipDocument"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this }) -$sync["WPFSearchChipGames"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this }) -$sync["WPFSearchChipMicrosoftTools"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this }) -$sync["WPFSearchChipMultimediaTools"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this }) -$sync["WPFSearchChipProTools"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this }) -$sync["WPFSearchChipSelfhostedTools"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this }) -$sync["WPFSearchChipUtilities"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this }) - -$sync["Form"].Add_Loaded({ - param($e) - $null = $e - $sync.Form.MinWidth = "1150" - $sync["Form"].MaxWidth = [Double]::PositiveInfinity - $sync["Form"].MaxHeight = [Double]::PositiveInfinity -}) - -$NavLogoPanel = $sync["Form"].FindName("NavLogoPanel") -$NavLogoPanel.Children.Add((Invoke-WinUtilAssets -Type "logo" -Size 25)) | Out-Null -Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $true -IncludeStatusAssets $false - -Set-WinUtilTaskbaritem -overlay "logo" - -$sync["Form"].Add_Activated({ - Set-WinUtilTaskbaritem -overlay "logo" -}) - -$sync["ThemeButton"].Add_Click({ - Invoke-WPFPopup -PopupActionTable @{ "Settings" = "Hide"; "Theme" = "Toggle"; "FontScaling" = "Hide" } -}) -$sync["AutoThemeMenuItem"].Add_Click({ - Invoke-WPFPopup -Action "Hide" -Popups @("Theme") - Invoke-WinutilThemeChange -theme "Auto" -}) -$sync["DarkThemeMenuItem"].Add_Click({ - Invoke-WPFPopup -Action "Hide" -Popups @("Theme") - Invoke-WinutilThemeChange -theme "Dark" -}) -$sync["LightThemeMenuItem"].Add_Click({ - Invoke-WPFPopup -Action "Hide" -Popups @("Theme") - Invoke-WinutilThemeChange -theme "Light" -}) - -$sync["SettingsButton"].Add_Click({ - Invoke-WPFPopup -PopupActionTable @{ "Settings" = "Toggle"; "Theme" = "Hide"; "FontScaling" = "Hide" } -}) -$sync["ImportMenuItem"].Add_Click({ - Invoke-WPFPopup -Action "Hide" -Popups @("Settings") - Invoke-WPFImpex -type "import" -}) -$sync["ExportMenuItem"].Add_Click({ - Invoke-WPFPopup -Action "Hide" -Popups @("Settings") - Invoke-WPFImpex -type "export" -}) -$sync["AboutMenuItem"].Add_Click({ - Invoke-WPFPopup -Action "Hide" -Popups @("Settings") - - $authorInfo = @" -Author : @ChrisTitusTech -UI : @MyDrift-user, @Marterich -Runspace : @DeveloperDurp, @Marterich -GitHub : ChrisTitusTech/winutil -Version : $($sync.version) -"@ - Show-CustomDialog -Title "About" -Message $authorInfo -}) -$sync["DocumentationMenuItem"].Add_Click({ - Invoke-WPFPopup -Action "Hide" -Popups @("Settings") - Start-Process "https://winutil.christitus.com/" -}) -$sync["SponsorMenuItem"].Add_Click({ - Invoke-WPFPopup -Action "Hide" -Popups @("Settings") - - $authorInfo = @" -Current sponsors for ChrisTitusTech: -"@ - $authorInfo += "`n" - try { - $sponsors = Invoke-WinUtilSponsors - foreach ($sponsor in $sponsors) { - $authorInfo += "$sponsor`n" - } - } catch { - $authorInfo += "An error occurred while fetching or processing the sponsors: $_`n" - } - Show-CustomDialog -Title "Sponsors" -Message $authorInfo -EnableScroll $true -}) - -# Font Scaling Event Handlers -$sync["FontScalingButton"].Add_Click({ - Invoke-WPFPopup -PopupActionTable @{ "Settings" = "Hide"; "Theme" = "Hide"; "FontScaling" = "Toggle" } -}) - -$sync["FontScalingSlider"].Add_ValueChanged({ - param($slider) - $percentage = [math]::Round($slider.Value * 100) - $sync.FontScalingValue.Text = "$percentage%" -}) - -$sync["FontScalingResetButton"].Add_Click({ - $sync.FontScalingSlider.Value = 1.0 - $sync.FontScalingValue.Text = "100%" -}) - -$sync["FontScalingApplyButton"].Add_Click({ - $scaleFactor = $sync.FontScalingSlider.Value - Invoke-WinUtilFontScaling -ScaleFactor $scaleFactor - Invoke-WPFPopup -Action "Hide" -Popups @("FontScaling") -}) - -# ── Win11ISO Tab button handlers ────────────────────────────────────────────── - -$sync["WPFWin11ISOBrowseButton"].Add_Click({ - Invoke-WinUtilISOBrowse -}) - -$sync["WPFWin11ISODownloadLink"].Add_Click({ - Start-Process "https://www.microsoft.com/software-download/windows11" -}) - -$sync["WPFWin11ISOMountButton"].Add_Click({ - Invoke-WinUtilISOMountAndVerify -}) - -$sync["WPFWin11ISOModifyButton"].Add_Click({ - Invoke-WinUtilISOModify -}) - -$sync["WPFWin11ISOChooseISOButton"].Add_Click({ - $sync["WPFWin11ISOOptionUSB"].Visibility = "Collapsed" - Invoke-WinUtilISOExport -}) - -$sync["WPFWin11ISOChooseUSBButton"].Add_Click({ - $sync["WPFWin11ISOOptionUSB"].Visibility = "Visible" - Invoke-WinUtilISORefreshUSBDrives -}) - -$sync["WPFWin11ISORefreshUSBButton"].Add_Click({ - Invoke-WinUtilISORefreshUSBDrives -}) - -$sync["WPFWin11ISOWriteUSBButton"].Add_Click({ - Invoke-WinUtilISOWriteUSB -}) - -$sync["WPFWin11ISOCleanResetButton"].Add_Click({ - Invoke-WinUtilISOCleanAndReset -}) - -function Remove-WinUtilTempScript { - <# - .SYNOPSIS - Removes the temporary script downloaded by windev.ps1. - - .DESCRIPTION - Deletes the current script only when it is a winutil-*.ps1 file in - the system temporary directory. This preserves normal file-backed - and in-memory WinUtil launches. - #> +foreach ($uiError in $uiShell.Streams.Error) { + Write-WinUtilErrorRecord -ErrorRecord $uiError -Component "UI" -Context "Interface thread" +} - $scriptPath = $PSCommandPath - $tempPath = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') +$uiShell.Dispose() +$sync.UIRunspace.Dispose() +$sync.Remove("UIRunspace") - if ( - $scriptPath -and - [IO.Path]::GetDirectoryName($scriptPath) -eq $tempPath -and - [IO.Path]::GetFileName($scriptPath) -like 'winutil-*.ps1' - ) { - Remove-Item -LiteralPath $scriptPath -Force -ErrorAction SilentlyContinue - } -} +# The window may have been closed over a job that the user chose to let finish. It is still on +# the worker pool, so the pool cannot be closed until it is done. +Wait-WinUtilRemainingWork -# ────────────────────────────────────────────────────────────────────────────── +Close-WinUtilRunspacePool +[System.GC]::Collect() -$sync["Form"].ShowDialog() | out-null Remove-WinUtilTempScript +Write-Host "Bye bye!" -ForegroundColor Cyan Stop-Transcript diff --git a/scripts/start.ps1 b/scripts/start.ps1 index 95a28f38bb..44241f143f 100644 --- a/scripts/start.ps1 +++ b/scripts/start.ps1 @@ -44,6 +44,22 @@ if (!([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]: } $powershellCmd = if (Get-Command pwsh -ErrorAction SilentlyContinue) { "pwsh" } else { "powershell" } + + # A headless caller is waiting on this process for an outcome, so the elevated run has to be + # waited on and its code handed back. A terminal tab is skipped for the same reason: the + # exit code of wt.exe is its own, not the run's. + if ($Config -or $Preset) { + # A declined UAC prompt throws, which would leave $elevated null and exit 0: the caller + # waiting on this process would read that as a successful run + try { + $elevated = Start-Process $powershellCmd -ArgumentList "-ExecutionPolicy Bypass -NoProfile -Command `"$script`"" -Verb RunAs -Wait -PassThru -ErrorAction Stop + } catch { + Write-Host "Elevation was declined or failed: $($_.Exception.Message)" -ForegroundColor Red + exit 1 + } + exit $elevated.ExitCode + } + $processCmd = if (Get-Command wt.exe -ErrorAction SilentlyContinue) { "wt.exe" } else { "$powershellCmd" } if ($processCmd -eq "wt.exe") { @@ -61,8 +77,13 @@ $sync.version = "#{replaceme}" $sync.configs = @{} $sync.Buttons = [System.Collections.Generic.List[PSObject]]::new() $sync.preferences = @{} -$sync.ProcessRunning = $false -$sync.Win11ISOProcessRunning = $false +# Name of the job currently running, or $null when idle. Owned by Start-WinUtilJob. +$sync.ActiveJob = $null +# Every step recorded by Measure-WinUtilStep, from any thread +$sync.StepTimings = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new()) +# Every error logged, so a job can report that something went wrong even when it did not throw +$sync.LoggedErrors = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new()) +$sync.StartedAt = Get-Date $sync.selectedAppx = [System.Collections.Generic.List[string]]::new() $sync.selectedApps = [System.Collections.Generic.List[string]]::new() $sync.selectedTweaks = [System.Collections.Generic.List[string]]::new() @@ -75,9 +96,15 @@ $winutildir = "$env:LocalAppData\winutil" $sync.winutildir = $winutildir $logdir = "$winutildir\logs" +# Start-Transcript fails outright when the directory is missing, which is every first run +if (-not (Test-Path $logdir)) { + New-Item -ItemType Directory -Path $logdir -Force | Out-Null +} +# Structured session log, written by Write-WinUtilLog from every thread. The console +# transcript goes to its own file because Start-Transcript keeps that one open and only +# ever records the runspace it was started on. $sync.logPath = "$logdir\winutil_$dateTime.log" -$sync.transcriptPath = $sync.logPath -Start-Transcript -Path $sync.logPath -Append -NoClobber | Out-Null +Start-Transcript -Path "$logdir\winutil_$dateTime.console.log" -Append -NoClobber | Out-Null $Host.UI.RawUI.WindowTitle = "WinUtil" Clear-Host diff --git a/xaml/inputXML.xaml b/xaml/inputXML.xaml index e7dabfe493..91d80943a4 100644 --- a/xaml/inputXML.xaml +++ b/xaml/inputXML.xaml @@ -136,15 +136,13 @@ - - - - - + + + @@ -361,26 +359,22 @@ - - - - - - - + + + - + @@ -422,15 +416,13 @@ - - - - - + + + @@ -459,40 +451,38 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + @@ -666,28 +656,26 @@ - - - - - - - + + + + + @@ -826,16 +814,6 @@ - -