mirror of
https://github.com/ChrisTitusTech/winutil.git
synced 2026-08-09 09:31:15 +10:00
Fix Show Installed Apps reliability and selection (#4850)
* Fix Windows PowerShell runspace cleanup callback * Fix installed app package ID matching * Synchronize installed app selection UI * Streamline installed app detection * Avoid installed app UI runspace deadlock
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 $_
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 = @{}
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -435,7 +435,6 @@ Describe "XAML and sync wiring" {
|
||||
"appPopup",
|
||||
"appPopupSelectedApp",
|
||||
"ItemsControl",
|
||||
"InstalledPrograms",
|
||||
"ImportInProgress",
|
||||
"ScriptsInstallPrograms",
|
||||
"keys",
|
||||
|
||||
Reference in New Issue
Block a user