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 e47b9f5..7b875d2 100644 --- a/Public/Uninstall-TecharyApp.ps1 +++ b/Public/Uninstall-TecharyApp.ps1 @@ -27,26 +27,115 @@ 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) { + # 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) + } + } 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 + $Scope = "for all users" + } else { + Remove-AppxPackage -Package $Package.PackageFullName -ErrorAction Stop + $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 { + # -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 }