Fix uninstallation of user-scope apps in WinGet with deferred uninstall (#802)

This commit is contained in:
Jeffrey
2026-10-12 23:53:08 +02:00
committed by GitHub
parent 47cc891547
commit 89424472a2
8 changed files with 386 additions and 44 deletions
+194 -43
View File
@@ -2,14 +2,6 @@
.SYNOPSIS
Removes one or more Windows app packages based on the target scope.
.DESCRIPTION
Iterates over the provided list of app identifiers and removes each one.
The removal method (winget vs. Appx cmdlets) is determined per-app from
Apps.json. A scheduled task is only created when the User or Sysprep
parameter was passed. After winget removal, the system is checked to
confirm whether the app is still installed before reporting an error.
Returns early if the CancelRequested flag is set.
.PARAMETER appsList
An array of app package identifiers to remove (e.g. 'Microsoft.BingNews').
@@ -43,6 +35,7 @@ function Remove-SelectedApps {
$edgeIds = @('Microsoft.Edge', 'XPFFTQ037JWMHS')
$wingetRemovedApps = @()
$wingetRemovalFailures = @{}
if (-not $script:WingetDeferredRemovals) { $script:WingetDeferredRemovals = @{} }
Foreach ($app in $appsList) {
if ($script:CancelRequested) { return $false }
@@ -53,12 +46,18 @@ function Remove-SelectedApps {
& $script:ApplySubStepCallback (Get-Translation -Key 'RemovingAppsSubStep' -FormatArgs @($appIndex, $appCount)) $appIndex $appCount
}
if ($script:WingetDeferredRemovals.ContainsKey($app)) {
Write-Host "Skipping $app because its uninstall is already scheduled."
continue
}
Write-Host "Removing $app"
if ((Get-AppRemovalMethod $app) -eq 'WinGet') {
$removalSucceeded = Remove-WinGetApp -app $app
$schedulingFailed = $false
$removalSucceeded = Remove-WinGetApp -app $app -SchedulingFailed ([ref]$schedulingFailed)
$wingetRemovedApps += $app
if (($script:Params.ContainsKey('User') -or $script:Params.ContainsKey('Sysprep')) -and -not $removalSucceeded) {
if ($schedulingFailed -or (($script:Params.ContainsKey('User') -or $script:Params.ContainsKey('Sysprep')) -and -not $removalSucceeded)) {
$wingetRemovalFailures[$app] = $true
}
}
@@ -74,6 +73,10 @@ function Remove-SelectedApps {
}
# Check whether any winget-removed apps are still present, and report errors for each one.
if ($wingetRemovedApps.Count -gt 0) {
$wingetRemovedApps = @($wingetRemovedApps | Where-Object { -not $script:WingetDeferredRemovals.ContainsKey($_) })
}
if ($wingetRemovedApps.Count -gt 0) {
$postRemovalList = if ($script:WingetInstalled) { Get-WingetInstalledApps -TimeOut 10 -NonBlocking } else { $null }
$edgeForceRemoveRequested = $false
@@ -118,15 +121,6 @@ function Remove-SelectedApps {
.SYNOPSIS
Uninstalls an app via WinGet and/or schedules its removal.
.DESCRIPTION
Runs winget uninstall for a single app, with a bounded execution time.
WinGet's own exit code/success reporting is unreliable and is only logged
for diagnostics; it never causes this function to report failure. Callers
verify removal with a post-removal inventory check instead. This function
only reports failure when the winget invocation itself throws a terminating
error (e.g. it times out or cannot be started). If the User or Sysprep
parameter was passed, also schedules removal for future logins.
.PARAMETER app
The WinGet package ID to uninstall (e.g. 'Microsoft.BingNews').
@@ -134,6 +128,9 @@ function Remove-SelectedApps {
Maximum time to allow the foreground WinGet uninstall to run. Defaults
to 120 seconds.
.PARAMETER SchedulingFailed
Optional reference indicating that required RunOnce scheduling was incomplete.
.OUTPUTS
System.Boolean. $true unless the winget invocation threw a terminating error
or any required RunOnce scheduling failed; otherwise $false.
@@ -141,9 +138,12 @@ function Remove-SelectedApps {
function Remove-WinGetApp {
param(
[string]$app,
[int]$TimeoutSeconds = 120
[int]$TimeoutSeconds = 120,
[ref]$SchedulingFailed
)
if ($null -ne $SchedulingFailed) { $SchedulingFailed.Value = $false }
if (-not $script:WingetInstalled) {
Write-Error "WinGet is either not installed or is outdated; $app could not be removed"
return $false
@@ -151,6 +151,9 @@ function Remove-WinGetApp {
$uninstallCommandSucceeded = $true
$exitCode = $null
$userScopeBlocked = $false
$scheduleSucceeded = $true
$runOnceTarget = $null
try {
$uninstallResult = Invoke-NonBlocking -ScriptBlock {
param($appId)
@@ -163,6 +166,32 @@ function Remove-WinGetApp {
Write-WinGetUninstallOutput -Output $(if ($uninstallResult) { $uninstallResult.Output } else { $null })
$exitCode = if ($uninstallResult) { $uninstallResult.ExitCode } else { 'unknown' }
Write-Verbose "WinGet uninstall for $app returned exit code $exitCode."
# WinGet reports APPINSTALLER_CLI_ERROR_ADMIN_CONTEXT_ACTION_PROHIBITED (0x8A15007D).
# Keep the English message check as a fallback for clients that don't return the specific code.
$userScopeBlocked = ([string]$exitCode -eq '-1978335107') -or ([string]$exitCode -match '^0x0?8A15007D$')
if (-not $userScopeBlocked) {
$userScopeBlocked = @($uninstallResult.Output | Where-Object {
$null -ne $_ -and $_.ToString() -match 'package installed for user scope cannot be uninstalled when running with administrator privileges'
}).Count -gt 0
}
if ($userScopeBlocked) {
$scheduleSucceeded = $false
$runOnceTarget = Get-RunOnceWingetTarget
$targetUserName = $runOnceTarget.UserName
$scheduleSucceeded = Set-RunOnceWingetTask -appId $app -Target $runOnceTarget
if ($scheduleSucceeded) {
if (-not $script:WingetDeferredRemovals) { $script:WingetDeferredRemovals = @{} }
$script:WingetDeferredRemovals[$app] = $targetUserName
if ($runOnceTarget.AllUsers) {
Write-Host (Get-Translation -Key 'WingetAllUsersUninstallDeferred' -FormatArgs @($app)) -ForegroundColor Yellow
}
else {
Write-Host (Get-Translation -Key 'WingetUserScopeUninstallDeferred' -FormatArgs @($app, $targetUserName)) -ForegroundColor Yellow
}
}
}
}
catch {
$uninstallCommandSucceeded = $false
@@ -174,16 +203,17 @@ function Remove-WinGetApp {
}
}
$scheduleSucceeded = $true
if ($script:Params.ContainsKey("User")) {
Write-Host "Adding scheduled task to uninstall $app for user $(Get-UserName)..."
$scheduleSucceeded = Set-RunOnceWingetTask -appId $app
}
elseif ($script:Params.ContainsKey("Sysprep")) {
Write-Host "Adding scheduled task to uninstall $app for new users..."
$scheduleSucceeded = Set-RunOnceWingetTask -appId $app
if (-not $userScopeBlocked -and
($script:Params.ContainsKey('User') -or $script:Params.ContainsKey('Sysprep'))) {
$runOnceTarget = Get-RunOnceWingetTarget
$targetDescription = if ($runOnceTarget.AllUsers) { 'all users' }
elseif ($script:Params.ContainsKey('Sysprep') -and -not $script:Params.ContainsKey('AppRemovalTarget')) { 'new users' }
else { "user $($runOnceTarget.UserName)" }
Write-Host "Adding RunOnce uninstall for $app for $targetDescription..."
$scheduleSucceeded = Set-RunOnceWingetTask -appId $app -Target $runOnceTarget
}
if ($null -ne $SchedulingFailed) { $SchedulingFailed.Value = -not $scheduleSucceeded }
return ($uninstallCommandSucceeded -and $scheduleSucceeded)
}
@@ -345,11 +375,112 @@ function Request-EdgeForceRemove {
<#
.SYNOPSIS
Dynamically sets a RunOnce registry key to schedule a winget uninstall.
Resolves the user scope for deferred WinGet uninstall tasks.
.DESCRIPTION
Writes directly to HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce
via the PowerShell registry API within Invoke-WithTargetUserHive,
Uses the selected app-removal target and launch parameters to determine
whether tasks apply to all users or a single user, including the Default profile.
.OUTPUTS
System.Management.Automation.PSCustomObject. AllUsers (Boolean) indicates
whether to schedule for all users. UserName (String) identifies the target
account or Default profile when AllUsers is false.
#>
function Get-RunOnceWingetTarget {
$appRemovalTarget = Get-TargetUserForAppRemoval
$hasExplicitAppRemovalTarget = $script:Params.ContainsKey('AppRemovalTarget')
$allUsers = $appRemovalTarget -eq 'AllUsers' -and (
$hasExplicitAppRemovalTarget -or
-not ($script:Params.ContainsKey('User') -or $script:Params.ContainsKey('Sysprep'))
)
$userName = if ($allUsers) {
Get-UserName
}
elseif ($hasExplicitAppRemovalTarget) {
if ($appRemovalTarget -ieq 'CurrentUser' -or $appRemovalTarget -ieq 'AllUsers') {
Get-UserName
}
else {
$appRemovalTarget
}
}
elseif ($script:Params.ContainsKey('Sysprep')) {
'Default'
}
else {
Get-UserName
}
return [PSCustomObject]@{
AllUsers = [bool]$allUsers
UserName = [string]$userName
}
}
<#
.SYNOPSIS
Returns the profile targets for deferred AllUsers WinGet uninstall tasks.
.DESCRIPTION
Lists resolvable accounts with non-special profiles containing NTUSER.DAT.
Includes Default so the task also applies to profiles created later.
Profiles whose SIDs cannot be resolved are skipped with a warning and mark
the result incomplete.
.OUTPUTS
System.Management.Automation.PSCustomObject. UserNames contains account names
and Default. Success is false if enumeration or any SID translation fails.
#>
function Get-RunOnceWingetTargetUserNames {
$targetUsers = New-Object 'System.Collections.Generic.List[string]'
$seenSids = @{}
$allSucceeded = $true
try {
foreach ($userProfile in @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop)) {
if ($userProfile.Special -or [string]::IsNullOrWhiteSpace([string]$userProfile.SID) -or
[string]::IsNullOrWhiteSpace([string]$userProfile.LocalPath)) {
continue
}
$hivePath = Join-Path $userProfile.LocalPath 'NTUSER.DAT'
if (-not (Test-Path -LiteralPath $hivePath)) { continue }
$sid = [string]$userProfile.SID
if ($seenSids.ContainsKey($sid)) { continue }
try {
$accountName = ([System.Security.Principal.SecurityIdentifier]$sid).Translate(
[System.Security.Principal.NTAccount]
).Value
}
catch {
$allSucceeded = $false
Write-Warning "The deferred AllUsers WinGet uninstall may be incomplete: unable to resolve account name for profile SID '$sid'. This profile will not be scheduled. $_"
continue
}
$seenSids[$sid] = $true
$targetUsers.Add($accountName)
}
}
catch {
Write-Error "Unable to enumerate user profiles for deferred WinGet uninstall: $_"
return [PSCustomObject]@{ UserNames = @(); Success = $false }
}
# RunOnce values in the Default profile are copied into profiles created later.
$targetUsers.Add('Default')
return [PSCustomObject]@{ UserNames = @($targetUsers); Success = $allSucceeded }
}
<#
.SYNOPSIS
Dynamically sets RunOnce registry keys to schedule a winget uninstall.
.DESCRIPTION
Writes through Invoke-WithTargetUserHive,
which handles hive loading and HKEY_USERS\Default → SID remapping.
Used instead of static .reg files to avoid file dependency for each WinGet app.
@@ -360,11 +491,28 @@ function Request-EdgeForceRemove {
.PARAMETER appId
The winget package ID to schedule for uninstall (e.g. 'XP9CXNGPPJ97XX').
.PARAMETER Target
Resolved RunOnce target from Get-RunOnceWingetTarget.
#>
function Set-RunOnceWingetTask {
param([string]$appId)
param(
[string]$appId,
[PSCustomObject]$Target
)
$targetUserName = if ($script:Params.ContainsKey("Sysprep")) { "Default" } else { $script:Params.Item("User") }
if ($null -eq $Target) { $Target = Get-RunOnceWingetTarget }
$allSucceeded = $true
$targetUserNames = if ($Target.AllUsers) {
$profileTargets = Get-RunOnceWingetTargetUserNames
$allSucceeded = $profileTargets.Success
@($profileTargets.UserNames)
}
else {
@($Target.UserName)
}
if ($targetUserNames.Count -eq 0) { return $false }
# Sanitize appId for use in registry value names (backslashes are path separators)
$safeAppId = $appId.Replace('\', '_')
@@ -386,15 +534,18 @@ function Set-RunOnceWingetTask {
OperationType = 'SetValue'
}
try {
Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock {
param($op)
Invoke-RegistryOperation -Operation $op -RegFilePath '<dynamic>'
} -ArgumentObject $operation
return $true
}
catch {
Write-Error "Failed to schedule uninstall task for $($appId): $_"
return $false
foreach ($targetUserName in $targetUserNames) {
try {
Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock {
param($op)
Invoke-RegistryOperation -Operation $op -RegFilePath '<dynamic>'
} -ArgumentObject $operation
}
catch {
Write-Error "Failed to schedule uninstall task for $($appId) for '$targetUserName': $_"
$allSucceeded = $false
}
}
return $allSucceeded
}