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
+3
View File
@@ -134,10 +134,13 @@
"ApplyCompletionTitleSuccess": "Changes Applied",
"ApplyCompletionMessageDefault": "Please note that some changes will only take effect after a reboot. Thanks for using Win11Debloat!",
"ApplyRebootRequiredHeader": "A reboot is required for these changes to take effect:",
"ApplyRebootRequiredWingetDeferred": "Uninstallation of {0}",
"ApplyCompletionTitleCancelled": "Cancelled",
"ApplyCompletionMessageCancelled": "Script execution was cancelled by the user.",
"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!",
+3
View File
@@ -125,10 +125,13 @@
"ApplyCompletionTitleSuccess": "Modifiche applicate",
"ApplyCompletionMessageDefault": "Nota che alcune modifiche avranno effetto solo dopo il riavvio. Grazie per aver usato Win11Debloat!",
"ApplyRebootRequiredHeader": "È necessario riavviare il sistema affinché le seguenti modifiche abbiano effetto:",
"ApplyRebootRequiredWingetDeferred": "Disinstallazione di {0}",
"ApplyCompletionTitleCancelled": "Annullato",
"ApplyCompletionMessageCancelled": "L'esecuzione dello script è stata annullata dall'utente.",
"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!",
+4 -1
View File
@@ -124,11 +124,14 @@
"ApplyCreatingRestorePoint": "Systeemherstelpunt maken. Dit kan even duren...",
"ApplyCompletionTitleSuccess": "Wijzigingen toegepast",
"ApplyCompletionMessageDefault": "Sommige wijzigingen worden pas actief nadat je de computer opnieuw hebt opgestart. Bedankt voor het gebruik van Win11Debloat!",
"ApplyRebootRequiredHeader": "Start de computer opnieuw op om de volgende wijzigingen te activeren:",
"ApplyRebootRequiredHeader": "De volgende wijzigingen vereisen een herstart:",
"ApplyRebootRequiredWingetDeferred": "Verwijderen van {0}",
"ApplyCompletionTitleCancelled": "Geannuleerd",
"ApplyCompletionMessageCancelled": "Het toepassen van de wijzigingen is geannuleerd.",
"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!",
+3
View File
@@ -134,10 +134,13 @@
"ApplyCompletionTitleSuccess": "Alterações aplicadas",
"ApplyCompletionMessageDefault": "Observe que algumas alterações só terão efeito após uma reinicialização. Obrigado por usar o Win11Debloat!",
"ApplyRebootRequiredHeader": "Uma reinicialização é necessária para que estas alterações tenham efeito:",
"ApplyRebootRequiredWingetDeferred": "Desinstalação de {0}",
"ApplyCompletionTitleCancelled": "Cancelado",
"ApplyCompletionMessageCancelled": "A execução do script foi cancelada pelo usuário.",
"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.",
+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
}
+1
View File
@@ -351,6 +351,7 @@ function Invoke-AllChanges {
$script:AppRemovalFailures = 0
$script:FeatureFailures = 0
$script:AppRemovalVerificationUnavailable = $false
$script:WingetDeferredRemovals = @{}
# ---- Gather work items ----
$applyIds = @()
+15
View File
@@ -184,6 +184,21 @@ function Show-ApplyModal {
}
}
}
if ($script:WingetDeferredRemovals -and $script:WingetDeferredRemovals.Count -gt 0) {
foreach ($appId in @($script:WingetDeferredRemovals.Keys | Sort-Object)) {
$tb = [System.Windows.Controls.TextBlock]::new()
$appName = Get-Translation -Key $appId -Field 'FriendlyName' -Section 'Apps'
$label = Get-Translation -Key 'ApplyRebootRequiredWingetDeferred' -FormatArgs @($appName)
$tb.Text = "$([char]0x2022) $label"
$tb.FontSize = 12
$tb.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, "AppFgColor")
$tb.Opacity = 0.85
$tb.Margin = [System.Windows.Thickness]::new(0, 2, 0, 0)
$applyRebootList.Children.Add($tb) | Out-Null
}
$applyRebootPanel.Visibility = 'Visible'
}
$applyWindow.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Render, [action]{})
}
catch {
+163
View File
@@ -4,6 +4,7 @@ BeforeAll {
function Test-AppInWingetList { param($appId, $InstalledList) $false }
function Invoke-NonBlocking { param($ScriptBlock, $ArgumentList, $TimeoutSeconds) }
function Get-UserName { 'Alice' }
function Get-Translation { param($Key, $FormatArgs) if ($FormatArgs) { return "$Key $($FormatArgs -join ' ')" }; return $Key }
function Invoke-ForceRemoveEdge {}
function Show-MessageBox { 'No' }
function Invoke-WithTargetUserHive { param($TargetUserName, $ScriptBlock, $ArgumentObject) }
@@ -20,6 +21,7 @@ Describe 'Remove-SelectedApps' {
$script:CancelRequested = $false
$script:ApplySubStepCallback = $null
$script:WingetInstalled = $true
$script:WingetDeferredRemovals = @{}
$script:AppRemovalFailures = 0
$script:AppRemovalVerificationUnavailable = $false
Mock Get-TargetUserForAppRemoval { 'AllUsers' }
@@ -54,6 +56,46 @@ Describe 'Remove-SelectedApps' {
Should -Invoke Test-AppInWingetList -Times 1 -Exactly -ParameterFilter { $appId -eq 'Winget.App' -and $InstalledList[0].Id -eq 'Other.App' }
}
It 'skips immediate inventory verification for a deferred WinGet uninstall' {
Mock Get-AppRemovalMethod { 'WinGet' }
Mock Remove-WinGetApp {
$script:WingetDeferredRemovals[$app] = 'Alice'
return $true
}
Remove-SelectedApps -appsList @('Winget.App') | Should -BeTrue
Should -Invoke Get-WingetInstalledApps -Times 0 -Exactly
}
It 'skips an already deferred app within a list and across removal calls' {
Mock Get-AppRemovalMethod { 'WinGet' }
Mock Remove-WinGetApp {
$script:WingetDeferredRemovals[$app] = 'Alice'
return $true
}
Remove-SelectedApps -appsList @('One.App', 'One.App') | Should -BeTrue
Remove-SelectedApps -appsList @('One.App') | Should -BeTrue
Should -Invoke Remove-WinGetApp -Times 1 -Exactly -ParameterFilter { $app -eq 'One.App' }
Should -Invoke Get-WingetInstalledApps -Times 0 -Exactly
$script:WingetDeferredRemovals['One.App'] | Should -Be 'Alice'
$script:AppRemovalFailures | Should -Be 0
}
It 'continues removing and verifying other apps while preserving deferrals' {
$script:WingetDeferredRemovals = @{ 'One.App' = 'Alice'; 'Other.App' = 'Bob' }
Mock Get-AppRemovalMethod { 'WinGet' }
Remove-SelectedApps -appsList @('One.App', 'New.App') | Should -BeTrue
Should -Invoke Remove-WinGetApp -Times 1 -Exactly -ParameterFilter { $app -eq 'New.App' }
Should -Invoke Test-AppInWingetList -Times 1 -Exactly -ParameterFilter { $appId -eq 'New.App' }
$script:WingetDeferredRemovals['One.App'] | Should -Be 'Alice'
$script:WingetDeferredRemovals['Other.App'] | Should -Be 'Bob'
}
It 'stops before the first removal when cancellation is requested' {
$script:CancelRequested = $true
Remove-SelectedApps -appsList @('One.App')
@@ -157,6 +199,7 @@ Describe 'Remove-WinGetApp' {
BeforeEach {
$script:Params = @{}
$script:WingetInstalled = $true
$script:WingetDeferredRemovals = @{}
Mock Invoke-NonBlocking { [PSCustomObject]@{ Success = $true; ExitCode = 0; Output = @() } }
Mock Set-RunOnceWingetTask { $true }
Mock Get-UserName { 'Alice' }
@@ -196,6 +239,19 @@ Describe 'Remove-WinGetApp' {
}
}
It 'defers a user-scope uninstall when WinGet returns the administrator-context error code' {
$script:Params = @{ AppRemovalTarget = 'CurrentUser' }
Mock Get-TargetUserForAppRemoval { 'CurrentUser' }
Mock Invoke-NonBlocking { [PSCustomObject]@{ ExitCode = -1978335107; Output = @('localized WinGet error') } }
Remove-WinGetApp -app 'One.App' | Should -BeTrue
Should -Invoke Set-RunOnceWingetTask -Times 1 -Exactly -ParameterFilter {
$appId -eq 'One.App'
}
$script:WingetDeferredRemovals['One.App'] | Should -Be 'Alice'
}
It 'reports a timed-out winget uninstall and continues' {
$script:Params = @{ User = 'Alice' }
Mock Invoke-NonBlocking { throw 'Operation timed out after 120 seconds' }
@@ -352,4 +408,111 @@ Describe 'Set-RunOnceWingetTask' {
$decodedCommand = [System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encodedCommand))
$decodedCommand | Should -Be "winget uninstall --accept-source-agreements --disable-interactivity --id 'Vendor.App&''Test'"
}
It 'schedules every resolved target but reports incomplete profile enumeration' {
Mock Get-RunOnceWingetTargetUserNames {
[PSCustomObject]@{ UserNames = @('Alice', 'Default'); Success = $false }
}
Set-RunOnceWingetTask -appId 'One.App' -Target ([PSCustomObject]@{ AllUsers = $true }) | Should -BeFalse
Should -Invoke Invoke-WithTargetUserHive -Times 1 -Exactly -ParameterFilter { $TargetUserName -eq 'Alice' }
Should -Invoke Invoke-WithTargetUserHive -Times 1 -Exactly -ParameterFilter { $TargetUserName -eq 'Default' }
}
It 'reports success when all profile targets are scheduled' {
Mock Get-RunOnceWingetTargetUserNames {
[PSCustomObject]@{ UserNames = @('Alice', 'Default'); Success = $true }
}
Set-RunOnceWingetTask -appId 'One.App' -Target ([PSCustomObject]@{ AllUsers = $true }) | Should -BeTrue
Should -Invoke Invoke-WithTargetUserHive -Times 2 -Exactly
}
It 'continues scheduling other profiles after a hive failure and reports failure' {
Mock Get-RunOnceWingetTargetUserNames {
[PSCustomObject]@{ UserNames = @('Alice', 'Default'); Success = $true }
}
Mock Invoke-WithTargetUserHive { throw 'hive unavailable' } -ParameterFilter { $TargetUserName -eq 'Alice' }
Mock Write-Error {}
Set-RunOnceWingetTask -appId 'One.App' -Target ([PSCustomObject]@{ AllUsers = $true }) | Should -BeFalse
Should -Invoke Invoke-WithTargetUserHive -Times 1 -Exactly -ParameterFilter { $TargetUserName -eq 'Default' }
}
}
Describe 'AllUsers deferred WinGet profile enumeration' {
BeforeEach {
$script:Params = @{}
$script:WingetInstalled = $true
$script:WingetDeferredRemovals = @{}
$script:AppRemovalFailures = 0
$script:CancelRequested = $false
$script:ApplySubStepCallback = $null
Mock Get-CimInstance {
@(
[PSCustomObject]@{ Special = $false; SID = 'S-1-5-18'; LocalPath = 'C:\Users\Resolved' }
[PSCustomObject]@{ Special = $false; SID = 'S-1-5-21-0-0-0-1001'; LocalPath = 'C:\Users\Unresolved' }
)
}
Mock Test-Path { $true }
Mock Write-Warning {}
Mock Write-Host {}
Mock Get-AppRemovalMethod { 'WinGet' }
Mock Invoke-NonBlocking { [PSCustomObject]@{ ExitCode = -1978335107; Output = @() } }
Mock Invoke-WithTargetUserHive {}
Mock Get-WingetInstalledApps { return ,@() }
Mock Test-AppInWingetList { $false }
}
It 'keeps resolved profiles and Default while returning a SID translation failure' {
$result = Get-RunOnceWingetTargetUserNames
$result.Success | Should -BeFalse
$result.UserNames.Count | Should -Be 2
$result.UserNames | Should -Contain 'Default'
Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -like '*S-1-5-21-0-0-0-1001*will not be scheduled*' }
}
It 'returns failure when profile enumeration fails' {
Mock Get-CimInstance { throw 'enumeration failed' }
Mock Write-Error {}
$result = Get-RunOnceWingetTargetUserNames
$result.Success | Should -BeFalse
$result.UserNames.Count | Should -Be 0
}
It 'reports incomplete all-users removal even if the immediate inventory is empty' {
Remove-SelectedApps -appsList @('One.App') | Should -BeFalse
$script:AppRemovalFailures | Should -Be 1
$script:WingetDeferredRemovals.ContainsKey('One.App') | Should -BeFalse
Should -Invoke Invoke-WithTargetUserHive -Times 2 -Exactly
Should -Invoke Invoke-WithTargetUserHive -Times 1 -Exactly -ParameterFilter { $TargetUserName -eq 'Default' }
Should -Invoke Write-Host -Times 0 -Exactly -ParameterFilter { $Object -like 'WingetAllUsersUninstallDeferred*' }
}
It 'reports failure when required fallback scheduling throws during <Stage>' -ForEach @(
@{ Stage = 'target resolution' }
@{ Stage = 'profile enumeration' }
@{ Stage = 'task scheduling' }
) {
$ErrorActionPreference = 'Stop'
switch ($Stage) {
'target resolution' { Mock Get-RunOnceWingetTarget { throw 'target unavailable' } }
'profile enumeration' { Mock Get-CimInstance { throw 'enumeration failed' } }
'task scheduling' { Mock Set-RunOnceWingetTask { throw 'scheduling failed' } }
}
Remove-SelectedApps -appsList @('One.App') | Should -BeFalse
$script:AppRemovalFailures | Should -Be 1
$script:WingetDeferredRemovals.ContainsKey('One.App') | Should -BeFalse
Should -Invoke Get-WingetInstalledApps -Times 1 -Exactly
Should -Invoke Invoke-WithTargetUserHive -Times 0 -Exactly
}
}