Add support for deferred uninstallation of user-scope apps for all users in WinGet

This commit is contained in:
Jeffrey
2026-10-10 02:24:15 +02:00
parent b9924b16ab
commit de6d25fc10
5 changed files with 128 additions and 48 deletions
+1
View File
@@ -140,6 +140,7 @@
"ApplyCompletionTitleErrors": "Changes Applied with Errors",
"ApplyCompletionMessageVerificationUnavailable": "All changes were applied without errors, but Win11Debloat could not confirm that all selected apps were successfully uninstalled.",
"WingetUserScopeUninstallDeferred": "WinGet could not uninstall {0} while running as administrator because it is installed for user scope. Restart the PC or sign out and back in as {1} to complete removal.",
"WingetAllUsersUninstallDeferred": "WinGet could not uninstall {0} while running as administrator because it is installed for user scope. The uninstall is scheduled to run at sign-in for all existing user profiles and profiles created later.",
"ApplyCompletionMessageFailures_one": "{0} change failed. See console for details.",
"ApplyCompletionMessageFailures_other": "{0} changes failed. See console for details.",
"ApplyCompletionMessageReady": "Your system is ready. Thanks for using Win11Debloat!",
+1
View File
@@ -131,6 +131,7 @@
"ApplyCompletionTitleErrors": "Modifiche applicate con errori",
"ApplyCompletionMessageVerificationUnavailable": "Tutte le modifiche sono state applicate senza errori, ma Win11Debloat non ha potuto confermare che tutte le app selezionate siano state disinstallate correttamente.",
"WingetUserScopeUninstallDeferred": "WinGet non ha potuto disinstallare {0} con privilegi di amministratore perché è installata per l'utente. Riavvia il PC oppure esci e accedi di nuovo come {1} per completare la rimozione.",
"WingetAllUsersUninstallDeferred": "WinGet non ha potuto disinstallare {0} con privilegi di amministratore perché è installata per l'utente. La disinstallazione verrà eseguita all'accesso di ogni profilo utente esistente e dei profili creati in seguito.",
"ApplyCompletionMessageFailures_one": "{0} modifica non riuscita. Consulta la console per i dettagli.",
"ApplyCompletionMessageFailures_other": "{0} modifiche non riuscite. Consulta la console per i dettagli.",
"ApplyCompletionMessageReady": "Il sistema è pronto. Grazie per aver usato Win11Debloat!",
+1
View File
@@ -131,6 +131,7 @@
"ApplyCompletionTitleErrors": "Wijzigingen toegepast met fouten",
"ApplyCompletionMessageVerificationUnavailable": "Er zijn geen fouten gemeld, maar Win11Debloat kon niet controleren of alle geselecteerde apps zijn verwijderd.",
"WingetUserScopeUninstallDeferred": "WinGet kon {0} niet verwijderen als administrator, omdat de app voor een gebruiker is geïnstalleerd. Start de pc opnieuw op of meld je af en weer aan als {1} om de verwijdering te voltooien.",
"WingetAllUsersUninstallDeferred": "WinGet kon {0} niet verwijderen als administrator, omdat de app voor een gebruiker is geïnstalleerd. De verwijdering wordt uitgevoerd wanneer bestaande gebruikers zich aanmelden en bij de eerste aanmelding van nieuwe gebruikers.",
"ApplyCompletionMessageFailures_one": "{0} wijziging is mislukt. Zie de console voor meer informatie.",
"ApplyCompletionMessageFailures_other": "{0} wijzigingen zijn mislukt. Zie de console voor meer informatie.",
"ApplyCompletionMessageReady": "Je systeem is gereed. Bedankt voor het gebruik van Win11Debloat!",
+1
View File
@@ -140,6 +140,7 @@
"ApplyCompletionTitleErrors": "Alterações aplicadas com erros",
"ApplyCompletionMessageVerificationUnavailable": "Todas as alterações foram aplicadas sem erros, mas o Win11Debloat não conseguiu confirmar que todos os apps selecionados foram desinstalados com sucesso.",
"WingetUserScopeUninstallDeferred": "O WinGet não conseguiu desinstalar {0} com privilégios de administrador porque ele está instalado para o usuário. Reinicie o PC ou saia e entre novamente como {1} para concluir a remoção.",
"WingetAllUsersUninstallDeferred": "O WinGet não conseguiu desinstalar {0} com privilégios de administrador porque ele está instalado para o usuário. A desinstalação será executada quando cada perfil existente entrar e no primeiro acesso dos novos perfis.",
"ApplyCompletionMessageFailures_one": "{0} alteração falhou. Veja o console para detalhes.",
"ApplyCompletionMessageFailures_other": "{0} alterações falharam. Veja o console para detalhes.",
"ApplyCompletionMessageFailures_many": "{0} de alterações falharam. Veja o console para detalhes.",
+124 -48
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').
@@ -123,15 +115,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. If WinGet blocks
an elevated uninstall of a user-scope package, removal is deferred to the
target user's next logon. If the User or Sysprep parameter was passed, this
function also schedules removal for future logins.
.PARAMETER app
The WinGet package ID to uninstall (e.g. 'Microsoft.BingNews').
@@ -156,7 +139,9 @@ function Remove-WinGetApp {
$uninstallCommandSucceeded = $true
$exitCode = $null
$uninstallDeferred = $false
$userScopeBlocked = $false
$scheduleSucceeded = $true
$runOnceTarget = $null
try {
$uninstallResult = Invoke-NonBlocking -ScriptBlock {
param($appId)
@@ -179,13 +164,19 @@ function Remove-WinGetApp {
}).Count -gt 0
}
if ($userScopeBlocked) {
$targetUserName = Get-RunOnceWingetTargetUserName
$runOnceTarget = Get-RunOnceWingetTarget
$targetUserName = $runOnceTarget.UserName
$uninstallDeferred = Set-RunOnceWingetTask -appId $app
if ($uninstallDeferred) {
$scheduleSucceeded = Set-RunOnceWingetTask -appId $app -Target $runOnceTarget
if ($scheduleSucceeded) {
if (-not $script:WingetDeferredRemovals) { $script:WingetDeferredRemovals = @{} }
$script:WingetDeferredRemovals[$app] = $targetUserName
Write-Host (Get-Translation -Key 'WingetUserScopeUninstallDeferred' -FormatArgs @($app, $targetUserName)) -ForegroundColor Yellow
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
}
}
}
}
@@ -199,14 +190,14 @@ function Remove-WinGetApp {
}
}
$scheduleSucceeded = $true
if (-not $uninstallDeferred -and $script:Params.ContainsKey("User")) {
Write-Host "Adding scheduled task to uninstall $app for user $(Get-UserName)..."
$scheduleSucceeded = Set-RunOnceWingetTask -appId $app
}
elseif (-not $uninstallDeferred -and $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
}
return ($uninstallCommandSucceeded -and $scheduleSucceeded)
@@ -368,18 +359,85 @@ function Request-EdgeForceRemove {
return $false
}
function Get-RunOnceWingetTargetUserName {
if ($script:Params.ContainsKey("Sysprep")) { return "Default" }
return Get-UserName
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
}
}
function Get-RunOnceWingetTargetUserNames {
$targetUsers = New-Object 'System.Collections.Generic.List[string]'
$seenSids = @{}
try {
foreach ($profile in @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop)) {
if ($profile.Special -or [string]::IsNullOrWhiteSpace([string]$profile.SID) -or
[string]::IsNullOrWhiteSpace([string]$profile.LocalPath)) {
continue
}
$hivePath = Join-Path $profile.LocalPath 'NTUSER.DAT'
if (-not (Test-Path -LiteralPath $hivePath)) { continue }
$sid = [string]$profile.SID
if ($seenSids.ContainsKey($sid)) { continue }
try {
$accountName = ([System.Security.Principal.SecurityIdentifier]$sid).Translate(
[System.Security.Principal.NTAccount]
).Value
}
catch {
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 @()
}
# RunOnce values in the Default profile are copied into profiles created later.
$targetUsers.Add('Default')
return @($targetUsers)
}
<#
.SYNOPSIS
Dynamically sets a RunOnce registry key to schedule a winget uninstall.
Dynamically sets RunOnce registry keys to schedule a winget uninstall.
.DESCRIPTION
Writes directly to HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce
via the PowerShell registry API within Invoke-WithTargetUserHive,
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.
@@ -390,11 +448,25 @@ function Get-RunOnceWingetTargetUserName {
.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 = Get-RunOnceWingetTargetUserName
if ($null -eq $Target) { $Target = Get-RunOnceWingetTarget }
$targetUserNames = if ($Target.AllUsers) {
@(Get-RunOnceWingetTargetUserNames)
}
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('\', '_')
@@ -416,15 +488,19 @@ 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
$allSucceeded = $true
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
}