mirror of
https://github.com/Raphire/Win11Debloat.git
synced 2026-10-15 10:12:01 +00:00
Fix uninstallation of user-scope apps in WinGet with deferred uninstall (#802)
This commit is contained in:
@@ -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!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -351,6 +351,7 @@ function Invoke-AllChanges {
|
||||
$script:AppRemovalFailures = 0
|
||||
$script:FeatureFailures = 0
|
||||
$script:AppRemovalVerificationUnavailable = $false
|
||||
$script:WingetDeferredRemovals = @{}
|
||||
|
||||
# ---- Gather work items ----
|
||||
$applyIds = @()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user