diff --git a/Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1 b/Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1 index 48cca139..c7041be5 100644 --- a/Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1 +++ b/Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1 @@ -1,36 +1,50 @@ <# .SYNOPSIS Forcefully uninstalls Microsoft Edge and removes its leftover shortcuts and autostart entries. + + .OUTPUTS + System.Boolean. $true when Edge is uninstalled and cleanup succeeds; otherwise $false. #> function Invoke-ForceRemoveEdge { if ($script:Params.ContainsKey("WhatIf")) { Write-Host "[WhatIf] Forcefully uninstall Microsoft Edge" -ForegroundColor Cyan - Write-Host "" - return + return $true } - Write-Host "> Forcefully uninstalling Microsoft Edge..." + try { + Write-Host "> Forcefully uninstalling Microsoft Edge..." - $regView = [Microsoft.Win32.RegistryView]::Registry32 - $hklm = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $regView) - $hklm.CreateSubKey('SOFTWARE\Microsoft\EdgeUpdateDev').SetValue('AllowUninstall', '') + $regView = [Microsoft.Win32.RegistryView]::Registry32 + $hklm = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $regView) + $edgeUpdateKey = $hklm.CreateSubKey('SOFTWARE\Microsoft\EdgeUpdateDev') + $edgeUpdateKey.SetValue('AllowUninstall', '') - # Create stub (This somehow allows uninstalling Edge) - $edgeStub = "$env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe" - New-Item $edgeStub -ItemType Directory | Out-Null - New-Item "$edgeStub\MicrosoftEdge.exe" | Out-Null + # Create stub (This somehow allows uninstalling Edge) + $edgeStub = "$env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe" + New-Item $edgeStub -ItemType Directory -Force -ErrorAction Stop | Out-Null + New-Item "$edgeStub\MicrosoftEdge.exe" -ItemType File -Force -ErrorAction Stop | Out-Null # Remove edge - $uninstallRegKey = $hklm.OpenSubKey('SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft Edge') - if ($null -ne $uninstallRegKey) { + $uninstallRegKey = $hklm.OpenSubKey('SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft Edge') + if ($null -eq $uninstallRegKey) { + Write-Host "Unable to forcefully uninstall Microsoft Edge, uninstaller could not be found" -ForegroundColor Red + return $false + } + Write-Host "Running uninstaller..." $uninstallString = $uninstallRegKey.GetValue('UninstallString') + ' --force-uninstall' - Invoke-NonBlocking -ScriptBlock { + $exitCode = Invoke-NonBlocking -ScriptBlock { param($cmd) - Start-Process cmd.exe "/c $cmd" -WindowStyle Hidden -Wait + $process = Start-Process cmd.exe "/c $cmd" -WindowStyle Hidden -Wait -PassThru + return $process.ExitCode } -ArgumentList $uninstallString + if ($exitCode -ne 0) { + Write-Warning "Microsoft Edge uninstaller failed with exit code $exitCode." + return $false + } Write-Host "Removing leftover files..." + $cleanupSucceeded = $true $edgePaths = @( "$env:ProgramData\Microsoft\Windows\Start Menu\Programs\Microsoft Edge.lnk", @@ -44,22 +58,89 @@ function Invoke-ForceRemoveEdge { foreach ($path in $edgePaths) { if (Test-Path -Path $path) { - Remove-Item -Path $path -Force -Recurse -ErrorAction SilentlyContinue - Write-Host " Removed $path" -ForegroundColor DarkGray + try { + Remove-Item -Path $path -Force -Recurse -ErrorAction Stop + Write-Host " Removed $path" -ForegroundColor DarkGray + } + catch { + Write-Warning "Failed to remove Edge leftover '$path': $($_.Exception.Message)" + $cleanupSucceeded = $false + } } } Write-Host "Cleaning up registry..." + $registryCleanupSucceeded = $true - # Remove MS Edge from autostart - reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" /v "MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C" /f *>$null - reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" /v "Microsoft Edge Update" /f *>$null - reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run" /v "MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C" /f *>$null - reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run" /v "Microsoft Edge Update" /f *>$null + # Remove MS Edge from autostart. Missing values are already-clean state, + # while failures to inspect or remove an existing value are reported. + $autostartValues = @( + @{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'; Name = 'MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C' }, + @{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'; Name = 'Microsoft Edge Update' }, + @{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run'; Name = 'MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C' }, + @{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run'; Name = 'Microsoft Edge Update' } + ) + foreach ($autostartValue in $autostartValues) { + if (-not (Remove-EdgeAutostartValue -Path $autostartValue.Path -Name $autostartValue.Name)) { + $registryCleanupSucceeded = $false + } + } + + if (-not $cleanupSucceeded -or -not $registryCleanupSucceeded) { + Write-Warning "Microsoft Edge was uninstalled, but some leftover files or autostart entries could not be removed." + return $false + } Write-Host "Microsoft Edge was uninstalled" + return $true + } + catch { + Write-Warning "Failed to forcefully uninstall Microsoft Edge: $($_.Exception.Message)" + return $false + } + finally { + if ($edgeUpdateKey) { $edgeUpdateKey.Dispose() } + if ($uninstallRegKey) { $uninstallRegKey.Dispose() } + if ($hklm) { $hklm.Dispose() } + } +} + +<# + .SYNOPSIS + Removes an Edge autostart registry value when it exists. + + .OUTPUTS + System.Boolean. $true when the value is absent or removed; $false when inspection or removal fails. +#> +function Remove-EdgeAutostartValue { + param( + [Parameter(Mandatory)] + [string]$Path, + [Parameter(Mandatory)] + [string]$Name + ) + + try { + $properties = Get-ItemProperty -Path $Path -ErrorAction Stop + } + catch [System.Management.Automation.ItemNotFoundException] { + return $true + } + catch { + Write-Warning "Failed to inspect Edge autostart entry '$Path\$Name': $($_.Exception.Message)" + return $false + } + + if (-not $properties.PSObject.Properties[$Name]) { + return $true + } + + try { + Remove-ItemProperty -Path $Path -Name $Name -ErrorAction Stop + return $true } - else { - Write-Host "Unable to forcefully uninstall Microsoft Edge, uninstaller could not be found" -ForegroundColor Red + catch { + Write-Warning "Failed to remove Edge autostart entry '$Path\$Name': $($_.Exception.Message)" + return $false } } diff --git a/Scripts/AppRemoval/Remove-SelectedApps.ps1 b/Scripts/AppRemoval/Remove-SelectedApps.ps1 index ea06da82..f5e0c75b 100644 --- a/Scripts/AppRemoval/Remove-SelectedApps.ps1 +++ b/Scripts/AppRemoval/Remove-SelectedApps.ps1 @@ -18,6 +18,9 @@ .EXAMPLE Remove-SelectedApps -appsList (Generate-AppsList) + + .OUTPUTS + System.Boolean. $true when all removals can be confirmed; otherwise $false. #> function Remove-SelectedApps { param ( @@ -29,10 +32,10 @@ function Remove-SelectedApps { Write-Host "[WhatIf] Remove App Package: $app" -ForegroundColor Cyan } - Write-Host "" - return + return $true } + $failuresBefore = $script:AppRemovalFailures $targetUser = Get-TargetUserForAppRemoval $appCount = @($appsList).Count $appIndex = 0 @@ -42,7 +45,7 @@ function Remove-SelectedApps { $wingetRemovalFailures = @{} Foreach ($app in $appsList) { - if ($script:CancelRequested) { return } + if ($script:CancelRequested) { return $false } $appIndex++ @@ -53,10 +56,11 @@ function Remove-SelectedApps { Write-Host "Removing $app" if ((Get-AppRemovalMethod $app) -eq 'WinGet') { - if (-not (Remove-WinGetApp -app $app)) { + $removalSucceeded = Remove-WinGetApp -app $app + $wingetRemovedApps += $app + if (($script:Params.ContainsKey('User') -or $script:Params.ContainsKey('Sysprep')) -and -not $removalSucceeded) { $wingetRemovalFailures[$app] = $true } - $wingetRemovedApps += $app } else { if (-not (Remove-AppxApp -app $app -targetUser $targetUser)) { @@ -66,17 +70,20 @@ function Remove-SelectedApps { } if ($script:CancelRequested) { - Write-Host "" - return + return $false } # Check whether any winget-removed apps are still present, and report errors for each one. if ($wingetRemovedApps.Count -gt 0) { $postRemovalList = if ($script:WingetInstalled) { Get-WingetInstalledApps -TimeOut 10 -NonBlocking } else { $null } $edgeForceRemoveRequested = $false + $edgeForceRemoveSucceeded = $false if ($null -eq $postRemovalList) { $script:AppRemovalVerificationUnavailable = $true + foreach ($app in $wingetRemovedApps) { + $wingetRemovalFailures[$app] = $true + } } else { foreach ($app in $wingetRemovedApps) { @@ -87,8 +94,11 @@ function Remove-SelectedApps { if ($edgeIds -contains $app) { Write-Host "Unable to uninstall Microsoft Edge via WinGet" -ForegroundColor Red if (-not $edgeForceRemoveRequested) { - Request-EdgeForceRemove $edgeForceRemoveRequested = $true + $edgeForceRemoveSucceeded = Request-EdgeForceRemove + } + if ($edgeForceRemoveSucceeded) { + continue } } else { @@ -101,7 +111,7 @@ function Remove-SelectedApps { $script:AppRemovalFailures += $wingetRemovalFailures.Count - Write-Host "" + return ($script:AppRemovalFailures -eq $failuresBefore) } <# @@ -110,8 +120,12 @@ function Remove-SelectedApps { .DESCRIPTION Runs winget uninstall for a single app, with a bounded execution time. - If the User or Sysprep parameter was passed, also schedules removal for - future logins. + 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'). @@ -119,6 +133,10 @@ function Remove-SelectedApps { .PARAMETER TimeoutSeconds Maximum time to allow the foreground WinGet uninstall to run. Defaults to 120 seconds. + + .OUTPUTS + System.Boolean. $true unless the winget invocation threw a terminating error + or any required RunOnce scheduling failed; otherwise $false. #> function Remove-WinGetApp { param( @@ -131,22 +149,28 @@ function Remove-WinGetApp { return $false } - $uninstallSucceeded = $true + $uninstallCommandSucceeded = $true + $exitCode = $null try { - $uninstallSucceeded = Invoke-NonBlocking -ScriptBlock { + $uninstallResult = Invoke-NonBlocking -ScriptBlock { param($appId) - $null = & winget uninstall --accept-source-agreements --disable-interactivity --id $appId 2>&1 - return $true + $output = @(& winget uninstall --accept-source-agreements --disable-interactivity --id $appId 2>&1) + return [PSCustomObject]@{ + ExitCode = $LASTEXITCODE + Output = $output + } } -ArgumentList $app -TimeoutSeconds $TimeoutSeconds - $uninstallSucceeded = [bool]$uninstallSucceeded + 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." } catch { - $uninstallSucceeded = $false + $uninstallCommandSucceeded = $false if ($_.Exception.Message -like 'Operation timed out after *') { - Write-Error "WinGet uninstall for $app did not complete within $TimeoutSeconds seconds: $_" + Write-Verbose "WinGet uninstall for $app did not complete within $TimeoutSeconds seconds: $_" } else { - Write-Error "WinGet uninstall for $app failed: $_" + Write-Verbose "WinGet uninstall for $app failed: $_" } } @@ -160,7 +184,29 @@ function Remove-WinGetApp { $scheduleSucceeded = Set-RunOnceWingetTask -appId $app } - return ($uninstallSucceeded -and $scheduleSucceeded) + return ($uninstallCommandSucceeded -and $scheduleSucceeded) +} + +<# + .SYNOPSIS + Writes captured WinGet uninstall output to the verbose stream. + + .OUTPUTS + None. +#> +function Write-WinGetUninstallOutput { + param( + [object[]]$Output + ) + + foreach ($line in @($Output)) { + if ($null -eq $line) { continue } + + $lineText = if ($line -is [System.Management.Automation.ErrorRecord]) { $line.Exception.Message } else { $line.ToString() } + if ([string]::IsNullOrWhiteSpace($lineText)) { continue } + + Write-Verbose $lineText + } } <# @@ -277,19 +323,24 @@ function Get-AppRemovalMethod { following all winget uninstall attempts. In GUI mode, displays a warning message box; in CLI mode, prompts via Read-Host. On confirmation, performs a force-remove of the Edge package. + + .OUTPUTS + System.Boolean. $true when Edge is forcefully removed; otherwise $false. #> function Request-EdgeForceRemove { if ($script:GuiWindow) { $result = Show-MessageBox -Message 'Unable to uninstall Microsoft Edge via WinGet. Would you like to forcefully uninstall it? NOT RECOMMENDED!' -Title 'Force Uninstall Microsoft Edge?' -Button 'YesNo' -Icon 'Warning' if ($result -eq 'Yes') { Write-Host "" - Invoke-ForceRemoveEdge + return (Invoke-ForceRemoveEdge) } } elseif ($(Read-Host -Prompt "Would you like to forcefully uninstall Microsoft Edge? NOT RECOMMENDED! (y/n)") -eq 'y') { Write-Host "" - Invoke-ForceRemoveEdge + return (Invoke-ForceRemoveEdge) } + + return $false } <# diff --git a/Scripts/Features/Import-RegistryFile.ps1 b/Scripts/Features/Import-RegistryFile.ps1 index 1c17d982..b856c7f1 100644 --- a/Scripts/Features/Import-RegistryFile.ps1 +++ b/Scripts/Features/Import-RegistryFile.ps1 @@ -1,114 +1,120 @@ -# Import & execute regfile +<# + .SYNOPSIS + Imports and executes a registry file. + + .OUTPUTS + System.Boolean. $true when the registry file is applied or previewed successfully; otherwise $false. +#> function Import-RegistryFile { param ( $message, $path ) - Write-Host $message - - $usesOfflineHive = $script:Params.ContainsKey("Sysprep") -or $script:Params.ContainsKey("User") - $regFilePath = Get-RegistryFilePathForFeature -RegistryKey $path - - if (-not (Test-Path $regFilePath)) { - $errorMessage = "Unable to find registry file: $path ($regFilePath)" - $script:RegistryImportFailures++ - Write-Host "Error: $errorMessage" -ForegroundColor Red - Write-Host "" - throw $errorMessage - } + try { + Write-Host $message - $importScript = { - param($targetRegFilePath, $hiveContext) + $usesOfflineHive = $script:Params.ContainsKey("Sysprep") -or $script:Params.ContainsKey("User") + $regFilePath = Get-RegistryFilePathForFeature -RegistryKey $path - if ($script:Params.ContainsKey("WhatIf")) { - Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath - Write-Host "" - return + if (-not (Test-Path $regFilePath)) { + $errorMessage = "Unable to find registry file: $path ($regFilePath)" + Write-Host "Error: $errorMessage" -ForegroundColor Red + return $false } - # When the target user's hive is already loaded under their SID, the .reg file's - # HKEY_USERS\Default paths won't match. Use the PowerShell registry writer instead, - # which remaps Default → SID via Split-RegistryPath. - $usePowerShellFallbackOnly = $hiveContext -and [bool]$hiveContext.WasAlreadyLoaded + $importScript = { + param($targetRegFilePath, $hiveContext) - if ($usePowerShellFallbackOnly) { - Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath - Write-Host "The operation completed successfully via PowerShell registry writer." - Write-Host "" - return - } - - $regResult = Invoke-NonBlocking -ScriptBlock { - param($targetRegFilePath) - $result = @{ - Output = @() - ExitCode = 0 - Error = $null + if ($script:Params.ContainsKey("WhatIf")) { + return (Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath) } - try { - $global:LASTEXITCODE = 0 - $output = reg import $targetRegFilePath 2>&1 - $importExitCode = $LASTEXITCODE + # When the target user's hive is already loaded under their SID, the .reg file's + # HKEY_USERS\Default paths won't match. Use the PowerShell registry writer instead, + # which remaps Default → SID via Split-RegistryPath. + $usePowerShellFallbackOnly = $hiveContext -and [bool]$hiveContext.WasAlreadyLoaded - if ($output) { - $result.Output = @($output) + if ($usePowerShellFallbackOnly) { + $fallbackSucceeded = Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath + if ($fallbackSucceeded) { + Write-Host "The operation completed successfully via PowerShell registry writer." } - $result.ExitCode = $importExitCode + return $fallbackSucceeded + } - if ($importExitCode -ne 0) { - throw "Registry import failed with exit code $importExitCode for '$targetRegFilePath'" + $regResult = Invoke-NonBlocking -ScriptBlock { + param($targetRegFilePath) + $result = @{ + Output = @() + ExitCode = 0 + Error = $null } - } - catch { - $result.Error = $_.Exception.Message - $result.ExitCode = if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 1 } - } - return $result - } -ArgumentList $targetRegFilePath + try { + $global:LASTEXITCODE = 0 + $output = reg import $targetRegFilePath 2>&1 + $importExitCode = $LASTEXITCODE - $regOutput = @($regResult.Output) - $hasSuccess = ($regResult.ExitCode -eq 0) -and -not $regResult.Error + if ($output) { + $result.Output = @($output) + } + $result.ExitCode = $importExitCode - if ($regOutput) { - foreach ($line in $regOutput) { - $lineText = if ($line -is [System.Management.Automation.ErrorRecord]) { $line.Exception.Message } else { $line.ToString() } - if ($lineText -and $lineText.Length -gt 0) { - if ($hasSuccess) { - Write-Host $lineText + if ($importExitCode -ne 0) { + throw "Registry import failed with exit code $importExitCode for '$targetRegFilePath'" } - else { - Write-Host $lineText -ForegroundColor Red + } + catch { + $result.Error = $_.Exception.Message + $result.ExitCode = if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 1 } + } + + return $result + } -ArgumentList $targetRegFilePath + + $regOutput = @($regResult.Output) + $hasSuccess = ($regResult.ExitCode -eq 0) -and -not $regResult.Error + + if ($regOutput) { + foreach ($line in $regOutput) { + $lineText = if ($line -is [System.Management.Automation.ErrorRecord]) { $line.Exception.Message } else { $line.ToString() } + if ($lineText -and $lineText.Length -gt 0) { + if ($hasSuccess) { + Write-Host $lineText + } + else { + Write-Host $lineText -ForegroundColor Red + } } } } - } - if (-not $hasSuccess) { - $details = if ($regResult.Error) { $regResult.Error } else { "Exit code: $($regResult.ExitCode)" } - Write-Warning "reg import failed for '$path'. Falling back to PowerShell registry writer. Details: $details" - Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath - Write-Host "The operation completed successfully via PowerShell registry writer." - } + if (-not $hasSuccess) { + $details = if ($regResult.Error) { $regResult.Error } else { "Exit code: $($regResult.ExitCode)" } + Write-Warning "reg import failed for '$path'. Falling back to PowerShell registry writer. Details: $details" + $fallbackSucceeded = Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath + if ($fallbackSucceeded) { + Write-Host "The operation completed successfully via PowerShell registry writer." + } + return $fallbackSucceeded + } - Write-Host "" - } + return $true + } - try { if ($usesOfflineHive) { # Sysprep targets Default user, User targets the specified user. Logged-in users already have their hive mounted under HKU\. $targetUserName = if ($script:Params.ContainsKey("Sysprep")) { "Default" } else { $script:Params.Item("User") } - Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock $importScript -ArgumentObject $regFilePath -PassHiveContext + $succeeded = Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock $importScript -ArgumentObject $regFilePath -PassHiveContext } else { - & $importScript $regFilePath $null + $succeeded = & $importScript $regFilePath $null } + return [bool]$succeeded } catch { - $script:RegistryImportFailures++ Write-Host $_.Exception.Message -ForegroundColor Red - Write-Host "" + return $false } } diff --git a/Scripts/Features/Invoke-Changes.ps1 b/Scripts/Features/Invoke-Changes.ps1 index 02015b37..d9894510 100644 --- a/Scripts/Features/Invoke-Changes.ps1 +++ b/Scripts/Features/Invoke-Changes.ps1 @@ -7,7 +7,9 @@ - Registry-backed: imports the .reg file via Import-RegistryFile, then runs any post-import side effects (e.g., removing companion app packages). - Custom logic: app removal, Windows optional features, start menu - replacement, and other special-case features. + replacement, and other special-case features. Returns $true when the + feature completes successfully; otherwise writes a warning and returns + $false. #> function Invoke-FeatureApply { param( @@ -15,30 +17,32 @@ function Invoke-FeatureApply { [string]$FeatureId ) - # Resolve feature metadata from Features.json - $feature = $script:Features[$FeatureId] - $applyText = $feature.ApplyText + try { + # Resolve feature metadata from Features.json + $feature = $script:Features[$FeatureId] + $applyText = $feature.ApplyText - # ---- Registry-backed features: import .reg file, then handle side effects ---- + # ---- Registry-backed features: import .reg file, then handle additional tasks ---- if ($feature.RegistryKey) { - Import-RegistryFile "> $applyText..." $feature.RegistryKey + if (-not (Import-RegistryFile "> $applyText..." $feature.RegistryKey)) { + return $false + } - # Post-import side effects for specific features switch ($FeatureId) { 'DisableBing' { # Also remove the app package for Bing search - Remove-SelectedApps @('Microsoft.BingSearch') + return (Remove-SelectedApps @('Microsoft.BingSearch')) } 'DisableCopilot' { # Also remove the app packages for Copilot - Remove-SelectedApps @('Microsoft.Copilot', 'XP9CXNGPPJ97XX') + return (Remove-SelectedApps @('Microsoft.Copilot', 'XP9CXNGPPJ97XX')) } 'DisableTelemetry' { # Also disable telemetry scheduled tasks - Disable-TelemetryScheduledTasks + return (Disable-TelemetryScheduledTasks) } } - return + return $true } # ---- Custom features (no registry backing, or special handling required) ---- @@ -49,30 +53,25 @@ function Invoke-FeatureApply { if ($appsList.Count -eq 0) { Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow - Write-Host "" - return + return $true } Write-Host "$($appsList.Count) apps selected for removal" - Remove-SelectedApps $appsList - return + return (Remove-SelectedApps $appsList) } 'RemoveGamingApps' { $appsList = @('Microsoft.GamingApp', 'Microsoft.XboxGameOverlay', 'Microsoft.XboxGamingOverlay') Write-Host "> $applyText..." - Remove-SelectedApps $appsList - return + return (Remove-SelectedApps $appsList) } 'RemoveHPApps' { $appsList = @('AD2F1837.HPAIExperienceCenter', 'AD2F1837.HPJumpStarts', 'AD2F1837.HPPCHardwareDiagnosticsWindows', 'AD2F1837.HPPowerManager', 'AD2F1837.HPPrivacySettings', 'AD2F1837.HPSupportAssistant', 'AD2F1837.HPSureShieldAI', 'AD2F1837.HPSystemInformation', 'AD2F1837.HPQuickDrop', 'AD2F1837.HPWorkWell', 'AD2F1837.myHP', 'AD2F1837.HPDesktopSupportUtilities', 'AD2F1837.HPQuickTouch', 'AD2F1837.HPEasyClean', 'AD2F1837.HPConnectedMusic', 'AD2F1837.HPFileViewer', 'AD2F1837.HPRegistration', 'AD2F1837.HPWelcome', 'AD2F1837.HPConnectedPhotopoweredbySnapfish', 'AD2F1837.HPPrinterControl') Write-Host "> $applyText..." - Remove-SelectedApps $appsList - return + return (Remove-SelectedApps $appsList) } 'ForceRemoveEdge' { Write-Host "> $applyText..." - Invoke-ForceRemoveEdge - return + return (Invoke-ForceRemoveEdge) } 'DisableWidgets' { Write-Host "> $applyText..." @@ -81,76 +80,75 @@ function Invoke-FeatureApply { Get-Process *Widget* -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue } - Remove-SelectedApps @('Microsoft.StartExperiencesApp','MicrosoftWindows.Client.WebExperience','Microsoft.WidgetsPlatformRuntime') - return + return (Remove-SelectedApps @('Microsoft.StartExperiencesApp','MicrosoftWindows.Client.WebExperience','Microsoft.WidgetsPlatformRuntime')) } 'EnableWindowsSandbox' { Write-Host "> $applyText..." - Enable-WindowsFeature "Containers-DisposableClientVM" - Write-Host "" - return + return (Enable-WindowsFeature "Containers-DisposableClientVM") } 'EnableWindowsSubsystemForLinux' { Write-Host "> $applyText..." - Enable-WindowsFeature "VirtualMachinePlatform" - Enable-WindowsFeature "Microsoft-Windows-Subsystem-Linux" - Write-Host "" - return + if (-not (Enable-WindowsFeature "VirtualMachinePlatform")) { return $false } + return (Enable-WindowsFeature "Microsoft-Windows-Subsystem-Linux") } 'ClearStart' { Write-Host "> $applyText for user $(Get-UserName)..." $startMenuBinFile = Get-StartMenuBinPathForUser -UserName (Get-UserName) if (-not [string]::IsNullOrWhiteSpace($startMenuBinFile)) { - Replace-StartMenu -startMenuBinFile $startMenuBinFile + return (Replace-StartMenu -startMenuBinFile $startMenuBinFile) } - Write-Host "" - return + Write-Warning "Unable to apply '$applyText': the Start menu path for user $(Get-UserName) could not be resolved." + return $false } 'ReplaceStart' { Write-Host "> $applyText for user $(Get-UserName)..." $startMenuBinFile = Get-StartMenuBinPathForUser -UserName (Get-UserName) if (-not [string]::IsNullOrWhiteSpace($startMenuBinFile)) { - Replace-StartMenu -startMenuBinFile $startMenuBinFile -startMenuTemplate $script:Params.Item("ReplaceStart") + return (Replace-StartMenu -startMenuBinFile $startMenuBinFile -startMenuTemplate $script:Params.Item("ReplaceStart")) } - Write-Host "" - return + Write-Warning "Unable to apply '$applyText': the Start menu path for user $(Get-UserName) could not be resolved." + return $false } 'ClearStartAllUsers' { - Replace-StartMenuForAllUsers - return + return (Replace-StartMenuForAllUsers) } 'ReplaceStartAllUsers' { - Replace-StartMenuForAllUsers -startMenuTemplate $script:Params.Item("ReplaceStartAllUsers") - return + return (Replace-StartMenuForAllUsers -startMenuTemplate $script:Params.Item("ReplaceStartAllUsers")) } 'DisableStoreSearchSuggestions' { if ($script:Params.ContainsKey("Sysprep")) { Write-Host "> Disabling Microsoft Store search suggestions in the start menu for all users..." - Set-StoreSearchSuggestionsDisabledForAllUsers - Write-Host "" - return + return (Set-StoreSearchSuggestionsDisabledForAllUsers) } Write-Host "> Disabling Microsoft Store search suggestions for user $(Get-UserName)..." $storeDb = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName) if ($storeDb) { - Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase $storeDb + return (Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase $storeDb) } - Write-Host "" - return + Write-Warning "Unable to disable Microsoft Store search suggestions because the Store database for user $(Get-UserName) could not be resolved." + return $false } } + } + catch { + Write-Warning "Failed to apply '$applyText': $($_.Exception.Message)" + return $false + } + + Write-Warning "Unknown feature '$FeatureId' could not be applied." + return $false } <# .SYNOPSIS - Undoes a single feature that has no RegistryUndoKey. + Undoes a single feature. .DESCRIPTION - Handles undo for features that require custom logic rather than a simple - .reg file import. Features with a RegistryUndoKey are handled directly - via Import-RegistryFile in Invoke-UndoFeatures. + Handles registry-backed undo imports and custom undo logic. Returns + $true when the requested undo succeeds; otherwise writes a warning and + returns $false. #> function Invoke-FeatureUndo { param( @@ -159,43 +157,65 @@ function Invoke-FeatureUndo { ) $feature = if ($script:Features.ContainsKey($FeatureId)) { $script:Features[$FeatureId] } else { $null } + if (-not $feature) { + Write-Warning "Unknown feature '$FeatureId' could not be undone." + return $false + } - switch ($FeatureId) { - 'DisableStoreSearchSuggestions' { - if ($script:Params.ContainsKey('Sysprep')) { - Write-Host "> Re-enabling Microsoft Store search suggestions in the start menu for all users..." - Set-StoreSearchSuggestionsEnabledForAllUsers - Write-Host "" - return + $undoText = if ($feature.ApplyUndoText) { $feature.ApplyUndoText } elseif ($feature.UndoLabel) { $feature.UndoLabel } else { $FeatureId } + + try { + # ---- Registry-backed features: import undo data, then handle additional tasks ---- + if ($feature.RegistryUndoKey) { + if (-not (Import-RegistryFile "> $undoText" (Resolve-UndoRegFilePath $feature.RegistryUndoKey))) { + return $false } - Write-Host "> Re-enabling Microsoft Store search suggestions for user $(Get-UserName)..." - $storeDb = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName) - if ($storeDb) { - Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase $storeDb + switch ($FeatureId) { + 'DisableTelemetry' { + # Also re-enable telemetry scheduled tasks. + return (Enable-TelemetryScheduledTasks) + } } - Write-Host "" - return - } - 'EnableWindowsSandbox' { - Write-Host "> $($feature.ApplyUndoText)..." - Disable-WindowsFeature 'Containers-DisposableClientVM' - Write-Host "" - return - } - 'EnableWindowsSubsystemForLinux' { - Write-Host "> $($feature.ApplyUndoText)..." - Disable-WindowsFeature 'Microsoft-Windows-Subsystem-Linux' - Disable-WindowsFeature 'VirtualMachinePlatform' - Write-Host "" - return + + return $true } - 'DisableTelemetry' { - # Also re-enable telemetry scheduled tasks - Enable-TelemetryScheduledTasks - return + + # ---- Custom undo features (no registry backing) ---- + switch ($FeatureId) { + 'DisableStoreSearchSuggestions' { + if ($script:Params.ContainsKey('Sysprep')) { + Write-Host "> Re-enabling Microsoft Store search suggestions in the start menu for all users..." + return (Set-StoreSearchSuggestionsEnabledForAllUsers) + } + + Write-Host "> Re-enabling Microsoft Store search suggestions for user $(Get-UserName)..." + $storeDb = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName) + if ($storeDb) { + return (Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase $storeDb) + } + Write-Warning "Unable to re-enable Microsoft Store search suggestions because the Store database for user $(Get-UserName) could not be resolved." + return $false + } + 'EnableWindowsSandbox' { + Write-Host "> $undoText..." + return (Disable-WindowsFeature 'Containers-DisposableClientVM') + } + 'EnableWindowsSubsystemForLinux' { + Write-Host "> $undoText..." + if (-not (Disable-WindowsFeature 'Microsoft-Windows-Subsystem-Linux')) { return $false } + return (Disable-WindowsFeature 'VirtualMachinePlatform') + } } } + + catch { + Write-Warning "Failed to undo '$undoText': $($_.Exception.Message)" + return $false + } + + Write-Warning "Feature '$FeatureId' does not support undo." + return $false } @@ -251,7 +271,13 @@ function Invoke-ApplyFeatures { & $script:ApplyProgressCallback $step $TotalSteps $displayName } - Invoke-FeatureApply -FeatureId $featureId + # Compare app-removal failure counts so a feature that only fails due to + # app removal isn't also double-reported as a feature failure. + $appRemovalFailuresBefore = $script:AppRemovalFailures + if ((-not (Invoke-FeatureApply -FeatureId $featureId)) -and ($script:AppRemovalFailures -eq $appRemovalFailuresBefore)) { + $script:FeatureFailures++ + } + Write-Host "" $step++ } } @@ -262,9 +288,8 @@ function Invoke-ApplyFeatures { Undoes a list of features, reporting progress for each. .DESCRIPTION - Iterates through the provided feature IDs. Features with a RegistryUndoKey - are handled by importing the undo .reg file; all others delegate to - Invoke-FeatureUndo for custom undo logic. + Iterates through the provided feature IDs and delegates each to + Invoke-FeatureUndo, which handles registry-backed and custom undo logic. This is called by Invoke-AllChanges during the undo phase. #> function Invoke-UndoFeatures { @@ -291,11 +316,10 @@ function Invoke-UndoFeatures { & $script:ApplyProgressCallback $step $TotalSteps $undoText } - if ($f -and $f.RegistryUndoKey) { - Import-RegistryFile "> $undoText" (Resolve-UndoRegFilePath $f.RegistryUndoKey) + if (-not (Invoke-FeatureUndo -FeatureId $featureId)) { + $script:FeatureFailures++ } - - Invoke-FeatureUndo -FeatureId $featureId + Write-Host "" $step++ } } @@ -324,8 +348,8 @@ function Invoke-AllChanges { throw "Win11Debloat is running as the SYSTEM account. Use the '-User' or '-Sysprep' parameter to target a specific user." } - $script:RegistryImportFailures = 0 $script:AppRemovalFailures = 0 + $script:FeatureFailures = 0 $script:AppRemovalVerificationUnavailable = $false # ---- Gather work items ---- @@ -405,7 +429,11 @@ function Invoke-AllChanges { } else { Write-Host "> Creating a system restore point..." - Invoke-SystemRestorePoint + $restorePointSucceeded = Invoke-SystemRestorePoint + if (-not $restorePointSucceeded) { + if ($script:CancelRequested) { return } + $script:FeatureFailures++ + } Write-Host "" } } @@ -429,19 +457,20 @@ function Invoke-AllChanges { } # ================================================================ - # Final: Report registry import and app removal failures + # Final: Report failures # ================================================================ - if ($script:RegistryImportFailures -gt 0) { + if ($script:AppRemovalFailures -gt 0) { Write-Host "" - Write-Warning "$($script:RegistryImportFailures) registry import change(s) failed. See output above for details." + Write-Warning "$($script:AppRemovalFailures) app removal(s) failed. See output above for details." } - if ($script:AppRemovalFailures -gt 0) { + if ($script:FeatureFailures -gt 0) { Write-Host "" - Write-Warning "$($script:AppRemovalFailures) app removal(s) failed. See output above for details." + Write-Warning "$($script:FeatureFailures) feature change(s) failed. See output above for details." } if ($script:AppRemovalVerificationUnavailable) { + Write-Host "" Write-Warning "Unable to verify if all apps were uninstalled successfully." } diff --git a/Scripts/Features/Invoke-SystemRestorePoint.ps1 b/Scripts/Features/Invoke-SystemRestorePoint.ps1 index 7c0d72d7..caa46b3a 100644 --- a/Scripts/Features/Invoke-SystemRestorePoint.ps1 +++ b/Scripts/Features/Invoke-SystemRestorePoint.ps1 @@ -1,10 +1,25 @@ +<# + .SYNOPSIS + Creates a system restore point. + + .OUTPUTS + System.Boolean. $true when a restore point is created; otherwise $false. +#> function Invoke-SystemRestorePoint { - $SysRestore = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "RPSessionInterval" $failed = $false + $isSilent = ($script:Params -and $script:Params.ContainsKey('Silent')) -or $script:Silent - if ($SysRestore.RPSessionInterval -eq 0) { + try { + $SysRestore = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "RPSessionInterval" -ErrorAction Stop + } + catch { + Write-Host "Error: Unable to determine whether System Restore is enabled: $($_.Exception.Message)" -ForegroundColor Red + $failed = $true + } + + if (-not $failed -and $SysRestore.RPSessionInterval -eq 0) { # In GUI mode, skip the prompt and just try to enable it - if ($script:GuiWindow -or $Silent -or $( Read-Host -Prompt "System restore is disabled, would you like to enable it and create a restore point? (y/n)") -eq 'y') { + if ($script:GuiWindow -or $isSilent -or $( Read-Host -Prompt "System restore is disabled, would you like to enable it and create a restore point? (y/n)") -eq 'y') { try { $enableResult = Invoke-NonBlocking -TimeoutSeconds 90 -ScriptBlock { try { @@ -26,7 +41,6 @@ function Invoke-SystemRestorePoint { } } else { - Write-Host "" $failed = $true } } @@ -79,17 +93,20 @@ function Invoke-SystemRestorePoint { if ($result -ne "Yes") { $script:CancelRequested = $true - return + return $false } } - elseif (-not $Silent) { + elseif (-not $isSilent) { Write-Host "Failed to create a system restore point. Do you want to continue without a restore point? (y/n)" -ForegroundColor Yellow if ($( Read-Host ) -ne 'y') { $script:CancelRequested = $true - return + return $false } } Write-Host "Warning: Continuing without restore point" -ForegroundColor Yellow + return $false } + + return $true } diff --git a/Scripts/Features/Replace-StartMenu.ps1 b/Scripts/Features/Replace-StartMenu.ps1 index 3970d9e7..bbc54dda 100644 --- a/Scripts/Features/Replace-StartMenu.ps1 +++ b/Scripts/Features/Replace-StartMenu.ps1 @@ -18,6 +18,9 @@ .EXAMPLE Replace-StartMenuForAllUsers -startMenuTemplate "C:\CustomLayout.bin" + + .OUTPUTS + System.Boolean. $true when all resolved profiles are updated or the change is previewed; otherwise $false. #> function Replace-StartMenuForAllUsers { param ( @@ -29,8 +32,7 @@ function Replace-StartMenuForAllUsers { # Check if template bin file exists if (-not (Test-Path $startMenuTemplate)) { Write-Host "Error: Unable to clear start menu, start2.bin file missing from script folder" -ForegroundColor Red - Write-Host "" - return + return $false } # Get path to start menu file for all users @@ -38,8 +40,11 @@ function Replace-StartMenuForAllUsers { $usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue # Go through all users and replace the start menu file + $success = $true ForEach ($startMenuPath in $usersStartMenuPaths) { - Replace-StartMenu -startMenuBinFile "$($startMenuPath.Fullname)\start2.bin" -startMenuTemplate $startMenuTemplate + if (-not (Replace-StartMenu -startMenuBinFile "$($startMenuPath.Fullname)\start2.bin" -startMenuTemplate $startMenuTemplate)) { + $success = $false + } } # Also replace the start menu file for the default user profile @@ -47,19 +52,29 @@ function Replace-StartMenuForAllUsers { if ($script:Params.ContainsKey("WhatIf")) { Write-Host "[WhatIf] Replace Start Menu for Default user profile with template $startMenuTemplate" -ForegroundColor Cyan - return + return $true } # Create folder if it doesn't exist if (-not (Test-Path $defaultStartMenuPath)) { - new-item $defaultStartMenuPath -ItemType Directory -Force | Out-Null - Write-Host "Created LocalState folder for default user profile" + try { + New-Item $defaultStartMenuPath -ItemType Directory -Force -ErrorAction Stop | Out-Null + Write-Host "Created LocalState folder for default user profile" + } + catch { + Write-Warning "Failed to create the Default profile Start Menu directory: $($_.Exception.Message)" + return $false + } } # Copy template to default profile - Replace-StartMenu -startMenuBinFile "$($defaultStartMenuPath)\start2.bin" -startMenuTemplate $startMenuTemplate - Write-Host "Replaced start menu for the default user profile" - Write-Host "" + if (-not (Replace-StartMenu -startMenuBinFile "$($defaultStartMenuPath)\start2.bin" -startMenuTemplate $startMenuTemplate)) { + $success = $false + } + else { + Write-Host "Replaced start menu for the default user profile" + } + return $success } @@ -87,6 +102,9 @@ function Replace-StartMenuForAllUsers { .EXAMPLE Replace-StartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -startMenuTemplate "C:\CustomLayout.bin" + + .OUTPUTS + System.Boolean. $true when the template is valid and copied, or the change is previewed; otherwise $false. #> function Replace-StartMenu { param ( @@ -98,19 +116,19 @@ function Replace-StartMenu { # Check if template bin file exists if (-not (Test-Path $startMenuTemplate)) { Write-Host "Error: Unable to replace start menu, template file not found" -ForegroundColor Red - return + return $false } if ([IO.Path]::GetExtension($startMenuTemplate) -ne ".bin") { Write-Host "Error: Unable to replace start menu, template file is not a valid .bin file" -ForegroundColor Red - return + return $false } $userName = Get-StartMenuUserNameFromPath -StartMenuBinFile $startMenuBinFile if ($script:Params.ContainsKey("WhatIf")) { Write-Host "[WhatIf] Replace Start Menu for user $userName with template $startMenuTemplate" -ForegroundColor Cyan - return + return $true } $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' @@ -118,20 +136,27 @@ function Replace-StartMenu { $startMenuDir = Split-Path $startMenuBinFile -Parent $backupBinFile = Join-Path $startMenuDir $backupFileName - if (Test-Path $startMenuBinFile) { - # Backup current start menu file - Copy-Item -Path $startMenuBinFile -Destination $backupBinFile -Force - Write-Verbose "Start menu backup for user $userName saved to $backupFileName" + try { + if (Test-Path $startMenuBinFile) { + # Backup current start menu file + Copy-Item -Path $startMenuBinFile -Destination $backupBinFile -Force -ErrorAction Stop + Write-Verbose "Start menu backup for user $userName saved to $backupFileName" + } + else { + Write-Host "Unable to find original start2.bin file for user $userName, no backup was created for this user" -ForegroundColor Yellow + New-Item -ItemType File -Path $startMenuBinFile -Force -ErrorAction Stop | Out-Null + } + + # Copy template file + Copy-Item -Path $startMenuTemplate -Destination $startMenuBinFile -Force -ErrorAction Stop } - else { - Write-Host "Unable to find original start2.bin file for user $userName, no backup was created for this user" -ForegroundColor Yellow - New-Item -ItemType File -Path $startMenuBinFile -Force + catch { + Write-Warning "Failed to replace Start Menu for user ${userName}: $($_.Exception.Message)" + return $false } - # Copy template file - Copy-Item -Path $startMenuTemplate -Destination $startMenuBinFile -Force - Write-Host "Replaced start menu for user $userName" + return $true } <# @@ -447,4 +472,4 @@ function Restore-StartMenuForAllUsers { } return $results -} \ No newline at end of file +} diff --git a/Scripts/Features/Set-StoreSearchSuggestions.ps1 b/Scripts/Features/Set-StoreSearchSuggestions.ps1 index 85b3959f..0dd6b131 100644 --- a/Scripts/Features/Set-StoreSearchSuggestions.ps1 +++ b/Scripts/Features/Set-StoreSearchSuggestions.ps1 @@ -10,22 +10,41 @@ .EXAMPLE DisableStoreSearchSuggestionsForAllUsers + + .OUTPUTS + System.Boolean. $true when a profile is processed and all ACL changes succeed; otherwise $false. #> function Set-StoreSearchSuggestionsDisabledForAllUsers { + $success = $true + $processedProfiles = 0 + # Get path to Store app database for all users $userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages" $usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue # Go through all users and disable start search suggestions foreach ($storeDbPath in $usersStoreDbPaths) { - Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db") + $processedProfiles++ + if (-not (Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"))) { + $success = $false + } } # Also disable start search suggestions for the default user profile $defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -UserName "Default" if ($defaultStoreDbPath) { - Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase $defaultStoreDbPath + $processedProfiles++ + if (-not (Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase $defaultStoreDbPath)) { + $success = $false + } } + + if ($processedProfiles -eq 0) { + Write-Warning 'Unable to disable Microsoft Store search suggestions because no target user profiles could be resolved.' + return $false + } + + return $success } @@ -44,6 +63,9 @@ function Set-StoreSearchSuggestionsDisabledForAllUsers { .EXAMPLE DisableStoreSearchSuggestions -StoreAppsDatabase "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" + + .OUTPUTS + System.Boolean. $true when the database ACL is restricted or previewed; otherwise $false. #> function Set-StoreSearchSuggestionsDisabled { param ( @@ -56,24 +78,22 @@ function Set-StoreSearchSuggestionsDisabled { if ($script:Params.ContainsKey("WhatIf")) { Write-Host "[WhatIf] Disable Microsoft Store search suggestions for user $userName by restricting access to ${StoreAppsDatabase}" -ForegroundColor Cyan - return + return $true } - # This file doesn't exist in EEA (No Store app suggestions). - if (-not (Test-Path -Path $StoreAppsDatabase)) - { - Write-Host "Unable to find Store app database for user $userName, creating it now to prevent Windows from creating it later..." -ForegroundColor Yellow + try { + # This file doesn't exist in EEA (No Store app suggestions). + if (-not (Test-Path -Path $StoreAppsDatabase)) { + Write-Host "Unable to find Store app database for user $userName, creating it now to prevent Windows from creating it later..." -ForegroundColor Yellow - $storeDbDir = Split-Path -Path $StoreAppsDatabase -Parent + $storeDbDir = Split-Path -Path $StoreAppsDatabase -Parent + if (-not (Test-Path -Path $storeDbDir)) { + New-Item -Path $storeDbDir -ItemType Directory -Force -ErrorAction Stop | Out-Null + } - if (-not (Test-Path -Path $storeDbDir)) { - New-Item -Path $storeDbDir -ItemType Directory -Force | Out-Null + New-Item -Path $StoreAppsDatabase -ItemType File -Force -ErrorAction Stop | Out-Null } - New-Item -Path $StoreAppsDatabase -ItemType File -Force | Out-Null - } - - try { $AccountSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group $Acl = Get-Acl -Path $StoreAppsDatabase -ErrorAction Stop $Ace = [System.Security.AccessControl.FileSystemAccessRule]::new($AccountSid, 'FullControl', 'Deny') @@ -82,10 +102,11 @@ function Set-StoreSearchSuggestionsDisabled { } catch { Write-Warning "Failed to restrict ACL for store database '$StoreAppsDatabase': $($_.Exception.Message)" - return + return $false } Write-Host "Disabled Microsoft Store search suggestions for user $userName" + return $true } <# @@ -100,22 +121,41 @@ function Set-StoreSearchSuggestionsDisabled { .EXAMPLE EnableStoreSearchSuggestionsForAllUsers + + .OUTPUTS + System.Boolean. $true when a profile is processed and all ACL changes succeed; otherwise $false. #> function Set-StoreSearchSuggestionsEnabledForAllUsers { + $success = $true + $processedProfiles = 0 + # Get path to Store app database for all users $userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages" $usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue # Go through all users and re-enable start search suggestions foreach ($storeDbPath in $usersStoreDbPaths) { - Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db") + $processedProfiles++ + if (-not (Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"))) { + $success = $false + } } # Also re-enable for the default user profile $defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -UserName "Default" if ($defaultStoreDbPath) { - Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase $defaultStoreDbPath + $processedProfiles++ + if (-not (Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase $defaultStoreDbPath)) { + $success = $false + } } + + if ($processedProfiles -eq 0) { + Write-Warning 'Unable to re-enable Microsoft Store search suggestions because no target user profiles could be resolved.' + return $false + } + + return $success } <# @@ -133,6 +173,9 @@ function Set-StoreSearchSuggestionsEnabledForAllUsers { .EXAMPLE EnableStoreSearchSuggestions -StoreAppsDatabase "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" + + .OUTPUTS + System.Boolean. $true when the deny ACL is removed, the database is absent, or the change is previewed; otherwise $false. #> function Set-StoreSearchSuggestionsEnabled { param ( @@ -145,23 +188,31 @@ function Set-StoreSearchSuggestionsEnabled { if ($script:Params.ContainsKey("WhatIf")) { Write-Host "[WhatIf] Re-enable Microsoft Store search suggestions for user $userName by restoring access to ${StoreAppsDatabase}" -ForegroundColor Cyan - return + return $true } if (-not (Test-Path -Path $StoreAppsDatabase)) { Write-Host "Store app database not found for user $userName, nothing to undo" - return + return $true } # Ensure we can modify/delete the file even if restrictive ACLs were set. $global:LASTEXITCODE = 0 takeown /F "$StoreAppsDatabase" /A | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to take ownership of store database '$StoreAppsDatabase' while undoing Microsoft Store search suggestions. Exit code: $LASTEXITCODE" + return $false + } icacls "$StoreAppsDatabase" /grant *S-1-5-32-544:F /C | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to grant Administrators access to store database '$StoreAppsDatabase' while undoing Microsoft Store search suggestions. Exit code: $LASTEXITCODE" + return $false + } $everyoneSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group try { - $acl = Get-Acl -Path $StoreAppsDatabase + $acl = Get-Acl -Path $StoreAppsDatabase -ErrorAction Stop $denyRules = @( $acl.Access | Where-Object { if ($_.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Deny) { return $false } @@ -179,7 +230,7 @@ function Set-StoreSearchSuggestionsEnabled { $null = $acl.RemoveAccessRuleSpecific($denyRule) } - Set-Acl -Path $StoreAppsDatabase -AclObject $acl | Out-Null + Set-Acl -Path $StoreAppsDatabase -AclObject $acl -ErrorAction Stop | Out-Null } catch { Write-Warning "Failed to normalize ACL for store database '$StoreAppsDatabase': $($_.Exception.Message)" @@ -188,9 +239,11 @@ function Set-StoreSearchSuggestionsEnabled { try { Remove-Item -Path $StoreAppsDatabase -Force -ErrorAction Stop Write-Host "Re-enabled Microsoft Store search suggestions for user $userName" + return $true } catch { - throw "Failed to remove '$StoreAppsDatabase' while undoing Microsoft Store search suggestions for user $userName. $($_.Exception.Message)" + Write-Warning "Failed to remove '$StoreAppsDatabase' while undoing Microsoft Store search suggestions for user $userName. $($_.Exception.Message)" + return $false } } diff --git a/Scripts/Features/Telemetry-ScheduledTasks.ps1 b/Scripts/Features/Telemetry-ScheduledTasks.ps1 index 41b9eed6..e7dd1005 100644 --- a/Scripts/Features/Telemetry-ScheduledTasks.ps1 +++ b/Scripts/Features/Telemetry-ScheduledTasks.ps1 @@ -34,47 +34,67 @@ function Get-TelemetryScheduledTasks { .EXAMPLE Disable-TelemetryScheduledTasks + + .OUTPUTS + System.Boolean. $true when every task is disabled, absent, already disabled, or previewed; otherwise $false. #> function Disable-TelemetryScheduledTasks { Write-Host "> Disabling telemetry scheduled tasks..." $tasks = Get-TelemetryScheduledTasks + $success = $true foreach ($task in $tasks) { - if ($script:CancelRequested) { return } + if ($script:CancelRequested) { return $false } if ($script:Params.ContainsKey("WhatIf")) { Write-Host "[WhatIf] Disable Scheduled Task: $($task.Path)$($task.Name)" -ForegroundColor Cyan continue } - $result = Invoke-NonBlocking -ScriptBlock { - param($path, $name) - Import-Module ScheduledTasks -ErrorAction SilentlyContinue - $taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction SilentlyContinue - if (-not $taskObj) { - return @{ Success = $true; Status = 'NotFound' } - } - if ($taskObj.State -ne 'Disabled') { + try { + $result = Invoke-NonBlocking -ScriptBlock { + param($path, $name) try { - Disable-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction Stop | Out-Null - return @{ Success = $true; Status = 'Disabled' } + Import-Module ScheduledTasks -ErrorAction Stop + $taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction Stop } catch { + if ($_.Exception -isnot [System.Management.Automation.CommandNotFoundException] -and $_.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::ObjectNotFound) { + return @{ Success = $true; Status = 'NotFound' } + } return @{ Success = $false; Status = 'Error'; Error = $_.Exception.Message } } - } - return @{ Success = $true; Status = 'AlreadyDisabled' } - } -ArgumentList @($task.Path, $task.Name) + if (-not $taskObj) { + return @{ Success = $true; Status = 'NotFound' } + } + if ($taskObj.State -ne 'Disabled') { + try { + Disable-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction Stop | Out-Null + return @{ Success = $true; Status = 'Disabled' } + } + catch { + return @{ Success = $false; Status = 'Error'; Error = $_.Exception.Message } + } + } + return @{ Success = $true; Status = 'AlreadyDisabled' } + } -ArgumentList @($task.Path, $task.Name) + } + catch { + Write-Warning "Failed to disable Scheduled Task: $($task.Path)$($task.Name) - $($_.Exception.Message)" + $success = $false + continue + } switch ($result.Status) { 'Disabled' { Write-Host "Disabled Scheduled Task: $($task.Path)$($task.Name)" } 'AlreadyDisabled' { Write-Host "Scheduled Task $($task.Path)$($task.Name) is already disabled" -ForegroundColor DarkGray } 'NotFound' { Write-Host "Scheduled Task $($task.Path)$($task.Name) not found" -ForegroundColor DarkGray } - 'Error' { Write-Host "Failed to disable Scheduled Task: $($task.Path)$($task.Name) - $($result.Error)" -ForegroundColor Yellow } + 'Error' { Write-Host "Failed to disable Scheduled Task: $($task.Path)$($task.Name) - $($result.Error)" -ForegroundColor Yellow; $success = $false } + default { Write-Warning "Unable to determine the result of disabling Scheduled Task: $($task.Path)$($task.Name)."; $success = $false } } } - Write-Host "" + return $success } <# @@ -88,45 +108,65 @@ function Disable-TelemetryScheduledTasks { .EXAMPLE Enable-TelemetryScheduledTasks + + .OUTPUTS + System.Boolean. $true when every task is enabled, absent, already enabled, or previewed; otherwise $false. #> function Enable-TelemetryScheduledTasks { Write-Host "> Enabling telemetry scheduled tasks..." $tasks = Get-TelemetryScheduledTasks + $success = $true foreach ($task in $tasks) { - if ($script:CancelRequested) { return } + if ($script:CancelRequested) { return $false } if ($script:Params.ContainsKey("WhatIf")) { Write-Host "[WhatIf] Enable Scheduled Task: $($task.Path)$($task.Name)" -ForegroundColor Cyan continue } - $result = Invoke-NonBlocking -ScriptBlock { - param($path, $name) - Import-Module ScheduledTasks -ErrorAction SilentlyContinue - $taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction SilentlyContinue - if (-not $taskObj) { - return @{ Success = $true; Status = 'NotFound' } - } - if ($taskObj.State -eq 'Disabled') { + try { + $result = Invoke-NonBlocking -ScriptBlock { + param($path, $name) try { - Enable-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction Stop | Out-Null - return @{ Success = $true; Status = 'Enabled' } + Import-Module ScheduledTasks -ErrorAction Stop + $taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction Stop } catch { + if ($_.Exception -isnot [System.Management.Automation.CommandNotFoundException] -and $_.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::ObjectNotFound) { + return @{ Success = $true; Status = 'NotFound' } + } return @{ Success = $false; Status = 'Error'; Error = $_.Exception.Message } } - } + if (-not $taskObj) { + return @{ Success = $true; Status = 'NotFound' } + } + if ($taskObj.State -eq 'Disabled') { + try { + Enable-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction Stop | Out-Null + return @{ Success = $true; Status = 'Enabled' } + } + catch { + return @{ Success = $false; Status = 'Error'; Error = $_.Exception.Message } + } + } return @{ Success = $true; Status = 'AlreadyEnabled' } - } -ArgumentList @($task.Path, $task.Name) + } -ArgumentList @($task.Path, $task.Name) + } + catch { + Write-Warning "Failed to enable Scheduled Task: $($task.Path)$($task.Name) - $($_.Exception.Message)" + $success = $false + continue + } switch ($result.Status) { 'Enabled' { Write-Host "Enabled Scheduled Task: $($task.Path)$($task.Name)" } 'AlreadyEnabled' { Write-Host "Scheduled Task $($task.Path)$($task.Name) is already enabled." -ForegroundColor DarkGray } 'NotFound' { Write-Host "Scheduled Task $($task.Path)$($task.Name) not found." -ForegroundColor DarkGray } - 'Error' { Write-Host "Failed to enable Scheduled Task: $($task.Path)$($task.Name) - $($result.Error)" -ForegroundColor Yellow } + 'Error' { Write-Host "Failed to enable Scheduled Task: $($task.Path)$($task.Name) - $($result.Error)" -ForegroundColor Yellow; $success = $false } + default { Write-Warning "Unable to determine the result of enabling Scheduled Task: $($task.Path)$($task.Name)."; $success = $false } } } - Write-Host "" + return $success } diff --git a/Scripts/Features/Windows-OptionalFeatures.ps1 b/Scripts/Features/Windows-OptionalFeatures.ps1 index e284f79e..9ccc9bed 100644 --- a/Scripts/Features/Windows-OptionalFeatures.ps1 +++ b/Scripts/Features/Windows-OptionalFeatures.ps1 @@ -1,4 +1,10 @@ -# Enables a Windows optional feature and pipes its output to the console +<# + .SYNOPSIS + Enables a Windows optional feature and pipes its output to the console. + + .OUTPUTS + System.Boolean. $true when enabling succeeds or is previewed; otherwise $false. +#> function Enable-WindowsFeature { param ( [string]$FeatureName @@ -6,22 +12,51 @@ function Enable-WindowsFeature { if ($script:Params.ContainsKey("WhatIf")) { Write-Host "[WhatIf] Enable Windows feature: $FeatureName" -ForegroundColor Cyan - Write-Host "" - return + return $true } - $result = Invoke-NonBlocking -ScriptBlock { - param($name) - Enable-WindowsOptionalFeature -Online -FeatureName $name -All -NoRestart - } -ArgumentList $FeatureName + try { + $result = Invoke-NonBlocking -ScriptBlock { + param($name) + try { + $output = Enable-WindowsOptionalFeature -Online -FeatureName $name -All -NoRestart -ErrorAction Stop + return [PSCustomObject]@{ + Success = $true + Output = if ($output) { ($output | Out-String).Trim() } else { $null } + Error = $null + } + } + catch { + return [PSCustomObject]@{ + Success = $false + Output = $null + Error = $_.Exception.Message + } + } + } -ArgumentList $FeatureName + } + catch { + Write-Warning "Failed to enable Windows feature '$FeatureName': $($_.Exception.Message)" + return $false + } - $dismResult = @($result) | Where-Object { $_ -is [Microsoft.Dism.Commands.ImageObject] } - if ($dismResult) { - Write-Host ($dismResult | Out-String).Trim() + if (-not $result -or -not $result.Success) { + $details = if ($result -and $result.Error) { ": $($result.Error)" } else { '' } + Write-Warning "Failed to enable Windows feature '$FeatureName'$details" + return $false } + + if ($result.Output) { Write-Host $result.Output } + return $true } -# Disables a Windows optional feature and pipes its output to the console +<# + .SYNOPSIS + Disables a Windows optional feature and pipes its output to the console. + + .OUTPUTS + System.Boolean. $true when disabling succeeds or is previewed; otherwise $false. +#> function Disable-WindowsFeature { param ( [string]$FeatureName @@ -29,19 +64,42 @@ function Disable-WindowsFeature { if ($script:Params.ContainsKey("WhatIf")) { Write-Host "[WhatIf] Disable Windows feature: $FeatureName" -ForegroundColor Cyan - Write-Host "" - return + return $true } - $result = Invoke-NonBlocking -ScriptBlock { - param($name) - Disable-WindowsOptionalFeature -Online -FeatureName $name -NoRestart - } -ArgumentList $FeatureName + try { + $result = Invoke-NonBlocking -ScriptBlock { + param($name) + try { + $output = Disable-WindowsOptionalFeature -Online -FeatureName $name -NoRestart -ErrorAction Stop + return [PSCustomObject]@{ + Success = $true + Output = if ($output) { ($output | Out-String).Trim() } else { $null } + Error = $null + } + } + catch { + return [PSCustomObject]@{ + Success = $false + Output = $null + Error = $_.Exception.Message + } + } + } -ArgumentList $FeatureName + } + catch { + Write-Warning "Failed to disable Windows feature '$FeatureName': $($_.Exception.Message)" + return $false + } - $dismResult = @($result) | Where-Object { $_ -is [Microsoft.Dism.Commands.ImageObject] } - if ($dismResult) { - Write-Host ($dismResult | Out-String).Trim() + if (-not $result -or -not $result.Success) { + $details = if ($result -and $result.Error) { ": $($result.Error)" } else { '' } + Write-Warning "Failed to disable Windows feature '$FeatureName'$details" + return $false } + + if ($result.Output) { Write-Host $result.Output } + return $true } function Test-WindowsOptionalFeatureEnabled { @@ -58,4 +116,4 @@ function Test-WindowsOptionalFeatureEnabled { } return ($feature.State -eq 'Enabled') -} \ No newline at end of file +} diff --git a/Scripts/GUI/MainWindow-AppSelection.ps1 b/Scripts/GUI/MainWindow-AppSelection.ps1 index 37490f8f..16baff04 100644 --- a/Scripts/GUI/MainWindow-AppSelection.ps1 +++ b/Scripts/GUI/MainWindow-AppSelection.ps1 @@ -233,9 +233,11 @@ function Get-AppRemovalScopeTarget { switch ($selectedItem.Name) { "AppRemovalScopeAllUsers" { return 'AllUsers' } "AppRemovalScopeCurrentUser" { return 'CurrentUser' } + default { + Write-Warning "Unrecognized app-removal scope item '$($selectedItem.Name)'. Skipping app removal." + return $null + } } - - return $null } function Invoke-AppPreset { diff --git a/Scripts/GUI/MainWindow-TweaksBuilder.ps1 b/Scripts/GUI/MainWindow-TweaksBuilder.ps1 index 410667ae..fd8bf54d 100644 --- a/Scripts/GUI/MainWindow-TweaksBuilder.ps1 +++ b/Scripts/GUI/MainWindow-TweaksBuilder.ps1 @@ -369,7 +369,7 @@ function New-DynamicTweakControls { try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {} if ($lblBorderObj) { $lblBorderObj.ToolTip = $tipBlock } } - $script:UiControlMappings[$comboName] = @{ Type = 'feature'; FeatureId = $soleFeature.FeatureId; Label = $soleFeature.Label; Category = $categoryName; CategoryId = $categoryId } + $script:UiControlMappings[$comboName] = @{ Type = 'feature'; FeatureId = $soleFeature.FeatureId; Label = $soleFeature.Label; CategoryId = $categoryId } } continue } @@ -389,7 +389,7 @@ function New-DynamicTweakControls { try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {} if ($lblBorderObj) { $lblBorderObj.ToolTip = $tipBlock } } - $script:UiControlMappings[$comboName] = @{ Type = 'group'; Values = $filteredValues; Label = $group.Label; Category = $categoryName; CategoryId = $categoryId } + $script:UiControlMappings[$comboName] = @{ Type = 'group'; Values = $filteredValues; Label = $group.Label; CategoryId = $categoryId } } elseif ($item.Type -eq 'feature') { $feature = $item.Data @@ -416,7 +416,7 @@ function New-DynamicTweakControls { try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {} if ($lblBorderObj) { $lblBorderObj.ToolTip = $tipBlock } } - $script:UiControlMappings[$comboName] = @{ Type = 'feature'; FeatureId = $feature.FeatureId; Label = $feature.Label; Category = $categoryName; CategoryId = $categoryId } + $script:UiControlMappings[$comboName] = @{ Type = 'feature'; FeatureId = $feature.FeatureId; Label = $feature.Label; CategoryId = $categoryId } } } } diff --git a/Scripts/GUI/Show-ApplyModal.ps1 b/Scripts/GUI/Show-ApplyModal.ps1 index a0a9cdde..ab296e5a 100644 --- a/Scripts/GUI/Show-ApplyModal.ps1 +++ b/Scripts/GUI/Show-ApplyModal.ps1 @@ -114,9 +114,7 @@ function Show-ApplyModal { try { Invoke-AllChanges - $registryImportFailureCount = [int]$script:RegistryImportFailures - $appRemovalFailureCount = [int]$script:AppRemovalFailures - $failureCount = $registryImportFailureCount + $appRemovalFailureCount + $failureCount = [int]$script:FeatureFailures + [int]$script:AppRemovalFailures $appRemovalVerificationUnavailable = [bool]$script:AppRemovalVerificationUnavailable # Restart explorer if requested @@ -157,11 +155,7 @@ function Show-ApplyModal { } 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." + $script:ApplyCompletionMessageEl.Text = "$failureCount change(s) failed. See console for details." } } else { Write-Host "All changes have been applied successfully!" diff --git a/Scripts/GUI/Show-ImportExportConfigWindow.ps1 b/Scripts/GUI/Show-ImportExportConfigWindow.ps1 index 36055ee6..5836fc97 100644 --- a/Scripts/GUI/Show-ImportExportConfigWindow.ps1 +++ b/Scripts/GUI/Show-ImportExportConfigWindow.ps1 @@ -506,20 +506,15 @@ function Import-Configuration { return } - if (-not $config.Version) { - Write-Error "Invalid configuration file format: '$($openDialog.FileName)'" - Show-MessageBox -Message "Invalid configuration file format." -Title 'Invalid Config' -Button 'OK' -Icon 'Error' | Out-Null + $consistencyError = Test-ConfigConsistency -Config $config + if ($consistencyError) { + Write-Error "Invalid configuration file '$($openDialog.FileName)': $consistencyError" + Show-MessageBox -Message "Invalid configuration file: $consistencyError" -Title 'Invalid Config' -Button 'OK' -Icon 'Error' | Out-Null return } $availableCategories = Get-AvailableImportExportCategories -Config $config - if ($availableCategories.Count -eq 0) { - Write-Warning "Configuration file '$($openDialog.FileName)' contains no importable data." - Show-MessageBox -Message "The selected file contains no importable data." -Title 'Invalid Config' -Button 'OK' -Icon 'Error' | Out-Null - return - } - Write-Host "Available categories in config: $($availableCategories -join ', ')" $appCount = @($config.Apps | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) }).Count diff --git a/Scripts/GUI/Show-MainWindow.ps1 b/Scripts/GUI/Show-MainWindow.ps1 index 3dcdaa0d..99878989 100644 --- a/Scripts/GUI/Show-MainWindow.ps1 +++ b/Scripts/GUI/Show-MainWindow.ps1 @@ -670,13 +670,15 @@ function Show-MainWindow { if ($selectedApps.Count -gt 0) { if (-not (Confirm-UnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) { return } - Add-Parameter 'RemoveApps' - Add-Parameter 'Apps' ($selectedApps -join ',') - $scopeTarget = Get-AppRemovalScopeTarget -AppRemovalScopeCombo $appRemovalScopeCombo -OtherUsernameTextBox $otherUsernameTextBox - if ($scopeTarget) { - Add-Parameter 'AppRemovalTarget' $scopeTarget + if ([string]::IsNullOrWhiteSpace($scopeTarget)) { + Write-Warning 'App removal was cancelled because the selected removal scope is invalid.' + return } + + Add-Parameter 'RemoveApps' + Add-Parameter 'Apps' ($selectedApps -join ',') + Add-Parameter 'AppRemovalTarget' $scopeTarget } # Apply dynamic tweaks diff --git a/Scripts/Helpers/Apply-RegistryRegFile.ps1 b/Scripts/Helpers/Apply-RegistryRegFile.ps1 index f2d48d8a..7be99968 100644 --- a/Scripts/Helpers/Apply-RegistryRegFile.ps1 +++ b/Scripts/Helpers/Apply-RegistryRegFile.ps1 @@ -191,6 +191,13 @@ function Invoke-RegistryOperation { } } +<# + .SYNOPSIS + Applies all parsed operations from a registry file. + + .OUTPUTS + System.Boolean. $true when all operations complete, including WhatIf; otherwise $false. +#> function Invoke-RegistryOperationsFromRegFile { param( [Parameter(Mandatory)] @@ -203,7 +210,7 @@ function Invoke-RegistryOperationsFromRegFile { if ($script:Params.ContainsKey("WhatIf")) { Write-Host "[WhatIf] Apply $totalOperations registry changes from '$RegFilePath'" -ForegroundColor Cyan - return + return $true } foreach ($operation in $operations) { @@ -222,5 +229,8 @@ function Invoke-RegistryOperationsFromRegFile { if ($accessDeniedCount -gt 0) { Write-Warning "Registry fallback import completed with $accessDeniedCount access-restricted operation(s) skipped in '$RegFilePath'." + return $false } + + return $true } diff --git a/Scripts/Helpers/Import-ConfigToParams.ps1 b/Scripts/Helpers/Import-ConfigToParams.ps1 index 03ecd125..483e6c71 100644 --- a/Scripts/Helpers/Import-ConfigToParams.ps1 +++ b/Scripts/Helpers/Import-ConfigToParams.ps1 @@ -31,6 +31,11 @@ function Import-ConfigToParams { throw "Failed to read config file: $resolvedConfigPath" } + $consistencyError = Test-ConfigConsistency -Config $configJson + if ($consistencyError) { + throw "Invalid config file '$resolvedConfigPath': $consistencyError" + } + $importedItems = 0 if ($configJson.Apps) { diff --git a/Scripts/Helpers/Test-ConfigConsistency.ps1 b/Scripts/Helpers/Test-ConfigConsistency.ps1 new file mode 100644 index 00000000..6fd43526 --- /dev/null +++ b/Scripts/Helpers/Test-ConfigConsistency.ps1 @@ -0,0 +1,97 @@ +<# + .SYNOPSIS + Validates that a configuration file is structurally consistent before it is applied. + + .DESCRIPTION + Returns $null when the configuration is valid, otherwise a string describing the + first problem found. Used by both the CLI and GUI import paths to reject invalid + configs before any settings are applied. + + .OUTPUTS + System.String. $null when valid, otherwise an error message. +#> +function Test-ConfigConsistency { + param($Config) + + if (-not $Config) { + return 'Configuration is empty or could not be read.' + } + + if (-not $Config.Version) { + return 'Configuration is missing a Version field.' + } + + if (-not $Config.Apps -and -not $Config.Tweaks -and -not $Config.Deployment) { + return 'The configuration file contains no importable data.' + } + + if ($null -ne $Config.Apps) { + if ($Config.Apps -isnot [string] -and $Config.Apps -isnot [System.Collections.IEnumerable]) { + return 'Configuration Apps entries must be strings.' + } + foreach ($app in @($Config.Apps)) { + if ($app -isnot [string]) { + return 'Configuration Apps entries must be strings.' + } + } + } + + foreach ($categoryName in @('Tweaks', 'Deployment')) { + $category = $Config.$categoryName + if ($null -eq $category) { continue } + + if ($category -is [string] -or $category -isnot [System.Collections.IEnumerable]) { + return "Configuration $categoryName entries must contain Name and Value properties." + } + foreach ($setting in @($category)) { + $hasName = if ($setting -is [System.Collections.IDictionary]) { $setting.Contains('Name') } else { $null -ne $setting.PSObject.Properties['Name'] } + $hasValue = if ($setting -is [System.Collections.IDictionary]) { $setting.Contains('Value') } else { $null -ne $setting.PSObject.Properties['Value'] } + if (-not $setting -or -not $hasName -or -not $hasValue -or $setting.Name -isnot [string] -or [string]::IsNullOrWhiteSpace($setting.Name)) { + return "Configuration $categoryName entries must contain Name and Value properties." + } + } + } + + $lookup = @{} + foreach ($setting in @($Config.Deployment)) { + if ($setting -and $setting.Name) { + $lookup[$setting.Name] = $setting.Value + } + } + + $hasScope = $lookup.ContainsKey('AppRemovalScopeIndex') + $hasUser = $lookup.ContainsKey('UserSelectionIndex') + + $scopeIndex = $null + if ($hasScope) { + if (-not [int]::TryParse("$($lookup['AppRemovalScopeIndex'])", [ref]$scopeIndex) -or $scopeIndex -notin @(0, 1, 2)) { + return 'AppRemovalScopeIndex must be a supported numeric value (0, 1, or 2).' + } + } + + $userIndex = $null + if ($hasUser) { + if (-not [int]::TryParse("$($lookup['UserSelectionIndex'])", [ref]$userIndex) -or $userIndex -notin @(0, 1, 2)) { + return 'UserSelectionIndex must be a supported numeric value (0, 1, or 2).' + } + } + + # "Current user only" (index 1) is only valid together with "Current User" (index 0) + if ($hasScope -and $scopeIndex -eq 1) { + if (-not $hasUser -or $userIndex -ne 0) { + return "App removal scope 'Current user only' (AppRemovalScopeIndex 1) requires the deployment target 'Current User' (UserSelectionIndex 0)." + } + } + + # "Target user only" (index 2) is only valid together with "Other User" (index 1) + if ($hasScope -and $scopeIndex -eq 2) { + if (-not $hasUser -or $userIndex -ne 1) { + return "App removal scope 'Target user only' (AppRemovalScopeIndex 2) requires the deployment target 'Other User' (UserSelectionIndex 1)." + } + if (-not $lookup.ContainsKey('OtherUsername') -or [string]::IsNullOrWhiteSpace("$($lookup['OtherUsername'])")) { + return "App removal scope 'Target user only' (AppRemovalScopeIndex 2) requires an 'OtherUsername' value." + } + } + + return $null +} diff --git a/Tests/Import-ConfigToParams.Tests.ps1 b/Tests/Import-ConfigToParams.Tests.ps1 index 93744af5..b843cf8e 100644 --- a/Tests/Import-ConfigToParams.Tests.ps1 +++ b/Tests/Import-ConfigToParams.Tests.ps1 @@ -2,6 +2,7 @@ BeforeAll { . (Join-Path $PSScriptRoot '..\Scripts\FileIO\Import-JsonFile.ps1') . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Add-Parameter.ps1') . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Import-ConfigToParams.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Test-ConfigConsistency.ps1') $script:ConfigFixturePath = Join-Path $PSScriptRoot 'TestData\JsonFileLoading\ExportedConfig.WithSettings.json' $script:SkipRegistryBackupFixturePath = Join-Path $PSScriptRoot 'TestData\JsonFileLoading\ExportedConfig.SkipRegistryBackup.json' } @@ -44,3 +45,123 @@ Describe 'Import-ConfigToParams' { $script:Params['SkipRegistryBackup'] | Should -BeTrue } } + +Describe 'Test-ConfigConsistency' { + It 'reports an error for an empty config' { + Test-ConfigConsistency -Config $null | Should -Match 'empty or could not be read' + } + + It 'reports an error for a config missing a Version' { + $config = [PSCustomObject]@{ Tweaks = @( @{ Name = 'DisableTelemetry'; Value = $true } ) } + Test-ConfigConsistency -Config $config | Should -Match 'missing a Version' + } + + It 'reports an error for a config with no importable data' { + $config = [PSCustomObject]@{ Version = '1.0' } + Test-ConfigConsistency -Config $config | Should -Match 'no importable data' + } + + It 'reports an error for invalid app entries' { + $config = [PSCustomObject]@{ Version = '1.0'; Apps = 42 } + + Test-ConfigConsistency -Config $config | Should -Match 'Apps entries must be strings' + } + + It 'reports an error for malformed tweak entries' { + $config = [PSCustomObject]@{ Version = '1.0'; Tweaks = @(@{ Value = $true }) } + + Test-ConfigConsistency -Config $config | Should -Match 'Tweaks entries must contain Name and Value properties' + } + + It 'reports an error for deployment entries missing a required property' { + $config = [PSCustomObject]@{ Version = '1.0'; Deployment = @(@{ Name = 'CreateRestorePoint' }) } + + Test-ConfigConsistency -Config $config | Should -Match 'Deployment entries must contain Name and Value properties' + } + + It 'reports an error for nonnumeric deployment indexes' { + $config = [PSCustomObject]@{ + Version = '1.0' + Deployment = @(@{ Name = 'AppRemovalScopeIndex'; Value = 'all' }) + } + + Test-ConfigConsistency -Config $config | Should -Match 'AppRemovalScopeIndex must be a supported numeric value' + } + + It 'reports an error for out-of-range deployment indexes' { + $config = [PSCustomObject]@{ + Version = '1.0' + Deployment = @(@{ Name = 'UserSelectionIndex'; Value = 3 }) + } + + Test-ConfigConsistency -Config $config | Should -Match 'UserSelectionIndex must be a supported numeric value' + } + + It 'returns null for a consistent all-users scope' { + $config = [PSCustomObject]@{ + Version = '1.0' + Deployment = @( + @{ Name = 'UserSelectionIndex'; Value = 0 } + @{ Name = 'AppRemovalScopeIndex'; Value = 0 } + ) + } + Test-ConfigConsistency -Config $config | Should -BeNullOrEmpty + } + + It 'returns null for target-user scope combined with Other User and a username' { + $config = [PSCustomObject]@{ + Version = '1.0' + Deployment = @( + @{ Name = 'UserSelectionIndex'; Value = 1 } + @{ Name = 'OtherUsername'; Value = 'jdoe' } + @{ Name = 'AppRemovalScopeIndex'; Value = 2 } + ) + } + Test-ConfigConsistency -Config $config | Should -BeNullOrEmpty + } + + It 'returns null for current-user-only scope combined with Current User' { + $config = [PSCustomObject]@{ + Version = '1.0' + Deployment = @( + @{ Name = 'UserSelectionIndex'; Value = 0 } + @{ Name = 'AppRemovalScopeIndex'; Value = 1 } + ) + } + Test-ConfigConsistency -Config $config | Should -BeNullOrEmpty + } + + It 'reports an error for current-user-only scope without Current User selected' { + $config = [PSCustomObject]@{ + Version = '1.0' + Deployment = @( + @{ Name = 'UserSelectionIndex'; Value = 1 } + @{ Name = 'AppRemovalScopeIndex'; Value = 1 } + ) + } + Test-ConfigConsistency -Config $config | Should -Match "requires the deployment target 'Current User'" + } + + It 'reports an error for target-user scope without Other User selected' { + $config = [PSCustomObject]@{ + Version = '1.0' + Deployment = @( + @{ Name = 'UserSelectionIndex'; Value = 0 } + @{ Name = 'AppRemovalScopeIndex'; Value = 2 } + ) + } + Test-ConfigConsistency -Config $config | Should -Match "requires the deployment target 'Other User'" + } + + It 'reports an error for target-user scope with a blank username' { + $config = [PSCustomObject]@{ + Version = '1.0' + Deployment = @( + @{ Name = 'UserSelectionIndex'; Value = 1 } + @{ Name = 'OtherUsername'; Value = ' ' } + @{ Name = 'AppRemovalScopeIndex'; Value = 2 } + ) + } + Test-ConfigConsistency -Config $config | Should -Match "requires an 'OtherUsername' value" + } +} diff --git a/Tests/Import-RegistryFile.Tests.ps1 b/Tests/Import-RegistryFile.Tests.ps1 index 3ff3de23..c1bbf9a6 100644 --- a/Tests/Import-RegistryFile.Tests.ps1 +++ b/Tests/Import-RegistryFile.Tests.ps1 @@ -9,27 +9,33 @@ BeforeAll { Describe 'Import-RegistryFile' { BeforeEach { $script:Params = @{} - $script:RegistryImportFailures = 0 $script:regPath = Join-Path $TestDrive 'feature.reg' '' | Set-Content -LiteralPath $script:regPath Mock Get-RegistryFilePathForFeature { $script:regPath } - Mock Invoke-RegistryOperationsFromRegFile {} + Mock Invoke-RegistryOperationsFromRegFile { $true } Mock Invoke-WithTargetUserHive {} Mock Invoke-NonBlocking { [PSCustomObject]@{ Output = @(); ExitCode = 0; Error = $null } } Mock Write-Host {} Mock Write-Warning {} } - It 'throws and increments the failure count when the registry file is missing' { + It 'returns false when the registry file is missing' { Mock Get-RegistryFilePathForFeature { Join-Path $TestDrive 'missing.reg' } - { Import-RegistryFile -message 'Apply' -path 'missing.reg' } | Should -Throw 'Unable to find registry file:*' - $script:RegistryImportFailures | Should -Be 1 + + Import-RegistryFile -message 'Apply' -path 'missing.reg' | Should -BeFalse + Should -Invoke Invoke-NonBlocking -Times 0 -Exactly + } + + It 'returns false when registry file resolution throws' { + Mock Get-RegistryFilePathForFeature { throw 'path resolution failed' } + + Import-RegistryFile -message 'Apply' -path 'feature.reg' | Should -BeFalse Should -Invoke Invoke-NonBlocking -Times 0 -Exactly } It 'uses the PowerShell writer only in WhatIf mode' { $script:Params = @{ WhatIf = $true } - Import-RegistryFile -message 'Apply' -path 'feature.reg' + Import-RegistryFile -message 'Apply' -path 'feature.reg' | Should -BeTrue Should -Invoke Invoke-RegistryOperationsFromRegFile -Times 1 -Exactly -ParameterFilter { $RegFilePath -eq $script:regPath } Should -Invoke Invoke-NonBlocking -Times 0 -Exactly } @@ -41,7 +47,7 @@ Describe 'Import-RegistryFile' { & $ScriptBlock $ArgumentObject ([PSCustomObject]@{ WasAlreadyLoaded = $true }) } - Import-RegistryFile -message 'Apply' -path 'feature.reg' + Import-RegistryFile -message 'Apply' -path 'feature.reg' | Should -BeTrue Should -Invoke Invoke-WithTargetUserHive -Times 1 -Exactly -ParameterFilter { $TargetUserName -eq 'Alice' -and $PassHiveContext } Should -Invoke Invoke-RegistryOperationsFromRegFile -Times 1 -Exactly @@ -51,17 +57,22 @@ Describe 'Import-RegistryFile' { It 'falls back to the PowerShell writer when reg import fails' { Mock Invoke-NonBlocking { [PSCustomObject]@{ Output = @('denied'); ExitCode = 5; Error = 'access denied' } } - Import-RegistryFile -message 'Apply' -path 'feature.reg' + Import-RegistryFile -message 'Apply' -path 'feature.reg' | Should -BeTrue Should -Invoke Invoke-RegistryOperationsFromRegFile -Times 1 -Exactly Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -like "reg import failed*" } - $script:RegistryImportFailures | Should -Be 0 + } + + It 'returns false when the fallback cannot apply every registry operation' { + Mock Invoke-NonBlocking { [PSCustomObject]@{ Output = @('denied'); ExitCode = 5; Error = 'access denied' } } + Mock Invoke-RegistryOperationsFromRegFile { $false } + + Import-RegistryFile -message 'Apply' -path 'feature.reg' | Should -BeFalse } It 'does not invoke the fallback after a successful reg import' { - Import-RegistryFile -message 'Apply' -path 'feature.reg' + Import-RegistryFile -message 'Apply' -path 'feature.reg' | Should -BeTrue Should -Invoke Invoke-NonBlocking -Times 1 -Exactly Should -Invoke Invoke-RegistryOperationsFromRegFile -Times 0 -Exactly - $script:RegistryImportFailures | Should -Be 0 } } diff --git a/Tests/Invoke-Changes.Tests.ps1 b/Tests/Invoke-Changes.Tests.ps1 index 726b3bf2..6c771005 100644 --- a/Tests/Invoke-Changes.Tests.ps1 +++ b/Tests/Invoke-Changes.Tests.ps1 @@ -1,24 +1,24 @@ BeforeAll { function Import-RegistryFile { param($Message, $path) } - function Remove-SelectedApps { param($Apps) } - function Invoke-ForceRemoveEdge {} - function Disable-TelemetryScheduledTasks {} - function Enable-TelemetryScheduledTasks {} + function Remove-SelectedApps { param($Apps) $true } + function Invoke-ForceRemoveEdge { $true } + function Disable-TelemetryScheduledTasks { $true } + function Enable-TelemetryScheduledTasks { $true } function Generate-AppsList { @() } function Get-FriendlyTargetUserName { 'current user' } - function Set-StoreSearchSuggestionsEnabledForAllUsers {} - function Set-StoreSearchSuggestionsEnabled { param($StoreAppsDatabase) } + function Set-StoreSearchSuggestionsEnabledForAllUsers { $true } + function Set-StoreSearchSuggestionsEnabled { param($StoreAppsDatabase) $true } function Get-StoreAppsDatabasePathForUser { param($UserName) 'store.db' } function Get-UserName { 'Alice' } - function Disable-WindowsFeature { param($FeatureName) } + function Disable-WindowsFeature { param($FeatureName) $true } function New-RegistrySettingsBackup { param($ActionableKeys, $ExtraFeatures) } function Invoke-SystemRestorePoint {} - function Enable-WindowsFeature { param($FeatureName) } + function Enable-WindowsFeature { param($FeatureName) $true } function Get-StartMenuBinPathForUser { param($UserName) 'start.bin' } - function Replace-StartMenu { param($startMenuBinFile, $startMenuTemplate) } - function Replace-StartMenuForAllUsers { param($startMenuTemplate) } - function Set-StoreSearchSuggestionsDisabledForAllUsers {} - function Set-StoreSearchSuggestionsDisabled { param($StoreAppsDatabase) } + function Replace-StartMenu { param($startMenuBinFile, $startMenuTemplate) $true } + function Replace-StartMenuForAllUsers { param($startMenuTemplate) $true } + function Set-StoreSearchSuggestionsDisabledForAllUsers { $true } + function Set-StoreSearchSuggestionsDisabled { param($StoreAppsDatabase) $true } . (Join-Path $PSScriptRoot '..\Scripts\Features\Invoke-Changes.ps1') } @@ -62,19 +62,19 @@ Describe 'Invoke-FeatureApply' { ReplaceStartAllUsers = [PSCustomObject]@{ ApplyText = 'Replace Start all users'; RegistryKey = '' } DisableStoreSearchSuggestions = [PSCustomObject]@{ ApplyText = 'Disable Store suggestions'; RegistryKey = '' } } - Mock Import-RegistryFile {} - Mock Remove-SelectedApps {} - Mock Invoke-ForceRemoveEdge {} - Mock Disable-TelemetryScheduledTasks {} + Mock Import-RegistryFile { $true } + Mock Remove-SelectedApps { $true } + Mock Invoke-ForceRemoveEdge { $true } + Mock Disable-TelemetryScheduledTasks { $true } Mock Generate-AppsList { @() } Mock Get-FriendlyTargetUserName { 'current user' } - Mock Enable-WindowsFeature {} + Mock Enable-WindowsFeature { $true } Mock Get-StartMenuBinPathForUser { 'start.bin' } Mock Get-UserName { 'Alice' } - Mock Replace-StartMenu {} - Mock Replace-StartMenuForAllUsers {} - Mock Set-StoreSearchSuggestionsDisabledForAllUsers {} - Mock Set-StoreSearchSuggestionsDisabled {} + Mock Replace-StartMenu { $true } + Mock Replace-StartMenuForAllUsers { $true } + Mock Set-StoreSearchSuggestionsDisabledForAllUsers { $true } + Mock Set-StoreSearchSuggestionsDisabled { $true } Mock Get-StoreAppsDatabasePathForUser { 'store.db' } Mock Get-Process { @() } Mock Stop-Process { param($InputObject) } @@ -95,6 +95,14 @@ Describe 'Invoke-FeatureApply' { Should -Invoke Disable-TelemetryScheduledTasks -Times 1 -Exactly } + It 'returns false without side effects when a registry import fails' { + Mock Import-RegistryFile { $false } + + Invoke-FeatureApply -FeatureId 'DisableTelemetry' | Should -BeFalse + + Should -Invoke Disable-TelemetryScheduledTasks -Times 0 -Exactly + } + It 'does not call app removal when the generated selection is empty' { Invoke-FeatureApply -FeatureId 'RemoveApps' @@ -127,6 +135,23 @@ Describe 'Invoke-FeatureApply' { Should -Invoke Remove-SelectedApps -Times 0 -Exactly } + It 'returns false when applying a feature throws' { + Mock Invoke-ForceRemoveEdge { throw 'access denied' } + Mock Write-Warning {} + + Invoke-FeatureApply -FeatureId 'ForceRemoveEdge' | Should -BeFalse + + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match "Failed to apply 'Force remove Edge'.*access denied" } + } + + It 'returns false for an unknown feature' { + Mock Write-Warning {} + + Invoke-FeatureApply -FeatureId 'Unknown' | Should -BeFalse + + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match "Unknown feature 'Unknown'.*could not be applied" } + } + It 'uses the expected static app list for ' -ForEach @( @{ FeatureId = 'RemoveGamingApps'; MinimumCount = 3; ExpectedApp = 'Microsoft.GamingApp' } @{ FeatureId = 'RemoveHPApps'; MinimumCount = 10; ExpectedApp = 'AD2F1837.myHP' } @@ -215,7 +240,7 @@ Describe 'Invoke-ApplyFeatures' { } $script:progressCalls = New-Object System.Collections.Generic.List[object] $script:ApplyProgressCallback = { param($Step, $Total, $Text) $script:progressCalls.Add(@($Step, $Total, $Text)) } - Mock Invoke-FeatureApply {} + Mock Invoke-FeatureApply { $true } } It 'reports progress and applies each feature in order' { @@ -235,6 +260,19 @@ Describe 'Invoke-ApplyFeatures' { Should -Invoke Invoke-FeatureApply -Times 0 -Exactly $script:progressCalls | Should -HaveCount 0 } + + It 'counts a failed feature application and continues with later features' { + $script:FeatureFailures = 0 + Mock Invoke-FeatureApply { + param($FeatureId) + return ($FeatureId -ne 'One') + } + + Invoke-ApplyFeatures -FeatureIds @('One', 'Two') -StartStep 1 -TotalSteps 2 + + $script:FeatureFailures | Should -Be 1 + Should -Invoke Invoke-FeatureApply -Times 2 -Exactly + } } Describe 'Invoke-UndoFeatures' { @@ -246,14 +284,13 @@ Describe 'Invoke-UndoFeatures' { CustomUndo = [PSCustomObject]@{ UndoLabel = 'Undo custom'; ApplyUndoText = ''; RegistryUndoKey = '' } } Mock Resolve-UndoRegFilePath { param($FileName) "Undo\$FileName" } - Mock Import-RegistryFile {} - Mock Invoke-FeatureUndo {} + Mock Import-RegistryFile { $true } + Mock Invoke-FeatureUndo { $true } } - It 'imports registry undo data and still invokes custom undo side effects' { + It 'delegates registry-backed undo work to the feature undo handler' { Invoke-UndoFeatures -FeatureIds @('RegistryUndo') -StartStep 1 -TotalSteps 1 - Should -Invoke Import-RegistryFile -Times 1 -Exactly -ParameterFilter { $path -eq 'Undo\undo.reg' } Should -Invoke Invoke-FeatureUndo -Times 1 -Exactly -ParameterFilter { $FeatureId -eq 'RegistryUndo' } } @@ -264,6 +301,16 @@ Describe 'Invoke-UndoFeatures' { Should -Invoke Invoke-FeatureUndo -Times 2 -Exactly } + It 'counts one failure when a feature undo fails' { + $script:FeatureFailures = 0 + Mock Invoke-FeatureUndo { $false } + + Invoke-UndoFeatures -FeatureIds @('RegistryUndo') -StartStep 1 -TotalSteps 1 + + $script:FeatureFailures | Should -Be 1 + Should -Invoke Invoke-FeatureUndo -Times 1 -Exactly + } + It 'stops before undoing when cancellation is requested' { $script:CancelRequested = $true @@ -283,12 +330,14 @@ Describe 'Invoke-FeatureUndo' { DisableTelemetry = [PSCustomObject]@{} DisableStoreSearchSuggestions = [PSCustomObject]@{} } - Mock Set-StoreSearchSuggestionsEnabledForAllUsers {} - Mock Set-StoreSearchSuggestionsEnabled {} + Mock Set-StoreSearchSuggestionsEnabledForAllUsers { $true } + Mock Set-StoreSearchSuggestionsEnabled { $true } Mock Get-StoreAppsDatabasePathForUser { 'store.db' } Mock Get-UserName { 'Alice' } - Mock Disable-WindowsFeature {} - Mock Enable-TelemetryScheduledTasks {} + Mock Disable-WindowsFeature { $true } + Mock Enable-TelemetryScheduledTasks { $true } + Mock Import-RegistryFile { $true } + Mock Resolve-UndoRegFilePath { param($FileName) "Undo\$FileName" } Mock Write-Host {} } @@ -304,16 +353,35 @@ Describe 'Invoke-FeatureUndo' { It 'disables both WSL optional features in dependency-safe order' { $script:disabledFeatures = [System.Collections.Generic.List[string]]::new() - Mock Disable-WindowsFeature { param($FeatureName) $script:disabledFeatures.Add($FeatureName) } + Mock Disable-WindowsFeature { param($FeatureName) $script:disabledFeatures.Add($FeatureName); $true } Invoke-FeatureUndo -FeatureId 'EnableWindowsSubsystemForLinux' $script:disabledFeatures | Should -Be @('Microsoft-Windows-Subsystem-Linux', 'VirtualMachinePlatform') } It 'disables Sandbox and re-enables telemetry tasks' { + $script:Features.DisableTelemetry = [PSCustomObject]@{ ApplyUndoText = 'Enable telemetry'; RegistryUndoKey = 'enable-telemetry.reg' } Invoke-FeatureUndo -FeatureId 'EnableWindowsSandbox' Invoke-FeatureUndo -FeatureId 'DisableTelemetry' Should -Invoke Disable-WindowsFeature -Times 1 -Exactly -ParameterFilter { $FeatureName -eq 'Containers-DisposableClientVM' } Should -Invoke Enable-TelemetryScheduledTasks -Times 1 -Exactly + Should -Invoke Import-RegistryFile -Times 1 -Exactly -ParameterFilter { $path -eq 'Undo\enable-telemetry.reg' } + } + + It 'returns false without side effects when a registry undo import fails' { + $script:Features.DisableTelemetry = [PSCustomObject]@{ ApplyUndoText = 'Enable telemetry'; RegistryUndoKey = 'enable-telemetry.reg' } + Mock Import-RegistryFile { $false } + + Invoke-FeatureUndo -FeatureId 'DisableTelemetry' | Should -BeFalse + + Should -Invoke Enable-TelemetryScheduledTasks -Times 0 -Exactly + } + + It 'warns and returns false for an unknown feature' { + Mock Write-Warning {} + + Invoke-FeatureUndo -FeatureId 'Unknown' | Should -BeFalse + + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match "Unknown feature 'Unknown'.*could not be undone" } } } @@ -332,7 +400,7 @@ Describe 'Invoke-AllChanges' { Mock Test-RunningAsSystem { $false } Mock Resolve-UndoRegFilePath { param($FileName) "Undo\$FileName" } Mock New-RegistrySettingsBackup {} - Mock Invoke-SystemRestorePoint {} + Mock Invoke-SystemRestorePoint { $true } Mock Invoke-ApplyFeatures {} Mock Invoke-UndoFeatures {} Mock Write-Host {} @@ -406,20 +474,21 @@ Describe 'Invoke-AllChanges' { $script:Params = @{ CreateRestorePoint = $true; CustomApply = $true } $script:UndoParams = @{} $script:order = [System.Collections.Generic.List[string]]::new() - Mock Invoke-SystemRestorePoint { $script:order.Add('restore-point') } + Mock Invoke-SystemRestorePoint { $script:order.Add('restore-point'); $true } Mock Invoke-ApplyFeatures { $script:order.Add('apply') } Invoke-AllChanges $script:order | Should -Be @('restore-point', 'apply') } - It 'reports registry import failures after all requested work completes' { - $script:Params = @{ CustomApply = $true } + It 'counts a restore point failure as a feature failure when the user chooses to continue' { + $script:Params = @{ CreateRestorePoint = $true; CustomApply = $true } $script:UndoParams = @{} - Mock Invoke-ApplyFeatures { $script:RegistryImportFailures = 2 } + Mock Invoke-SystemRestorePoint { $false } Invoke-AllChanges - Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match '2 registry import change' } + $script:FeatureFailures | Should -Be 1 + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match '1 feature change\(s\) failed\.' } } It 'reports app removal failures after all requested work completes' { diff --git a/Tests/Invoke-SystemRestorePoint.Tests.ps1 b/Tests/Invoke-SystemRestorePoint.Tests.ps1 index 45b5826b..1f20a596 100644 --- a/Tests/Invoke-SystemRestorePoint.Tests.ps1 +++ b/Tests/Invoke-SystemRestorePoint.Tests.ps1 @@ -27,6 +27,26 @@ Describe 'Invoke-SystemRestorePoint' { $script:CancelRequested | Should -BeFalse } + It 'returns false through the continuation flow when the System Restore state cannot be read' { + Mock Get-ItemProperty { throw 'registry access denied' } + Mock Read-Host { 'y' } + + Invoke-SystemRestorePoint | Should -BeFalse + + $script:CancelRequested | Should -BeFalse + Should -Invoke Invoke-NonBlocking -Times 0 -Exactly + } + + It 'returns false without prompting when the System Restore state cannot be read in silent mode' { + $script:Silent = $true + Mock Get-ItemProperty { throw 'registry access denied' } + + Invoke-SystemRestorePoint | Should -BeFalse + + $script:CancelRequested | Should -BeFalse + Should -Invoke Read-Host -Times 0 -Exactly + } + It 'is loaded by the main entry point' { $entryPoint = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\Win11Debloat.ps1') -Raw $expectedImport = [regex]::Escape('Scripts/Features/Invoke-SystemRestorePoint.ps1') diff --git a/Tests/MainWindow-AppSelection.Tests.ps1 b/Tests/MainWindow-AppSelection.Tests.ps1 index 9ed933b4..50c32c7b 100644 --- a/Tests/MainWindow-AppSelection.Tests.ps1 +++ b/Tests/MainWindow-AppSelection.Tests.ps1 @@ -117,7 +117,7 @@ Describe 'Get-PendingTweakActions' { $checkBox = New-Object System.Windows.Controls.CheckBox $checkBox.Visibility = 'Visible' $window.RegisterName('DisableTelemetryCheckBox', $checkBox) - $script:UiControlMappings = @{ DisableTelemetryCheckBox = [PSCustomObject]@{ Category = 'Privacy & Suggested Content'; CategoryId = 'PrivacySuggestedContent'; Type = 'feature'; FeatureId = 'DisableTelemetry' } } + $script:UiControlMappings = @{ DisableTelemetryCheckBox = [PSCustomObject]@{ CategoryId = 'PrivacySuggestedContent'; Type = 'feature'; FeatureId = 'DisableTelemetry' } } $map = Get-CategoryTweakPresetMap -Window $window -CategoryId 'PrivacySuggestedContent' @@ -131,7 +131,7 @@ Describe 'Get-PendingTweakActions' { $checkBox.Visibility = 'Visible' $window.RegisterName('LegacyCheckBox', $checkBox) # New-DynamicTweakControls falls back to Name when a category has no CategoryId of its own. - $script:UiControlMappings = @{ LegacyCheckBox = [PSCustomObject]@{ Category = 'Legacy Category'; CategoryId = 'Legacy Category'; Type = 'feature'; FeatureId = 'SomeFeature' } } + $script:UiControlMappings = @{ LegacyCheckBox = [PSCustomObject]@{ CategoryId = 'Legacy Category'; Type = 'feature'; FeatureId = 'SomeFeature' } } $map = Get-CategoryTweakPresetMap -Window $window -CategoryId 'Legacy Category' diff --git a/Tests/Remove-SelectedApps.Tests.ps1 b/Tests/Remove-SelectedApps.Tests.ps1 index 18435dc1..5889ba44 100644 --- a/Tests/Remove-SelectedApps.Tests.ps1 +++ b/Tests/Remove-SelectedApps.Tests.ps1 @@ -11,6 +11,7 @@ BeforeAll { function Resolve-UserProfileContext { param($UserName) $null } . (Join-Path $PSScriptRoot '..\Scripts\AppRemoval\Remove-SelectedApps.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\AppRemoval\Invoke-ForceRemoveEdge.ps1') } Describe 'Remove-SelectedApps' { @@ -71,9 +72,33 @@ Describe 'Remove-SelectedApps' { It 'counts a failed WinGet removal' { Mock Get-AppRemovalMethod { 'WinGet' } Mock Remove-WinGetApp { $false } + Mock Test-AppInWingetList { $true } + Mock Write-Host {} Remove-SelectedApps -appsList @('One.App') + $script:AppRemovalFailures | Should -Be 1 + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -eq 'Unable to uninstall One.App via WinGet' -and $ForegroundColor -eq 'Red' } + } + + It 'does not count a non-zero WinGet command when the app is absent after verification' { + Mock Get-AppRemovalMethod { 'WinGet' } + Mock Remove-WinGetApp { $false } + Mock Test-AppInWingetList { $false } + + Remove-SelectedApps -appsList @('One.App') | Should -BeTrue + + $script:AppRemovalFailures | Should -Be 0 + } + + It 'counts a failed WinGet scheduling result for a target user' { + $script:Params = @{ User = 'Alice' } + Mock Get-AppRemovalMethod { 'WinGet' } + Mock Remove-WinGetApp { $false } + Mock Test-AppInWingetList { $false } + + Remove-SelectedApps -appsList @('One.App') | Should -BeFalse + $script:AppRemovalFailures | Should -Be 1 } @@ -132,7 +157,7 @@ Describe 'Remove-WinGetApp' { BeforeEach { $script:Params = @{} $script:WingetInstalled = $true - Mock Invoke-NonBlocking { $true } + Mock Invoke-NonBlocking { [PSCustomObject]@{ Success = $true; ExitCode = 0; Output = @() } } Mock Set-RunOnceWingetTask { $true } Mock Get-UserName { 'Alice' } Mock Write-Host {} @@ -174,14 +199,89 @@ Describe 'Remove-WinGetApp' { It 'reports a timed-out winget uninstall and continues' { $script:Params = @{ User = 'Alice' } Mock Invoke-NonBlocking { throw 'Operation timed out after 120 seconds' } + Mock Write-Verbose {} { Remove-WinGetApp -app 'One.App' } | Should -Not -Throw Should -Invoke Set-RunOnceWingetTask -Times 1 -Exactly - Should -Invoke Write-Error -Times 1 -Exactly -ParameterFilter { + Should -Invoke Write-Verbose -Times 1 -Exactly -ParameterFilter { $Message -like '*did not complete within 120 seconds*' } } + It 'returns true and logs WinGet output regardless of exit code' -ForEach @( + @{ ExitCode = 0; Output = 'Successfully uninstalled One.App' } + @{ ExitCode = 1; Output = 'Package was not found' } + @{ ExitCode = -1978335212; Output = 'No installed package found matching input criteria.' } + ) { + $script:expectedExitCode = $ExitCode + $script:expectedOutput = $Output + Mock Invoke-NonBlocking { [PSCustomObject]@{ ExitCode = $script:expectedExitCode; Output = @($script:expectedOutput) } } + Mock Write-Verbose {} + + Remove-WinGetApp -app 'One.App' | Should -BeTrue + + Should -Invoke Write-Verbose -Times 1 -Exactly -ParameterFilter { $Message -eq $script:expectedOutput } + Should -Invoke Write-Verbose -Times 1 -Exactly -ParameterFilter { $Message -eq "WinGet uninstall for One.App returned exit code $script:expectedExitCode." } + } + + It 'returns the RunOnce scheduling result for a target user' { + $script:Params = @{ User = 'Alice' } + Mock Invoke-NonBlocking { [PSCustomObject]@{ ExitCode = 1; Output = @() } } + Mock Set-RunOnceWingetTask { $false } + + Remove-WinGetApp -app 'One.App' | Should -BeFalse + } + +} + +Describe 'Remove-EdgeAutostartValue' { + BeforeEach { + Mock Write-Warning {} + } + + It 'treats a missing value as already cleaned up' { + Mock Get-ItemProperty { [PSCustomObject]@{} } + Mock Remove-ItemProperty {} + + Remove-EdgeAutostartValue -Path 'HKCU:\Software\Example' -Name 'Microsoft Edge Update' | Should -BeTrue + + Should -Invoke Remove-ItemProperty -Times 0 -Exactly + } + + It 'treats a missing registry key as already cleaned up' { + Mock Get-ItemProperty { throw [System.Management.Automation.ItemNotFoundException]::new('not found') } + Mock Remove-ItemProperty {} + + Remove-EdgeAutostartValue -Path 'HKCU:\Software\Example' -Name 'Microsoft Edge Update' | Should -BeTrue + + Should -Invoke Remove-ItemProperty -Times 0 -Exactly + } + + It 'removes an existing value' { + Mock Get-ItemProperty { [PSCustomObject]@{ 'Microsoft Edge Update' = 'enabled' } } + Mock Remove-ItemProperty {} + + Remove-EdgeAutostartValue -Path 'HKCU:\Software\Example' -Name 'Microsoft Edge Update' | Should -BeTrue + + Should -Invoke Remove-ItemProperty -Times 1 -Exactly -ParameterFilter { $Path -eq 'HKCU:\Software\Example' -and $Name -eq 'Microsoft Edge Update' } + } + + It 'returns false when an existing value cannot be removed' { + Mock Get-ItemProperty { [PSCustomObject]@{ 'Microsoft Edge Update' = 'enabled' } } + Mock Remove-ItemProperty { throw 'access denied' } + + Remove-EdgeAutostartValue -Path 'HKCU:\Software\Example' -Name 'Microsoft Edge Update' | Should -BeFalse + + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match 'access denied' } + } + + It 'returns false when the registry key cannot be inspected' { + Mock Get-ItemProperty { throw 'access denied' } + + Remove-EdgeAutostartValue -Path 'HKCU:\Software\Example' -Name 'Microsoft Edge Update' | Should -BeFalse + + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match 'access denied' } + } } Describe 'Remove-AppxApp' { diff --git a/Tests/Set-StoreSearchSuggestions.Tests.ps1 b/Tests/Set-StoreSearchSuggestions.Tests.ps1 index 468cf8f7..a6423686 100644 --- a/Tests/Set-StoreSearchSuggestions.Tests.ps1 +++ b/Tests/Set-StoreSearchSuggestions.Tests.ps1 @@ -37,20 +37,20 @@ Describe 'Store-search suggestion all-user operations' { ) } Mock Get-StoreAppsDatabasePathForUser { 'C:\Users\Default\AppData\Local\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db' } - Mock Set-StoreSearchSuggestionsDisabled {} - Mock Set-StoreSearchSuggestionsEnabled {} + Mock Set-StoreSearchSuggestionsDisabled { $true } + Mock Set-StoreSearchSuggestionsEnabled { $true } Mock Write-Warning {} } It 'disables suggestions for every discovered and Default profile' { - Set-StoreSearchSuggestionsDisabledForAllUsers + Set-StoreSearchSuggestionsDisabledForAllUsers | Should -BeTrue Should -Invoke Set-StoreSearchSuggestionsDisabled -Times 3 -Exactly Should -Invoke Set-StoreSearchSuggestionsDisabled -Times 1 -Exactly -ParameterFilter { $StoreAppsDatabase -match 'Users\\Default\\' } } It 'enables suggestions for every discovered and Default profile' { - Set-StoreSearchSuggestionsEnabledForAllUsers + Set-StoreSearchSuggestionsEnabledForAllUsers | Should -BeTrue Should -Invoke Set-StoreSearchSuggestionsEnabled -Times 3 -Exactly Should -Invoke Set-StoreSearchSuggestionsEnabled -Times 1 -Exactly -ParameterFilter { $StoreAppsDatabase -match 'Users\\Default\\' } @@ -72,6 +72,22 @@ Describe 'Store-search suggestion all-user operations' { Should -Invoke Set-StoreSearchSuggestionsEnabled -Times 2 -Exactly } + It 'returns false when any profile cannot be updated' { + Mock Set-StoreSearchSuggestionsDisabled { $false } -ParameterFilter { $StoreAppsDatabase -match 'Users\\Bob\\' } + + Set-StoreSearchSuggestionsDisabledForAllUsers | Should -BeFalse + Should -Invoke Set-StoreSearchSuggestionsDisabled -Times 3 -Exactly + } + + It 'returns false when no target profile can be resolved' { + Mock Get-ChildItem { @() } + Mock Get-StoreAppsDatabasePathForUser { $null } + Mock Write-Warning {} + + Set-StoreSearchSuggestionsDisabledForAllUsers | Should -BeFalse + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match 'no target user profiles' } + } + } Describe 'Set-StoreSearchSuggestionsDisabled' { @@ -84,7 +100,7 @@ Describe 'Set-StoreSearchSuggestionsDisabled' { } It 'does not touch the filesystem in WhatIf mode' { - Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db' + Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db' | Should -BeTrue Should -Invoke Test-Path -Times 0 -Exactly Should -Invoke Get-Acl -Times 0 -Exactly @@ -99,7 +115,7 @@ Describe 'Set-StoreSearchSuggestionsDisabled' { Mock Get-Acl { $acl } Mock Set-Acl {} - Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db' + Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db' | Should -BeTrue Should -Invoke New-Item -Times 2 -Exactly Should -Invoke New-Item -Times 1 -Exactly -ParameterFilter { $ItemType -eq 'Directory' } @@ -118,7 +134,7 @@ Describe 'Set-StoreSearchSuggestionsDisabled' { Mock Get-Acl { $acl } Mock Set-Acl {} - Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db' + Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db' | Should -BeTrue Should -Invoke New-Item -Times 0 -Exactly Should -Invoke Get-Acl -Times 1 -Exactly @@ -133,7 +149,7 @@ Describe 'Set-StoreSearchSuggestionsDisabled' { Mock Set-Acl { throw 'ACL must not be written after a read failure.' } Mock Write-Warning {} - Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db' + Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db' | Should -BeFalse Should -Invoke Write-Warning -Times 1 -Exactly Should -Invoke Write-Host -Times 0 -Exactly -ParameterFilter { $Object -like 'Disabled Microsoft Store search suggestions*' } @@ -165,7 +181,7 @@ Describe 'Set-StoreSearchSuggestionsEnabled' { } It 'does nothing when the Store database does not exist' { - Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db' + Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db' | Should -BeTrue Should -Invoke Get-Acl -Times 0 -Exactly Should -Invoke Remove-Item -Times 0 -Exactly @@ -177,7 +193,7 @@ Describe 'Set-StoreSearchSuggestionsEnabled' { Mock takeown { throw 'WhatIf should not take ownership.' } Mock icacls { throw 'WhatIf should not change ACLs.' } - Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db' + Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db' | Should -BeTrue Should -Invoke Test-Path -Times 0 -Exactly Should -Invoke takeown -Times 0 -Exactly @@ -193,7 +209,7 @@ Describe 'Set-StoreSearchSuggestionsEnabled' { Mock Set-Acl {} Mock Remove-Item {} - Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db' + Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db' | Should -BeTrue Should -Invoke takeown -Times 1 -Exactly Should -Invoke icacls -Times 1 -Exactly @@ -211,14 +227,14 @@ Describe 'Set-StoreSearchSuggestionsEnabled' { Mock Remove-Item {} Mock Write-Warning {} - Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db' + Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db' | Should -BeTrue Should -Invoke Write-Warning -Times 1 -Exactly Should -Invoke Set-Acl -Times 0 -Exactly Should -Invoke Remove-Item -Times 1 -Exactly } - It 'throws a contextual error when the database cannot be removed' { + It 'returns false when the database cannot be removed' { $acl = New-TestStoreDatabaseAcl Mock Test-Path { $true } Mock takeown {} @@ -226,9 +242,9 @@ Describe 'Set-StoreSearchSuggestionsEnabled' { Mock Get-Acl { $acl } Mock Set-Acl {} Mock Remove-Item { throw 'database is locked' } + Mock Write-Warning {} - { - Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db' - } | Should -Throw '*Failed to remove*database is locked*' + Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db' | Should -BeFalse + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match 'Failed to remove.*database is locked' } } } diff --git a/Tests/Telemetry-ScheduledTasks.Tests.ps1 b/Tests/Telemetry-ScheduledTasks.Tests.ps1 index 9b8e70c8..2375fad6 100644 --- a/Tests/Telemetry-ScheduledTasks.Tests.ps1 +++ b/Tests/Telemetry-ScheduledTasks.Tests.ps1 @@ -95,6 +95,69 @@ Describe 'Disable-TelemetryScheduledTasks' { $result.Error | Should -Match 'access denied' } + It 'returns an error when scheduled-task lookup fails' { + Mock Invoke-NonBlocking { + param($ScriptBlock, $ArgumentList) + $script:taskBlock = $ScriptBlock + $script:taskArguments = $ArgumentList + } + Mock Import-Module {} + Mock Get-ScheduledTask { throw 'scheduler unavailable' } + + Disable-TelemetryScheduledTasks + $result = & $script:taskBlock @script:taskArguments + + $result.Status | Should -Be 'Error' + $result.Error | Should -Match 'scheduler unavailable' + } + + It 'returns an error when the scheduled-task module cannot load' { + Mock Invoke-NonBlocking { + param($ScriptBlock, $ArgumentList) + $script:taskBlock = $ScriptBlock + $script:taskArguments = $ArgumentList + } + Mock Import-Module { throw 'module unavailable' } + + Disable-TelemetryScheduledTasks + $result = & $script:taskBlock @script:taskArguments + + $result.Status | Should -Be 'Error' + $result.Error | Should -Match 'module unavailable' + } + + It 'returns an error when the scheduled-task command is unavailable' { + Mock Invoke-NonBlocking { + param($ScriptBlock, $ArgumentList) + $script:taskBlock = $ScriptBlock + $script:taskArguments = $ArgumentList + } + Mock Import-Module {} + Mock Get-ScheduledTask { + throw [System.Management.Automation.ErrorRecord]::new( + [System.Management.Automation.CommandNotFoundException]::new('Get-ScheduledTask unavailable'), + 'CommandNotFoundException', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $null) + } + + Disable-TelemetryScheduledTasks + (& $script:taskBlock @script:taskArguments).Status | Should -Be 'Error' + } + + It 'treats an absent scheduled task as not found' { + Mock Invoke-NonBlocking { + param($ScriptBlock, $ArgumentList) + $script:taskBlock = $ScriptBlock + $script:taskArguments = $ArgumentList + } + Mock Import-Module {} + Mock Get-ScheduledTask { $null } + + Disable-TelemetryScheduledTasks + (& $script:taskBlock @script:taskArguments).Status | Should -Be 'NotFound' + } + It 'reports task results' -ForEach @( @{ Status = 'Disabled'; Expected = 'Disabled Scheduled Task' } @{ Status = 'AlreadyDisabled'; Expected = 'already disabled' } @@ -108,6 +171,15 @@ Describe 'Disable-TelemetryScheduledTasks' { Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -like "*$Expected*" } } + + It 'returns false for an unknown scheduler result' { + Mock Get-TelemetryScheduledTasks { @(@{ Path = '\Microsoft\Windows\Test\'; Name = 'Telemetry' }) } + Mock Invoke-NonBlocking { $null } + Mock Write-Warning {} + + Disable-TelemetryScheduledTasks | Should -BeFalse + Should -Invoke Write-Warning -Times 1 -Exactly + } } Describe 'Enable-TelemetryScheduledTasks' { @@ -166,6 +238,56 @@ Describe 'Enable-TelemetryScheduledTasks' { Should -Invoke Enable-ScheduledTask -Times 1 -Exactly -ParameterFilter { $TaskPath -eq '\Microsoft\Windows\Test\' -and $TaskName -eq 'Second' } } + It 'returns an error when scheduled-task lookup fails' { + Mock Invoke-NonBlocking { + param($ScriptBlock, $ArgumentList) + $script:taskBlock = $ScriptBlock + $script:taskArguments = $ArgumentList + } + Mock Import-Module {} + Mock Get-ScheduledTask { throw 'scheduler unavailable' } + + Enable-TelemetryScheduledTasks + $result = & $script:taskBlock @script:taskArguments + + $result.Status | Should -Be 'Error' + $result.Error | Should -Match 'scheduler unavailable' + } + + It 'returns an error when the scheduled-task module cannot load' { + Mock Invoke-NonBlocking { + param($ScriptBlock, $ArgumentList) + $script:taskBlock = $ScriptBlock + $script:taskArguments = $ArgumentList + } + Mock Import-Module { throw 'module unavailable' } + + Enable-TelemetryScheduledTasks + $result = & $script:taskBlock @script:taskArguments + + $result.Status | Should -Be 'Error' + $result.Error | Should -Match 'module unavailable' + } + + It 'returns an error when the scheduled-task command is unavailable' { + Mock Invoke-NonBlocking { + param($ScriptBlock, $ArgumentList) + $script:taskBlock = $ScriptBlock + $script:taskArguments = $ArgumentList + } + Mock Import-Module {} + Mock Get-ScheduledTask { + throw [System.Management.Automation.ErrorRecord]::new( + [System.Management.Automation.CommandNotFoundException]::new('Get-ScheduledTask unavailable'), + 'CommandNotFoundException', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $null) + } + + Enable-TelemetryScheduledTasks + (& $script:taskBlock @script:taskArguments).Status | Should -Be 'Error' + } + It 'reports task results' -ForEach @( @{ Status = 'Enabled'; Expected = 'Enabled Scheduled Task' } @{ Status = 'AlreadyEnabled'; Expected = 'already enabled' } @@ -179,4 +301,13 @@ Describe 'Enable-TelemetryScheduledTasks' { Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -like "*$Expected*" } } + + It 'returns false when the scheduler throws' { + Mock Get-TelemetryScheduledTasks { @(@{ Path = '\Microsoft\Windows\Test\'; Name = 'Telemetry' }) } + Mock Invoke-NonBlocking { throw 'scheduler unavailable' } + Mock Write-Warning {} + + Enable-TelemetryScheduledTasks | Should -BeFalse + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match 'scheduler unavailable' } + } } diff --git a/Tests/Windows-OptionalFeatures.Tests.ps1 b/Tests/Windows-OptionalFeatures.Tests.ps1 index f0f11860..551733df 100644 --- a/Tests/Windows-OptionalFeatures.Tests.ps1 +++ b/Tests/Windows-OptionalFeatures.Tests.ps1 @@ -23,12 +23,12 @@ BeforeAll { Describe 'Enable-WindowsFeature' { BeforeEach { $script:Params = @{} - Mock Invoke-NonBlocking { @() } + Mock Invoke-NonBlocking { [PSCustomObject]@{ Success = $true; Output = $null; Error = $null } } Mock Write-Host {} } It 'schedules the requested feature with the non-blocking runner' { - Enable-WindowsFeature -FeatureName 'Feature.One' + Enable-WindowsFeature -FeatureName 'Feature.One' | Should -BeTrue Should -Invoke Invoke-NonBlocking -Times 1 -Exactly -ParameterFilter { $ArgumentList -eq 'Feature.One' } } @@ -40,7 +40,7 @@ Describe 'Enable-WindowsFeature' { $script:optionalFeatureBlock = $ScriptBlock $script:optionalFeatureArguments = $ArgumentList } - Enable-WindowsFeature -FeatureName 'Feature.One' + Enable-WindowsFeature -FeatureName 'Feature.One' | Should -BeFalse & $script:optionalFeatureBlock $script:optionalFeatureArguments $global:OptionalFeatureCalls | Should -HaveCount 1 @@ -51,6 +51,27 @@ Describe 'Enable-WindowsFeature' { $global:OptionalFeatureCalls[0].NoRestart | Should -BeTrue } + It 'writes optional-feature output while returning true' { + Mock Invoke-NonBlocking { + param($ScriptBlock, $ArgumentList) + & $ScriptBlock $ArgumentList + } + Mock Enable-WindowsOptionalFeature { [PSCustomObject]@{ State = 'Enabled'; RestartNeeded = $false } } + + Enable-WindowsFeature -FeatureName 'Feature.One' | Should -BeTrue + + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -match 'Enabled' } + } + + It 'reports the worker error and returns false' { + Mock Invoke-NonBlocking { [PSCustomObject]@{ Success = $false; Output = $null; Error = 'feature servicing failed' } } + Mock Write-Warning {} + + Enable-WindowsFeature -FeatureName 'Feature.One' | Should -BeFalse + + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match 'feature servicing failed' } + } + It 'does not schedule changes in WhatIf mode' { $script:Params = @{ WhatIf = $true } @@ -69,19 +90,19 @@ Describe 'Enable-WindowsFeature' { Enable-WindowsFeature -FeatureName 'Feature.One' - { & $script:optionalFeatureBlock $script:optionalFeatureArguments } | Should -Throw 'feature servicing failed' + (& $script:optionalFeatureBlock $script:optionalFeatureArguments).Success | Should -BeFalse } } Describe 'Disable-WindowsFeature' { BeforeEach { $script:Params = @{} - Mock Invoke-NonBlocking { @() } + Mock Invoke-NonBlocking { [PSCustomObject]@{ Success = $true; Output = $null; Error = $null } } Mock Write-Host {} } It 'schedules the requested feature with the non-blocking runner' { - Disable-WindowsFeature -FeatureName 'Feature.One' + Disable-WindowsFeature -FeatureName 'Feature.One' | Should -BeTrue Should -Invoke Invoke-NonBlocking -Times 1 -Exactly -ParameterFilter { $ArgumentList -eq 'Feature.One' } } @@ -93,7 +114,7 @@ Describe 'Disable-WindowsFeature' { $script:optionalFeatureBlock = $ScriptBlock $script:optionalFeatureArguments = $ArgumentList } - Disable-WindowsFeature -FeatureName 'Feature.One' + Disable-WindowsFeature -FeatureName 'Feature.One' | Should -BeFalse & $script:optionalFeatureBlock $script:optionalFeatureArguments $global:OptionalFeatureCalls | Should -HaveCount 1 @@ -104,6 +125,27 @@ Describe 'Disable-WindowsFeature' { $global:OptionalFeatureCalls[0].NoRestart | Should -BeTrue } + It 'writes optional-feature output while returning true' { + Mock Invoke-NonBlocking { + param($ScriptBlock, $ArgumentList) + & $ScriptBlock $ArgumentList + } + Mock Disable-WindowsOptionalFeature { [PSCustomObject]@{ State = 'Disabled'; RestartNeeded = $false } } + + Disable-WindowsFeature -FeatureName 'Feature.One' | Should -BeTrue + + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -match 'Disabled' } + } + + It 'reports the worker error and returns false' { + Mock Invoke-NonBlocking { [PSCustomObject]@{ Success = $false; Output = $null; Error = 'feature servicing failed' } } + Mock Write-Warning {} + + Disable-WindowsFeature -FeatureName 'Feature.One' | Should -BeFalse + + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match 'feature servicing failed' } + } + It 'does not schedule changes in WhatIf mode' { $script:Params = @{ WhatIf = $true } @@ -122,7 +164,7 @@ Describe 'Disable-WindowsFeature' { Disable-WindowsFeature -FeatureName 'Feature.One' - { & $script:optionalFeatureBlock $script:optionalFeatureArguments } | Should -Throw 'feature servicing failed' + (& $script:optionalFeatureBlock $script:optionalFeatureArguments).Success | Should -BeFalse } } diff --git a/Win11Debloat.ps1 b/Win11Debloat.ps1 index 908f60b1..c9a82971 100644 --- a/Win11Debloat.ps1 +++ b/Win11Debloat.ps1 @@ -427,6 +427,7 @@ if (-not $script:WingetInstalled -and -not $Silent) { . "$PSScriptRoot/Scripts/Helpers/Get-FriendlyTargetUserName.ps1" . "$PSScriptRoot/Scripts/Helpers/Get-RebootFeatureLabels.ps1" . "$PSScriptRoot/Scripts/Helpers/Import-ConfigToParams.ps1" +. "$PSScriptRoot/Scripts/Helpers/Test-ConfigConsistency.ps1" . "$PSScriptRoot/Scripts/Helpers/Get-TargetUserForAppRemoval.ps1" . "$PSScriptRoot/Scripts/Helpers/Get-RegFileOperations.ps1" . "$PSScriptRoot/Scripts/Helpers/Test-TargetUserName.ps1"