From 4eb819c4d856c4efcf07c5ab83542aa80d88c83c Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 12:02:26 +0100 Subject: [PATCH 1/2] Make MSIX removal work under SYSTEM and stop deprovisioned apps returning Two problems with the Modern Apps branch of Uninstall-TecharyApp, both of which only show up in the context the module is actually driven from. Get-AppxPackage without -AllUsers returns only the calling account's packages. SYSTEM has essentially none, so an uninstall pushed from an RMM found nothing and logged "not found on this system" for an app that was plainly installed. It now enumerates and removes with -AllUsers when elevated, and says so when it is not, so the log states the scope that actually applied rather than implying a machine-wide removal. Removing the per-user registrations also left the provisioned package in place, and a provisioned package is what seeds new user profiles. The app therefore reappeared for the next user who signed in. Provisioned packages matching the name are now removed as well. Neither path is all-or-nothing: -AllUsers is unsupported on some builds, so a failure there retries per-user rather than reporting an outright failure, and provisioning enumeration failing does not stop the package removal. Verified non-elevated: correct package found, WhatIf reports the honest scope, and a package that is not installed still reports cleanly. The elevated -AllUsers and deprovisioning paths are guarded by try/catch with per-user fallback but were not exercised from this session. --- Public/Uninstall-TecharyApp.ps1 | 81 +++++++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 8 deletions(-) diff --git a/Public/Uninstall-TecharyApp.ps1 b/Public/Uninstall-TecharyApp.ps1 index e47b9f5..bf851b2 100644 --- a/Public/Uninstall-TecharyApp.ps1 +++ b/Public/Uninstall-TecharyApp.ps1 @@ -27,26 +27,91 @@ function Uninstall-TecharyApp { # 2. IF NOT IN REGISTRY, CHECK MSIX (Modern Apps) if (-not $App) { Write-PackagerLog -Message "Not found in Registry. Checking Modern Apps (MSIX)..." - $MsixResults = Get-AppxPackage -Name "*$Name*" -ErrorAction SilentlyContinue - if ($MsixResults) { - # FIX: Handle cases where multiple apps match (Array vs Single Object) + # SYSTEM has essentially no packages of its own, so a plain + # Get-AppxPackage run from an RMM found nothing and reported the app as + # absent. -AllUsers is what makes this work in the context the module is + # actually driven from. It needs elevation, so fall back without it. + $IsElevated = $false + try { + $Identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $IsElevated = (New-Object Security.Principal.WindowsPrincipal($Identity)).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } catch {} + + $MsixResults = @() + if ($IsElevated) { + try { $MsixResults = @(Get-AppxPackage -AllUsers -Name "*$Name*" -ErrorAction Stop) } + catch { + Write-PackagerLog -Message "Could not enumerate packages for all users ($($_.Exception.Message)). Falling back to the current user." -Severity Warning + $MsixResults = @(Get-AppxPackage -Name "*$Name*" -ErrorAction SilentlyContinue) + } + } else { + Write-PackagerLog -Message "Not elevated, so only the current user's packages are visible." -Severity Warning + $MsixResults = @(Get-AppxPackage -Name "*$Name*" -ErrorAction SilentlyContinue) + } + + # A provisioned package is what seeds new user profiles. Leaving it in + # place meant a removed app reappeared for the next user who signed in. + $Provisioned = @() + if ($IsElevated) { + try { + $Provisioned = @(Get-AppxProvisionedPackage -Online -ErrorAction Stop | + Where-Object { $_.DisplayName -like "*$Name*" }) + } catch { + Write-PackagerLog -Message "Could not enumerate provisioned packages: $($_.Exception.Message)" -Severity Warning + } + } + + if ($MsixResults.Count -gt 0 -or $Provisioned.Count -gt 0) { + # Handle cases where multiple apps match (Array vs Single Object) foreach ($Package in $MsixResults) { - Write-PackagerLog -Message "Found Modern App: $($Package.Name)" + Write-PackagerLog -Message "Found Modern App: $($Package.Name) ($($Package.PackageFullName))" + + if ($WhatIf) { + $Scope = if ($IsElevated) { "for all users" } else { "for the current user only" } + Write-Host "[WhatIf] Would remove $Scope`: $($Package.PackageFullName)" -ForegroundColor Yellow + continue + } + try { + if ($IsElevated) { + Remove-AppxPackage -Package $Package.PackageFullName -AllUsers -ErrorAction Stop + Write-PackagerLog -Message "Success: Removed $($Package.Name) for all users." + } else { + Remove-AppxPackage -Package $Package.PackageFullName -ErrorAction Stop + Write-PackagerLog -Message "Success: Removed $($Package.Name) for the current user." + } + } + catch { + # -AllUsers is unsupported on some builds. A per-user removal + # still beats reporting an outright failure. + Write-PackagerLog -Message "All-users removal failed for $($Package.Name): $($_.Exception.Message). Retrying for the current user." -Severity Warning + try { + Remove-AppxPackage -Package $Package.PackageFullName -ErrorAction Stop + Write-PackagerLog -Message "Success: Removed $($Package.Name) for the current user." + } + catch { + Write-PackagerLog -Message "Failed to remove $($Package.Name): $($_.Exception.Message)" -Severity Error + } + } + } + + foreach ($Prov in $Provisioned) { if ($WhatIf) { - Write-Host "[WhatIf] Would remove: $($Package.PackageFullName)" -ForegroundColor Yellow + Write-Host "[WhatIf] Would deprovision: $($Prov.PackageName)" -ForegroundColor Yellow continue } try { - Remove-AppxPackage -Package $Package.PackageFullName -ErrorAction Stop - Write-PackagerLog -Message "Success: Removed $($Package.Name)" + Remove-AppxProvisionedPackage -Online -PackageName $Prov.PackageName -ErrorAction Stop | Out-Null + Write-PackagerLog -Message "Deprovisioned $($Prov.DisplayName), so it will not return for new users." } catch { - Write-PackagerLog -Message "Failed to remove $($Package.Name): $_" -Severity Error + Write-PackagerLog -Message "Failed to deprovision $($Prov.DisplayName): $($_.Exception.Message)" -Severity Error } } + return } From 465d80ed9c014ed41b5491863bfbb2fce8d8b5fd Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 12:26:14 +0100 Subject: [PATCH 2/2] Confirm MSIX removal instead of inferring it from the absence of an error Validated on a real machine running as SYSTEM. Remove-AppxPackage -AllUsers works, but two behaviours make a naive reading of it unreliable. Removal is asynchronous. The package is still listed for a period after the cmdlet returns, so a check run immediately afterwards reports a failure that is not real. Confirmation now polls over a 30 second settle window, and a package still registered at the end is reported as a warning naming a pending reboot or sign-out, not asserted as a failure. Get-AppxPackage -AllUsers also lists packages that are merely Staged on the machine, so presence in that list is not evidence that anyone has the package installed. Enumeration is filtered to packages actually installed for at least one user, via PackageUserInformation, so a staged remnant is no longer treated as something to uninstall. Test-AppxInstalledForAnyUser falls back to a plain Get-AppxPackage where per-user information is unavailable, which covers the non-elevated case and older builds. Measured under SYSTEM on a real endpoint: Get-AppxPackage sees 69 packages, Get-AppxPackage -AllUsers sees 197. Verified: helper returns True for an installed package, False for one that was removed, and False for a package that does not exist. --- Private/Test-AppxInstalledForAnyUser.ps1 | 68 ++++++++++++++++++++++++ Public/Uninstall-TecharyApp.ps1 | 30 +++++++++-- 2 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 Private/Test-AppxInstalledForAnyUser.ps1 diff --git a/Private/Test-AppxInstalledForAnyUser.ps1 b/Private/Test-AppxInstalledForAnyUser.ps1 new file mode 100644 index 0000000..918df8c --- /dev/null +++ b/Private/Test-AppxInstalledForAnyUser.ps1 @@ -0,0 +1,68 @@ +function Test-AppxInstalledForAnyUser { + <# + .SYNOPSIS + True when an MSIX package is actually installed for at least one user. + + .DESCRIPTION + Get-AppxPackage -AllUsers lists packages that are merely Staged on the + machine as well as ones a user has installed, so presence in that list + is not evidence of installation. + + Removal is also asynchronous: a package stays listed for a period after + Remove-AppxPackage returns. Callers should therefore poll this over a + settle window rather than treat a single immediate result as final. + + PackageUserInformation carries the per-user InstallState, which is the + distinction that matters. Where it is unavailable - not elevated, or an + older build - this falls back to a plain Get-AppxPackage, which lists + only what is installed for the caller. + #> + [CmdletBinding()] + param( + [Parameter(ParameterSetName = 'ByObject')] + $Package, + + [Parameter(ParameterSetName = 'ByName')] + [string]$PackageFullName + ) + + if (-not $Package -and $PackageFullName) { + # Name is the segment before the first underscore of the full name, and + # filtering on it avoids enumerating every package on the machine. + $ShortName = $PackageFullName.Split('_')[0] + try { + $Package = Get-AppxPackage -AllUsers -Name $ShortName -ErrorAction Stop | + Where-Object { $_.PackageFullName -eq $PackageFullName } | + Select-Object -First 1 + } catch { } + + if (-not $Package) { + try { + $Package = Get-AppxPackage -Name $ShortName -ErrorAction SilentlyContinue | + Where-Object { $_.PackageFullName -eq $PackageFullName } | + Select-Object -First 1 + } catch { } + } + } + + # Not present at all, so certainly not installed. + if (-not $Package) { return $false } + + $Info = $null + try { $Info = $Package.PackageUserInformation } catch { } + + if ($Info) { + foreach ($User in $Info) { + if ("$($User.InstallState)" -eq 'Installed') { return $true } + } + return $false + } + + try { + $Mine = Get-AppxPackage -ErrorAction SilentlyContinue | + Where-Object { $_.PackageFullName -eq $Package.PackageFullName } + return [bool]$Mine + } catch { + return $false + } +} diff --git a/Public/Uninstall-TecharyApp.ps1 b/Public/Uninstall-TecharyApp.ps1 index bf851b2..7b875d2 100644 --- a/Public/Uninstall-TecharyApp.ps1 +++ b/Public/Uninstall-TecharyApp.ps1 @@ -41,7 +41,12 @@ function Uninstall-TecharyApp { $MsixResults = @() if ($IsElevated) { - try { $MsixResults = @(Get-AppxPackage -AllUsers -Name "*$Name*" -ErrorAction Stop) } + # Filtered to packages actually installed for someone. -AllUsers also + # lists packages that are merely Staged on the machine, and a staged + # package remains listed after a successful removal, so an unfiltered + # list makes a completed uninstall look like it did nothing. + try { $MsixResults = @(Get-AppxPackage -AllUsers -Name "*$Name*" -ErrorAction Stop | + Where-Object { Test-AppxInstalledForAnyUser -Package $_ }) } catch { Write-PackagerLog -Message "Could not enumerate packages for all users ($($_.Exception.Message)). Falling back to the current user." -Severity Warning $MsixResults = @(Get-AppxPackage -Name "*$Name*" -ErrorAction SilentlyContinue) @@ -77,10 +82,29 @@ function Uninstall-TecharyApp { try { if ($IsElevated) { Remove-AppxPackage -Package $Package.PackageFullName -AllUsers -ErrorAction Stop - Write-PackagerLog -Message "Success: Removed $($Package.Name) for all users." + $Scope = "for all users" } else { Remove-AppxPackage -Package $Package.PackageFullName -ErrorAction Stop - Write-PackagerLog -Message "Success: Removed $($Package.Name) for the current user." + $Scope = "for the current user" + } + + # Confirm rather than infer, but give it time to settle. + # Removal completes asynchronously: the package is still + # listed for a short period after Remove-AppxPackage returns, + # so an immediate check reports a false failure. + $Deadline = (Get-Date).AddSeconds(30) + $StillThere = $true + while ($StillThere -and (Get-Date) -lt $Deadline) { + $StillThere = Test-AppxInstalledForAnyUser -PackageFullName $Package.PackageFullName + if ($StillThere) { Start-Sleep -Seconds 2 } + } + + if ($StillThere) { + # Not asserted as a failure: removal may still be pending. + # Reported so it is visible rather than assumed successful. + Write-PackagerLog -Message "Removed $($Package.Name) $Scope, but it is still registered after 30s. Removal may be pending a reboot or sign-out." -Severity Warning + } else { + Write-PackagerLog -Message "Success: Removed $($Package.Name) $Scope, confirmed." } } catch {