diff --git a/Config/Languages/en-US/Chrome.json b/Config/Languages/en-US/Chrome.json index 4ab2c14..955cb31 100644 --- a/Config/Languages/en-US/Chrome.json +++ b/Config/Languages/en-US/Chrome.json @@ -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!", diff --git a/Config/Languages/it-IT/Chrome.json b/Config/Languages/it-IT/Chrome.json index abd4802..f87219d 100644 --- a/Config/Languages/it-IT/Chrome.json +++ b/Config/Languages/it-IT/Chrome.json @@ -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!", diff --git a/Config/Languages/nl-NL/Chrome.json b/Config/Languages/nl-NL/Chrome.json index a29d1f2..1d20919 100644 --- a/Config/Languages/nl-NL/Chrome.json +++ b/Config/Languages/nl-NL/Chrome.json @@ -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!", diff --git a/Config/Languages/pt-BR/Chrome.json b/Config/Languages/pt-BR/Chrome.json index 601553f..ad76db3 100644 --- a/Config/Languages/pt-BR/Chrome.json +++ b/Config/Languages/pt-BR/Chrome.json @@ -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.", diff --git a/Scripts/AppRemoval/Remove-SelectedApps.ps1 b/Scripts/AppRemoval/Remove-SelectedApps.ps1 index 5930521..cca53f2 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'). @@ -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 '' - } -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 '' + } -ArgumentObject $operation + } + catch { + Write-Error "Failed to schedule uninstall task for $($appId) for '$targetUserName': $_" + $allSucceeded = $false + } } + + return $allSucceeded } diff --git a/Scripts/Features/Invoke-Changes.ps1 b/Scripts/Features/Invoke-Changes.ps1 index aefb41e..f892a7a 100644 --- a/Scripts/Features/Invoke-Changes.ps1 +++ b/Scripts/Features/Invoke-Changes.ps1 @@ -351,6 +351,7 @@ function Invoke-AllChanges { $script:AppRemovalFailures = 0 $script:FeatureFailures = 0 $script:AppRemovalVerificationUnavailable = $false + $script:WingetDeferredRemovals = @{} # ---- Gather work items ---- $applyIds = @() diff --git a/Scripts/GUI/Show-ApplyModal.ps1 b/Scripts/GUI/Show-ApplyModal.ps1 index 7ac62dd..86f6889 100644 --- a/Scripts/GUI/Show-ApplyModal.ps1 +++ b/Scripts/GUI/Show-ApplyModal.ps1 @@ -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 { diff --git a/Tests/Remove-SelectedApps.Tests.ps1 b/Tests/Remove-SelectedApps.Tests.ps1 index 5889ba4..0107e5c 100644 --- a/Tests/Remove-SelectedApps.Tests.ps1 +++ b/Tests/Remove-SelectedApps.Tests.ps1 @@ -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 ' -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 + } }