diff --git a/functions/private/Invoke-WinUtilTweaks.ps1 b/functions/private/Invoke-WinUtilTweaks.ps1 index 2576f7f9..406efec0 100644 --- a/functions/private/Invoke-WinUtilTweaks.ps1 +++ b/functions/private/Invoke-WinUtilTweaks.ps1 @@ -78,6 +78,7 @@ function Invoke-WinUtilTweaks { $sync.configs.tweaks.$CheckBox.appx | ForEach-Object { Remove-WinUtilAPPX -Name $psitem } + Remove-WinUtilProvisionedAPPX -PackageList $sync.configs.tweaks.$CheckBox.appx } } Write-WinUtilLog -Component "Tweaks" -Message "$action tweak completed: $CheckBox" diff --git a/functions/private/Remove-WinUtilAPPX.ps1 b/functions/private/Remove-WinUtilAPPX.ps1 index 38759157..8efffea8 100644 --- a/functions/private/Remove-WinUtilAPPX.ps1 +++ b/functions/private/Remove-WinUtilAPPX.ps1 @@ -17,7 +17,20 @@ function Remove-WinUtilAPPX { Write-Host "Removing $Name" Write-WinUtilLog -Component "AppX" -Message "Removing AppX package pattern: $Name" - Get-AppxPackage $Name -AllUsers | Remove-AppxPackage -AllUsers - Get-AppxProvisionedPackage -Online | Where-Object DisplayName -like $Name | Remove-AppxProvisionedPackage -Online + + # We explicitly loop through packages instead of using the pipeline because PowerShell 7 pipeline binding + # for Remove-AppxPackage fails silently, and Get-AppxPackage -AllUsers returns duplicate objects for each user profile. + $pkgs = Get-AppxPackage "*$Name*" -AllUsers | Sort-Object -Property PackageFullName -Unique + if ($null -ne $pkgs) { + foreach ($pkg in $pkgs) { + try { + Remove-AppxPackage -Package $pkg.PackageFullName -AllUsers -ErrorAction Stop + } + catch { + Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message "Failed to remove AppX package $($pkg.PackageFullName): $($_.Exception.Message)" + } + } + } + Write-WinUtilLog -Component "AppX" -Message "AppX removal completed for package pattern: $Name" } diff --git a/functions/private/Remove-WinUtilProvisionedAPPX.ps1 b/functions/private/Remove-WinUtilProvisionedAPPX.ps1 new file mode 100644 index 00000000..98709fb4 --- /dev/null +++ b/functions/private/Remove-WinUtilProvisionedAPPX.ps1 @@ -0,0 +1,61 @@ +function Remove-WinUtilProvisionedAPPX { + <# + + .SYNOPSIS + Removes all AppX provisioned packages that match the given names + + .PARAMETER PackageList + An array of names of the APPX packages to remove + + .EXAMPLE + Remove-WinUtilProvisionedAPPX -PackageList @("Microsoft.Microsoft3DViewer", "Microsoft.WindowsCalculator") + + #> + param ( + [string[]]$PackageList + ) + + if ($null -eq $PackageList -or $PackageList.Count -eq 0) { + return + } + + Write-Host "`nRemoving provisioned packages..." + Write-WinUtilLog -Component "AppX" -Message "Removing AppX provisioned packages: $($PackageList -join ', ')" + + # DISM cmdlets like Get-AppxProvisionedPackage often fail with "Class not registered" or hang in PowerShell 7. + # We shell out to Windows PowerShell 5.1 (powershell.exe) to reliably remove the provisioned packages. + $ps5Command = { + $pkgs = $args + $provisionedPackages = Get-AppxProvisionedPackage -Online -ErrorAction SilentlyContinue + $failures = [System.Collections.Generic.List[string]]::new() + + foreach ($Package in $pkgs) { + $provs = $provisionedPackages | + Where-Object DisplayName -Like "*$Package*" + + if ($null -ne $provs) { + foreach ($prov in $provs) { + try { + Remove-AppxProvisionedPackage -Online -PackageName $prov.PackageName -ErrorAction Stop | Out-Null + } + catch { + $failures.Add("Failed to remove provisioned AppX package $($prov.PackageName): $($_.Exception.Message)") + } + } + } + } + + if ($failures.Count -gt 0) { + throw ($failures -join [Environment]::NewLine) + } + } + + $removalOutput = powershell.exe -NoProfile -NonInteractive -Command $ps5Command -args $PackageList 2>&1 + if ($LASTEXITCODE -ne 0 -or $null -ne $removalOutput) { + $failureDetails = ($removalOutput | Out-String).Trim() + Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message "AppX provisioned package removal failed: $failureDetails" + return + } + + Write-WinUtilLog -Component "AppX" -Message "AppX provisioned package removal completed." +} diff --git a/functions/public/Invoke-WPFAppxRemoval.ps1 b/functions/public/Invoke-WPFAppxRemoval.ps1 index 7facc196..ef4f13ea 100644 --- a/functions/public/Invoke-WPFAppxRemoval.ps1 +++ b/functions/public/Invoke-WPFAppxRemoval.ps1 @@ -13,11 +13,13 @@ function Invoke-WPFAppxRemoval { $sync.ProcessRunning = $true Write-WinUtilLog -Component "AppX" -Message "Starting AppX removal for $(@($selected).Count) selected package(s)." + $packageList = [System.Collections.Generic.List[string]]::new() + foreach ($key in $selected) { 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 + 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." @@ -25,14 +27,14 @@ function Invoke-WPFAppxRemoval { } if ($key -eq "WPFAppxMicrosoft_WindowsNotepad") { - # i hope your having fun reading this Write-WinUtilLog -Component "AppX" -Message "Stopping dllhost before removing Notepad." - Stop-Process -Name dllhost + Stop-Process -Name dllhost -Force -Confirm:$false -ErrorAction SilentlyContinue } Write-Host "Removing $($apps[$key].Content)" Write-WinUtilLog -Component "AppX" -Message "Removing $($apps[$key].Content) ($($apps[$key].PackageId))." - Get-AppxPackage -Name $apps[$key].PackageId -AllUsers | Remove-AppxPackage -AllUsers + Remove-WinUtilAPPX -Name $apps[$key].PackageId + $packageList.Add($apps[$key].PackageId) if ($key -eq "WPFAppxMSTeams") { # Uninstalls Microsoft Teams Meeting Add-in for Microsoft Office @@ -41,6 +43,10 @@ function Invoke-WPFAppxRemoval { } } + if ($packageList.Count -gt 0) { + Remove-WinUtilProvisionedAPPX -PackageList $packageList.ToArray() + } + Write-Host "=================================" Write-Host "-- AppX Removal Finished ---" Write-Host "=================================" diff --git a/functions/public/Invoke-WPFButton.ps1 b/functions/public/Invoke-WPFButton.ps1 index 9b76f8f7..8a13c378 100644 --- a/functions/public/Invoke-WPFButton.ps1 +++ b/functions/public/Invoke-WPFButton.ps1 @@ -65,6 +65,8 @@ function Invoke-WPFButton { "WPFUpdatessecurity" {Invoke-WPFUpdatessecurity} "WPFGetInstalled" {Invoke-WPFGetInstalled -CheckBox "winget"} "WPFGetInstalledTweaks" {Invoke-WPFGetInstalled -CheckBox "tweaks"} + "WPFAppxRemoval" {Invoke-WPFTab "WPFTab6BT"} + "WPFBackToTweaks" {Invoke-WPFTab "WPFTab2BT"} "WPFRemoveSelectedAppx" {Invoke-WPFAppxRemoval} "WPFDefaultAppxSelection" {Invoke-WPFPresets "AppxDefault" -checkboxfilterpattern "WPFAppx*"} "WPFSelectAllAppx" { diff --git a/functions/public/Invoke-WPFTab.ps1 b/functions/public/Invoke-WPFTab.ps1 index 8640318f..43c7a06a 100644 --- a/functions/public/Invoke-WPFTab.ps1 +++ b/functions/public/Invoke-WPFTab.ps1 @@ -19,13 +19,12 @@ function Invoke-WPFTab { $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 - $tabNumber = [int]($ClickedTab-replace "WPFTab","" -replace "BT","") - 1 - $sync.$tabNav.Items[$tabNumber].IsSelected = $true } } $sync.currentTab = $sync.$tabNav.Items[$tabNumber].Header diff --git a/pester/appx.Tests.ps1 b/pester/appx.Tests.ps1 index c56c8061..3a341dff 100644 --- a/pester/appx.Tests.ps1 +++ b/pester/appx.Tests.ps1 @@ -5,8 +5,21 @@ BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path . (Join-Path $script:repoRoot "functions\private\Remove-WinUtilAPPX.ps1") + . (Join-Path $script:repoRoot "functions\private\Remove-WinUtilProvisionedAPPX.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFAppxRemoval.ps1") + $tokens = $null + $parseErrors = $null + $provisionedSourcePath = Join-Path $script:repoRoot "functions\private\Remove-WinUtilProvisionedAPPX.ps1" + $provisionedSourceAst = [System.Management.Automation.Language.Parser]::ParseFile($provisionedSourcePath, [ref]$tokens, [ref]$parseErrors) + $ps5CommandAssignment = $provisionedSourceAst.Find({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left -is [System.Management.Automation.Language.VariableExpressionAst] -and + $node.Left.VariablePath.UserPath -eq "ps5Command" + }, $true) + $script:provisionedRemovalScriptBlock = $ps5CommandAssignment.Right.Expression.ScriptBlock.GetScriptBlock() + function Write-WinUtilLog { param($Message, $Level, $Component) } @@ -23,18 +36,25 @@ BeforeAll { param( [Parameter(ValueFromPipeline = $true)] $InputObject, - [switch]$AllUsers + $Package, + [switch]$AllUsers, + $ErrorAction ) process { } } + function Remove-WinUtilProvisionedAPPX { + param($PackageList) + } function Get-AppxProvisionedPackage { - param([switch]$Online) + param([switch]$Online, $ErrorAction) } function Remove-AppxProvisionedPackage { param( [Parameter(ValueFromPipeline = $true)] $InputObject, - [switch]$Online + [switch]$Online, + $PackageName, + $ErrorAction ) process { } } @@ -58,35 +78,90 @@ Describe "Remove-WinUtilAPPX" { Mock Write-Host { } Mock Write-WinUtilLog { } Mock Get-AppxPackage { - [pscustomobject]@{ - Name = $Name - } + @( + [pscustomobject]@{ + Name = $Name + PackageFullName = "$Name.FullName" + } + [pscustomobject]@{ + Name = $Name + PackageFullName = "$Name.FullName" + } + ) } Mock Remove-AppxPackage { } + } + + It "removes matching installed AppX packages" { + Remove-WinUtilAPPX -Name "Microsoft.Xbox*" + + Should -Invoke -CommandName Get-AppxPackage -Times 1 -Exactly -ParameterFilter { + $Name -eq "*Microsoft.Xbox**" -and $AllUsers -eq $true + } + Should -Invoke -CommandName Remove-AppxPackage -Times 1 -Exactly -ParameterFilter { + $Package -eq "*Microsoft.Xbox**.FullName" -and + $AllUsers -eq $true -and + $ErrorAction -eq "Stop" + } + } + + It "logs installed AppX removal failures" { + Mock Remove-AppxPackage { throw "Removal failed" } + + { Remove-WinUtilAPPX -Name "Example.Package" } | Should -Not -Throw + + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Level -eq "ERROR" -and + $Component -eq "AppX" -and + $Message -eq "Failed to remove AppX package *Example.Package*.FullName: Removal failed" + } + } +} + +Describe "Remove-WinUtilProvisionedAPPX" { + BeforeEach { Mock Get-AppxProvisionedPackage { @( - [pscustomobject]@{ DisplayName = "Microsoft.XboxGamingOverlay" } - [pscustomobject]@{ DisplayName = "Microsoft.WindowsCalculator" } + [pscustomobject]@{ DisplayName = "Example.One"; PackageName = "Example.One_1.0" } + [pscustomobject]@{ DisplayName = "Example.Two"; PackageName = "Example.Two_1.0" } ) } Mock Remove-AppxProvisionedPackage { } } - It "removes matching installed and provisioned AppX packages" { - Remove-WinUtilAPPX -Name "Microsoft.Xbox*" + It "queries provisioned packages once for all selected package names" { + & $script:provisionedRemovalScriptBlock "Example.One" "Example.Two" - Should -Invoke -CommandName Get-AppxPackage -Times 1 -Exactly -ParameterFilter { - $Name -eq "Microsoft.Xbox*" -and $AllUsers -eq $true - } - Should -Invoke -CommandName Remove-AppxPackage -Times 1 -Exactly -ParameterFilter { - $InputObject.Name -eq "Microsoft.Xbox*" -and $AllUsers -eq $true - } Should -Invoke -CommandName Get-AppxProvisionedPackage -Times 1 -Exactly -ParameterFilter { $Online -eq $true } Should -Invoke -CommandName Remove-AppxProvisionedPackage -Times 1 -Exactly -ParameterFilter { - $InputObject.DisplayName -eq "Microsoft.XboxGamingOverlay" -and $Online -eq $true + $PackageName -eq "Example.One_1.0" -and $Online -eq $true } + Should -Invoke -CommandName Remove-AppxProvisionedPackage -Times 1 -Exactly -ParameterFilter { + $PackageName -eq "Example.Two_1.0" -and $Online -eq $true + } + } + + It "surfaces provisioned package removal failures from the child process" { + Mock Remove-AppxProvisionedPackage { throw "DISM failed" } + + { & $script:provisionedRemovalScriptBlock "Example.One" } | + Should -Throw "*Failed to remove provisioned AppX package Example.One_1.0: DISM failed*" + Should -Invoke -CommandName Remove-AppxProvisionedPackage -Times 1 -Exactly -ParameterFilter { + $PackageName -eq "Example.One_1.0" -and + $Online -eq $true -and + $ErrorAction -eq "Stop" + } + } + + It "handles child process failures before logging completion" { + $source = Get-Content -Path $provisionedSourcePath -Raw + + $source | Should -Match '\$removalOutput = powershell\.exe .* 2>&1' + $source | Should -Match 'if \(\$LASTEXITCODE -ne 0 -or \$null -ne \$removalOutput\)' + $source | Should -Match 'Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message "AppX provisioned package removal failed:' + $source | Should -Match '(?s)AppX provisioned package removal failed:.*return.*AppX provisioned package removal completed\.' } } @@ -190,9 +265,11 @@ Describe "Invoke-WPFAppxRemoval runspace body" { Mock Get-AppxPackage { [pscustomobject]@{ Name = $Name + PackageFullName = "$Name.FullName" } } Mock Remove-AppxPackage { } + Mock Remove-WinUtilProvisionedAPPX { } Mock Get-Package { [pscustomobject]@{ Name = "Microsoft Teams Meeting Add-in" @@ -216,10 +293,13 @@ Describe "Invoke-WPFAppxRemoval runspace body" { & $script:capturedAppxScriptBlock -selected $selected -apps $script:apps Should -Invoke -CommandName Get-AppxPackage -Times 1 -Exactly -ParameterFilter { - $Name -eq "Example.Package" -and $AllUsers -eq $true + $Name -eq "*Example.Package*" -and $AllUsers -eq $true } Should -Invoke -CommandName Remove-AppxPackage -Times 1 -Exactly -ParameterFilter { - $InputObject.Name -eq "Example.Package" -and $AllUsers -eq $true + $Package -eq "*Example.Package*.FullName" + } + Should -Invoke -CommandName Remove-WinUtilProvisionedAPPX -Times 1 -Exactly -ParameterFilter { + $PackageList.Count -eq 1 -and $PackageList[0] -eq "Example.Package" } $script:sync.ProcessRunning | Should -BeFalse } @@ -239,7 +319,10 @@ Describe "Invoke-WPFAppxRemoval runspace body" { & $script:capturedAppxScriptBlock -selected $selected -apps $script:apps Should -Invoke -CommandName Stop-Process -Times 1 -Exactly -ParameterFilter { - $Name -eq "GameBarFTServer" + $Name -eq "GameBarFTServer" -and + $Force -eq $true -and + $Confirm -eq $false -and + $ErrorAction -eq "SilentlyContinue" } Should -Invoke -CommandName Set-ItemProperty -Times 1 -Exactly -ParameterFilter { $Path -eq "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\GameDVR" -and @@ -247,7 +330,10 @@ Describe "Invoke-WPFAppxRemoval runspace body" { $Value -eq 0 } Should -Invoke -CommandName Stop-Process -Times 1 -Exactly -ParameterFilter { - $Name -eq "dllhost" + $Name -eq "dllhost" -and + $Force -eq $true -and + $Confirm -eq $false -and + $ErrorAction -eq "SilentlyContinue" } Should -Invoke -CommandName Get-Package -Times 1 -Exactly -ParameterFilter { $Name -eq "Microsoft Teams*" @@ -256,6 +342,12 @@ Describe "Invoke-WPFAppxRemoval runspace body" { $InputObject.Name -eq "Microsoft Teams Meeting Add-in" -and $Force -eq $true } Should -Invoke -CommandName Remove-AppxPackage -Times 3 -Exactly + Should -Invoke -CommandName Remove-WinUtilProvisionedAPPX -Times 1 -Exactly -ParameterFilter { + $PackageList.Count -eq 3 -and + $PackageList[0] -eq "Microsoft.XboxGamingOverlay" -and + $PackageList[1] -eq "Microsoft.WindowsNotepad" -and + $PackageList[2] -eq "MSTeams" + } $script:sync.ProcessRunning | Should -BeFalse } } diff --git a/pester/tweaks.Tests.ps1 b/pester/tweaks.Tests.ps1 index 7eda9d2d..bf6cf08a 100644 --- a/pester/tweaks.Tests.ps1 +++ b/pester/tweaks.Tests.ps1 @@ -19,6 +19,9 @@ BeforeAll { function Remove-WinUtilAPPX { param($Name) } + function Remove-WinUtilProvisionedAPPX { + param($PackageList) + } function Set-WinUtilDNS { param($DNSProvider) } @@ -89,6 +92,7 @@ Describe "Invoke-WinUtilTweaks" { Mock Set-WinUtilRegistry { } Mock Invoke-WinUtilScript { } Mock Remove-WinUtilAPPX { } + Mock Remove-WinUtilProvisionedAPPX { } Mock Write-WinUtilLog { } Mock Write-Warning { } } @@ -118,6 +122,9 @@ Describe "Invoke-WinUtilTweaks" { Should -Invoke -CommandName Remove-WinUtilAPPX -Times 1 -Exactly -ParameterFilter { $Name -eq "Microsoft.ExampleApp" } + Should -Invoke -CommandName Remove-WinUtilProvisionedAPPX -Times 1 -Exactly -ParameterFilter { + $PackageList.Count -eq 1 -and $PackageList[0] -eq "Microsoft.ExampleApp" + } } It "uses original registry values and service startup types in undo mode" { @@ -137,6 +144,7 @@ Describe "Invoke-WinUtilTweaks" { $Name -eq "WPFTweaksExample" -and $ScriptBlock.ToString() -eq "Write-Output 'undo tweak'" } Should -Invoke -CommandName Remove-WinUtilAPPX -Times 0 -Exactly + Should -Invoke -CommandName Remove-WinUtilProvisionedAPPX -Times 0 -Exactly } It "keeps a user-changed service startup type by default" { diff --git a/pester/xaml.Tests.ps1 b/pester/xaml.Tests.ps1 index 42aac74a..be312992 100644 --- a/pester/xaml.Tests.ps1 +++ b/pester/xaml.Tests.ps1 @@ -134,7 +134,6 @@ Describe "XAML document" { "WPFTab3BT", "WPFTab4BT", "WPFTab5BT", - "WPFTab6BT", "SearchBar", "SearchBarClearButton", "appscategory", @@ -148,6 +147,7 @@ Describe "XAML document" { "WPFAdvanced", "WPFClearTweaksSelection", "WPFGetInstalledTweaks", + "WPFAppxRemoval", "WPFTweaksbutton", "WPFUndoall", "WPFUpdatesdefault", @@ -157,6 +157,7 @@ Describe "XAML document" { "WPFGetInstalledAppx", "WPFSelectAllAppx", "WPFClearAppxSelection", + "WPFBackToTweaks", "WPFRemoveSelectedAppx" ) @@ -223,6 +224,27 @@ 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="WPFRemoveSelectedAppx"]') | Should -Not -BeNullOrEmpty + $buttonSource | Should -Match '"WPFAppxRemoval"\s*\{Invoke-WPFTab "WPFTab6BT"\}' + $buttonSource | Should -Match '"WPFBackToTweaks"\s*\{Invoke-WPFTab "WPFTab2BT"\}' + $tabSource | Should -Match '\$sync\.\$tabNav\.Items\[\$tabNumber\]\.IsSelected = \$true' + } } Describe "XAML and sync wiring" { diff --git a/xaml/inputXML.xaml b/xaml/inputXML.xaml index f03bd00a..45b3d6ff 100644 --- a/xaml/inputXML.xaml +++ b/xaml/inputXML.xaml @@ -983,14 +983,6 @@ - - - - AppX Removal - - - @@ -1237,6 +1229,7 @@