diff --git a/functions/private/Invoke-WinUtilCurrentSystem.ps1 b/functions/private/Invoke-WinUtilCurrentSystem.ps1 index 12239d55..ed0f4a3d 100644 --- a/functions/private/Invoke-WinUtilCurrentSystem.ps1 +++ b/functions/private/Invoke-WinUtilCurrentSystem.ps1 @@ -15,32 +15,36 @@ Function Invoke-WinUtilCurrentSystem { ) if ($CheckBox -eq "choco") { $apps = (choco list | Select-String -Pattern "^\S+").Matches.Value - $filter = Get-WinUtilVariables -Type Checkbox | Where-Object {$psitem -like "WPFInstall*"} - $sync.GetEnumerator() | Where-Object {$psitem.Key -in $filter} | ForEach-Object { - $dependencies = @($sync.configs.applications.$($psitem.Key).choco -split ";") - if ($dependencies -in $apps) { - Write-Output $psitem.name + $sync.configs.applicationsHashtable.GetEnumerator() | ForEach-Object { + $packageId = ($_.Value.choco -split ";")[-1].Trim() + if ($packageId -ne "na" -and $packageId -in $apps) { + Write-Output $_.Key } } } if ($checkbox -eq "winget") { - $originalEncoding = [Console]::OutputEncoding - [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() - $Sync.InstalledPrograms = @("winget", "msstore") | ForEach-Object { - winget list -s $psitem | Select-Object -skip 3 | ConvertFrom-String -PropertyNames "Name", "Id", "Version", "Available" -Delimiter '\s{2,}' + try { + [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() + $installedProgramOutput = @(winget list --accept-source-agreements --disable-interactivity 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "winget list failed with exit code $LASTEXITCODE." + } + } finally { + [Console]::OutputEncoding = $originalEncoding } - [Console]::OutputEncoding = $originalEncoding + $installedProgramText = $installedProgramOutput -join "`n" - $filter = Get-WinUtilVariables -Type Checkbox | Where-Object {$psitem -like "WPFInstall*"} - $sync.GetEnumerator() | Where-Object {$psitem.Key -in $filter} | ForEach-Object { - $dependencies = @($sync.configs.applications.$($psitem.Key).winget -split ";") | ForEach-Object { - $psitem -replace "^msstore:", "" + $sync.configs.applicationsHashtable.GetEnumerator() | ForEach-Object { + $packageId = (($_.Value.winget -split ";")[-1] -replace "^msstore:", "").Trim() + if ([string]::IsNullOrWhiteSpace($packageId) -or $packageId -eq "na") { + return } - if ($dependencies[-1] -in $sync.InstalledPrograms.Id) { - Write-Output $psitem.name + $packagePattern = "(?im)[^\S\r\n]{2,}$([regex]::Escape($packageId))(?=[^\S\r\n]{2,}|$)" + if ($installedProgramText -match $packagePattern) { + Write-Output $_.Key } } } diff --git a/functions/public/Invoke-WPFGetInstalled.ps1 b/functions/public/Invoke-WPFGetInstalled.ps1 index 8813a704..bc28623d 100644 --- a/functions/public/Invoke-WPFGetInstalled.ps1 +++ b/functions/public/Invoke-WPFGetInstalled.ps1 @@ -1,6 +1,5 @@ function Invoke-WPFGetInstalled { <# - TODO: Add the Option to use Chocolatey as Engine .SYNOPSIS Invokes the function that gets the checkboxes to check in a new runspace @@ -19,35 +18,72 @@ function Invoke-WPFGetInstalled { return } $managerPreference = $sync.preferences.packagemanager - - Invoke-WPFRunspace -ParameterList @(("managerPreference", $managerPreference),("checkbox", $checkbox)) -ScriptBlock { - param ( - [string]$checkbox, - [string]$managerPreference + $operation = [Hashtable]::Synchronized(@{ + Checkboxes = @() + Error = $null + }) + $completeAction = [Action[hashtable, string]]{ + param( + [hashtable]$completedOperation, + [string]$completedCheckbox ) - $sync.ProcessRunning = $true - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Indeterminate" } + 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 ($checkbox -eq "winget") { - Write-Host "Getting Installed Programs..." - switch ($managerPreference) { - "Choco"{$Checkboxes = Invoke-WinUtilCurrentSystem -CheckBox "choco"; break} - "Winget"{$Checkboxes = Invoke-WinUtilCurrentSystem -CheckBox $checkbox; break} + if ($completedCheckbox -eq "winget") { + foreach ($checkboxName in $completedOperation.Checkboxes) { + if (-not $sync.selectedApps.Contains($checkboxName)) { + $sync.selectedApps.Add($checkboxName) + } + } + 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) + } + } catch { + $operation.Error = $_.Exception.Message + } finally { + $sync.Form.Dispatcher.BeginInvoke($completeAction, [object[]]@($operation, $checkbox)) | Out-Null } } - elseif ($checkbox -eq "tweaks") { - Write-Host "Getting Installed Tweaks..." - $Checkboxes = Invoke-WinUtilCurrentSystem -CheckBox $checkbox - } - - $sync.form.Dispatcher.invoke({ - foreach ($checkbox in $Checkboxes) { - $sync.$checkbox.ischecked = $True - } - }) - - Write-Host "Done..." - $sync.ProcessRunning = $false - Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" } + } catch { + $operation.Error = $_.Exception.Message + $completeAction.Invoke($operation, $checkbox) } } diff --git a/functions/public/Invoke-WPFRunspace.ps1 b/functions/public/Invoke-WPFRunspace.ps1 index 570e810b..d57a76c2 100644 --- a/functions/public/Invoke-WPFRunspace.ps1 +++ b/functions/public/Invoke-WPFRunspace.ps1 @@ -44,6 +44,8 @@ public sealed class WinUtilRunspaceCleanupState 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; @@ -89,8 +91,7 @@ public static class WinUtilRunspaceCleanup $cleanupState = [WinUtilRunspaceCleanupState]::new() $cleanupState.PowerShell = $powershell $cleanupState.Handle = $handle - $cleanupCallback = [System.Threading.WaitOrTimerCallback][WinUtilRunspaceCleanup]::Cleanup - [System.Threading.ThreadPool]::RegisterWaitForSingleObject($handle.AsyncWaitHandle, $cleanupCallback, $cleanupState, -1, $true) | Out-Null + [System.Threading.ThreadPool]::RegisterWaitForSingleObject($handle.AsyncWaitHandle, [WinUtilRunspaceCleanup]::Callback, $cleanupState, -1, $true) | Out-Null # Return the handle return $handle diff --git a/functions/public/Invoke-WPFSelectedCheckboxesUpdate.ps1 b/functions/public/Invoke-WPFSelectedCheckboxesUpdate.ps1 index b4ca3fb6..b0a951d3 100644 --- a/functions/public/Invoke-WPFSelectedCheckboxesUpdate.ps1 +++ b/functions/public/Invoke-WPFSelectedCheckboxesUpdate.ps1 @@ -7,11 +7,21 @@ function Invoke-WPFSelectedCheckboxesUpdate ($type, $checkboxName) { '^WPFAppx' { 'selectedAppx' } } + $selectionChanged = $false if ($type -eq "Add") { if (-not $sync.$listName.Contains($checkboxName)) { $sync.$listName.Add($checkboxName) + $selectionChanged = $true } } else { - $sync.$listName.Remove($checkboxName) + $selectionChanged = $sync.$listName.Remove($checkboxName) + } + + if ($listName -eq "selectedApps" -and $selectionChanged) { + $sync.WPFselectedAppsButton.Content = "Selected Apps: $($sync.selectedApps.Count)" + $sync.selectedAppsstackPanel.Children.Clear() + $sync.selectedApps | Sort-Object | ForEach-Object { + Add-SelectedAppsMenuItem -name $sync.configs.applicationsHashtable.$_.Content -key $_ + } } } diff --git a/pester/runspace.Tests.ps1 b/pester/runspace.Tests.ps1 index ae404db9..1ad019a3 100644 --- a/pester/runspace.Tests.ps1 +++ b/pester/runspace.Tests.ps1 @@ -140,6 +140,10 @@ Describe "Invoke-WPFRunspace behavior" { $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 + } } Describe "Public runspace callers" { diff --git a/pester/system-helpers.Tests.ps1 b/pester/system-helpers.Tests.ps1 index 875467cc..84b05d74 100644 --- a/pester/system-helpers.Tests.ps1 +++ b/pester/system-helpers.Tests.ps1 @@ -6,12 +6,82 @@ $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilCurrentSystem.ps1") . (Join-Path $script:repoRoot "functions\private\Set-WinUtilRegistry.ps1") . (Join-Path $script:repoRoot "functions\private\Set-WinUtilService.ps1") + function winget { + param([Parameter(ValueFromRemainingArguments = $true)]$Arguments) + } + function choco { + param([Parameter(ValueFromRemainingArguments = $true)]$Arguments) + } function Write-WinUtilLog { } } +Describe "Invoke-WinUtilCurrentSystem installed apps" { + BeforeEach { + $script:sync = [Hashtable]::Synchronized(@{ + configs = [pscustomobject]@{ + applicationsHashtable = @{ + WPFInstallGit = [pscustomobject]@{ winget = "Git.Git"; choco = "git" } + WPFInstallChatGPT = [pscustomobject]@{ winget = "msstore:9NT1R1C2HH7J"; choco = "na" } + WPFInstallMissing = [pscustomobject]@{ winget = "Git"; choco = "missing" } + } + } + }) + + Mock winget { + $global:LASTEXITCODE = 0 + $script:wingetArguments = @($Arguments) + @( + "Name Id Version Source", + "--------------------------------", + "Git Git.Git 2.0 winget", + "ChatGPT 9NT1R1C2HH7J 1.0 msstore" + ) + } + Mock choco { + $script:chocoArguments = @($Arguments) + @("Chocolatey v2", "git 2.0", "2 packages installed.") + } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name wingetArguments -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name chocoArguments -Scope Script -ErrorAction SilentlyContinue + } + + It "matches single standard and Microsoft Store package IDs" { + $result = @(Invoke-WinUtilCurrentSystem -CheckBox "winget") + + $result | Should -HaveCount 2 + $result | Should -Contain "WPFInstallGit" + $result | Should -Contain "WPFInstallChatGPT" + $result | Should -Not -Contain "WPFInstallMissing" + Should -Invoke -CommandName winget -Times 1 -Exactly + $script:wingetArguments | Should -Be @("list", "--accept-source-agreements", "--disable-interactivity") + } + + It "fails promptly when Winget cannot list applications" { + Mock winget { + $global:LASTEXITCODE = 1 + "winget failed" + } + + { Invoke-WinUtilCurrentSystem -CheckBox "winget" } | Should -Throw "winget list failed with exit code 1." + } + + It "matches the primary Chocolatey package ID in one list call" { + $result = @(Invoke-WinUtilCurrentSystem -CheckBox "choco") + + $result | Should -Be @("WPFInstallGit") + Should -Invoke -CommandName choco -Times 1 -Exactly + $script:chocoArguments | Should -Be @("list") + } +} + Describe "Set-WinUtilRegistry" { BeforeEach { $script:testPathResults = @{} diff --git a/pester/ui-state.Tests.ps1 b/pester/ui-state.Tests.ps1 index 3eb93d56..31a2af37 100644 --- a/pester/ui-state.Tests.ps1 +++ b/pester/ui-state.Tests.ps1 @@ -61,6 +61,7 @@ namespace System.Windows.Controls . (Join-Path $script:repoRoot "functions\private\Update-WinUtilSelections.ps1") . (Join-Path $script:repoRoot "functions\private\Reset-WPFCheckBoxes.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFGetInstalled.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFSelectedCheckboxesUpdate.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFButton.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFToggleAllCategories.ps1") @@ -68,6 +69,24 @@ namespace System.Windows.Controls function Set-WinUtilTweaksProgressIndicator { param($Visible, $Label, $Percent) } + function Invoke-WPFRunspace { + param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) + } + function Invoke-WPFUIThread { + param([scriptblock]$ScriptBlock) + } + function Invoke-WinUtilCurrentSystem { + param($CheckBox) + } + function Set-WinUtilTaskbaritem { + param($state) + } + function Test-WinUtilPackageManager { + param([switch]$winget) + } + function Write-WinUtilLog { + param($Message, $Level, $Component) + } function script:New-WinUtilFakeCheckBox { param([bool]$IsChecked = $false) @@ -180,6 +199,9 @@ Describe "Invoke-WPFSelectedCheckboxesUpdate" { @($script:sync.selectedToggles) | Should -Be @("WPFToggleDarkMode") @($script:sync.selectedFeatures) | Should -Be @("WPFFeatureSandbox") @($script:sync.selectedAppx) | Should -Be @("WPFAppxExample") + $script:sync.WPFselectedAppsButton.Content | Should -Be "Selected Apps: 1" + $script:sync.selectedAppsstackPanel.Children.Count | Should -Be 1 + $script:sync.selectedAppsstackPanel.Children[0].Key | Should -Be "WPFInstallGit" } It "removes checkbox keys from the matching selected lists" { @@ -200,6 +222,94 @@ Describe "Invoke-WPFSelectedCheckboxesUpdate" { $script:sync.selectedToggles.Count | Should -Be 0 $script:sync.selectedFeatures.Count | Should -Be 0 $script:sync.selectedAppx.Count | Should -Be 0 + $script:sync.WPFselectedAppsButton.Content | Should -Be "Selected Apps: 0" + $script:sync.selectedAppsstackPanel.Children.Count | Should -Be 0 + } +} + +Describe "Invoke-WPFGetInstalled selection state" { + BeforeEach { + New-WinUtilUiStateTestContext + + $script:sync.ProcessRunning = $false + $script:sync.ChocoRadioButton = [pscustomobject]@{ IsChecked = $false } + $script:sync.preferences = [pscustomobject]@{ packagemanager = "Winget" } + $script:sync.WPFInstallGit = New-WinUtilFakeCheckBox + $dispatcher = [pscustomobject]@{} + $dispatcher | Add-Member -MemberType ScriptMethod -Name BeginInvoke -Value { + param($Action, [object[]]$Arguments) + $Action.DynamicInvoke($Arguments) + } + $script:sync.Form = [pscustomobject]@{ Dispatcher = $dispatcher } + $script:capturedGetInstalledScriptBlock = $null + $script:capturedGetInstalledParameters = @{} + + Mock Test-WinUtilPackageManager { "installed" } + Mock Invoke-WinUtilCurrentSystem { @("WPFInstallGit") } + Mock Set-WinUtilTaskbaritem { } + Mock Write-WinUtilLog { } + Mock Write-Warning { } + Mock Invoke-WPFRunspace { + $script:capturedGetInstalledScriptBlock = $ScriptBlock + foreach ($parameter in $ParameterList) { + $script:capturedGetInstalledParameters[$parameter[0]] = $parameter[1] + } + } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + Remove-Variable -Name capturedGetInstalledScriptBlock -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedGetInstalledParameters -Scope Script -ErrorAction SilentlyContinue + } + + 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 + + @($script:sync.selectedApps) | Should -Be @("WPFInstallGit") + $script:sync.WPFInstallGit.IsChecked | Should -BeTrue + $script:sync.WPFselectedAppsButton.Content | Should -Be "Selected Apps: 1" + $script:sync.selectedAppsstackPanel.Children.Count | Should -Be 1 + $script:sync.selectedAppsstackPanel.Children[0].Key | Should -Be "WPFInstallGit" + } + + It "clears the running state when detection fails" { + Mock Invoke-WinUtilCurrentSystem { throw "detection failed" } + + Invoke-WPFGetInstalled -CheckBox "winget" + & $script:capturedGetInstalledScriptBlock ` + -checkbox "winget" ` + -managerPreference "Winget" ` + -operation $script:capturedGetInstalledParameters.operation ` + -completeAction $script:capturedGetInstalledParameters.completeAction + + $script:sync.ProcessRunning | Should -BeFalse + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Level -eq "ERROR" -and + $Component -eq "Install" -and + $Message -eq "Get installed state failed: detection failed" + } + Should -Invoke -CommandName Set-WinUtilTaskbaritem -Times 1 -Exactly -ParameterFilter { $state -eq "None" } + } + + It "clears the running state when the worker cannot be queued" { + Mock Invoke-WPFRunspace { throw "queue failed" } + + Invoke-WPFGetInstalled -CheckBox "winget" + + $script:sync.ProcessRunning | Should -BeFalse + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Level -eq "ERROR" -and + $Component -eq "Install" -and + $Message -eq "Get installed state failed: queue failed" + } + Should -Invoke -CommandName Set-WinUtilTaskbaritem -Times 1 -Exactly -ParameterFilter { $state -eq "None" } } } diff --git a/pester/xaml.Tests.ps1 b/pester/xaml.Tests.ps1 index e601fd38..736c4bed 100644 --- a/pester/xaml.Tests.ps1 +++ b/pester/xaml.Tests.ps1 @@ -435,7 +435,6 @@ Describe "XAML and sync wiring" { "appPopup", "appPopupSelectedApp", "ItemsControl", - "InstalledPrograms", "ImportInProgress", "ScriptsInstallPrograms", "keys",