diff --git a/Scripts/AppRemoval/Get-WingetInstalledApps.ps1 b/Scripts/AppRemoval/Get-WingetInstalledApps.ps1 index 1ec7706..2f698e2 100644 --- a/Scripts/AppRemoval/Get-WingetInstalledApps.ps1 +++ b/Scripts/AppRemoval/Get-WingetInstalledApps.ps1 @@ -66,7 +66,14 @@ function Get-WingetInstalledApps { } } - if ($dataStart -lt 0 -or $dataStart -ge $lines.Count) { return @() } + # A missing table separator means the output is malformed or empty + if ($dataStart -lt 0) { + return $null + } + + if ($dataStart -ge $lines.Count) { + return ,@() + } $apps = [System.Collections.Generic.List[object]]::new() @@ -94,7 +101,7 @@ function Get-WingetInstalledApps { } } - return @($apps) + return ,@($apps) } Remove-Job -Job $job -Force -ErrorAction SilentlyContinue diff --git a/Scripts/AppRemoval/Remove-SelectedApps.ps1 b/Scripts/AppRemoval/Remove-SelectedApps.ps1 index cfa7b27..ea06da8 100644 --- a/Scripts/AppRemoval/Remove-SelectedApps.ps1 +++ b/Scripts/AppRemoval/Remove-SelectedApps.ps1 @@ -39,6 +39,7 @@ function Remove-SelectedApps { $edgeIds = @('Microsoft.Edge', 'XPFFTQ037JWMHS') $wingetRemovedApps = @() + $wingetRemovalFailures = @{} Foreach ($app in $appsList) { if ($script:CancelRequested) { return } @@ -52,11 +53,15 @@ function Remove-SelectedApps { Write-Host "Removing $app" if ((Get-AppRemovalMethod $app) -eq 'WinGet') { - Remove-WinGetApp -app $app + if (-not (Remove-WinGetApp -app $app)) { + $wingetRemovalFailures[$app] = $true + } $wingetRemovedApps += $app } else { - Remove-AppxApp -app $app -targetUser $targetUser + if (-not (Remove-AppxApp -app $app -targetUser $targetUser)) { + $script:AppRemovalFailures++ + } } } @@ -70,24 +75,32 @@ function Remove-SelectedApps { $postRemovalList = if ($script:WingetInstalled) { Get-WingetInstalledApps -TimeOut 10 -NonBlocking } else { $null } $edgeForceRemoveRequested = $false - foreach ($app in $wingetRemovedApps) { - if (-not (Test-AppStillInstalled -appId $app -InstalledList $postRemovalList)) { - continue - } - - if ($edgeIds -contains $app) { - Write-Host "Unable to uninstall Microsoft Edge via WinGet" -ForegroundColor Red - if (-not $edgeForceRemoveRequested) { - Request-EdgeForceRemove - $edgeForceRemoveRequested = $true + if ($null -eq $postRemovalList) { + $script:AppRemovalVerificationUnavailable = $true + } + else { + foreach ($app in $wingetRemovedApps) { + if (-not (Test-AppInWingetList -appId $app -InstalledList $postRemovalList)) { + continue } - } - else { - Write-Host "Unable to uninstall $app via WinGet" -ForegroundColor Red + + if ($edgeIds -contains $app) { + Write-Host "Unable to uninstall Microsoft Edge via WinGet" -ForegroundColor Red + if (-not $edgeForceRemoveRequested) { + Request-EdgeForceRemove + $edgeForceRemoveRequested = $true + } + } + else { + Write-Host "Unable to uninstall $app via WinGet" -ForegroundColor Red + } + $wingetRemovalFailures[$app] = $true } } } + $script:AppRemovalFailures += $wingetRemovalFailures.Count + Write-Host "" } @@ -114,33 +127,40 @@ function Remove-WinGetApp { ) if (-not $script:WingetInstalled) { - Write-Host "ERROR: WinGet is either not installed or is outdated, $app could not be removed" -ForegroundColor Red - return + Write-Error "WinGet is either not installed or is outdated; $app could not be removed" + return $false } + $uninstallSucceeded = $true try { - Invoke-NonBlocking -ScriptBlock { + $uninstallSucceeded = Invoke-NonBlocking -ScriptBlock { param($appId) - winget uninstall --accept-source-agreements --disable-interactivity --id $appId + $null = & winget uninstall --accept-source-agreements --disable-interactivity --id $appId 2>&1 + return $true } -ArgumentList $app -TimeoutSeconds $TimeoutSeconds + $uninstallSucceeded = [bool]$uninstallSucceeded } catch { + $uninstallSucceeded = $false if ($_.Exception.Message -like 'Operation timed out after *') { - Write-Host "WinGet uninstall for $app did not complete within $TimeoutSeconds seconds: $_" -ForegroundColor Red + Write-Error "WinGet uninstall for $app did not complete within $TimeoutSeconds seconds: $_" } else { - Write-Host "WinGet uninstall for $app failed: $_" -ForegroundColor Red + Write-Error "WinGet uninstall for $app failed: $_" } } + $scheduleSucceeded = $true if ($script:Params.ContainsKey("User")) { Write-Host "Adding scheduled task to uninstall $app for user $(Get-UserName)..." - Set-RunOnceWingetTask -appId $app + $scheduleSucceeded = Set-RunOnceWingetTask -appId $app } elseif ($script:Params.ContainsKey("Sysprep")) { Write-Host "Adding scheduled task to uninstall $app for new users..." - Set-RunOnceWingetTask -appId $app + $scheduleSucceeded = Set-RunOnceWingetTask -appId $app } + + return ($uninstallSucceeded -and $scheduleSucceeded) } <# @@ -159,80 +179,48 @@ function Remove-AppxApp { $appPattern = '*' + $app + '*' try { - switch ($targetUser) { - "AllUsers" { - Invoke-NonBlocking -ScriptBlock { - param($pattern) - Get-AppxPackage -Name $pattern -AllUsers | Remove-AppxPackage -AllUsers -ErrorAction Continue - Get-AppxProvisionedPackage -Online | Where-Object { $_.PackageName -like $pattern } | ForEach-Object { Remove-ProvisionedAppxPackage -Online -AllUsers -PackageName $_.PackageName } - } -ArgumentList $appPattern - } - "CurrentUser" { - Invoke-NonBlocking -ScriptBlock { - param($pattern) - Get-AppxPackage -Name $pattern | Remove-AppxPackage -ErrorAction Continue - } -ArgumentList $appPattern - } - default { - Invoke-NonBlocking -ScriptBlock { - param($pattern, $user) - $userAccount = New-Object System.Security.Principal.NTAccount($user) + $removalResult = Invoke-NonBlocking -ScriptBlock { + param($pattern, $target) + + $removalErrors = @() + $getPackageParams = @{ Name = $pattern; ErrorAction = 'Continue'; ErrorVariable = '+removalErrors' } + $removePackageParams = @{ ErrorAction = 'Continue'; ErrorVariable = '+removalErrors' } + + switch ($target) { + 'AllUsers' { + $getPackageParams.AllUsers = $true + $removePackageParams.AllUsers = $true + } + 'CurrentUser' { } + default { + $userAccount = New-Object System.Security.Principal.NTAccount($target) $userSid = $userAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value - Get-AppxPackage -Name $pattern -User $userSid | Remove-AppxPackage -User $userSid -ErrorAction Continue - } -ArgumentList @($appPattern, $targetUser) + $getPackageParams.User = $userSid + $removePackageParams.User = $userSid + } } - } + + foreach ($package in @(Get-AppxPackage @getPackageParams)) { + $removePackageParams.Package = $package.PackageFullName + $null = Remove-AppxPackage @removePackageParams + } + + if ($target -eq 'AllUsers') { + $provisionedPackages = @(Get-AppxProvisionedPackage -Online -ErrorAction Continue -ErrorVariable +removalErrors | Where-Object { $_.PackageName -like $pattern }) + foreach ($package in $provisionedPackages) { + $null = Remove-ProvisionedAppxPackage -Online -AllUsers -PackageName $package.PackageName -ErrorAction Continue -ErrorVariable +removalErrors + } + } + + return [PSCustomObject]@{ Success = ($removalErrors.Count -eq 0) } + } -ArgumentList @($appPattern, $targetUser) } catch { - Write-Verbose "Something went wrong while trying to remove $($app): $_" - } -} - -<# - .SYNOPSIS - Checks whether an app package is still installed after a removal attempt. - - .DESCRIPTION - Checks Get-AppxPackage across all users first (fast, no process launch), - then falls back to a pre-fetched or live winget list for non-Appx packages. - Uses Test-AppInWingetList which provides exact-match-first with substring - fallback against the parsed winget objects. - Returns $true if the app is still present, $false otherwise. - - .PARAMETER appId - The package identifier to check (e.g. 'Microsoft.BingNews'). - - .PARAMETER InstalledList - Optional pre-fetched array of winget objects from Get-WingetInstalledApps. - When provided, used directly; otherwise a live winget call is made. -#> -function Test-AppStillInstalled { - param( - [string]$appId, - [object[]]$InstalledList - ) - - # Check Get-AppxPackage for all users first (fast, covers all Store apps). - if (Get-AppxPackage -Name "$appId" -AllUsers -ErrorAction SilentlyContinue) { - return $true + Write-Error "Unable to remove $app via Appx: $_" + return $false } - # Use the pre-fetched list if provided; otherwise fall back to a live winget call. - if ($InstalledList) { - return (Test-AppInWingetList -appId $appId -InstalledList $InstalledList) - } - - if ($script:WingetInstalled) { - $liveList = Get-WingetInstalledApps -TimeOut 10 -NonBlocking - if (Test-AppInWingetList -appId $appId -InstalledList $liveList) { - return $true - } - } - else { - Write-Warning "Unable to verify whether '$appId' is still installed (WinGet is unavailable)" - } - - return $false + return [bool]($removalResult -and $removalResult.Success) } <# @@ -352,8 +340,10 @@ function Set-RunOnceWingetTask { param($op) Invoke-RegistryOperation -Operation $op -RegFilePath '' } -ArgumentObject $operation + return $true } catch { - Write-Host "Failed to schedule uninstall task for $($appId): $_" -ForegroundColor Red + Write-Error "Failed to schedule uninstall task for $($appId): $_" + return $false } } diff --git a/Scripts/CLI/Show-CliDefaultModeOptions.ps1 b/Scripts/CLI/Show-CliDefaultModeOptions.ps1 index 059086c..0fa6ff3 100644 --- a/Scripts/CLI/Show-CliDefaultModeOptions.ps1 +++ b/Scripts/CLI/Show-CliDefaultModeOptions.ps1 @@ -39,7 +39,7 @@ function Show-CliDefaultModeOptions { } catch { Write-Error "Failed to load settings from DefaultSettings.json file: $_" - Wait-ForKeyPress + Wait-ForKeyPress -ExitCode 1 } Save-Settings @@ -51,4 +51,4 @@ function Show-CliDefaultModeOptions { Write-PendingChanges Write-CliHeader 'Default Mode' -} \ No newline at end of file +} diff --git a/Scripts/CLI/Show-CliLastUsedSettings.ps1 b/Scripts/CLI/Show-CliLastUsedSettings.ps1 index 4c8ea31..22b8773 100644 --- a/Scripts/CLI/Show-CliLastUsedSettings.ps1 +++ b/Scripts/CLI/Show-CliLastUsedSettings.ps1 @@ -7,7 +7,7 @@ function Show-CliLastUsedSettings { } catch { Write-Error "Failed to load settings from LastUsedSettings.json file: $_" - Wait-ForKeyPress + Wait-ForKeyPress -ExitCode 1 } if ($Silent) { @@ -17,4 +17,4 @@ function Show-CliLastUsedSettings { Write-PendingChanges Write-CliHeader 'Custom Mode' -} \ No newline at end of file +} diff --git a/Scripts/CLI/Wait-ForKeyPress.ps1 b/Scripts/CLI/Wait-ForKeyPress.ps1 index 172d670..12d4f84 100644 --- a/Scripts/CLI/Wait-ForKeyPress.ps1 +++ b/Scripts/CLI/Wait-ForKeyPress.ps1 @@ -1,4 +1,15 @@ +<# + .SYNOPSIS + Waits for user acknowledgement, then exits the script. + + .PARAMETER ExitCode + Process exit code to return after acknowledgement. Defaults to 0. +#> function Wait-ForKeyPress { + param( + [int]$ExitCode = 0 + ) + # Suppress prompt if Silent parameter was passed if (-not $Silent) { Write-Output "" @@ -7,5 +18,5 @@ function Wait-ForKeyPress { } Stop-Transcript - Exit + Exit $ExitCode } diff --git a/Scripts/Features/Invoke-Changes.ps1 b/Scripts/Features/Invoke-Changes.ps1 index 85579e5..7965774 100644 --- a/Scripts/Features/Invoke-Changes.ps1 +++ b/Scripts/Features/Invoke-Changes.ps1 @@ -320,6 +320,8 @@ function Invoke-AllChanges { } $script:RegistryImportFailures = 0 + $script:AppRemovalFailures = 0 + $script:AppRemovalVerificationUnavailable = $false # ---- Gather work items ---- $applyIds = @() @@ -422,12 +424,22 @@ function Invoke-AllChanges { } # ================================================================ - # Final: Report registry import failures + # Final: Report registry import and app removal failures # ================================================================ if ($script:RegistryImportFailures -gt 0) { Write-Host "" - Write-Host "$($script:RegistryImportFailures) registry import change(s) failed. See output above for details." -ForegroundColor Yellow + Write-Warning "$($script:RegistryImportFailures) registry import change(s) failed. See output above for details." } + + if ($script:AppRemovalFailures -gt 0) { + Write-Host "" + Write-Warning "$($script:AppRemovalFailures) app removal(s) failed. See output above for details." + } + + if ($script:AppRemovalVerificationUnavailable) { + Write-Warning "Unable to verify if all apps were uninstalled successfully." + } + } <# diff --git a/Scripts/Features/Invoke-RestartExplorer.ps1 b/Scripts/Features/Invoke-RestartExplorer.ps1 index 490476a..22e16e5 100644 --- a/Scripts/Features/Invoke-RestartExplorer.ps1 +++ b/Scripts/Features/Invoke-RestartExplorer.ps1 @@ -23,7 +23,7 @@ function Invoke-RestartExplorer { Write-Host "Warning: '$displayLabel' requires a reboot to take full effect" -ForegroundColor Yellow } - # Only restart if the powershell process matches the OS architecture. + # Only restart if the PowerShell process matches the OS architecture. # Restarting explorer from a 32bit PowerShell window will fail on a 64bit OS if ([Environment]::Is64BitProcess -eq [Environment]::Is64BitOperatingSystem) { Write-Host "Restarting the Windows Explorer process... (This may cause your screen to flicker)" diff --git a/Scripts/FileIO/Import-AppsFromFile.ps1 b/Scripts/FileIO/Import-AppsFromFile.ps1 index f5eb9b3..f9bb534 100644 --- a/Scripts/FileIO/Import-AppsFromFile.ps1 +++ b/Scripts/FileIO/Import-AppsFromFile.ps1 @@ -41,6 +41,6 @@ function Import-AppsFromFile { } catch { Write-Error "Unable to read apps list from file: $appsFilePath" - Wait-ForKeyPress + Wait-ForKeyPress -ExitCode 1 } } diff --git a/Scripts/GUI/Show-ApplyModal.ps1 b/Scripts/GUI/Show-ApplyModal.ps1 index d30dc22..a0a9cdd 100644 --- a/Scripts/GUI/Show-ApplyModal.ps1 +++ b/Scripts/GUI/Show-ApplyModal.ps1 @@ -115,6 +115,9 @@ function Show-ApplyModal { Invoke-AllChanges $registryImportFailureCount = [int]$script:RegistryImportFailures + $appRemovalFailureCount = [int]$script:AppRemovalFailures + $failureCount = $registryImportFailureCount + $appRemovalFailureCount + $appRemovalVerificationUnavailable = [bool]$script:AppRemovalVerificationUnavailable # Restart explorer if requested if ($InvokeRestartExplorer -and -not $script:CancelRequested) { @@ -128,11 +131,6 @@ function Show-ApplyModal { } Write-Host "" - if ($script:CancelRequested) { - Write-Host "Script execution was cancelled by the user. Some changes may not have been applied." - } elseif ($registryImportFailureCount -eq 0) { - Write-Host "All changes have been applied successfully!" - } # Show completion state $script:ApplyProgressBarEl.Value = 100 @@ -140,16 +138,34 @@ function Show-ApplyModal { $script:ApplyCompletionPanel.Visibility = 'Visible' if ($script:CancelRequested) { + Write-Warning "Script execution was cancelled by the user. Any remaining changes were not applied." + $script:ApplyCompletionIconEl.Text = [char]0xE7BA $script:ApplyCompletionIconEl.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#e8912d")) $script:ApplyCompletionTitleEl.Text = "Cancelled" $script:ApplyCompletionMessageEl.Text = "Script execution was cancelled by the user." - } elseif ($registryImportFailureCount -gt 0) { + } elseif ($failureCount -gt 0 -or $appRemovalVerificationUnavailable) { + if ($failureCount -gt 0) { + Write-Host "Script completed with $failureCount error(s)." + } + $script:ApplyCompletionIconEl.Text = [char]0xE7BA $script:ApplyCompletionIconEl.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#e8912d")) - $script:ApplyCompletionTitleEl.Text = "Changes Applied with Errors" - $script:ApplyCompletionMessageEl.Text = "$registryImportFailureCount registry change(s) failed. See console for details." + if ($failureCount -eq 0 -and $appRemovalVerificationUnavailable) { + $script:ApplyCompletionTitleEl.Text = "Changes Applied" + $script:ApplyCompletionMessageEl.Text = "All changes were applied without errors, but Win11Debloat could not confirm that all selected apps were successfully uninstalled." + } + else { + $script:ApplyCompletionTitleEl.Text = "Changes Applied with Errors" + $failureMessages = @() + if ($registryImportFailureCount -gt 0) { $failureMessages += "$registryImportFailureCount registry change(s) failed" } + if ($appRemovalFailureCount -gt 0) { $failureMessages += "$appRemovalFailureCount app removal(s) failed" } + if ($appRemovalVerificationUnavailable) { $failureMessages += "Unable to verify if all apps were uninstalled successfully" } + $script:ApplyCompletionMessageEl.Text = "$($failureMessages -join '; '). See console for details." + } } else { + Write-Host "All changes have been applied successfully!" + $script:ApplyCompletionTitleEl.Text = "Changes Applied" # Show completion message with reboot instructions if any applied features require reboot diff --git a/Scripts/Get.ps1 b/Scripts/Get.ps1 index c13f8bc..9d088d4 100644 --- a/Scripts/Get.ps1 +++ b/Scripts/Get.ps1 @@ -105,13 +105,12 @@ param ( [switch]$HideDriveLetters ) -# Show error if current powershell environment does not have LanguageMode set to FullLanguage +# Check if current PowerShell environment is limited by security policies if ($ExecutionContext.SessionState.LanguageMode -ne "FullLanguage") { - Write-Host "Error: Win11Debloat is unable to run on your system. PowerShell execution is restricted by security policies" -ForegroundColor Red - Write-Output "" - Write-Output "Press enter to exit..." - Read-Host | Out-Null - Exit + Write-Error "Win11Debloat is unable to run on your system, PowerShell execution is restricted by security policies" + Write-Output "Press any key to exit..." + $null = [System.Console]::ReadKey() + Exit 1 } Clear-Host @@ -139,7 +138,7 @@ catch { Write-Output "" Write-Output "Press enter to exit..." Read-Host | Out-Null - Exit + Exit 1 } # Remove old script folder if it exists, but keep configs, logs and backups @@ -207,7 +206,7 @@ $arguments = $($PSBoundParameters.GetEnumerator() | Where-Object { $_.Key -ne 'D Write-Output "" Write-Output "> Launching Win11Debloat..." -# Minimize the powershell window when no parameters are provided +# Minimize the PowerShell window when no parameters are provided if ($arguments.Count -eq 0) { $windowStyle = "Minimized" } @@ -215,7 +214,7 @@ else { $windowStyle = "Normal" } -# Remove Powershell 7 modules from path to prevent module loading issues in the script +# Remove PowerShell 7 modules from path to prevent module loading issues in the script if ($PSVersionTable.PSVersion.Major -ge 7) { $NewPSModulePath = $env:PSModulePath -split ';' | Where-Object -FilterScript { $_ -like '*WindowsPowerShell*' } $env:PSModulePath = $NewPSModulePath -join ';' @@ -223,11 +222,20 @@ if ($PSVersionTable.PSVersion.Major -ge 7) { # Run Win11Debloat script with the provided arguments $debloatScriptPath = Join-Path $tempWorkPath 'Win11Debloat.ps1' -$debloatProcess = Start-Process powershell.exe -WindowStyle $windowStyle -PassThru -ArgumentList "-executionpolicy bypass -File `"$debloatScriptPath`" $arguments" -Verb RunAs +$exitCode = 0 +$debloatProcess = $null +try { + $debloatProcess = Start-Process powershell.exe -WindowStyle $windowStyle -PassThru -ArgumentList "-executionpolicy bypass -File `"$debloatScriptPath`" $arguments" -Verb RunAs -ErrorAction Stop +} +catch { + $exitCode = 1 + Write-Error "Failed to start Win11Debloat: $_" +} # Wait for the process to finish before continuing if ($null -ne $debloatProcess) { $debloatProcess.WaitForExit() + $exitCode = $debloatProcess.ExitCode } # Remove all remaining script files, except for configs, logs and backups @@ -240,3 +248,4 @@ if (Test-Path $tempWorkPath) { } Write-Output "" +Exit $exitCode diff --git a/Scripts/Helpers/Get-UserDirectory.ps1 b/Scripts/Helpers/Get-UserDirectory.ps1 index a3fe0d4..e64fdd1 100644 --- a/Scripts/Helpers/Get-UserDirectory.ps1 +++ b/Scripts/Helpers/Get-UserDirectory.ps1 @@ -46,9 +46,9 @@ function Get-UserDirectory { } catch { Write-Error "Something went wrong when trying to find the user directory path for user $userName. Please ensure the user exists on this system" - Wait-ForKeyPress + Wait-ForKeyPress -ExitCode 1 } Write-Error "Unable to find user directory path for user $userName" - Wait-ForKeyPress + Wait-ForKeyPress -ExitCode 1 } diff --git a/Tests/Get-WingetInstalledApps.Tests.ps1 b/Tests/Get-WingetInstalledApps.Tests.ps1 index 2800d53..73edda4 100644 --- a/Tests/Get-WingetInstalledApps.Tests.ps1 +++ b/Tests/Get-WingetInstalledApps.Tests.ps1 @@ -37,16 +37,33 @@ Describe 'Get-WingetInstalledApps' { Should -Invoke Invoke-NonBlocking -Times 1 -Exactly } - It 'returns an empty collection when winget output has no table separator' { + It 'returns null when winget output has no table separator' { $script:WingetTestJob = New-WingetTestJob Mock Start-Job { $script:WingetTestJob } Mock Wait-Job { $script:WingetTestJob } Mock Receive-Job { @('Name Id', 'No parseable table') } - @(Get-WingetInstalledApps) | Should -BeNullOrEmpty + Get-WingetInstalledApps | Should -BeNullOrEmpty Should -Invoke Remove-Job -Times 1 -Exactly -ParameterFilter { -not $Force } } + It 'returns an empty collection for a valid table with no data rows' { + $script:WingetTestJob = New-WingetTestJob + Mock Start-Job { $script:WingetTestJob } + Mock Wait-Job { $script:WingetTestJob } + Mock Receive-Job { + @( + 'Name Id Version' + '-----------------------------------------------------------------------' + ) + } + + $result = @(Get-WingetInstalledApps) + + $result | Should -HaveCount 1 + @($result[0]).Count | Should -Be 0 + } + It 'parses valid rows and skips malformed rows' { $script:WingetTestJob = New-WingetTestJob Mock Start-Job { $script:WingetTestJob } @@ -61,7 +78,7 @@ Describe 'Get-WingetInstalledApps' { ) } - $result = @(Get-WingetInstalledApps) + $result = @(Get-WingetInstalledApps)[0] $result | Should -HaveCount 2 $result.Id | Should -Be @('Contoso.App', 'Fabrikam.Tools') @@ -80,7 +97,7 @@ Describe 'Get-WingetInstalledApps' { ) } - $result = @(Get-WingetInstalledApps) + $result = @(Get-WingetInstalledApps)[0] $result | Should -HaveCount 2 $result[0].Name | Should -Be 'Contoso hulpmiddel voor gegevens' diff --git a/Tests/Invoke-Changes.Tests.ps1 b/Tests/Invoke-Changes.Tests.ps1 index 51bc2d0..130a87e 100644 --- a/Tests/Invoke-Changes.Tests.ps1 +++ b/Tests/Invoke-Changes.Tests.ps1 @@ -325,6 +325,7 @@ Describe 'Invoke-AllChanges' { Mock Invoke-ApplyFeatures {} Mock Invoke-UndoFeatures {} Mock Write-Host {} + Mock Write-Warning {} } It 'backs up registry work before applying and undoing selected features' { @@ -407,6 +408,28 @@ Describe 'Invoke-AllChanges' { Invoke-AllChanges - Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -match '2 registry import change' } + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match '2 registry import change' } } + + It 'reports app removal failures after all requested work completes' { + $script:Params = @{ CustomApply = $true } + $script:UndoParams = @{} + Mock Invoke-ApplyFeatures { $script:AppRemovalFailures = 2 } + + Invoke-AllChanges + + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match '2 app removal\(s\) failed' } + } + + It 'warns when app removals could not be verified' { + $script:Params = @{ CustomApply = $true } + $script:UndoParams = @{} + Mock Invoke-ApplyFeatures { $script:AppRemovalVerificationUnavailable = $true } + Mock Write-Warning {} + + Invoke-AllChanges + + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -eq 'Unable to verify if all apps were uninstalled successfully.' } + } + } diff --git a/Tests/Remove-SelectedApps.Tests.ps1 b/Tests/Remove-SelectedApps.Tests.ps1 index ff891c4..18435dc 100644 --- a/Tests/Remove-SelectedApps.Tests.ps1 +++ b/Tests/Remove-SelectedApps.Tests.ps1 @@ -1,6 +1,6 @@ BeforeAll { function Get-TargetUserForAppRemoval { 'AllUsers' } - function Get-WingetInstalledApps { param($TimeOut, [switch]$NonBlocking) @() } + function Get-WingetInstalledApps { param($TimeOut, [switch]$NonBlocking) return ,@() } function Test-AppInWingetList { param($appId, $InstalledList) $false } function Invoke-NonBlocking { param($ScriptBlock, $ArgumentList, $TimeoutSeconds) } function Get-UserName { 'Alice' } @@ -8,6 +8,7 @@ BeforeAll { function Show-MessageBox { 'No' } function Invoke-WithTargetUserHive { param($TargetUserName, $ScriptBlock, $ArgumentObject) } function Invoke-RegistryOperation { param($Operation, $RegFilePath) } + function Resolve-UserProfileContext { param($UserName) $null } . (Join-Path $PSScriptRoot '..\Scripts\AppRemoval\Remove-SelectedApps.ps1') } @@ -18,12 +19,14 @@ Describe 'Remove-SelectedApps' { $script:CancelRequested = $false $script:ApplySubStepCallback = $null $script:WingetInstalled = $true + $script:AppRemovalFailures = 0 + $script:AppRemovalVerificationUnavailable = $false Mock Get-TargetUserForAppRemoval { 'AllUsers' } Mock Get-AppRemovalMethod { 'Appx' } - Mock Remove-WinGetApp {} - Mock Remove-AppxApp {} - Mock Test-AppStillInstalled { $false } - Mock Get-WingetInstalledApps { @() } + Mock Remove-WinGetApp { $true } + Mock Remove-AppxApp { $true } + Mock Test-AppInWingetList { $false } + Mock Get-WingetInstalledApps { return ,@() } Mock Request-EdgeForceRemove {} Mock Write-Host {} } @@ -43,6 +46,13 @@ Describe 'Remove-SelectedApps' { Should -Invoke Remove-AppxApp -Times 1 -Exactly -ParameterFilter { $app -eq 'Appx.App' -and $targetUser -eq 'AllUsers' } } + It 'verifies WinGet removals against the fetched Winget list' { + Mock Get-AppRemovalMethod { 'WinGet' } + Mock Get-WingetInstalledApps { return ,@([PSCustomObject]@{ Id = 'Other.App' }) } + Remove-SelectedApps -appsList @('Winget.App') + Should -Invoke Test-AppInWingetList -Times 1 -Exactly -ParameterFilter { $appId -eq 'Winget.App' -and $InstalledList[0].Id -eq 'Other.App' } + } + It 'stops before the first removal when cancellation is requested' { $script:CancelRequested = $true Remove-SelectedApps -appsList @('One.App') @@ -50,9 +60,45 @@ Describe 'Remove-SelectedApps' { Should -Invoke Remove-AppxApp -Times 0 -Exactly } + It 'counts failed Appx removals and reports them' { + Mock Remove-AppxApp { $false } + + Remove-SelectedApps -appsList @('One.App') + + $script:AppRemovalFailures | Should -Be 1 + } + + It 'counts a failed WinGet removal' { + Mock Get-AppRemovalMethod { 'WinGet' } + Mock Remove-WinGetApp { $false } + + Remove-SelectedApps -appsList @('One.App') + + $script:AppRemovalFailures | Should -Be 1 + } + + It 'counts a WinGet removal that remains installed after a successful command' { + Mock Get-AppRemovalMethod { 'WinGet' } + Mock Test-AppInWingetList { $true } + + Remove-SelectedApps -appsList @('One.App') + + $script:AppRemovalFailures | Should -Be 1 + } + + It 'records an unavailable WinGet inventory as an unverified removal' { + Mock Get-AppRemovalMethod { 'WinGet' } + Mock Get-WingetInstalledApps { $null } + + Remove-SelectedApps -appsList @('One.App') + + $script:AppRemovalVerificationUnavailable | Should -BeTrue + Should -Invoke Test-AppInWingetList -Times 0 -Exactly + } + It 'prompts for forced Edge removal at most once after failed winget removals' { Mock Get-AppRemovalMethod { 'WinGet' } - Mock Test-AppStillInstalled { $true } + Mock Test-AppInWingetList { $true } Remove-SelectedApps -appsList @('Microsoft.Edge', 'XPFFTQ037JWMHS') Should -Invoke Request-EdgeForceRemove -Times 1 -Exactly } @@ -86,15 +132,16 @@ Describe 'Remove-WinGetApp' { BeforeEach { $script:Params = @{} $script:WingetInstalled = $true - Mock Invoke-NonBlocking {} - Mock Set-RunOnceWingetTask {} + Mock Invoke-NonBlocking { $true } + Mock Set-RunOnceWingetTask { $true } Mock Get-UserName { 'Alice' } Mock Write-Host {} + Mock Write-Error {} } It 'reports unavailable winget without invoking or scheduling removal' { $script:WingetInstalled = $false - Remove-WinGetApp -app 'One.App' + Remove-WinGetApp -app 'One.App' | Should -BeFalse Should -Invoke Invoke-NonBlocking -Times 0 -Exactly Should -Invoke Set-RunOnceWingetTask -Times 0 -Exactly } @@ -130,55 +177,52 @@ Describe 'Remove-WinGetApp' { { Remove-WinGetApp -app 'One.App' } | Should -Not -Throw Should -Invoke Set-RunOnceWingetTask -Times 1 -Exactly - Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { - $Object -like '*did not complete within 120 seconds*' -and $ForegroundColor -eq 'Red' + Should -Invoke Write-Error -Times 1 -Exactly -ParameterFilter { + $Message -like '*did not complete within 120 seconds*' } } + } Describe 'Remove-AppxApp' { - BeforeEach { Mock Invoke-NonBlocking {} } + BeforeEach { Mock Invoke-NonBlocking { [PSCustomObject]@{ Success = $true } } } It 'passes the wildcard and target user data for ' -ForEach @( - @{ Target = 'AllUsers'; ExpectedArguments = 1 } - @{ Target = 'CurrentUser'; ExpectedArguments = 1 } - @{ Target = 'Alice'; ExpectedArguments = 2 } + @{ Target = 'AllUsers' } + @{ Target = 'CurrentUser' } + @{ Target = 'Alice' } ) { Remove-AppxApp -app 'One.App' -targetUser $Target Should -Invoke Invoke-NonBlocking -Times 1 -Exactly -ParameterFilter { - @($ArgumentList).Count -eq $ExpectedArguments -and @($ArgumentList)[0] -eq '*One.App*' -and - ($ExpectedArguments -eq 1 -or @($ArgumentList)[1] -eq 'Alice') + @($ArgumentList).Count -eq 2 -and @($ArgumentList)[0] -eq '*One.App*' -and @($ArgumentList)[1] -eq $Target } } -} -Describe 'Test-AppStillInstalled' { - BeforeEach { - $script:WingetInstalled = $true - Mock Get-AppxPackage { $null } - Mock Test-AppInWingetList { $false } - Mock Get-WingetInstalledApps { @() } - Mock Write-Warning {} + It 'returns false when package discovery reports a non-terminating error' { + Mock Invoke-NonBlocking { param($ScriptBlock, $ArgumentList) & $ScriptBlock @ArgumentList } + Mock Get-AppxPackage { Write-Error 'access denied' } + + Remove-AppxApp -app 'One.App' -targetUser 'CurrentUser' | Should -BeFalse } - It 'prefers all-user Appx detection and avoids winget lookup' { - Mock Get-AppxPackage { [PSCustomObject]@{ Name = 'One.App' } } - Test-AppStillInstalled -appId 'One.App' | Should -BeTrue - Should -Invoke Get-AppxPackage -Times 1 -Exactly -ParameterFilter { $AllUsers } - Should -Invoke Get-WingetInstalledApps -Times 0 -Exactly + It 'returns false when package removal reports a non-terminating error' { + Mock Invoke-NonBlocking { param($ScriptBlock, $ArgumentList) & $ScriptBlock @ArgumentList } + Mock Get-AppxPackage { [PSCustomObject]@{ PackageFullName = 'One.App_1.0' } } + Mock Remove-AppxPackage { Write-Error 'access denied' } + + Remove-AppxApp -app 'One.App' -targetUser 'CurrentUser' | Should -BeFalse } - It 'uses a supplied winget list without launching a live query' { - Mock Test-AppInWingetList { $true } - Test-AppStillInstalled -appId 'One.App' -InstalledList @([PSCustomObject]@{ Id = 'One.App' }) | Should -BeTrue - Should -Invoke Get-WingetInstalledApps -Times 0 -Exactly + It 'returns false and reports a terminating Appx failure' { + Mock Invoke-NonBlocking { throw 'access denied' } + Mock Write-Error {} + + Remove-AppxApp -app 'One.App' -targetUser 'CurrentUser' | Should -BeFalse + Should -Invoke Write-Error -Times 1 -Exactly -ParameterFilter { + $Message -like '*Unable to remove One.App via Appx*access denied*' + } } - It 'warns when a non-Appx app cannot be verified without winget' { - $script:WingetInstalled = $false - Test-AppStillInstalled -appId 'One.App' | Should -BeFalse - Should -Invoke Write-Warning -Times 1 -Exactly - } } Describe 'Set-RunOnceWingetTask' { diff --git a/Tests/Wait-ForKeyPress.Tests.ps1 b/Tests/Wait-ForKeyPress.Tests.ps1 index e63396a..dfe9653 100644 --- a/Tests/Wait-ForKeyPress.Tests.ps1 +++ b/Tests/Wait-ForKeyPress.Tests.ps1 @@ -8,4 +8,14 @@ Describe 'Wait-ForKeyPress' { $LASTEXITCODE | Should -Be 0 } + + It 'uses the requested exit code' { + $scriptPath = Join-Path $PSScriptRoot '..\Scripts\CLI\Wait-ForKeyPress.ps1' + $command = "function Stop-Transcript {}; `$global:Silent = `$true; . '$scriptPath'; Wait-ForKeyPress -ExitCode 1" + $encodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + + & powershell.exe -NoProfile -EncodedCommand $encodedCommand + + $LASTEXITCODE | Should -Be 1 + } } diff --git a/Win11Debloat.ps1 b/Win11Debloat.ps1 index 63c9a6f..110192e 100644 --- a/Win11Debloat.ps1 +++ b/Win11Debloat.ps1 @@ -155,9 +155,18 @@ if (-not $isAdmin) { } } - Start-Process powershell -ArgumentList $elevatedArgs -Verb RunAs + try { + Start-Process powershell -ArgumentList $elevatedArgs -Verb RunAs -ErrorAction Stop + } + catch { + Write-Error "Failed to start Win11Debloat as Administrator: $_" + Exit 1 + } + + Exit 0 } - exit + + Exit 1 } # Define script-level variables & paths @@ -194,13 +203,16 @@ $script:GuiWindow = $null $script:CancelRequested = $false $script:ApplyProgressCallback = $null $script:ApplySubStepCallback = $null +$script:RegistryImportFailures = 0 +$script:AppRemovalFailures = 0 +$script:AppRemovalVerificationUnavailable = $false -# Check if current powershell environment is limited by security policies +# Check if current PowerShell environment is limited by security policies if ($ExecutionContext.SessionState.LanguageMode -ne "FullLanguage") { - Write-Error "Win11Debloat is unable to run on your system, powershell execution is restricted by security policies" + Write-Error "Win11Debloat is unable to run on your system, PowerShell execution is restricted by security policies" Write-Output "Press any key to exit..." $null = [System.Console]::ReadKey() - Exit + Exit 1 } Clear-Host @@ -286,10 +298,9 @@ catch { } # Check if script has all required files if (-not ((Test-Path $script:DefaultSettingsFilePath) -and (Test-Path $script:AppsListFilePath) -and (Test-Path $script:RegfilesPath) -and (Test-Path $script:AssetsPath) -and (Test-Path $script:AppSelectionSchema) -and (Test-Path $script:ApplyChangesWindowSchema) -and (Test-Path $script:SharedStylesSchema) -and (Test-Path $script:BubbleHintSchema) -and (Test-Path $script:RestoreBackupWindowSchema) -and (Test-Path $script:FeaturesFilePath))) { Write-Error "Win11Debloat is unable to find required files, please ensure all script files are present" - Write-Output "" Write-Output "Press any key to exit..." $null = [System.Console]::ReadKey() - Exit + Exit 1 } # Load feature info from file @@ -306,10 +317,9 @@ try { } catch { Write-Error "Failed to load feature info from Features.json file" - Write-Output "" Write-Output "Press any key to exit..." $null = [System.Console]::ReadKey() - Exit + Exit 1 } # Check if WinGet is installed & if it is, check if the version is at least v1.4 @@ -482,7 +492,7 @@ if ($script:Params.ContainsKey("Sysprep")) { # Exit script if run in Sysprep mode on Windows 10 if ($WinVersion -lt 22000) { Write-Error "Win11Debloat Sysprep mode is not supported on Windows 10" - Wait-ForKeyPress + Wait-ForKeyPress -ExitCode 1 } } @@ -515,7 +525,7 @@ if ((-not $script:Params.Count) -or $RunDefaults -or $RunDefaultsLite -or $RunSa if (-not (Test-Path $script:SavedSettingsFilePath)) { Write-CliHeader 'Custom Mode' Write-Error "Unable to find LastUsedSettings.json file, no changes were made" - Wait-ForKeyPress + Wait-ForKeyPress -ExitCode 1 } Show-CliLastUsedSettings @@ -526,7 +536,7 @@ if ((-not $script:Params.Count) -or $RunDefaults -or $RunDefaultsLite -or $RunSa } catch { Write-Error "$_" - Wait-ForKeyPress + Wait-ForKeyPress -ExitCode 1 } if (-not $Silent) {