From de6d25fc1041f9f687b1b91323d3e7c1cac960f3 Mon Sep 17 00:00:00 2001 From: Jeffrey <9938813+Raphire@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:24:15 +0200 Subject: [PATCH] Add support for deferred uninstallation of user-scope apps for all users in WinGet --- Config/Languages/en-US/Chrome.json | 1 + Config/Languages/it-IT/Chrome.json | 1 + Config/Languages/nl-NL/Chrome.json | 1 + Config/Languages/pt-BR/Chrome.json | 1 + Scripts/AppRemoval/Remove-SelectedApps.ps1 | 172 +++++++++++++++------ 5 files changed, 128 insertions(+), 48 deletions(-) diff --git a/Config/Languages/en-US/Chrome.json b/Config/Languages/en-US/Chrome.json index 4df31ea..955cb31 100644 --- a/Config/Languages/en-US/Chrome.json +++ b/Config/Languages/en-US/Chrome.json @@ -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!", diff --git a/Config/Languages/it-IT/Chrome.json b/Config/Languages/it-IT/Chrome.json index 90a5c66..f87219d 100644 --- a/Config/Languages/it-IT/Chrome.json +++ b/Config/Languages/it-IT/Chrome.json @@ -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!", diff --git a/Config/Languages/nl-NL/Chrome.json b/Config/Languages/nl-NL/Chrome.json index 6d70534..1d20919 100644 --- a/Config/Languages/nl-NL/Chrome.json +++ b/Config/Languages/nl-NL/Chrome.json @@ -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!", diff --git a/Config/Languages/pt-BR/Chrome.json b/Config/Languages/pt-BR/Chrome.json index 36afa87..ad76db3 100644 --- a/Config/Languages/pt-BR/Chrome.json +++ b/Config/Languages/pt-BR/Chrome.json @@ -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.", diff --git a/Scripts/AppRemoval/Remove-SelectedApps.ps1 b/Scripts/AppRemoval/Remove-SelectedApps.ps1 index 97d99f4..3eea8f6 100644 --- a/Scripts/AppRemoval/Remove-SelectedApps.ps1 +++ b/Scripts/AppRemoval/Remove-SelectedApps.ps1 @@ -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 '' - } -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 '' + } -ArgumentObject $operation + } + catch { + Write-Error "Failed to schedule uninstall task for $($appId) for '$targetUserName': $_" + $allSucceeded = $false + } } + + return $allSucceeded }